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}