csp_solver/constraint/traits.rs
1//! Core constraint trait and supporting types.
2
3use std::fmt::Debug;
4
5use crate::domain::Domain;
6use crate::variable::Variable;
7
8/// Unique variable identifier (index into the variable array).
9pub type VarId = u32;
10
11/// Result of running `revise` on a constraint.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Revision {
14 Unchanged,
15 Changed,
16 Unsatisfiable,
17}
18
19/// Thread-safety marker for `Constraint`, scoped to the `py` feature.
20///
21/// The `pyo3::Python::allow_threads` boundary — compiled **only** under the
22/// `py` feature — releases the GIL and runs the solver off-thread, which
23/// requires the whole `Csp<D>` (and therefore every `Box<dyn Constraint<D>>`
24/// it holds) to be `Send`. That requirement is real *only* at the py boundary.
25///
26/// Making `Send + Sync` an unconditional supertrait of `Constraint` (the prior
27/// shape) rejected `!Send`/`!Sync` external implementors — concretely
28/// bbnf-lang's `RefConstraint`, which holds an `Rc<RefCell<…>>` obligation
29/// sink (E0277). The old "non-breaking, purely additive" claim was therefore
30/// false: it broke a real downstream consumer.
31///
32/// This marker carries `Send + Sync` **under `py`** and is **vacuous
33/// otherwise**, so:
34/// * `--features py` → `dyn Constraint<D>: Send + Sync` transitively via the
35/// supertrait chain, hence `Csp<D>: Send` and `allow_threads` compiles; a
36/// `!Send` constraint is rejected loudly at its `impl` site (fail-explicit,
37/// never a silent downgrade).
38/// * default build (bbnf's) → the bound is inert; `RefConstraint` and every
39/// other lattice/reference constraint compiles unchanged, zero bbnf edits.
40///
41/// The two configurations are kept honest by the sync gate, which compiles
42/// BOTH (`--features py` and the default). See `scripts/sync-csp-solver-vendor`.
43#[cfg(feature = "py")]
44pub trait ThreadSafe: Send + Sync {}
45#[cfg(feature = "py")]
46impl<T: Send + Sync> ThreadSafe for T {}
47#[cfg(not(feature = "py"))]
48pub trait ThreadSafe {}
49#[cfg(not(feature = "py"))]
50impl<T> ThreadSafe for T {}
51
52/// A constraint over one or more CSP variables.
53pub trait Constraint<D: Domain>: Debug + ThreadSafe {
54 /// The variables this constraint involves (its "scope").
55 fn scope(&self) -> &[VarId];
56
57 /// Check whether a full or partial assignment satisfies this constraint.
58 fn check(&self, assignment: &[Option<D::Value>]) -> bool;
59
60 /// AC-3 style revision: prune unsupported values from domains.
61 ///
62 /// Dispatches on scope size to the module-private `revise_unary_default`
63 /// / `revise_binary_default` helpers below (n-ary constraints get their
64 /// own devirtualized `revise_impl` — `AllDifferent`,
65 /// `AllDifferentExcept` — the default here only needs to cover what
66 /// `LambdaConstraint`/`Custom` actually construct: `add_equals` is
67 /// unary, `add_less_than`/`add_greater_than` are binary).
68 ///
69 /// Previously scope.len() == 1 unconditionally returned `Unchanged` —
70 /// silently disabling propagation for every unary constraint
71 /// (`add_equals`), which was then enforced only by `check()` at
72 /// assignment time (Pass-1 propagation audit, P2-1). Fixed below.
73 fn revise(&self, vars: &mut [Variable<D>], depth: usize) -> Revision {
74 match self.scope().len() {
75 1 => revise_unary_default(self, vars, depth),
76 2 => revise_binary_default(self, vars, depth),
77 _ => Revision::Unchanged,
78 }
79 }
80}
81
82/// Default unary revision: prune every domain value unsupported by
83/// `check()` in isolation. Iterates the domain's owned snapshot directly
84/// (`Domain::iter`'s `+ use<Self>` bound), no heap `Vec`. A free function
85/// (not a trait method) so it doesn't grow `Constraint`'s public surface —
86/// it's purely `revise`'s own dispatch target.
87///
88/// `changed` folds in [`Variable::prune`]'s bool: a pruned value that was
89/// already absent (or that the domain refuses to remove) does not count as
90/// a revision. See `revise_binary_default` for why this honesty is
91/// load-bearing rather than cosmetic.
92fn revise_unary_default<D: Domain, C: Constraint<D> + ?Sized>(
93 c: &C,
94 vars: &mut [Variable<D>],
95 depth: usize,
96) -> Revision {
97 let xi = c.scope()[0] as usize;
98 let mut assignment: Vec<Option<D::Value>> = vec![None; vars.len()];
99 let mut changed = false;
100
101 for vi in vars[xi].domain.iter() {
102 assignment[xi] = Some(vi.clone());
103 if !c.check(&assignment) {
104 changed |= vars[xi].prune(&vi, depth);
105 }
106 }
107
108 if vars[xi].domain.is_empty() {
109 return Revision::Unsatisfiable;
110 }
111
112 if changed {
113 Revision::Changed
114 } else {
115 Revision::Unchanged
116 }
117}
118
119/// Default binary revision: the "change-mask" rewrite of the previous
120/// 5-`Vec` implementation (one `vars.len()`-sized `assignment`, plus four
121/// `domain.values()` snapshots — `vals_i`/`vals_j` each collected twice,
122/// once per direction pass, per the Pass-1 constraint audit's F5).
123///
124/// The `assignment` scratch is kept (its size is dictated by
125/// `Constraint::check`'s existing `&[Option<D::Value>]` contract, indexed
126/// by absolute `VarId` — narrowing it would be a breaking signature change
127/// reaching every hand-rolled `revise`/`check` in bbnf-lang's 11+ `Custom`
128/// constraint types). The four domain snapshots are eliminated: each pass
129/// calls `domain.iter()` fresh inside the loop instead of pre-collecting
130/// into a `Vec` — for `BitsetDomain` (the crate's only production domain)
131/// re-deriving the iterator is a cheap `u128` copy, not a re-scan, so this
132/// costs nothing extra while dropping 4 allocations to 0. The second
133/// pass's `vals_i` must still reflect pass 1's pruning (correctness-
134/// critical: pass 1 may have narrowed `xi`), which a live
135/// `vars[xi].domain.iter()` gives for free — no explicit re-collection
136/// needed.
137///
138/// # Reporting `Changed` honestly (the lattice-hang repair)
139///
140/// `changed` folds in [`Variable::prune`]'s bool return rather than being
141/// set unconditionally in the unsupported branch. On a finite domain this
142/// is behavior-neutral — the enumerate loop only ever prunes a value the
143/// domain currently holds, so `prune` always removes it and returns
144/// `true`. On a **lattice** domain it is the difference between converging
145/// and hanging: `BitsetLatticeDomain::remove` is a no-op (lattice values
146/// only grow via `join`, never shrink), so every `prune` returns `false`
147/// and this arc-consistency revise legitimately changes nothing. The old
148/// unconditional `changed = true` reported `Revision::Changed` while
149/// pruning nothing — a lie that made AC-3 (and the monotonic sweep)
150/// re-enqueue the same arcs forever on a disjoint-seed lattice chain (W1
151/// bench-attribution F1). Honoring the bool lets the worklist drain.
152/// A check-based constraint cannot *grow* a lattice domain regardless;
153/// genuine lattice propagators supply their own join-based `revise`.
154fn revise_binary_default<D: Domain, C: Constraint<D> + ?Sized>(
155 c: &C,
156 vars: &mut [Variable<D>],
157 depth: usize,
158) -> Revision {
159 let scope = c.scope();
160 let xi = scope[0] as usize;
161 let xj = scope[1] as usize;
162 let mut changed = false;
163
164 let mut assignment: Vec<Option<D::Value>> = vec![None; vars.len()];
165
166 for vi in vars[xi].domain.iter() {
167 let mut supported = false;
168 assignment[xi] = Some(vi.clone());
169 for vj in vars[xj].domain.iter() {
170 assignment[xj] = Some(vj);
171 if c.check(&assignment) {
172 supported = true;
173 break;
174 }
175 }
176 if !supported {
177 changed |= vars[xi].prune(&vi, depth);
178 }
179 }
180 assignment[xi] = None;
181 assignment[xj] = None;
182
183 if vars[xi].domain.is_empty() {
184 return Revision::Unsatisfiable;
185 }
186
187 for vj in vars[xj].domain.iter() {
188 let mut supported = false;
189 assignment[xj] = Some(vj.clone());
190 for vi in vars[xi].domain.iter() {
191 assignment[xi] = Some(vi);
192 if c.check(&assignment) {
193 supported = true;
194 break;
195 }
196 }
197 if !supported {
198 changed |= vars[xj].prune(&vj, depth);
199 }
200 }
201
202 if vars[xj].domain.is_empty() {
203 return Revision::Unsatisfiable;
204 }
205
206 if changed {
207 Revision::Changed
208 } else {
209 Revision::Unchanged
210 }
211}