fill constraint propagation

This commit is contained in:
2025-05-24 22:58:00 +03:00
parent 5a5e16dbd3
commit 038cc36b78
+530
View File
@@ -0,0 +1,530 @@
use std::collections::VecDeque;
use std::sync::atomic::Ordering;
use std::time::Instant;
use rand::rngs::StdRng;
use rand::Rng;
use rand::SeedableRng;
use crate::config::GenerationConfig;
use crate::dict::Dictionary;
use crate::error::{CrucivError, Result};
use crate::grid::slot::Slot;
use crate::grid::Grid;
use super::state::SolverState;
pub struct SolveResult {
pub grid: Grid,
pub word_assignments: Vec<(usize, usize)>,
}
pub fn solve(
grid: &Grid,
slots: &[Slot],
dict: &Dictionary,
config: &GenerationConfig,
) -> Result<SolveResult> {
let start = Instant::now();
let timeout = std::time::Duration::from_secs(config.timeout_seconds);
let mut rng = match config.seed {
Some(s) => StdRng::seed_from_u64(s),
None => StdRng::from_os_rng(),
};
let total_restarts = config.max_restarts.max(slots.len());
let bt_budget = (config.max_backtrack as u64).max(slots.len() as u64 * 2000);
for restart in 0..total_restarts {
if start.elapsed() > timeout {
return Err(CrucivError::Timeout(config.timeout_seconds));
}
if let Some(ref flag) = config.cancelled {
if flag.load(Ordering::Relaxed) {
return Err(CrucivError::Generation("cancelled".into()));
}
}
let mut state = SolverState::new(slots, grid.width, grid.height, dict.word_count());
state.init_domains(slots, &dict.index);
// run initial domain propagation to detect infeasible cells early
if !propagate_all_domains(&mut state, slots, dict) {
tracing::debug!(
"restart {}: initial domain propagation found infeasible cell",
restart + 1
);
continue;
}
let mut bt = 0u64;
let rand_top = config.rand_top_base + restart.min(5);
let result = backtrack(
&mut state, slots, dict, config, &start, timeout, &mut rng, &mut bt, rand_top,
bt_budget,
);
if let Some(assignments) = result {
let mut filled = grid.clone();
for &(si, wi) in &assignments {
let word = &dict.get(wi).text;
for (pos, (r, c)) in slots[si].cells().iter().enumerate() {
filled.set(
*r,
*c,
crate::grid::Cell::Letter(word.as_bytes()[pos] as char),
);
}
}
return Ok(SolveResult {
grid: filled,
word_assignments: assignments,
});
}
tracing::debug!("restart {} failed ({} backtracks)", restart + 1, bt);
}
Err(CrucivError::Generation(format!(
"failed to fill grid after {} restarts",
total_restarts
)))
}
fn backtrack(
state: &mut SolverState,
slots: &[Slot],
dict: &Dictionary,
config: &GenerationConfig,
start: &Instant,
timeout: std::time::Duration,
rng: &mut StdRng,
bt: &mut u64,
rand_top: usize,
bt_budget: u64,
) -> Option<Vec<(usize, usize)>> {
if *bt > bt_budget {
return None;
}
if *bt % 512 == 0 {
if start.elapsed() > timeout {
return None;
}
if let Some(ref flag) = config.cancelled {
if flag.load(Ordering::Relaxed) {
return None;
}
}
}
let slot_idx = match pick_slot(state, slots, dict) {
Some(i) => i,
None => {
return Some(
state
.assignments
.iter()
.enumerate()
.filter_map(|(i, a)| a.map(|w| (i, w)))
.collect(),
);
}
};
let slot = &slots[slot_idx];
let constraints = state.current_constraints(slot);
let all_candidates =
dict.candidates_filtered(slot.length, &constraints, config.frequency_threshold);
// score and filter candidates
let mut scored: Vec<(usize, f64)> = all_candidates
.iter()
.filter(|&&w| !state.is_used(w))
.map(|&w| {
let word = dict.get(w);
let mut s = word.frequency;
let bytes = word.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;
}
}
s += (unique as f64 / bytes.len() as f64) * 0.1;
// penalize recently used words so the solver picks fresh ones
if let Some(ref staleness) = config.staleness {
if let Some(&penalty) = staleness.get(&word.text) {
s *= penalty;
}
}
(w, s)
})
.collect();
if scored.is_empty() {
return None;
}
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
// randomize top candidates
let top_n = scored.len().min(rand_top);
if top_n > 1 {
for i in 0..top_n - 1 {
let j = rng.random_range(i..top_n);
scored.swap(i, j);
}
}
scored.truncate(24);
// filter out words related to already-placed words
let placed_texts: Vec<&str> = state
.assignments
.iter()
.filter_map(|a| a.map(|wi| dict.get(wi).text.as_str()))
.collect();
if !placed_texts.is_empty() {
scored.retain(|(w, _)| {
let text = &dict.get(*w).text;
!placed_texts
.iter()
.any(|p| super::candidate::is_related(text, p))
});
}
// LCV ordering
if scored.len() > 1 && scored.len() <= 20 && slots[slot_idx].intersections.len() >= 2 {
lcv_sort(&mut scored, slot_idx, state, slots, dict);
}
let max_try = scored.len().min(16);
for i in 0..max_try {
let (word_idx, _) = scored[i];
let word_bytes: Vec<u8> = dict.get(word_idx).text.bytes().collect();
// save state for undo
let cells = slot.cells();
let mut prev_letters: Vec<(usize, usize, Option<u8>)> = Vec::with_capacity(cells.len());
let mut prev_domains: Vec<(usize, usize, u32)> = Vec::new();
for (pos, &(r, c)) in cells.iter().enumerate() {
prev_letters.push((r, c, state.get_letter(r, c)));
state.set_letter(r, c, word_bytes[pos]);
}
state.assignments[slot_idx] = Some(word_idx);
state.mark_used(word_idx);
// AC-3 propagation with domain narrowing
let ac3_ok = ac3_propagate(state, slots, dict, slot_idx, &mut prev_domains);
if ac3_ok {
if let Some(result) = backtrack(
state, slots, dict, config, start, timeout, rng, bt, rand_top, bt_budget,
) {
return Some(result);
}
}
*bt += 1;
// undo
state.assignments[slot_idx] = None;
state.unmark_used(word_idx);
for &(r, c, p) in &prev_letters {
match p {
Some(l) => state.set_letter(r, c, l),
None => state.clear_letter(r, c),
}
}
for &(r, c, d) in &prev_domains {
state.set_domain(r, c, d);
}
}
None
}
/// Compute the set of letters possible at a given position in a slot,
/// given the current constraints. Returns a 26-bit mask.
fn compute_slot_domain_at(state: &SolverState, slot: &Slot, pos: usize, dict: &Dictionary) -> u32 {
let constraints = state.current_constraints(slot);
let len = slot.length;
let chunks = match dict.index.chunk_count(len) {
Some(c) => c,
None => return 0,
};
let n = dict.index.count_of_length(len);
if n == 0 {
return 0;
}
// build intersection bitset for all existing constraints
let mut result = vec![u64::MAX; chunks];
let last_bits = n % 64;
if last_bits > 0 {
result[chunks - 1] = (1u64 << last_bits) - 1;
}
for &(cpos, letter) in &constraints {
if cpos == pos {
continue;
} // skip the position we're computing domain for
if let Some(bits) = dict.index.get_letter_bits(len, cpos, letter) {
for (i, chunk) in result.iter_mut().enumerate() {
*chunk &= bits[i];
}
} else {
return 0;
}
}
// now union all letters that appear at `pos` across remaining candidates
let mut domain = 0u32;
for letter in 0u8..26 {
if let Some(bits) = dict.index.get_letter_bits(len, pos, b'A' + letter) {
for (i, &chunk) in result.iter().enumerate() {
if chunk & bits[i] != 0 {
domain |= 1u32 << letter;
break;
}
}
}
}
domain
}
/// Full domain propagation for all unassigned cells.
/// Returns false if any cell has an empty domain.
fn propagate_all_domains(state: &mut SolverState, slots: &[Slot], dict: &Dictionary) -> bool {
let mut changed = true;
while changed {
changed = false;
for slot in slots {
if state.assignments[slot.index].is_some() {
continue;
}
for (pos, &(r, c)) in slot.cells().iter().enumerate() {
if state.get_letter(r, c).is_some() {
continue;
}
let new_domain = compute_slot_domain_at(state, slot, pos, dict);
let old_domain = state.get_domain(r, c);
let narrowed = old_domain & new_domain;
if narrowed != old_domain {
if narrowed == 0 {
return false;
}
state.set_domain(r, c, narrowed);
changed = true;
}
}
}
}
true
}
/// AC-3 propagation after placing a word.
/// Checks crossing slots and narrows cell domains.
/// Saves changed domains for undo.
fn ac3_propagate(
state: &mut SolverState,
slots: &[Slot],
dict: &Dictionary,
placed: usize,
prev_domains: &mut Vec<(usize, usize, u32)>,
) -> bool {
let mut queue: VecDeque<usize> = VecDeque::new();
let mut checked = vec![false; slots.len()];
checked[placed] = true;
for ix in &slots[placed].intersections {
if state.assignments[ix.other_slot].is_none() {
queue.push_back(ix.other_slot);
}
}
while let Some(si) = queue.pop_front() {
if checked[si] || state.assignments[si].is_some() {
continue;
}
checked[si] = true;
let slot = &slots[si];
let constraints = state.current_constraints(slot);
let count = dict.index.candidate_count(slot.length, &constraints);
if count == 0 {
return false;
}
// narrow cell domains for unconstrained positions in this slot
let mut domain_changed = false;
for (pos, &(r, c)) in slot.cells().iter().enumerate() {
if state.get_letter(r, c).is_some() {
continue;
}
let new_domain = compute_slot_domain_at(state, slot, pos, dict);
let old_domain = state.get_domain(r, c);
let narrowed = old_domain & new_domain;
if narrowed == 0 {
return false;
}
if narrowed != old_domain {
prev_domains.push((r, c, old_domain));
state.set_domain(r, c, narrowed);
domain_changed = true;
}
}
// if domains changed, propagate to crossings
if domain_changed || count <= 20 {
for ix in &slot.intersections {
if !checked[ix.other_slot] && state.assignments[ix.other_slot].is_none() {
queue.push_back(ix.other_slot);
}
}
}
}
true
}
// Full MRV: check candidate_count for ALL unassigned slots.
// Tiebreak by degree (number of unassigned crossings).
fn pick_slot(state: &SolverState, slots: &[Slot], dict: &Dictionary) -> Option<usize> {
let mut best_idx = None;
let mut best_count = usize::MAX;
let mut best_degree = 0usize;
for (i, a) in state.assignments.iter().enumerate() {
if a.is_some() {
continue;
}
let slot = &slots[i];
let constraints = state.current_constraints(slot);
let count = dict.index.candidate_count(slot.length, &constraints);
if count == 0 {
return Some(i); // force failure fast
}
let degree = slot
.intersections
.iter()
.filter(|ix| state.assignments[ix.other_slot].is_none())
.count();
if count < best_count || (count == best_count && degree > best_degree) {
best_count = count;
best_degree = degree;
best_idx = Some(i);
}
}
best_idx
}
// LCV: sort candidates by how much freedom they leave in crossing slots.
fn lcv_sort(
scored: &mut Vec<(usize, f64)>,
slot_idx: usize,
state: &SolverState,
slots: &[Slot],
dict: &Dictionary,
) {
let slot = &slots[slot_idx];
let crossings: Vec<_> = slot
.intersections
.iter()
.filter(|ix| state.assignments[ix.other_slot].is_none())
.collect();
if crossings.is_empty() {
return;
}
let mut freedom: Vec<(usize, f64, i64)> = scored
.iter()
.map(|&(wi, score)| {
let word = dict.get(wi);
let bytes = word.text.as_bytes();
let mut total_freedom = 0i64;
for ix in &crossings {
let letter = bytes[ix.self_pos];
let other = &slots[ix.other_slot];
let mut other_constraints = state.current_constraints(other);
other_constraints.push((ix.other_pos, letter));
let count = dict.index.candidate_count(other.length, &other_constraints);
total_freedom += count as i64;
}
(wi, score, total_freedom)
})
.collect();
freedom.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.partial_cmp(&a.1).unwrap()));
*scored = freedom.into_iter().map(|(wi, s, _)| (wi, s)).collect();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::grid::slot::extract_slots;
use crate::grid::Cell;
#[test]
fn solve_5x5_real_dict() {
use std::path::Path;
let path = Path::new("../data/wordlists/enable.txt");
if !path.exists() {
return;
}
let dict = Dictionary::load(path, None, 3).unwrap();
let grid = Grid::new(5, 5);
let slots = extract_slots(&grid, 3);
let config = GenerationConfig {
width: 5,
height: 5,
timeout_seconds: 10,
frequency_threshold: 0.0,
max_restarts: 5,
seed: Some(42),
..Default::default()
};
let result = solve(&grid, &slots, &dict, &config);
assert!(result.is_ok());
}
#[test]
fn solve_with_real_dict() {
use std::path::Path;
let path = Path::new("../data/wordlists/enable.txt");
if !path.exists() {
return;
}
let dict = Dictionary::load(path, None, 3).unwrap();
let mut grid = Grid::new(5, 5);
grid.set(2, 2, Cell::Black);
let slots = extract_slots(&grid, 3);
let config = GenerationConfig {
width: 5,
height: 5,
timeout_seconds: 10,
frequency_threshold: 0.0,
max_restarts: 5,
seed: Some(42),
..Default::default()
};
let result = solve(&grid, &slots, &dict, &config);
assert!(result.is_ok());
}
}