CP7.6: craft-libs#497 re-review — prefix flags sorted before lookup; one step budget per simple-word check

- simple_word sorts the grouped prefix flags (non-cross then cross flags were unsorted, so
  `redo`/`rework` from a no-cross prefix beside cross ones were missed).
- One budget of 65,536 affix rules per simple-word check, shared by every prefix group and
  suffix walk; past it the word is taken for no word. Prefixes that each strip their own
  text (h2.aff: 1,000 roots) no longer cost a full walk each: 1.7 s → 1.1 ms per two
  words, suggest 14.9 s → 65 ms. The 77 fetched spelling packs answer as without a budget
  on 6,000 generated words each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
This commit is contained in:
Martin BallandClaude Opus 5.5 committed 2026-11-02 04:22:14 +00:00
1 parent 188c3154ae
commit d0c341c88f
1 file changed
+73 -7
+73 -7
View File
@@ -224,6 +224,8 @@ fn parse_cond(text: &str) -> Result<Box<[Cond]>, String> {
Ok(out.into_boxed_slice())
}
/// Most affix rules one simple-word check tries, all prefixes and suffix chains together.
const MAX_SIMPLE_WORK: usize = 1 << 16;
/// Most homonyms of one stem kept apart ([`AffixDict::homonyms`]); past it their flags count merged.
const MAX_HOMONYMS: usize = 16;
/// How many suffixes a word may carry in a row through continuation classes (`work` +
@@ -879,6 +881,18 @@ impl<'a> FlagProbe<'a> {
}
}
/// Affix rules a word's check may still try ([`MAX_SIMPLE_WORK`] at first).
struct Steps(std::cell::Cell<usize>);
impl Steps {
/// Take `n` steps; false when they ran out.
fn spend(&self, n: usize) -> bool {
let left = self.0.get();
self.0.set(left.saturating_sub(n.max(1)));
left > 0
}
}
/// Does the sorted flag set `set` hold `flag`?
fn has(set: &[u16], flag: u16) -> bool {
set.binary_search(&flag).is_ok()
@@ -1660,7 +1674,7 @@ impl AffixDict {
/// With `pfx` set (a prefix was taken off: the flags of the prefixes that could be it), every
/// rule on the way must allow cross products and one prefix's flag must come from the stem or
/// a continuation class on the way (`meg` + `épít` + `és` + `e`).
fn form_has_flag(&self, w: &str, flags: &[u16], depth: u8, pfx: Option<&FlagProbe>, excluded: Excluded) -> bool {
fn form_has_flag(&self, w: &str, flags: &[u16], depth: u8, pfx: Option<&FlagProbe>, excluded: Excluded, steps: &Steps) -> bool {
let fits = |f: &[u16]| intersects(f, flags) && pfx.is_none_or(|p| p.meets(f));
if if pfx.is_some() { self.homonym_meets(w, excluded, fits) } else { self.stem_flags_except(w, excluded).is_some_and(fits) } {
return true;
@@ -1683,6 +1697,9 @@ impl AffixDict {
let (Some(base), Some(add)) = (w.get(..b), w.get(b..)) else { continue };
let Some(groups) = self.sfx_index.get(add) else { continue };
for g in groups.iter().filter(|g| !g.cont_rules.is_empty()) {
if !steps.spend(g.cont_rules.len()) {
return false;
}
root.clear();
root.push_str(base);
root.push_str(&g.strip);
@@ -1702,7 +1719,7 @@ impl AffixDict {
for (list, p) in [(&mut inner, pfx), (&mut met, None)] {
list.sort_unstable();
list.dedup();
if !list.is_empty() && self.form_has_flag(&root, list, depth - 1, p, excluded) {
if !list.is_empty() && self.form_has_flag(&root, list, depth - 1, p, excluded, steps) {
return true;
}
}
@@ -1794,8 +1811,11 @@ impl AffixDict {
if w.is_empty() || w.len() > MAX_WORD_BYTES || self.is_forbidden(w) {
return false;
}
// The affix rules tried for this word, all ways together: past MAX_SIMPLE_WORK it is
// taken for no word (hostile rules can't make one word cost seconds).
let steps = Steps(std::cell::Cell::new(MAX_SIMPLE_WORK));
// A stem that needs an affix is no word on its own.
if self.stem_flags_except(w, excluded.and(self.need_affix)).is_some() || self.suffixed(w, None, false, false, excluded) {
if self.stem_flags_except(w, excluded.and(self.need_affix)).is_some() || self.suffixed(w, None, false, false, excluded, &steps) {
return true;
}
if !prefixes {
@@ -1828,6 +1848,9 @@ impl AffixDict {
let mut plain: Vec<u16> = Vec::new();
let mut crossed: Vec<u16> = Vec::new();
for group in taken.chunk_by(|a, b| (&a.0, a.1, a.2) == (&b.0, b.1, b.2)) {
if !steps.spend(group.len()) {
return false;
}
let Some((root, circ, needs, ..)) = group.first() else { continue };
plain.clear();
crossed.clear();
@@ -1839,18 +1862,20 @@ impl AffixDict {
crossed.push(flag);
}
}
plain.sort_unstable();
plain.dedup();
crossed.sort_unstable();
crossed.dedup();
if !plain.is_empty() {
if self.stem_flags_except(root, excluded).is_some_and(|f| intersects(f, &plain)) {
return true;
}
plain.retain(|&f| self.is_cont_flag(f));
if !plain.is_empty() && self.form_has_flag(root, &plain, INNER_CHAIN, None, excluded) {
if !plain.is_empty() && self.form_has_flag(root, &plain, INNER_CHAIN, None, excluded, &steps) {
return true;
}
}
if !crossed.is_empty() && self.suffixed(root, Some(&FlagProbe::new(&crossed)), *circ, *needs, excluded) {
if !crossed.is_empty() && self.suffixed(root, Some(&FlagProbe::new(&crossed)), *circ, *needs, excluded, &steps) {
return true;
}
}
@@ -1905,7 +1930,7 @@ impl AffixDict {
/// from `re/N` and `s/N`).
/// `need` holds the flags of all the prefixes that could have been taken off (one walk for
/// them all: a thousand prefix rules adding nothing cost one walk, not a thousand).
fn suffixed(&self, w: &str, need: Option<&FlagProbe>, circ: bool, pfx_needs: bool, excluded: Excluded) -> bool {
fn suffixed(&self, w: &str, need: Option<&FlagProbe>, circ: bool, pfx_needs: bool, excluded: Excluded, steps: &Steps) -> bool {
// Fits the suffix rule `r`: circumfix alike, and in need of another affix only when a
// prefix that doesn't is there.
let fits = |r: &Rule| {
@@ -1923,6 +1948,9 @@ impl AffixDict {
let (Some(base), Some(add)) = (w.get(..b), w.get(b..)) else { continue };
let Some(groups) = self.sfx_index.get(add) else { continue };
for g in groups {
if !steps.spend(g.rules.len()) {
return false;
}
root.clear();
root.push_str(base);
root.push_str(&g.strip);
@@ -1968,7 +1996,7 @@ impl AffixDict {
for (list, p) in [(&mut flags, need), (&mut met, None)] {
list.sort_unstable();
list.dedup();
if !list.is_empty() && self.form_has_flag(&root, list, INNER_CHAIN, p, excluded) {
if !list.is_empty() && self.form_has_flag(&root, list, INNER_CHAIN, p, excluded, steps) {
return true;
}
}
@@ -3496,6 +3524,44 @@ pub(crate) mod tests {
assert!(t.elapsed().as_secs() < 5, "{:?}", t.elapsed());
}
#[test]
fn prefixes_with_and_without_cross_products_on_one_root() {
// The CLI's answers: `redo` and `rework` right, from `c` (no cross products) beside `a`
// and `b` (cross products) adding the same `re`.
let aff = "SET UTF-8\nPFX a Y 1\nPFX a 0 re .\nPFX b Y 1\nPFX b 0 re .\nPFX c N 1\nPFX c 0 re .\nSFX S Y 1\nSFX S 0 s .\n";
let d = AffixDict::from_hunspell(aff, "2\ndo/c\nwork/cS\n", &[]).unwrap();
assert!(d.check("redo") && d.check("rework") && d.check("works"));
assert!(!d.check("reworks"));
let aff = "SET UTF-8\nFLAG num\nPFX 1 Y 1\nPFX 1 0 re .\nPFX 2 Y 1\nPFX 2 0 re .\nPFX 3 N 1\nPFX 3 0 re .\nPFX 4 N 1\nPFX 4 0 re .\nPFX 5 N 1\nPFX 5 0 re .\n";
let d = AffixDict::from_hunspell(aff, "5\nfoo/3\nbar/4\nbaz/5\nqux/1\nquu/2\n", &[]).unwrap();
assert!(["refoo", "rebar", "rebaz", "requx", "requu"].iter().all(|w| d.check(w)));
}
#[test]
fn many_prefixes_with_their_own_roots_stay_cheap() {
// A thousand prefix rules each stripping its own letter (a thousand roots), and 40
// chained suffix lengths of 50 flags each: one word's rules share one budget.
let mut aff = String::from("SET UTF-8\nFLAG num\n");
for f in 1..=1000u32 {
let c = char::from_u32(0x4e00 + f).unwrap();
aff.push_str(&format!("PFX {f} Y 1\nPFX {f} {c} 0 .\n"));
}
let conts: Vec<String> = (0..50).map(|k| (5000 + k * 7).to_string()).collect();
for len in 1..=40 {
for k in 0..50 {
let f = 5000 + (len - 1) * 50 + k;
aff.push_str(&format!("SFX {f} Y 1\nSFX {f} 0 {}/{} .\n", "a".repeat(len), conts.join(",")));
}
}
let d = AffixDict::from_hunspell(&aff, "2\nb/1,5000\nab/2\n", &[]).unwrap();
let t = std::time::Instant::now();
for n in [40, 119] {
let _ = d.check(&"a".repeat(n));
}
let _ = d.suggest(&format!("{}x", "a".repeat(39)), 8);
assert!(t.elapsed().as_secs() < 10, "{:?}", t.elapsed());
}
#[test]
fn hostile_compound_rules_are_refused_or_cheap() {
for bad in ["COMPOUNDRULE *a", "COMPOUNDRULE (ab)", "COMPOUNDRULE a**", "COMPOUNDMIN x", "ONLYINCOMPOUND ab"] {