diff --git a/crates/proof/src/hyphen.rs b/crates/proof/src/hyphen.rs index 73541715..358502e9 100644 --- a/crates/proof/src/hyphen.rs +++ b/crates/proof/src/hyphen.rs @@ -153,41 +153,55 @@ pub fn hyphen_points(word: &str, lim: &Limits) -> Vec { } let min_before = lim.after_first.max(1); let min_after = lim.before_last.max(1); - // Run bounds of each point's letter run (trimmed of apostrophes). - let run_of = |p: usize| { - let mut a = p; - while a > 0 && is_word_char(chars[a - 1]) { - a -= 1; + // Counted once, so each point's checks take constant time however long the word: letters and + // uppercase letters before each char, and the letter run (trimmed of apostrophes) of each char. + let (mut letters, mut upper) = (Vec::with_capacity(chars.len() + 1), Vec::with_capacity(chars.len() + 1)); + let (mut l, mut u) = (0usize, 0usize); + for c in &chars { + letters.push(l); + upper.push(u); + if c.is_alphabetic() { + l += 1; + u += usize::from(c.is_uppercase()); } - let mut b = p; - while b < chars.len() && is_word_char(chars[b]) { - b += 1; + } + letters.push(l); + upper.push(u); + let mut run_of: Vec<(usize, usize)> = vec![(0, 0); chars.len()]; + let mut i = 0; + while i < chars.len() { + let mut j = i; + while chars.get(j).is_some_and(|c| is_word_char(*c)) { + j += 1; } - while a < b && is_apostrophe(chars[a]) { + let (mut a, mut b) = (i, j); + while a < b && chars.get(a).is_some_and(|c| is_apostrophe(*c)) { a += 1; } - while b > a && is_apostrophe(chars[b - 1]) { + while b > a && chars.get(b - 1).is_some_and(|c| is_apostrophe(*c)) { b -= 1; } - (a, b) - }; + for r in run_of.get_mut(i..j).into_iter().flatten() { + *r = (a, b); + } + i = j.max(i + 1); + } + let count = |v: &[usize], a: usize, b: usize| v.get(b).zip(v.get(a)).map_or(0, |(y, x)| y.saturating_sub(*x)); pts.into_iter() .filter(|&p| { - let (a, b) = run_of(p); - let run = &chars[a..b]; - let letters = run.iter().filter(|c| c.is_alphabetic()).count(); - if letters < lim.min_word.max(2) { + // A point lies between two letters of one run; the char after it gives the run. + let Some(&(a, b)) = run_of.get(p) else { return false }; + let n = count(&letters, a, b); + if n < lim.min_word.max(2) { return false; } - if letters > 1 && run.iter().filter(|c| c.is_alphabetic()).all(|c| c.is_uppercase()) { + if n > 1 && count(&upper, a, b) == n { return false; } - if !lim.capitalized && run[0].is_uppercase() { + if !lim.capitalized && chars.get(a).is_some_and(|c| c.is_uppercase()) { return false; } - let before = chars[a..p].iter().filter(|c| c.is_alphabetic()).count(); - let after = chars[p..b].iter().filter(|c| c.is_alphabetic()).count(); - before >= min_before && after >= min_after + count(&letters, a, p) >= min_before && count(&letters, p, b) >= min_after }) .collect() } @@ -230,8 +244,33 @@ fn heuristic_points(c: &[char]) -> Vec { return vec![]; } let v: Vec = c.iter().map(|&x| is_vowel(x)).collect(); + // Both parts need a sounded vowel: a trailing silent `e`, `ed` or `es` doesn't count. The part + // after `i` is `c[i..end]`, `end` before that ending; computed once, so each point is O(1). + let end = |i: usize| { + if n - i >= 2 && (c.ends_with(&['e', 'd']) || c.ends_with(&['e', 's'])) { + n - 2 + } else if c.ends_with(&['e']) { + n - 1 + } else { + n + } + }; + // UTF-8 bytes before each char, and the first vowel at or after each char. + let mut bytes = Vec::with_capacity(n + 1); + let mut acc = 0usize; + for ch in c { + bytes.push(acc); + acc += ch.len_utf8(); + } + bytes.push(acc); + let mut next_vowel = vec![n; n + 1]; + for i in (0..n).rev() { + next_vowel[i] = if v[i] { i } else { next_vowel[i + 1] }; + } let mut out = vec![]; + let mut vowel_before = false; for i in 1..n { + vowel_before |= v[i - 1]; let ok = if v[i - 1] && !v[i] { (i + 1 < n && v[i + 1]) || (i + 2 < n && onset_pair(c[i], c[i + 1]) && v[i + 2]) } else if !v[i - 1] && !v[i] { @@ -239,10 +278,8 @@ fn heuristic_points(c: &[char]) -> Vec { } else { false }; - // Both parts need a sounded vowel: a trailing silent `e`, `ed` or `es` doesn't count. - let right: String = c[i..].iter().collect(); - let core = right.strip_suffix("ed").or_else(|| right.strip_suffix("es")).or_else(|| right.strip_suffix('e')).unwrap_or(&right); - if ok && core.chars().any(is_vowel) && core.len() > 1 && v[..i].iter().any(|x| *x) { + let e = end(i); + if ok && next_vowel[i] < e && bytes[e] - bytes[i] > 1 && vowel_before { out.push(i); } } @@ -319,6 +356,172 @@ mod tests { assert!(hyphen_points("typography", &lim).is_empty()); } + /// The fastest of three timings of `f`, after a warm-up. + fn fastest(f: impl Fn()) -> f64 { + f(); + (0..3) + .map(|_| { + let t = std::time::Instant::now(); + f(); + t.elapsed().as_secs_f64() + }) + .fold(f64::INFINITY, f64::min) + } + + /// `hyphen_points`' limit checks as first written (a rescan of the letter run per point): the + /// reference for the linear version. + fn hyphen_points_ref(word: &str, lim: &Limits) -> Vec { + let chars: Vec = word.chars().collect(); + let (pts, _) = word_breaks(word); + let min_before = lim.after_first.max(1); + let min_after = lim.before_last.max(1); + let run_of = |p: usize| { + let mut a = p; + while a > 0 && is_word_char(chars[a - 1]) { + a -= 1; + } + let mut b = p; + while b < chars.len() && is_word_char(chars[b]) { + b += 1; + } + while a < b && is_apostrophe(chars[a]) { + a += 1; + } + while b > a && is_apostrophe(chars[b - 1]) { + b -= 1; + } + (a, b) + }; + pts.into_iter() + .filter(|&p| { + let (a, b) = run_of(p); + let run = &chars[a..b]; + let letters = run.iter().filter(|c| c.is_alphabetic()).count(); + if letters < lim.min_word.max(2) { + return false; + } + if letters > 1 && run.iter().filter(|c| c.is_alphabetic()).all(|c| c.is_uppercase()) { + return false; + } + if !lim.capitalized && run[0].is_uppercase() { + return false; + } + let before = chars[a..p].iter().filter(|c| c.is_alphabetic()).count(); + let after = chars[p..b].iter().filter(|c| c.is_alphabetic()).count(); + before >= min_before && after >= min_after + }) + .collect() + } + + /// `heuristic_points` as first written (the right part rebuilt per point): the reference. + fn heuristic_ref(c: &[char]) -> Vec { + let n = c.len(); + if n < 4 || !c.iter().all(|c| c.is_alphabetic()) { + return vec![]; + } + let v: Vec = c.iter().map(|&x| is_vowel(x)).collect(); + let mut out = vec![]; + for i in 1..n { + let ok = if v[i - 1] && !v[i] { + (i + 1 < n && v[i + 1]) || (i + 2 < n && onset_pair(c[i], c[i + 1]) && v[i + 2]) + } else if !v[i - 1] && !v[i] { + i >= 2 && v[i - 2] && i + 1 < n && v[i + 1] && !onset_pair(c[i - 1], c[i]) && c[i - 1] != c[i] + || (c[i - 1] == c[i] && i >= 2 && v[i - 2]) + } else { + false + }; + let right: String = c[i..].iter().collect(); + let core = right.strip_suffix("ed").or_else(|| right.strip_suffix("es")).or_else(|| right.strip_suffix('e')).unwrap_or(&right); + if ok && core.chars().any(is_vowel) && core.len() > 1 && v[..i].iter().any(|x| *x) { + out.push(i); + } + } + out + } + + #[test] + fn linear_limits_and_heuristic_match_the_reference() { + let long = "hyphenation".repeat(40); + let words = [ + "", + "x", + "''", + "a'b'c", + "cat", + "happen", + "hyphenation", + "Typography's", + "TYPOGRAPHY", + "Typography", + "typography’s", + "'typography'", + "“typography”,", + "typography-composition", + "don't", + "rock'n'roll", + "naïveté", + "crème-brûlée", + "Ünïcödé", + "ÉTUDE", + "strengths", + "ee", + "bed", + "hopped", + "rhythmed", + "aeiouy", + "xxyyzz", + "bookkeeper", + "ʼtwas", + "mid-1990s", + "twenty-one", + "école", + "Ålesund", + "smørrebrød", + "hopes", + "hoped", + "taed", + "teeed", + &long, + ]; + for w in words { + for min_word in [2, 5, 7] { + for (after_first, before_last) in [(1, 1), (2, 2), (3, 2), (2, 4)] { + for capitalized in [true, false] { + let lim = Limits { min_word, after_first, before_last, capitalized }; + assert_eq!(hyphen_points(w, &lim), hyphen_points_ref(w, &lim), "{w:?} {lim:?}"); + } + } + } + let c: Vec = w.to_lowercase().chars().collect(); + assert_eq!(heuristic_points(&c), heuristic_ref(&c), "{w:?}"); + } + } + + #[test] + fn hyphenating_a_long_word_is_linear() { + // A word of n letters against one of 4n: linear ~4x the time, the old rescans ~16x. One in + // the pattern alphabet, one outside it (the syllable heuristic). + let pattern = |k: usize| "hyphenation".repeat(k); + let heuristic = |k: usize| "smørrebrød".repeat(k); + assert_eq!(word_breaks(&heuristic(2)).1, Source::Heuristic); + // Each timing repeats the call so the shorter one is milliseconds, well above timer noise. + let ratios: Vec<(String, f64)> = [(pattern as fn(usize) -> String, 20), (heuristic, 40)] + .into_iter() + .map(|(word, repeat)| { + let (small, big) = (word(400), word(1600)); + let time = |w: &str| fastest(|| (0..repeat).for_each(|_| drop(hyphen_points(w, &Limits::default())))); + let (t1, t4) = (time(&small), time(&big)); + let ratio = t4 / t1.max(1e-9); + let what = format!("{}…: t(n) {t1:.4} s, t(4n) {t4:.4} s, ratio {ratio:.1}", word(1).chars().take(10).collect::()); + eprintln!("{what}"); + (what, ratio) + }) + .collect(); + for (what, ratio) in ratios { + assert!(ratio < 8.0, "4x the letters, not linear: {what}"); + } + } + #[test] fn heuristic_fallback() { let c: Vec = "happen".chars().collect();