csp_solver/solver/ac3.rs
1//! AC-3 worklist propagation using the adjacency graph.
2//!
3//! Uses a bitset worklist for lower overhead than VecDeque + Vec<bool>.
4
5use crate::adjacency::Adjacency;
6use crate::bitscan::pop_lowest_bit;
7use crate::constraint::{ConstraintEnum, Revision, VarId};
8use crate::domain::Domain;
9use crate::solver::Trail;
10use crate::variable::Variable;
11use crate::{SolveStats, Unsatisfiable};
12
13/// A simple bitset worklist — O(1) insert/membership, O(words) scan for next.
14///
15/// Sized once (to the constraint count) and reused as scratch across an entire
16/// search — the kernel's `Kernel` (and the `propagate_with`/`solve_with_given`
17/// root calls) own one instance and every candidate-value attempt calls
18/// `clear()`/`fill_full()` (an O(words) sweep, no allocation) instead of
19/// constructing a fresh `Vec<u64>` per call (Pass-1 propagation audit, P2-2).
20pub struct BitsetWorklist {
21 words: Vec<u64>,
22 /// Constraint count this worklist was sized for — needed by `fill_full`
23 /// to mask off the unused high bits of the last word.
24 capacity: usize,
25}
26
27impl BitsetWorklist {
28 pub fn new(capacity: usize) -> Self {
29 Self {
30 words: vec![0; capacity.div_ceil(64)],
31 capacity,
32 }
33 }
34
35 /// Zero every word — O(words), no allocation. Used before reseeding from a
36 /// single variable's incident constraints.
37 fn clear(&mut self) {
38 self.words.fill(0);
39 }
40
41 /// Set every constraint index in `0..capacity` — O(words), no allocation.
42 /// Used before a full sweep (`ac3_full`).
43 fn fill_full(&mut self) {
44 self.words.fill(u64::MAX);
45 let remainder = self.capacity % 64;
46 if remainder != 0
47 && let Some(last) = self.words.last_mut()
48 {
49 *last = (1u64 << remainder) - 1;
50 }
51 }
52
53 fn insert(&mut self, idx: usize) {
54 self.words[idx / 64] |= 1u64 << (idx % 64);
55 }
56
57 fn contains(&self, idx: usize) -> bool {
58 self.words[idx / 64] & (1u64 << (idx % 64)) != 0
59 }
60
61 fn pop(&mut self) -> Option<usize> {
62 for (wi, word) in self.words.iter_mut().enumerate() {
63 if let Some(bit) = pop_lowest_bit(word) {
64 return Some(wi * 64 + bit);
65 }
66 }
67 None
68 }
69}
70
71/// Run AC-3 propagation over all constraints.
72///
73/// Returns `Err(Unsatisfiable)` if a domain wipe-out is detected. `worklist` is
74/// caller-owned reusable scratch (see `BitsetWorklist`) — reset here via
75/// `fill_full()` rather than freshly allocated.
76pub fn ac3_full<D: Domain>(
77 variables: &mut [Variable<D>],
78 constraints: &[ConstraintEnum<D>],
79 adjacency: &Adjacency,
80 stats: &mut SolveStats,
81 worklist: &mut BitsetWorklist,
82 depth: usize,
83) -> Result<(), Unsatisfiable>
84where
85 D::Value: PartialEq + 'static,
86{
87 worklist.fill_full();
88
89 while let Some(idx) = worklist.pop() {
90 match constraints[idx].revise(variables, depth) {
91 Revision::Unchanged => {}
92 Revision::Changed => {
93 stats.propagations += 1;
94 for &neighbor in adjacency.neighbors_of_constraint(idx) {
95 worklist.insert(neighbor as usize);
96 }
97 }
98 Revision::Unsatisfiable => return Err(Unsatisfiable),
99 }
100 }
101
102 Ok(())
103}
104
105/// Run AC-3 propagation seeded from a single variable assignment (MAC).
106///
107/// Every constraint that revises records its scope on `trail` — a superset of
108/// the variables actually pruned (scopes are small), so backtrack undoes only
109/// touched variables instead of sweeping all of them.
110///
111/// Returns `Some(ci)` if constraint `ci`'s revision wiped out a domain (the
112/// "blame" signal for conflict-history weighting), `None` on success.
113///
114/// `worklist` is caller-owned reusable scratch — reset here via `clear()`
115/// rather than freshly allocated on every candidate-value attempt (P2-2).
116#[allow(clippy::too_many_arguments)]
117pub(crate) fn ac3_from_variable<D: Domain>(
118 var: VarId,
119 variables: &mut [Variable<D>],
120 constraints: &[ConstraintEnum<D>],
121 adjacency: &Adjacency,
122 assignment: &[Option<D::Value>],
123 stats: &mut SolveStats,
124 trail: &mut Trail,
125 worklist: &mut BitsetWorklist,
126 depth: usize,
127) -> Option<usize>
128where
129 D::Value: PartialEq + 'static,
130{
131 worklist.clear();
132
133 for &ci in adjacency.constraints_for(var) {
134 let ci = ci as usize;
135 let scope = constraints[ci].scope();
136 if scope
137 .iter()
138 .any(|&v| v != var && assignment[v as usize].is_none())
139 {
140 worklist.insert(ci);
141 }
142 }
143
144 while let Some(idx) = worklist.pop() {
145 match constraints[idx].revise(variables, depth) {
146 Revision::Unchanged => {}
147 Revision::Changed => {
148 stats.propagations += 1;
149 // Record the whole scope before any early return: revise() may
150 // have pruned several scope vars, and the trail must hold all of
151 // them so backtrack restores every one.
152 let scope = constraints[idx].scope();
153 for &v in scope {
154 trail.push(v);
155 }
156 if scope
157 .iter()
158 .any(|&v| variables[v as usize].domain.is_empty())
159 {
160 return Some(idx);
161 }
162 for &neighbor in adjacency.neighbors_of_constraint(idx) {
163 let n = neighbor as usize;
164 if !worklist.contains(n) {
165 let scope = constraints[n].scope();
166 if scope.iter().any(|&v| assignment[v as usize].is_none()) {
167 worklist.insert(n);
168 }
169 }
170 }
171 }
172 Revision::Unsatisfiable => {
173 // revise() may prune several scope vars before detecting the
174 // wipe-out (e.g. AllDifferent's singleton-removal loop empties
175 // a peer mid-iteration, after real prunes to earlier peers).
176 // Those prunes live on each Variable's own depth-keyed undo log
177 // but must also be on the external Trail, or Trail::undo_to —
178 // which only restores variables it was told were touched —
179 // leaks them permanently into sibling branches. Record the
180 // whole scope before returning, exactly as the Changed arm does.
181 let scope = constraints[idx].scope();
182 for &v in scope {
183 trail.push(v);
184 }
185 return Some(idx);
186 }
187 }
188 }
189
190 None
191}