csp_solver/solver/gac/mod.rs
1//! Unified GAC all-different core (Régin 1994), sentinel-generic and incremental.
2//!
3//! One propagator body serves both the plain all-different constraint and the
4//! sentinel-aware "all-different-except" variant. The distinction is a single
5//! [`Option<&D::Value>`] parameter:
6//!
7//! * `sentinel = None` — plain all-different. Every unassigned variable must be
8//! matched to a distinct value.
9//! * `sentinel = Some(s)` — the value `s` is an "escape valve" any number of
10//! variables may take; it is removed from the value side of the bipartite
11//! graph, and variables whose domain has narrowed to `{s}` drop out of the
12//! variable side.
13//!
14//! The Régin pipeline (Hopcroft-Karp maximum matching → residual graph → Tarjan
15//! SCC → prune edges outside every maximum matching) is identical for both; only
16//! participant/value collection and free-vertex reachability seeding branch on
17//! the sentinel. The graph primitives live in [`matching`].
18//!
19//! # Incrementality
20//!
21//! Two costs the from-scratch predecessor paid on *every* `revise()` are gone:
22//!
23//! 1. **Per-call heap allocation.** All working buffers (adjacency, matching,
24//! residual graph, Tarjan stacks) live in a thread-local [`GacScratch`] that
25//! is cleared — not freed — between calls. The value universe is the sole
26//! per-call `Vec<V>` the generic bound cannot pool.
27//! 2. **Cold Hopcroft-Karp.** With a stable constraint id, the previous maximum
28//! matching is cached per constraint and warm-starts the next call: cached
29//! edges still valid against the live domains seed `match_u`/`match_v`, and
30//! Hopcroft-Karp only augments from the vertices left free — O(E) repair
31//! rather than O(E·√V) reconstruction. The cache is a pure hint: every seeded
32//! edge is validated against live domains, so a stale cache costs extra
33//! augmentation, never correctness.
34
35mod matching;
36
37use std::any::Any;
38use std::cell::RefCell;
39use std::collections::HashMap;
40use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
41
42use crate::constraint::traits::{Revision, VarId};
43use crate::domain::Domain;
44use crate::variable::Variable;
45
46use matching::{NONE, hopcroft_karp, reset_adj, tarjan_scc};
47
48/// Instrumentation: total entries into the unified GAC core (all variants).
49pub static GAC_CORE_CALLS: AtomicU64 = AtomicU64::new(0);
50
51/// Monotonic source of per-constraint cache ids.
52static NEXT_GAC_ID: AtomicU32 = AtomicU32::new(0);
53
54/// Allocate a fresh, process-unique constraint id for matching-cache keying.
55pub fn next_gac_id() -> u32 {
56 NEXT_GAC_ID.fetch_add(1, Ordering::Relaxed)
57}
58
59/// Below this live-participant count, GAC finds nothing singleton removal
60/// misses, so the caller's cheaper path is preferred. Exposed so constraints
61/// gate consistently.
62pub const GAC_MIN_PARTICIPANTS: usize = 3;
63
64/// A/B measurement toggle for the plain `AllDifferent` GAC path (Sudoku,
65/// Futoshiki). Default on; flipping it off reverts `AllDifferent` to
66/// singleton-removal-only so the pruning-strength delta can be measured.
67pub static GAC_IN_ALLDIFF_ENABLED: AtomicBool = AtomicBool::new(true);
68
69/// Cap on the per-thread matching cache; cleared wholesale past this many
70/// distinct constraints so a long-lived worker thread cannot leak unboundedly.
71const CACHE_CAP: usize = 8192;
72
73// ---------------------------------------------------------------------------
74// Reusable scratch
75// ---------------------------------------------------------------------------
76
77/// Per-thread, per-value-type reusable working set for the GAC core.
78///
79/// Generic in the value type only for the value universe and matching cache;
80/// every other buffer is integer-indexed and value-agnostic.
81struct GacScratch<V> {
82 participants: Vec<usize>,
83 has_sentinel: Vec<bool>,
84 assigned_ns: Vec<V>,
85 all_vals: Vec<V>,
86 adj: Vec<Vec<u32>>,
87 match_u: Vec<u32>,
88 match_v: Vec<u32>,
89 dist: Vec<u32>,
90 queue: Vec<u32>,
91 res_adj: Vec<Vec<u32>>,
92 reachable: Vec<bool>,
93 bfs: Vec<u32>,
94 t_index: Vec<u32>,
95 t_lowlink: Vec<u32>,
96 t_onstack: Vec<bool>,
97 t_scc: Vec<u32>,
98 t_stack: Vec<u32>,
99 t_call: Vec<(u32, u32)>,
100 cache: HashMap<u32, Vec<Option<V>>>,
101 /// Generation-stamped reverse map for the integer fast path (Beat 1).
102 ///
103 /// When `D::Value` is a small non-negative integer, `val_index[k]` holds
104 /// the `all_vals` slot for value `k` **iff** `val_index_gen[k] == cur_gen`.
105 /// The stamp makes the per-call reset O(1) (bump `cur_gen`) so the buffer
106 /// never leaks a prior call's numbering across a value-universe shrink —
107 /// see [`INV-A`](self). Non-integer / out-of-range values fall back to the
108 /// generic `PartialEq` `position` scan, so no `Hash`/`Ord`/`ValueIndex`
109 /// bound is introduced.
110 val_index: Vec<u32>,
111 val_index_gen: Vec<u32>,
112 cur_gen: u32,
113}
114
115/// Upper bound on the integer key the value→index fast path will address.
116/// A value above this (or negative, or non-integer) uses the `position`-scan
117/// fallback, bounding `val_index`'s worst-case footprint. Live GAC value
118/// universes are tiny (`BitsetDomain` is 0..128; assignment columns 0..n_cols),
119/// so the cap is never approached in practice.
120const MAX_FAST_INDEX: usize = 1 << 20;
121
122/// If `v` is one of the supported integer types and lands in
123/// `0..=MAX_FAST_INDEX`, return it as a reverse-map key; otherwise `None`
124/// (caller falls back to the `PartialEq` scan). Uses the `Any` blanket impl
125/// available to every `'static` type, so it adds **no** trait bound to the
126/// generic value — `FiniteDomain<String>` still routes entirely through the
127/// scan and compiles unchanged.
128fn fast_index<V: 'static>(v: &V) -> Option<usize> {
129 let any = v as &dyn Any;
130 macro_rules! try_ints {
131 ($($t:ty),*) => {$(
132 if let Some(&x) = any.downcast_ref::<$t>() {
133 return usize::try_from(x).ok().filter(|&k| k <= MAX_FAST_INDEX);
134 }
135 )*};
136 }
137 try_ints!(u8, u16, u32, u64, usize, i8, i16, i32, i64, isize);
138 None
139}
140
141impl<V> Default for GacScratch<V> {
142 fn default() -> Self {
143 Self {
144 participants: Vec::new(),
145 has_sentinel: Vec::new(),
146 assigned_ns: Vec::new(),
147 all_vals: Vec::new(),
148 adj: Vec::new(),
149 match_u: Vec::new(),
150 match_v: Vec::new(),
151 dist: Vec::new(),
152 queue: Vec::new(),
153 res_adj: Vec::new(),
154 reachable: Vec::new(),
155 bfs: Vec::new(),
156 t_index: Vec::new(),
157 t_lowlink: Vec::new(),
158 t_onstack: Vec::new(),
159 t_scc: Vec::new(),
160 t_stack: Vec::new(),
161 t_call: Vec::new(),
162 cache: HashMap::new(),
163 val_index: Vec::new(),
164 val_index_gen: Vec::new(),
165 cur_gen: 0,
166 }
167 }
168}
169
170thread_local! {
171 /// One type-erased [`GacScratch`] slot per thread. The concrete `V` for a
172 /// given solve is fixed, so the slot re-materializes only if a thread runs
173 /// solves over different value types.
174 static SCRATCH: RefCell<Box<dyn Any>> = RefCell::new(Box::new(()));
175}
176
177fn with_scratch<V, R>(f: impl FnOnce(&mut GacScratch<V>) -> R) -> R
178where
179 V: 'static,
180{
181 SCRATCH.with(|slot| {
182 let mut b = slot.borrow_mut();
183 if !b.is::<GacScratch<V>>() {
184 *b = Box::new(GacScratch::<V>::default());
185 }
186 let s = b.downcast_mut::<GacScratch<V>>().unwrap();
187 f(s)
188 })
189}
190
191// ---------------------------------------------------------------------------
192// Unified GAC core
193// ---------------------------------------------------------------------------
194
195/// Run Régin GAC on `scope`, sentinel-generic and incremental.
196///
197/// * `sentinel = None` → plain all-different; `Some(s)` → all-different-except.
198/// * `gac_id = Some(id)` enables the per-constraint matching warm-start cache;
199/// `None` runs stateless (used by the one-shot public wrappers and tests).
200///
201/// Only requires `D::Value: Clone + PartialEq + Debug` (all implied by `Domain`)
202/// plus `'static` for the thread-local scratch — no `Ord`, `Hash`, or
203/// `ValueIndex` bound, so `FiniteDomain<String>` still compiles.
204pub fn propagate_gac_core<D: Domain>(
205 scope: &[VarId],
206 sentinel: Option<&D::Value>,
207 variables: &mut [Variable<D>],
208 depth: usize,
209 gac_id: Option<u32>,
210) -> Revision
211where
212 D::Value: 'static,
213{
214 GAC_CORE_CALLS.fetch_add(1, Ordering::Relaxed);
215 with_scratch::<D::Value, _>(|s| propagate_inner(scope, sentinel, variables, depth, gac_id, s))
216}
217
218fn propagate_inner<D: Domain>(
219 scope: &[VarId],
220 sentinel: Option<&D::Value>,
221 variables: &mut [Variable<D>],
222 depth: usize,
223 gac_id: Option<u32>,
224 s: &mut GacScratch<D::Value>,
225) -> Revision
226where
227 D::Value: 'static,
228{
229 // Advance the value→index fast-path generation (Beat 1). Every stamped
230 // entry from a prior call is now stale in O(1); on the rare u32 wrap we
231 // clear the stamps once and restart at 1 (0 means "never written").
232 s.cur_gen = s.cur_gen.wrapping_add(1);
233 if s.cur_gen == 0 {
234 s.val_index_gen.iter_mut().for_each(|g| *g = 0);
235 s.cur_gen = 1;
236 }
237
238 // ----- Participant + assigned-value collection -----
239 s.assigned_ns.clear();
240 for &v in scope {
241 let dom = &variables[v as usize].domain;
242 if dom.is_empty() {
243 return Revision::Unsatisfiable;
244 }
245 if let Some(val) = dom.singleton_value()
246 && sentinel != Some(&val)
247 {
248 s.assigned_ns.push(val);
249 }
250 }
251
252 s.participants.clear();
253 s.has_sentinel.clear();
254 for (i, &v) in scope.iter().enumerate() {
255 let dom = &variables[v as usize].domain;
256 if dom.is_singleton() {
257 continue;
258 }
259 let dom_has_sentinel = sentinel.map(|sv| dom.contains(sv)).unwrap_or(false);
260 let non_sentinel_count = dom.size() - dom_has_sentinel as usize;
261 if non_sentinel_count == 0 {
262 // {sentinel}-only domain: committed to escape, drops out.
263 continue;
264 }
265 s.participants.push(i);
266 s.has_sentinel.push(dom_has_sentinel);
267 }
268
269 if s.participants.is_empty() {
270 return Revision::Unchanged;
271 }
272 let n_vars = s.participants.len();
273
274 // Plain all-different: below the threshold, singleton removal (which the
275 // caller performs) captures everything GAC would — skip the machinery.
276 if sentinel.is_none() && n_vars < GAC_MIN_PARTICIPANTS {
277 return Revision::Unchanged;
278 }
279
280 // ----- Value universe + bipartite adjacency -----
281 s.all_vals.clear();
282 reset_adj(&mut s.adj, n_vars);
283 for pu in 0..n_vars {
284 let var_id = scope[s.participants[pu]] as usize;
285 for val in variables[var_id].domain.iter() {
286 if sentinel == Some(&val) {
287 continue;
288 }
289 if s.assigned_ns.contains(&val) {
290 continue;
291 }
292 // Value→index resolution. Integer values in range take the O(1)
293 // generation-stamped reverse map; everything else falls back to
294 // the O(n_vals) `position` scan. Both dedup exactly (INV-B) and
295 // agree on the slot numbering, so the two paths are observably
296 // identical — the map is a pure accelerator over `all_vals`.
297 let vi = match fast_index(&val) {
298 Some(k) => {
299 if k >= s.val_index.len() {
300 s.val_index.resize(k + 1, 0);
301 s.val_index_gen.resize(k + 1, 0);
302 }
303 if s.val_index_gen[k] == s.cur_gen {
304 s.val_index[k]
305 } else {
306 let idx = s.all_vals.len() as u32;
307 s.all_vals.push(val);
308 s.val_index[k] = idx;
309 s.val_index_gen[k] = s.cur_gen;
310 idx
311 }
312 }
313 None => match s.all_vals.iter().position(|x| *x == val) {
314 Some(k) => k as u32,
315 None => {
316 s.all_vals.push(val);
317 (s.all_vals.len() - 1) as u32
318 }
319 },
320 };
321 s.adj[pu].push(vi);
322 }
323 }
324
325 // All non-sentinel values consumed by assigned singletons.
326 if s.all_vals.is_empty() {
327 return finish_all_consumed::<D>(scope, sentinel, variables, depth, s);
328 }
329 let n_vals = s.all_vals.len();
330
331 // ----- Warm-start matching from the per-constraint cache -----
332 s.match_u.clear();
333 s.match_u.resize(n_vars, NONE);
334 s.match_v.clear();
335 s.match_v.resize(n_vals, NONE);
336 s.dist.clear();
337 s.dist.resize(n_vars, 0);
338
339 if let Some(id) = gac_id
340 && let Some(cvec) = s.cache.get(&id)
341 {
342 for pu in 0..n_vars {
343 let Some(Some(cv)) = cvec.get(s.participants[pu]) else {
344 continue;
345 };
346 // Re-resolve the cached value against THIS call's `all_vals`
347 // (built just above, same generation), via the same fast path.
348 // A cached value not present in the current universe → skip, the
349 // warm start is a pure hint (INV-G).
350 let vi = match fast_index(cv) {
351 Some(k) if k < s.val_index.len() && s.val_index_gen[k] == s.cur_gen => {
352 s.val_index[k]
353 }
354 Some(_) => continue,
355 None => match s.all_vals.iter().position(|x| x == cv) {
356 Some(k) => k as u32,
357 None => continue,
358 },
359 };
360 if s.match_v[vi as usize] == NONE && s.adj[pu].contains(&vi) {
361 s.match_u[pu] = vi;
362 s.match_v[vi as usize] = pu as u32;
363 }
364 }
365 }
366
367 hopcroft_karp(
368 n_vars,
369 n_vals,
370 &s.adj,
371 &mut s.match_u,
372 &mut s.match_v,
373 &mut s.dist,
374 &mut s.queue,
375 );
376
377 // ----- Coverage: sentinel-less participants must be matched -----
378 for pu in 0..n_vars {
379 if s.match_u[pu] == NONE && !s.has_sentinel[pu] {
380 return Revision::Unsatisfiable;
381 }
382 }
383
384 // Persist the fresh matching for the next call's warm start.
385 if let Some(id) = gac_id {
386 if s.cache.len() > CACHE_CAP {
387 s.cache.clear();
388 }
389 let cvec = s.cache.entry(id).or_default();
390 cvec.clear();
391 cvec.resize(scope.len(), None);
392 for pu in 0..n_vars {
393 if s.match_u[pu] != NONE {
394 cvec[s.participants[pu]] = Some(s.all_vals[s.match_u[pu] as usize].clone());
395 }
396 }
397 }
398
399 // ----- Residual graph -----
400 let total_nodes = n_vars + n_vals;
401 reset_adj(&mut s.res_adj, total_nodes);
402 for u in 0..n_vars {
403 let matched_vi = s.match_u[u];
404 for &vi in &s.adj[u] {
405 let val_node = (n_vars as u32) + vi;
406 if vi == matched_vi {
407 s.res_adj[val_node as usize].push(u as u32);
408 } else {
409 s.res_adj[u].push(val_node);
410 }
411 }
412 }
413
414 // ----- Reachability from free vertices -----
415 s.reachable.clear();
416 s.reachable.resize(total_nodes, false);
417 s.bfs.clear();
418 for vi in 0..n_vals {
419 if s.match_v[vi] == NONE {
420 let node = n_vars + vi;
421 s.reachable[node] = true;
422 s.bfs.push(node as u32);
423 }
424 }
425 for pu in 0..n_vars {
426 if s.match_u[pu] == NONE && s.has_sentinel[pu] {
427 s.reachable[pu] = true;
428 s.bfs.push(pu as u32);
429 }
430 }
431 let mut head = 0;
432 while head < s.bfs.len() {
433 let node = s.bfs[head] as usize;
434 head += 1;
435 for i in 0..s.res_adj[node].len() {
436 let next = s.res_adj[node][i] as usize;
437 if !s.reachable[next] {
438 s.reachable[next] = true;
439 s.bfs.push(next as u32);
440 }
441 }
442 }
443
444 // ----- SCCs -----
445 resize_tarjan(s, total_nodes);
446 tarjan_scc(
447 total_nodes,
448 &s.res_adj,
449 &mut s.t_index,
450 &mut s.t_lowlink,
451 &mut s.t_onstack,
452 &mut s.t_scc,
453 &mut s.t_stack,
454 &mut s.t_call,
455 );
456
457 // ----- Prune -----
458 let mut changed = false;
459
460 // Phase 1: assigned-singleton non-sentinel values were excluded from the
461 // graph; remove them from every participant's live domain.
462 if !s.assigned_ns.is_empty() {
463 for pu in 0..n_vars {
464 let var_id = scope[s.participants[pu]] as usize;
465 for k in 0..s.assigned_ns.len() {
466 let val = s.assigned_ns[k].clone();
467 if variables[var_id].prune(&val, depth) {
468 changed = true;
469 }
470 }
471 if variables[var_id].domain.is_empty() {
472 return Revision::Unsatisfiable;
473 }
474 }
475 }
476
477 // Phase 2: Régin SCC pruning — drop unmatched (var, val) edges crossing SCC
478 // boundaries and not reachable from a free vertex.
479 for pu in 0..n_vars {
480 let var_id = scope[s.participants[pu]] as usize;
481 let matched_vi = s.match_u[pu];
482 for i in 0..s.adj[pu].len() {
483 let vi = s.adj[pu][i];
484 if vi == matched_vi {
485 continue;
486 }
487 let val_node = n_vars + vi as usize;
488 if s.t_scc[pu] == s.t_scc[val_node] || s.reachable[val_node] {
489 continue;
490 }
491 let val = s.all_vals[vi as usize].clone();
492 if variables[var_id].prune(&val, depth) {
493 changed = true;
494 }
495 }
496 if variables[var_id].domain.is_empty() {
497 return Revision::Unsatisfiable;
498 }
499 }
500
501 if changed {
502 Revision::Changed
503 } else {
504 Revision::Unchanged
505 }
506}
507
508/// Handle the degenerate case where the value universe is empty (every
509/// non-sentinel value was consumed by an assigned singleton).
510fn finish_all_consumed<D: Domain>(
511 scope: &[VarId],
512 sentinel: Option<&D::Value>,
513 variables: &mut [Variable<D>],
514 depth: usize,
515 s: &mut GacScratch<D::Value>,
516) -> Revision {
517 // Plain variant: an unassigned variable with no available value is UNSAT.
518 if sentinel.is_none() {
519 return Revision::Unsatisfiable;
520 }
521 // Sentinel variant: participants survive only via the escape valve. Any
522 // participant without a sentinel is unsatisfiable; the rest have their
523 // (now-forbidden) assigned values pruned.
524 for pu in 0..s.participants.len() {
525 if !s.has_sentinel[pu] {
526 return Revision::Unsatisfiable;
527 }
528 }
529 let mut changed = false;
530 for pu in 0..s.participants.len() {
531 let var_id = scope[s.participants[pu]] as usize;
532 for k in 0..s.assigned_ns.len() {
533 let val = s.assigned_ns[k].clone();
534 if variables[var_id].prune(&val, depth) {
535 changed = true;
536 }
537 }
538 if variables[var_id].domain.is_empty() {
539 return Revision::Unsatisfiable;
540 }
541 }
542 if changed {
543 Revision::Changed
544 } else {
545 Revision::Unchanged
546 }
547}
548
549fn resize_tarjan<V>(s: &mut GacScratch<V>, n: usize) {
550 s.t_index.resize(n, NONE);
551 s.t_lowlink.resize(n, 0);
552 s.t_onstack.clear();
553 s.t_onstack.resize(n, false);
554 s.t_scc.resize(n, NONE);
555}