From e80d48db610b7f734e7ea384bef85b59113d1d6b Mon Sep 17 00:00:00 2001 From: lashman Date: Tue, 13 May 2025 23:32:38 +0300 Subject: [PATCH] grid validation --- cruciverb-core/src/validate.rs | 197 +++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 cruciverb-core/src/validate.rs diff --git a/cruciverb-core/src/validate.rs b/cruciverb-core/src/validate.rs new file mode 100644 index 0000000..c003a59 --- /dev/null +++ b/cruciverb-core/src/validate.rs @@ -0,0 +1,197 @@ +use std::collections::HashSet; + +use crate::dict::Dictionary; +use crate::grid::Grid; +use crate::grid::slot::extract_slots; +use crate::puzzle::PlacedWord; + +pub struct ValidationResult { + pub errors: Vec, +} + +impl ValidationResult { + pub fn is_valid(&self) -> bool { + self.errors.is_empty() + } +} + +pub fn validate_puzzle( + grid: &Grid, + words: &[PlacedWord], + dict: &Dictionary, + min_word_length: usize, + obscurity_budget: f64, +) -> ValidationResult { + let mut errors = Vec::new(); + + check_min_word_length(&mut errors, words, min_word_length); + check_no_duplicates(&mut errors, words); + check_no_orphan_letters(&mut errors, grid, min_word_length); + check_obscurity(&mut errors, words, dict, obscurity_budget); + + ValidationResult { errors } +} + +fn check_min_word_length(errors: &mut Vec, words: &[PlacedWord], min_len: usize) { + for w in words { + if w.word.len() < min_len { + errors.push(format!( + "word '{}' is {} letters, minimum is {}", + w.word, + w.word.len(), + min_len + )); + } + } +} + +fn check_no_duplicates(errors: &mut Vec, words: &[PlacedWord]) { + let mut seen = HashSet::new(); + for w in words { + if !seen.insert(&w.word) { + errors.push(format!("duplicate word: '{}'", w.word)); + } + } +} + +fn check_no_orphan_letters(errors: &mut Vec, grid: &Grid, min_len: usize) { + let slots = extract_slots(grid, min_len); + + // build set of all cells covered by slots + let mut covered: HashSet<(usize, usize)> = HashSet::new(); + for slot in &slots { + for cell in slot.cells() { + covered.insert(cell); + } + } + + // every white cell should be covered by at least one slot + for r in 0..grid.height { + for c in 0..grid.width { + if grid.get(r, c).is_white() && !covered.contains(&(r, c)) { + errors.push(format!( + "orphan letter at ({}, {}) - not part of any word", + r, c + )); + } + } + } +} + +fn check_obscurity( + errors: &mut Vec, + words: &[PlacedWord], + dict: &Dictionary, + budget: f64, +) { + if words.is_empty() { + return; + } + + let obscure_count = words + .iter() + .filter(|w| { + // look up frequency in dictionary + let freq = dict + .words + .iter() + .find(|dw| dw.text == w.word) + .map(|dw| dw.frequency) + .unwrap_or(0.0); + freq < 0.3 + }) + .count(); + + let ratio = obscure_count as f64 / words.len() as f64; + if ratio > budget { + errors.push(format!( + "obscurity budget exceeded: {:.0}% obscure words (max {:.0}%)", + ratio * 100.0, + budget * 100.0 + )); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dict::index::WordIndex; + use crate::dict::word::Word; + use crate::grid::Cell; + use crate::grid::slot::Direction; + + fn dummy_dict() -> Dictionary { + let words = vec![ + Word::new("CAT".into(), 0.8), + Word::new("DOG".into(), 0.8), + Word::new("BAT".into(), 0.7), + Word::new("HAT".into(), 0.7), + Word::new("RAT".into(), 0.7), + Word::new("XYZQ".into(), 0.1), + ]; + let index = WordIndex::build(&words); + Dictionary { words, index } + } + + fn placed(word: &str, row: usize, col: usize, dir: Direction) -> PlacedWord { + PlacedWord { + word: word.to_string(), + row, + col, + direction: dir, + clue_number: 1, + } + } + + #[test] + fn valid_puzzle_passes() { + let dict = dummy_dict(); + let words = vec![ + placed("CAT", 0, 0, Direction::Across), + placed("DOG", 1, 0, Direction::Across), + ]; + let grid = Grid::new(3, 2); + let result = validate_puzzle(&grid, &words, &dict, 3, 0.2); + assert!(result.is_valid()); + } + + #[test] + fn catches_short_words() { + let dict = dummy_dict(); + let words = vec![placed("AB", 0, 0, Direction::Across)]; + let grid = Grid::new(2, 1); + let result = validate_puzzle(&grid, &words, &dict, 3, 0.2); + assert!(!result.is_valid()); + assert!(result.errors[0].contains("minimum is 3")); + } + + #[test] + fn catches_duplicates() { + let dict = dummy_dict(); + let words = vec![ + placed("CAT", 0, 0, Direction::Across), + placed("CAT", 1, 0, Direction::Across), + ]; + let grid = Grid::new(3, 2); + let result = validate_puzzle(&grid, &words, &dict, 3, 0.2); + assert!(!result.is_valid()); + assert!(result.errors[0].contains("duplicate")); + } + + #[test] + fn catches_orphan_letters() { + let mut grid = Grid::new(3, 3); + // create a grid where position (1,1) is white but isolated by blacks around it + // but the runs through it are too short + grid.set(0, 1, Cell::Black); + grid.set(1, 0, Cell::Black); + grid.set(1, 2, Cell::Black); + grid.set(2, 1, Cell::Black); + // (1,1) is white but has no run >= 3 in any direction + + let dict = dummy_dict(); + let result = validate_puzzle(&grid, &[], &dict, 3, 0.2); + assert!(!result.is_valid()); + assert!(result.errors.iter().any(|e| e.contains("orphan"))); + } +}