Skip to main content

csp_solver/constraint/
cage.rs

1//! N-ary arithmetic-cage constraints: [`CageSum`] and [`CageProduct`].
2//!
3//! These are the two `revise_impl`s that clear the engine's **n-ary-lambda
4//! blindness wall** (`constraint/traits.rs`: the default `revise` returns
5//! [`Revision::Unchanged`] for any scope of 3+ variables, so a cage modelled as
6//! a [`LambdaConstraint`](super::lambda::LambdaConstraint) is consulted only by
7//! `check()` at assignment time and prunes *nothing*). A Killer cage (sum) or a
8//! KenKen `×` cage (product) modelled as a lambda therefore searches blind past
9//! the AllDifferent GAC. Modelled as one of these devirtualized
10//! [`ConstraintEnum`](super::dispatch::ConstraintEnum) variants, the cage's
11//! bounds-propagation `revise_impl` tightens each cell's domain by the residual
12//! target — the node-count drop banked in `tests/cage.rs`.
13//!
14//! Both are **bounds-consistency** propagators (in the `AllDifferent` /
15//! `NotEqual` mold — a real `revise_impl`, never an n-ary lambda): they prune a
16//! value only when the residual target cannot be met with any choice of the
17//! other cells' current bounds. Bounds consistency is *sound* (it never removes
18//! a value that participates in a full solution) but not domain-complete — the
19//! differential oracle (`tests/cage.rs`, and the revise-level randomized oracle
20//! below) is the born-RED guard on exactly that soundness property.
21//!
22//! ## The value seam
23//!
24//! [`ConstraintEnum`] is generic over the domain `D`, and its value type ranges
25//! over `u32` (the production [`BitsetDomain`](crate::domain::BitsetDomain)),
26//! `i32`, `String`, lattice `BitsetDomain`, … — cage arithmetic is meaningful
27//! only for the integer bitset domain. Rather than pin a numeric trait bound on
28//! the shared enum (which would reject every non-numeric domain the engine
29//! already serves), each cage carries its integer reading as **data**: a
30//! `fn(&V) -> `[`CageInt`] pointer, set by the `u32` constructor. The generic
31//! `revise_impl`/`check_impl` then compile for every `V` and do real arithmetic
32//! for the one value type a cage is ever built over. No enum bound changes; the
33//! variant is simply never constructed for a non-integer domain.
34
35use crate::domain::Domain;
36use crate::variable::Variable;
37
38use super::traits::{Revision, VarId};
39
40/// Signed accumulator for cage arithmetic. `i64` (not `i128`) deliberately:
41/// wasm32 has native 64-bit mul/div/rem but emulates 128-bit in software
42/// (`__multi3`/`__udivti3`/`__umodti3` — kilobytes of compiler-rt that land in
43/// the lean build), and 64 bits is ample. A Killer sum tops out near 1 143
44/// (9 cells × 127); a KenKen product near 9⁹ ≈ 3.8·10⁸ — both dwarfed by
45/// `i64::MAX` (≈ 9.2·10¹⁸). The product path still saturates every multiply so
46/// a pathological scope degrades to a sound "no upper prune" rather than wrap.
47type CageInt = i64;
48
49/// Integer `(min, max, has_zero)` of a domain, or `None` when it is empty
50/// (a wipe-out the caller reports as [`Revision::Unsatisfiable`]).
51fn cell_bounds<D: Domain>(
52    dom: &D,
53    to_int: fn(&D::Value) -> CageInt,
54) -> Option<(CageInt, CageInt, bool)> {
55    let mut lo = CageInt::MAX;
56    let mut hi = CageInt::MIN;
57    let mut has_zero = false;
58    for val in dom.iter() {
59        let x = to_int(&val);
60        lo = lo.min(x);
61        hi = hi.max(x);
62        has_zero |= x == 0;
63    }
64    if hi < lo {
65        None
66    } else {
67        Some((lo, hi, has_zero))
68    }
69}
70
71// ===========================================================================
72// CageSum — Σ scope == target
73// ===========================================================================
74
75/// N-ary cage-sum: the scope's values sum to `target`.
76///
77/// Serves Killer cages and the `+` KenKen cages. `revise_impl` is bounds
78/// consistency for the linear equality `Σ xᵢ == target`: each cell is pinned to
79/// `[target − Σ(others' max), target − Σ(others' min)]`, iterated to an internal
80/// fixpoint because pruning one cell tightens the residual for the rest.
81pub struct CageSum<V> {
82    pub(crate) scope: Vec<VarId>,
83    target: CageInt,
84    to_int: fn(&V) -> CageInt,
85}
86
87impl<V> std::fmt::Debug for CageSum<V> {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        write!(f, "CageSum(target={}, {:?})", self.target, self.scope)
90    }
91}
92
93impl CageSum<u32> {
94    /// Cage-sum over `scope` summing to `target`, for the production
95    /// [`BitsetDomain`](crate::domain::BitsetDomain) (values are `u32`).
96    pub fn new(scope: Vec<VarId>, target: u32) -> Self {
97        Self {
98            scope,
99            target: target as CageInt,
100            to_int: |v| *v as CageInt,
101        }
102    }
103}
104
105impl<V> CageSum<V> {
106    /// Satisfied iff the fully-assigned scope sums to `target`. A partial scope
107    /// passes — the search kernel calls `check` only once every scope variable
108    /// is bound, and propagation (`revise_impl`) owns partial pruning.
109    pub(crate) fn check_impl(&self, assignment: &[Option<V>]) -> bool {
110        let mut sum: CageInt = 0;
111        for &v in &self.scope {
112            match &assignment[v as usize] {
113                Some(val) => sum += (self.to_int)(val),
114                None => return true,
115            }
116        }
117        sum == self.target
118    }
119
120    pub(crate) fn revise_impl<D: Domain<Value = V>>(
121        &self,
122        vars: &mut [Variable<D>],
123        depth: usize,
124    ) -> Revision {
125        let mut changed = false;
126        loop {
127            // Running integer totals across the whole scope.
128            let mut s_min: CageInt = 0;
129            let mut s_max: CageInt = 0;
130            for &v in &self.scope {
131                match cell_bounds(&vars[v as usize].domain, self.to_int) {
132                    Some((lo, hi, _)) => {
133                        s_min += lo;
134                        s_max += hi;
135                    }
136                    None => return Revision::Unsatisfiable,
137                }
138            }
139
140            let mut pass_changed = false;
141            for &v in &self.scope {
142                // This cell's own bounds; the residual others-sum is then
143                // [s_min − lo_i, s_max − hi_i], pinning the cell to
144                // [target − others_max, target − others_min].
145                let (lo_i, hi_i, _) = cell_bounds(&vars[v as usize].domain, self.to_int)
146                    .expect("non-empty: totals pass above would have returned");
147                let allow_lo = self.target - (s_max - hi_i);
148                let allow_hi = self.target - (s_min - lo_i);
149                for val in vars[v as usize].domain.iter() {
150                    let x = (self.to_int)(&val);
151                    if (x < allow_lo || x > allow_hi) && vars[v as usize].prune(&val, depth) {
152                        pass_changed = true;
153                        changed = true;
154                    }
155                }
156                if vars[v as usize].domain.is_empty() {
157                    return Revision::Unsatisfiable;
158                }
159            }
160            if !pass_changed {
161                break;
162            }
163        }
164        if changed {
165            Revision::Changed
166        } else {
167            Revision::Unchanged
168        }
169    }
170}
171
172// ===========================================================================
173// CageProduct — Π scope == target
174// ===========================================================================
175
176/// N-ary cage-product: the scope's values multiply to `target`.
177///
178/// Serves the `×` KenKen cages. `revise_impl` is bounds consistency for the
179/// multiplicative equality `Π xᵢ == target` over non-negative integers, iterated
180/// to an internal fixpoint. For a non-zero target every cell must be non-zero
181/// (a zero factor forces the product to zero), each cell must **divide** the
182/// target, and the required product of the others (`target / xᵢ`) must lie in
183/// `[Π others' min, Π others' max]` — the positive-monotone product bound. The
184/// KenKen invariant (values 1..=n, no zeros) makes these bounds clean; the
185/// zero-target branch is handled soundly for completeness.
186pub struct CageProduct<V> {
187    pub(crate) scope: Vec<VarId>,
188    target: CageInt,
189    to_int: fn(&V) -> CageInt,
190}
191
192impl<V> std::fmt::Debug for CageProduct<V> {
193    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194        write!(f, "CageProduct(target={}, {:?})", self.target, self.scope)
195    }
196}
197
198impl CageProduct<u32> {
199    /// Cage-product over `scope` multiplying to `target`, for the production
200    /// [`BitsetDomain`](crate::domain::BitsetDomain) (values are `u32`).
201    pub fn new(scope: Vec<VarId>, target: u32) -> Self {
202        Self {
203            scope,
204            target: target as CageInt,
205            to_int: |v| *v as CageInt,
206        }
207    }
208}
209
210impl<V> CageProduct<V> {
211    /// Satisfied iff the fully-assigned scope multiplies to `target`. A partial
212    /// scope passes (see [`CageSum::check_impl`]).
213    pub(crate) fn check_impl(&self, assignment: &[Option<V>]) -> bool {
214        let mut prod: CageInt = 1;
215        for &v in &self.scope {
216            match &assignment[v as usize] {
217                Some(val) => prod = prod.saturating_mul((self.to_int)(val)),
218                None => return true,
219            }
220        }
221        prod == self.target
222    }
223
224    pub(crate) fn revise_impl<D: Domain<Value = V>>(
225        &self,
226        vars: &mut [Variable<D>],
227        depth: usize,
228    ) -> Revision {
229        let mut changed = false;
230        loop {
231            // Validity pass: any empty domain is an immediate wipe-out (it also
232            // lets the per-cell peer scans below `unwrap` their `cell_bounds`).
233            for &v in &self.scope {
234                if cell_bounds(&vars[v as usize].domain, self.to_int).is_none() {
235                    return Revision::Unsatisfiable;
236                }
237            }
238
239            let mut pass_changed = false;
240            for (i, &v) in self.scope.iter().enumerate() {
241                if self.target == 0 {
242                    // Product is zero iff some cell is zero. A cell whose peers
243                    // can never be zero is itself forced to zero.
244                    let peers_can_be_zero = self.scope.iter().enumerate().any(|(j, &u)| {
245                        j != i
246                            && cell_bounds(&vars[u as usize].domain, self.to_int)
247                                .is_some_and(|c| c.2)
248                    });
249                    if peers_can_be_zero {
250                        continue;
251                    }
252                    for val in vars[v as usize].domain.iter() {
253                        if (self.to_int)(&val) != 0 && vars[v as usize].prune(&val, depth) {
254                            pass_changed = true;
255                            changed = true;
256                        }
257                    }
258                } else {
259                    // Positive-monotone product bound of the other cells. A peer
260                    // still carrying a zero drags `others_min` to 0 (a sound,
261                    // looser lower bound); it is pruned on its own turn, and the
262                    // fixpoint re-tightens next pass.
263                    let mut others_min: CageInt = 1;
264                    let mut others_max: CageInt = 1;
265                    for (j, &u) in self.scope.iter().enumerate() {
266                        if j == i {
267                            continue;
268                        }
269                        let (lo, hi, _) = cell_bounds(&vars[u as usize].domain, self.to_int)
270                            .expect("non-empty: validity pass above would have returned");
271                        others_min = others_min.saturating_mul(lo);
272                        others_max = others_max.saturating_mul(hi);
273                    }
274                    for val in vars[v as usize].domain.iter() {
275                        let x = (self.to_int)(&val);
276                        // A supported value must be non-zero (a zero factor
277                        // forces a zero product), must divide the target, and
278                        // its required cofactor `target / x` must be reachable
279                        // by the others. `x == 0` short-circuits before the
280                        // modulo, so the divide is never by zero.
281                        let prune = x == 0
282                            || self.target % x != 0
283                            || !(others_min..=others_max).contains(&(self.target / x));
284                        if prune && vars[v as usize].prune(&val, depth) {
285                            pass_changed = true;
286                            changed = true;
287                        }
288                    }
289                }
290                if vars[v as usize].domain.is_empty() {
291                    return Revision::Unsatisfiable;
292                }
293            }
294            if !pass_changed {
295                break;
296            }
297        }
298        if changed {
299            Revision::Changed
300        } else {
301            Revision::Unchanged
302        }
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use crate::constraint::LambdaConstraint;
310    use crate::constraint::traits::Constraint;
311    use crate::domain::BitsetDomain;
312
313    fn vars_from(domains: &[Vec<u32>]) -> Vec<Variable<BitsetDomain>> {
314        domains
315            .iter()
316            .map(|d| Variable::new(BitsetDomain::new(d.iter().copied())))
317            .collect()
318    }
319
320    /// `n` copies of the same domain (array-repeat needs `Copy`, `Vec` isn't).
321    fn rep(d: &[u32], n: usize) -> Vec<Vec<u32>> {
322        (0..n).map(|_| d.to_vec()).collect()
323    }
324
325    fn domain_of(v: &Variable<BitsetDomain>) -> Vec<u32> {
326        let mut vals = v.domain.values();
327        vals.sort_unstable();
328        vals
329    }
330
331    // -- born-RED: the n-ary lambda wall is live ----------------------------
332
333    /// A cage-sum modelled as a 3-ary `LambdaConstraint` returns
334    /// `Revision::Unchanged` and prunes nothing — the wall the cage variants
335    /// clear (`traits.rs` default `revise`, `_ => Unchanged`).
336    #[test]
337    fn n_ary_lambda_cage_sum_does_not_propagate() {
338        let mut vars = vars_from(&rep(&[1, 2, 3, 4, 5, 6, 7, 8, 9], 3));
339        // Σ of three cells == 6: a bounds propagator would cap each cell at 4.
340        let lambda = LambdaConstraint::new(
341            vec![0, 1, 2],
342            |a: &[Option<u32>]| match (a[0], a[1], a[2]) {
343                (Some(x), Some(y), Some(z)) => x + y + z == 6,
344                _ => true,
345            },
346            "cage_sum(6)",
347        );
348        let rev = Constraint::revise(&lambda, &mut vars, 1);
349        assert_eq!(
350            rev,
351            Revision::Unchanged,
352            "the n-ary lambda wall must be live"
353        );
354        for v in &vars {
355            assert_eq!(v.domain.size(), 9, "lambda pruned nothing — the wall");
356        }
357    }
358
359    // -- CageSum propagation -------------------------------------------------
360
361    #[test]
362    fn cage_sum_revise_prunes_by_residual() {
363        let mut vars = vars_from(&rep(&[1, 2, 3, 4, 5, 6, 7, 8, 9], 3));
364        let cage = CageSum::new(vec![0, 1, 2], 6);
365        let rev = cage.revise_impl(&mut vars, 1);
366        assert_eq!(rev, Revision::Changed);
367        // others' min is 1+1 = 2, so each cell ≤ 6 − 2 = 4.
368        for v in &vars {
369            assert_eq!(domain_of(v), vec![1, 2, 3, 4]);
370        }
371    }
372
373    #[test]
374    fn cage_sum_revise_tightens_low_end() {
375        // Two cells, each 1..=9, sum 17 ⇒ each cell ≥ 17 − 9 = 8.
376        let mut vars = vars_from(&rep(&[1, 2, 3, 4, 5, 6, 7, 8, 9], 2));
377        let cage = CageSum::new(vec![0, 1], 17);
378        assert_eq!(cage.revise_impl(&mut vars, 1), Revision::Changed);
379        assert_eq!(domain_of(&vars[0]), vec![8, 9]);
380        assert_eq!(domain_of(&vars[1]), vec![8, 9]);
381    }
382
383    #[test]
384    fn cage_sum_revise_detects_unsat() {
385        // Max reachable sum is 2+2 = 4 < target 9.
386        let mut vars = vars_from(&[vec![1, 2], vec![1, 2]]);
387        let cage = CageSum::new(vec![0, 1], 9);
388        assert_eq!(cage.revise_impl(&mut vars, 1), Revision::Unsatisfiable);
389    }
390
391    // -- CageProduct propagation --------------------------------------------
392
393    #[test]
394    fn cage_product_revise_prunes_by_divisibility_and_bound() {
395        // Π of three cells (1..=6) == 6. Others' max product is 6*6 = 36, so
396        // every value divides 6 (5 is dropped) and required = 6/x must be
397        // reachable — 1,2,3,6 survive; 4,5 do not.
398        let mut vars = vars_from(&rep(&[1, 2, 3, 4, 5, 6], 3));
399        let cage = CageProduct::new(vec![0, 1, 2], 6);
400        assert_eq!(cage.revise_impl(&mut vars, 1), Revision::Changed);
401        for v in &vars {
402            assert_eq!(domain_of(v), vec![1, 2, 3, 6]);
403        }
404    }
405
406    #[test]
407    fn cage_product_revise_prunes_zero_for_nonzero_target() {
408        let mut vars = vars_from(&[vec![0, 1, 2, 3], vec![0, 1, 2, 3]]);
409        let cage = CageProduct::new(vec![0, 1], 6);
410        assert_eq!(cage.revise_impl(&mut vars, 1), Revision::Changed);
411        // 0 gone (zero factor); 1 gone (6/1 = 6 exceeds other's max 3); 2,3 stay.
412        assert_eq!(domain_of(&vars[0]), vec![2, 3]);
413        assert_eq!(domain_of(&vars[1]), vec![2, 3]);
414    }
415
416    #[test]
417    fn cage_product_revise_detects_unsat() {
418        // Max reachable product is 2*2 = 4 < target 9.
419        let mut vars = vars_from(&[vec![1, 2], vec![1, 2]]);
420        let cage = CageProduct::new(vec![0, 1], 9);
421        assert_eq!(cage.revise_impl(&mut vars, 1), Revision::Unsatisfiable);
422    }
423
424    // -- randomized differential oracle (revise-level soundness) ------------
425    //
426    // The born-RED guard the spec names: the propagator must NEVER prune a
427    // value that participates in a full solution over the ORIGINAL domains
428    // (bounds consistency is sound, not domain-complete). A brute-force cartesian
429    // enumeration computes every solution-supported value per cell; the assert
430    // is that each survives the revise.
431
432    struct Lcg(u64);
433    impl Lcg {
434        fn next(&mut self) -> u64 {
435            self.0 = self
436                .0
437                .wrapping_mul(6364136223846793005)
438                .wrapping_add(1442695040888963407);
439            self.0
440        }
441        fn below(&mut self, n: u32) -> u32 {
442            (self.next() >> 33) as u32 % n
443        }
444    }
445
446    /// Random non-empty subset of `pool`.
447    fn random_domain(rng: &mut Lcg, pool: &[u32]) -> Vec<u32> {
448        loop {
449            let d: Vec<u32> = pool.iter().copied().filter(|_| rng.below(2) == 0).collect();
450            if !d.is_empty() {
451                return d;
452            }
453        }
454    }
455
456    /// Every solution-supported value per cell, by brute-force cartesian filter.
457    fn supported(domains: &[Vec<u32>], keep: impl Fn(&[u32]) -> bool) -> Vec<Vec<u32>> {
458        let n = domains.len();
459        let mut supp: Vec<std::collections::BTreeSet<u32>> = vec![Default::default(); n];
460        let mut cur = vec![0u32; n];
461        fn rec(
462            i: usize,
463            domains: &[Vec<u32>],
464            cur: &mut Vec<u32>,
465            keep: &dyn Fn(&[u32]) -> bool,
466            supp: &mut [std::collections::BTreeSet<u32>],
467        ) {
468            if i == domains.len() {
469                if keep(cur) {
470                    for (k, &v) in cur.iter().enumerate() {
471                        supp[k].insert(v);
472                    }
473                }
474                return;
475            }
476            for &v in &domains[i] {
477                cur[i] = v;
478                rec(i + 1, domains, cur, keep, supp);
479            }
480        }
481        rec(0, domains, &mut cur, &keep, &mut supp);
482        supp.into_iter().map(|s| s.into_iter().collect()).collect()
483    }
484
485    fn assert_sound<F, R>(label: &str, domains: &[Vec<u32>], keep: F, revise: R)
486    where
487        F: Fn(&[u32]) -> bool,
488        R: Fn(&mut [Variable<BitsetDomain>]) -> Revision,
489    {
490        let supp = supported(domains, &keep);
491        let mut vars = vars_from(domains);
492        let rev = revise(&mut vars);
493        // Every solution-supported value must survive.
494        for (i, want) in supp.iter().enumerate() {
495            let got = domain_of(&vars[i]);
496            for v in want {
497                assert!(
498                    got.contains(v),
499                    "{label}: cell {i} pruned solution-supported value {v}\n  domains={domains:?}\n  survived={got:?}\n  supported={want:?}",
500                );
501            }
502            // The propagator only ever removes values.
503            for g in &got {
504                assert!(
505                    domains[i].contains(g),
506                    "{label}: cell {i} invented value {g}"
507                );
508            }
509        }
510        // If any cell has no support, the whole cage is unsat: either a wipe-out
511        // was reported or some cell emptied.
512        let unsatisfiable = supp.iter().any(|s| s.is_empty());
513        if unsatisfiable {
514            let emptied = vars.iter().any(|v| v.domain.is_empty());
515            assert!(
516                rev == Revision::Unsatisfiable || emptied || rev == Revision::Changed,
517                "{label}: unsatisfiable cage not flagged for domains={domains:?}"
518            );
519        }
520    }
521
522    #[test]
523    fn cage_sum_revise_soundness_randomized() {
524        let mut rng = Lcg(0x51ED_C0DE_u64);
525        let pool: Vec<u32> = (1..=6).collect();
526        for _ in 0..2000 {
527            let n = 2 + rng.below(3) as usize; // 2..=4 cells
528            let domains: Vec<Vec<u32>> = (0..n).map(|_| random_domain(&mut rng, &pool)).collect();
529            let target = 1 + rng.below(24) as i128; // 1..=24
530            let scope: Vec<VarId> = (0..n as VarId).collect();
531            assert_sound(
532                "cage_sum",
533                &domains,
534                |c| c.iter().map(|&x| x as i128).sum::<i128>() == target,
535                |vars| CageSum::new(scope.clone(), target as u32).revise_impl(vars, 1),
536            );
537        }
538    }
539
540    #[test]
541    fn cage_product_revise_soundness_randomized() {
542        let mut rng = Lcg(0xC0FF_EE42_u64);
543        // Pool includes 0 to exercise the zero-factor and zero-target logic.
544        let pool: Vec<u32> = (0..=6).collect();
545        for _ in 0..2000 {
546            let n = 2 + rng.below(3) as usize;
547            let domains: Vec<Vec<u32>> = (0..n).map(|_| random_domain(&mut rng, &pool)).collect();
548            let target = rng.below(50) as i128; // 0..=49 (includes the zero target)
549            let scope: Vec<VarId> = (0..n as VarId).collect();
550            assert_sound(
551                "cage_product",
552                &domains,
553                |c| c.iter().map(|&x| x as i128).product::<i128>() == target,
554                |vars| CageProduct::new(scope.clone(), target as u32).revise_impl(vars, 1),
555            );
556        }
557    }
558}