grid validation

This commit is contained in:
2025-05-13 23:32:38 +03:00
parent 55748b4bd7
commit e80d48db61
+197
View File
@@ -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<String>,
}
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<String>, 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<String>, 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<String>, 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<String>,
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")));
}
}