fill constraint propagation
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user