From 5a5e16dbd3fa87903e4f680fb2437a63d5836f7b Mon Sep 17 00:00:00 2001 From: lashman Date: Mon, 19 May 2025 20:48:10 +0300 Subject: [PATCH] candidate sets for the fill --- cruciverb-core/src/solver/candidate.rs | 107 +++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 cruciverb-core/src/solver/candidate.rs diff --git a/cruciverb-core/src/solver/candidate.rs b/cruciverb-core/src/solver/candidate.rs new file mode 100644 index 0000000..f5e6c67 --- /dev/null +++ b/cruciverb-core/src/solver/candidate.rs @@ -0,0 +1,107 @@ +use crate::dict::Dictionary; + +pub fn score_candidate( + word_idx: usize, + dict: &Dictionary, + used_words: &[bool], + placed_texts: &[String], +) -> f64 { + if used_words[word_idx] { + return -1.0; + } + + let word = dict.get(word_idx); + let text = &word.text; + let mut score = word.frequency; + + // reject words that are too similar to already-placed words + for placed in placed_texts { + if is_related(text, placed) { + return -1.0; + } + } + + // bonus for letter variety + let bytes = text.as_bytes(); + let mut seen = [false; 26]; + let mut unique = 0; + for &b in bytes { + let idx = (b - b'A') as usize; + if !seen[idx] { + seen[idx] = true; + unique += 1; + } + } + let variety = unique as f64 / bytes.len() as f64; + score += variety * 0.1; + + // bonus for common letters + let common = b"ETAOINSHRDLU"; + let common_count = bytes.iter().filter(|b| common.contains(b)).count(); + let common_ratio = common_count as f64 / bytes.len() as f64; + score += common_ratio * 0.05; + + score +} + +pub fn is_related(a: &str, b: &str) -> bool { + // one is a prefix of the other (SEAL / SEALS, RUN / RUNNING) + if a.len() >= 3 && b.len() >= 3 { + let (short, long) = if a.len() <= b.len() { (a, b) } else { (b, a) }; + if long.starts_with(short) { + let suffix = &long[short.len()..]; + // common inflection suffixes + if matches!(suffix, "S" | "ES" | "ED" | "ER" | "ING" | "LY" | "TION" | "NESS" | "MENT" | "ABLE" | "ISH" | "IST" | "IZE") { + return true; + } + } + // handle e-dropping: MAKE/MAKING, BAKE/BAKING + if short.len() >= 4 && long.ends_with("ING") { + let stem = &long[..long.len() - 3]; + if short.len() == stem.len() + 1 && short.starts_with(stem) && short.ends_with('E') { + return true; + } + } + // consonant doubling: RUN/RUNNING, SIT/SITTING + if long.ends_with("ING") && long.len() >= 6 { + let before_ing = &long[..long.len() - 3]; + let bb = before_ing.as_bytes(); + if bb.len() >= 4 && bb[bb.len() - 1] == bb[bb.len() - 2] { + let stem = &before_ing[..before_ing.len() - 1]; + if short == stem { + return true; + } + } + } + // also ED doubling: STOP/STOPPED + if long.ends_with("ED") && long.len() >= 5 { + let before_ed = &long[..long.len() - 2]; + let bb = before_ed.as_bytes(); + if bb.len() >= 4 && bb[bb.len() - 1] == bb[bb.len() - 2] { + let stem = &before_ed[..before_ed.len() - 1]; + if short == stem { + return true; + } + } + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn related_words() { + assert!(is_related("SEAL", "SEALS")); + assert!(is_related("RUN", "RUNNING")); + assert!(is_related("MAKE", "MAKING")); + assert!(is_related("PLAY", "PLAYED")); + assert!(is_related("PLAY", "PLAYER")); + assert!(is_related("QUICK", "QUICKLY")); + assert!(!is_related("CAT", "DOG")); + assert!(!is_related("SEA", "SEAL")); + assert!(!is_related("THE", "THEM")); + } +}