Skip to main content

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}