Skip to main content

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::bitscan::pop_lowest_bit;
6use crate::constraint::{ConstraintEnum, Revision, VarId};
7use crate::domain::Domain;
8use crate::solver::Trail;
9use crate::solver::adjacency::Adjacency;
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(crate) 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(crate) 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(crate) 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}