From fdb52ec3c67ef67a281b87106c80f9cb0eabec11 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 2 Nov 2026 00:27:37 +0000 Subject: [PATCH] =?UTF-8?q?CP6.13:=20craft-libs#495=20review=20=E2=80=94?= =?UTF-8?q?=20MAP=20variants=20are=20made=20by=20number=20of=20changes,=20?= =?UTF-8?q?so=20the=204,096=20cap=20drops=20the=20most-changed=20ones,=20n?= =?UTF-8?q?ever=20a=20single=20slip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2 --- crates/proof/src/affix_suggest.rs | 43 ++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/crates/proof/src/affix_suggest.rs b/crates/proof/src/affix_suggest.rs index b20dfb93..3c9b06bc 100644 --- a/crates/proof/src/affix_suggest.rs +++ b/crates/proof/src/affix_suggest.rs @@ -201,25 +201,32 @@ fn alternatives(c: char, map: &[Vec]) -> Vec { } /// Every way of changing up to `t.max_map_changes` letters of `chars` to a MAP relative (`alts`, -/// letter by letter), with its cost, each written after `buf`. -fn map_variants(chars: &[char], alts: &[Vec], t: &Tuning, changes: usize, cost: u32, buf: &mut Vec, out: &mut Vec<(String, u32)>) { - if out.len() >= MAX_MAP_VARIANTS { +/// letter by letter), with its cost: all one-change variants first, then two, and so on, so the +/// [`MAX_MAP_VARIANTS`] cap drops the variants with the most changes. +fn map_variants(chars: &[char], alts: &[Vec], t: &Tuning, out: &mut Vec<(String, u32)>) { + let mut buf = Vec::with_capacity(chars.len()); + for n in 1..=t.max_map_changes.min(chars.len()) { + exact_map_variants(chars, alts, t, n, 0, &mut buf, out); + } +} + +/// [`map_variants`] with exactly `left` more changes, each written after `buf`. +fn exact_map_variants(chars: &[char], alts: &[Vec], t: &Tuning, left: usize, cost: u32, buf: &mut Vec, out: &mut Vec<(String, u32)>) { + if out.len() >= MAX_MAP_VARIANTS || left > chars.len() { return; } let Some((&c, rest)) = chars.split_first() else { - if changes > 0 { - out.push((buf.iter().collect(), cost)); - } + out.push((buf.iter().collect(), cost)); return; }; let (here, later) = alts.split_first().map_or((&[][..], &[][..]), |(h, l)| (h.as_slice(), l)); buf.push(c); - map_variants(rest, later, t, changes, cost, buf, out); + exact_map_variants(rest, later, t, left, cost, buf, out); buf.pop(); - if changes < t.max_map_changes { + if left > 0 { for &a in here { buf.push(a); - map_variants(rest, later, t, changes + 1, cost + (t.map_cost)(c, a), buf, out); + exact_map_variants(rest, later, t, left - 1, cost + (t.map_cost)(c, a), buf, out); buf.pop(); } } @@ -368,7 +375,7 @@ fn add_case(col: &mut Collector, word: &str, input: &str) { fn add_map_slips(col: &mut Collector, chars: &[char], map: &[Vec], t: &Tuning) { let alts: Vec> = chars.iter().map(|&c| alternatives(c, map)).collect(); let mut variants = Vec::new(); - map_variants(chars, &alts, t, 0, 0, &mut Vec::with_capacity(chars.len()), &mut variants); + map_variants(chars, &alts, t, &mut variants); variants.sort_by_key(|v| v.1); for (v, cost) in &variants { col.offer(v, *cost); @@ -535,6 +542,22 @@ fn scan(col: &mut Collector, chars: &[char], map: &[Vec]) { mod tests { use super::*; + /// craft-libs#487 review: MAP variants are made by number of changes, so when a long word has + /// more than [`MAX_MAP_VARIANTS`] of them the cap drops the most-changed ones, never a single + /// slip (here the first letter's, which depth-first order reached last). + #[test] + fn the_map_cap_keeps_the_fewest_changes() { + let chars: Vec = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".chars().collect(); + let alts = vec!["áàâäãåā".chars().collect::>(); chars.len()]; + let mut out = Vec::new(); + map_variants(&chars, &alts, &Tuning::DEFAULT, &mut out); + assert_eq!(out.len(), MAX_MAP_VARIANTS); + let changed = |v: &str| v.chars().filter(|&c| c != 'a').count(); + assert_eq!(out.iter().filter(|(v, _)| changed(v) == 1).count(), 30 * 7, "every single slip is kept"); + assert!(out.iter().any(|(v, _)| v.starts_with('á') && changed(v) == 1)); + assert!(out.windows(2).all(|w| changed(&w[0].0) <= changed(&w[1].0)), "fewest changes first"); + } + /// craft-libs#490 review: a suggestion recased to capitals keeps `ß` (`STRAßE`, right with or /// without CHECKSHARPS); under CHECKSHARPS the SS form comes first, and a KEEPCASE `ß` word may /// be capitalized or written in capitals with SS. Expected lists are the hunspell 1.7.5 CLI's