grid validation
This commit is contained in:
@@ -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")));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user