storytold/wordcraft#3657: Russian hyphenation and hyphenated output linear in word length

Audit findings 10 (storytold/wordcraft#3657) and 15 (storytold/wordcraft#3692).

- ru::hyphen::points: the cuts come in increasing order, so a repeat can only be the last one;
  compare with `out.last()` instead of `out.contains` per syllable (O(V^2) on a long Cyrillic
  word, during layout).
- hyphenate_word_in: walk the sorted break points alongside the chars instead of searching the
  point list for every char. Same output.

Test: russian_hyphenation_and_hyphenated_output_are_linear (8x the letters costs ~8x; the old
code measured 31x and 30x).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Martin BallandClaude Opus 5.5 committed 2026-10-26 00:17:25 +00:00
1 parent f0561b7fb0
commit eb37158db4
2 files changed
+30 -4

No files matched your search

+27 -3
View File
@@ -320,10 +320,14 @@ pub fn hyphenate_word(word: &str, lim: &Limits) -> String {
/// `word`, a word in `lang`, with `-` at every hyphenation point (tests and diagnostics).
pub fn hyphenate_word_in(word: &str, lim: &Limits, lang: ProofLang) -> String {
let pts = hyphen_points_in(word, lim, lang);
let mut s = String::with_capacity(word.len() + pts.len());
let mut pts = hyphen_points_in(word, lim, lang);
pts.sort_unstable();
// Walk the points alongside the chars rather than searching them for each char.
let mut next = pts.into_iter().peekable();
let mut s = String::with_capacity(word.len() + 8);
for (i, ch) in word.chars().enumerate() {
if pts.contains(&i) {
if next.next_if_eq(&i).is_some() {
while next.next_if_eq(&i).is_some() {}
s.push('-');
}
s.push(ch);
@@ -706,6 +710,26 @@ mod tests {
}
}
#[test]
fn russian_hyphenation_and_hyphenated_output_are_linear() {
// A Russian word breaks at every syllable: each break used to be checked against all the
// earlier ones (#3657), and the hyphenated output searched every break for each char.
let word = |k: usize| "мама".repeat(k);
assert_eq!(word_breaks_in(&word(2), ProofLang::Ru).0, [2, 4, 6]);
assert_eq!(hyphenate_word_in(&word(2), &Limits::default(), ProofLang::Ru), "ма-ма-ма-ма");
// The breaks' rescan is a cheap one per break: it takes longer words to stand out.
for (what, k, repeat, f) in [
("points", 1000, 3, (|w: &str| drop(hyphen_points_in(w, &Limits::default(), ProofLang::Ru))) as fn(&str)),
("hyphenated", 400, 20, |w: &str| drop(hyphenate_word_in(w, &Limits::default(), ProofLang::Ru))),
] {
let (small, big) = (word(k), word(8 * k));
let time = |w: &str| (0..repeat).for_each(|_| f(w));
let (t1, t8, ratio) = cost_ratio(8, || time(&small), || time(&big));
eprintln!("{what}: t(n) {t1:.4} s, t(8n) {t8:.4} s, ratio {ratio:.1}");
assert!(ratio < 24.0, "8x the letters, not linear: {what} {ratio:.1}");
}
}
#[test]
fn heuristic_fallback() {
let c: Vec<char> = "happen".chars().collect();
+3 -1
View File
@@ -57,7 +57,9 @@ pub fn points(c: &[char]) -> Vec<usize> {
while c.get(cut).is_some_and(|&x| sticks_left(x)) && cut < end {
cut += 1;
}
if cut >= 2 && n - cut >= 2 && !out.contains(&cut) {
// Cuts come in increasing order (each lies after the previous pair's vowel), so a repeat
// can only be the last one: no scan of the whole list per syllable (#3657).
if cut >= 2 && n - cut >= 2 && out.last().is_none_or(|&l| l < cut) {
out.push(cut);
}
}