csp_solver/solver/gac.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;
36mod scratch;
37
38use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
39
40use crate::constraint::traits::{Revision, VarId};
41use crate::domain::Domain;
42use crate::variable::Variable;
43
44use matching::{NONE, hopcroft_karp, tarjan_scc};
45use scratch::{GacScratch, fast_index, resize_tarjan, with_scratch};
46
47/// Instrumentation: total entries into the unified GAC core (all variants).
48pub(crate) static GAC_CORE_CALLS: AtomicU64 = AtomicU64::new(0);
49
50/// Monotonic source of per-constraint cache ids.
51static NEXT_GAC_ID: AtomicU32 = AtomicU32::new(0);
52
53/// Allocate a fresh, process-unique constraint id for matching-cache keying.
54pub fn next_gac_id() -> u32 {
55 NEXT_GAC_ID.fetch_add(1, Ordering::Relaxed)
56}
57
58/// Below this live-participant count, GAC finds nothing singleton removal
59/// misses, so the caller's cheaper path is preferred. Exposed so constraints
60/// gate consistently.
61pub const GAC_MIN_PARTICIPANTS: usize = 3;
62
63/// A/B measurement toggle for the plain `AllDifferent` GAC path (Sudoku,
64/// Futoshiki). Default on; flipping it off reverts `AllDifferent` to
65/// singleton-removal-only so the pruning-strength delta can be measured.
66pub static GAC_IN_ALLDIFF_ENABLED: AtomicBool = AtomicBool::new(true);
67
68/// Cap on the per-thread matching cache; cleared wholesale past this many
69/// distinct constraints so a long-lived worker thread cannot leak unboundedly.
70const CACHE_CAP: usize = 8192;
71
72// ---------------------------------------------------------------------------
73// Unified GAC core
74// ---------------------------------------------------------------------------
75
76/// Run Régin GAC on `scope`, sentinel-generic and incremental.
77///
78/// * `sentinel = None` → plain all-different; `Some(s)` → all-different-except.
79/// * `gac_id = Some(id)` enables the per-constraint matching warm-start cache;
80/// `None` runs stateless (used by the one-shot public wrappers and tests).
81///
82/// Only requires `D::Value: Clone + PartialEq + Debug` (all implied by `Domain`)
83/// plus `'static` for the thread-local scratch — no `Ord`, `Hash`, or
84/// `ValueIndex` bound, so `FiniteDomain<String>` still compiles.
85pub fn propagate_gac_core<D: Domain>(
86 scope: &[VarId],
87 sentinel: Option<&D::Value>,
88 variables: &mut [Variable<D>],
89 depth: usize,
90 gac_id: Option<u32>,
91) -> Revision
92where
93 D::Value: 'static,
94{
95 GAC_CORE_CALLS.fetch_add(1, Ordering::Relaxed);
96 with_scratch::<D::Value, _>(|s| propagate_inner(scope, sentinel, variables, depth, gac_id, s))
97}
98
99fn propagate_inner<D: Domain>(
100 scope: &[VarId],
101 sentinel: Option<&D::Value>,
102 variables: &mut [Variable<D>],
103 depth: usize,
104 gac_id: Option<u32>,
105 s: &mut GacScratch<D::Value>,
106) -> Revision
107where
108 D::Value: 'static,
109{
110 // Advance the value→index fast-path generation (Beat 1). Every stamped
111 // entry from a prior call is now stale in O(1); on the rare u32 wrap we
112 // clear the stamps once and restart at 1 (0 means "never written").
113 s.cur_gen = s.cur_gen.wrapping_add(1);
114 if s.cur_gen == 0 {
115 s.val_index_gen.iter_mut().for_each(|g| *g = 0);
116 s.assigned_mark.iter_mut().for_each(|g| *g = 0);
117 s.cur_gen = 1;
118 }
119
120 // ----- Participant + assigned-value collection -----
121 // Each assigned non-sentinel singleton is also stamped into `assigned_mark`
122 // (ROW-7) so the adjacency loop's membership test is O(1) for integers.
123 s.assigned_ns.clear();
124 for &v in scope {
125 let dom = &variables[v as usize].domain;
126 if dom.is_empty() {
127 return Revision::Unsatisfiable;
128 }
129 if let Some(val) = dom.singleton_value()
130 && sentinel != Some(&val)
131 {
132 if let Some(k) = fast_index(&val) {
133 if k >= s.assigned_mark.len() {
134 s.assigned_mark.resize(k + 1, 0);
135 }
136 s.assigned_mark[k] = s.cur_gen;
137 }
138 s.assigned_ns.push(val);
139 }
140 }
141
142 s.participants.clear();
143 s.has_sentinel.clear();
144 for (i, &v) in scope.iter().enumerate() {
145 let dom = &variables[v as usize].domain;
146 if dom.is_singleton() {
147 continue;
148 }
149 let dom_has_sentinel = sentinel.map(|sv| dom.contains(sv)).unwrap_or(false);
150 let non_sentinel_count = dom.size() - dom_has_sentinel as usize;
151 if non_sentinel_count == 0 {
152 // {sentinel}-only domain: committed to escape, drops out.
153 continue;
154 }
155 s.participants.push(i);
156 s.has_sentinel.push(dom_has_sentinel);
157 }
158
159 if s.participants.is_empty() {
160 return Revision::Unchanged;
161 }
162 let n_vars = s.participants.len();
163
164 // Plain all-different: below the threshold, singleton removal (which the
165 // caller performs) captures everything GAC would — skip the machinery.
166 if sentinel.is_none() && n_vars < GAC_MIN_PARTICIPANTS {
167 return Revision::Unchanged;
168 }
169
170 // ----- Value universe + bipartite adjacency -----
171 // Built as a flat CSR: rows (participants) are appended in order, each
172 // sealed by `finish_row`, so no counting pass is needed.
173 s.all_vals.clear();
174 s.adj.begin();
175 for pu in 0..n_vars {
176 let var_id = scope[s.participants[pu]] as usize;
177 for val in variables[var_id].domain.iter() {
178 if sentinel == Some(&val) {
179 continue;
180 }
181 // Assigned-singleton membership: O(1) via the stamped `assigned_mark`
182 // for integers (ROW-7), the linear `contains` fallback otherwise.
183 let is_assigned = match fast_index(&val) {
184 Some(k) if k < s.assigned_mark.len() => s.assigned_mark[k] == s.cur_gen,
185 Some(_) => false,
186 None => s.assigned_ns.contains(&val),
187 };
188 if is_assigned {
189 continue;
190 }
191 // Value→index resolution. Integer values in range take the O(1)
192 // generation-stamped reverse map; everything else falls back to
193 // the O(n_vals) `position` scan. Both dedup exactly (INV-B) and
194 // agree on the slot numbering, so the two paths are observably
195 // identical — the map is a pure accelerator over `all_vals`.
196 let vi = match fast_index(&val) {
197 Some(k) => {
198 if k >= s.val_index.len() {
199 s.val_index.resize(k + 1, 0);
200 s.val_index_gen.resize(k + 1, 0);
201 }
202 if s.val_index_gen[k] == s.cur_gen {
203 s.val_index[k]
204 } else {
205 let idx = s.all_vals.len() as u32;
206 s.all_vals.push(val);
207 s.val_index[k] = idx;
208 s.val_index_gen[k] = s.cur_gen;
209 idx
210 }
211 }
212 None => match s.all_vals.iter().position(|x| *x == val) {
213 Some(k) => k as u32,
214 None => {
215 s.all_vals.push(val);
216 (s.all_vals.len() - 1) as u32
217 }
218 },
219 };
220 s.adj.push(vi);
221 }
222 s.adj.finish_row();
223 }
224
225 // All non-sentinel values consumed by assigned singletons.
226 if s.all_vals.is_empty() {
227 return finish_all_consumed::<D>(scope, sentinel, variables, depth, s);
228 }
229 let n_vals = s.all_vals.len();
230
231 // ----- Warm-start matching from the per-constraint cache -----
232 s.match_u.clear();
233 s.match_u.resize(n_vars, NONE);
234 s.match_v.clear();
235 s.match_v.resize(n_vals, NONE);
236 s.dist.clear();
237 s.dist.resize(n_vars, 0);
238
239 if let Some(id) = gac_id
240 && (id as usize) < CACHE_CAP
241 && let Some(Some(cvec)) = s.cache.get(id as usize)
242 {
243 for pu in 0..n_vars {
244 let Some(Some(cv)) = cvec.get(s.participants[pu]) else {
245 continue;
246 };
247 // Re-resolve the cached value against THIS call's `all_vals`
248 // (built just above, same generation), via the same fast path.
249 // A cached value not present in the current universe → skip, the
250 // warm start is a pure hint (INV-G).
251 let vi = match fast_index(cv) {
252 Some(k) if k < s.val_index.len() && s.val_index_gen[k] == s.cur_gen => {
253 s.val_index[k]
254 }
255 Some(_) => continue,
256 None => match s.all_vals.iter().position(|x| x == cv) {
257 Some(k) => k as u32,
258 None => continue,
259 },
260 };
261 if s.match_v[vi as usize] == NONE && s.adj.row(pu).contains(&vi) {
262 s.match_u[pu] = vi;
263 s.match_v[vi as usize] = pu as u32;
264 }
265 }
266 }
267
268 hopcroft_karp(
269 n_vars,
270 n_vals,
271 &s.adj,
272 &mut s.match_u,
273 &mut s.match_v,
274 &mut s.dist,
275 &mut s.queue,
276 );
277
278 // ----- Coverage: sentinel-less participants must be matched -----
279 for pu in 0..n_vars {
280 if s.match_u[pu] == NONE && !s.has_sentinel[pu] {
281 return Revision::Unsatisfiable;
282 }
283 }
284
285 // Persist the fresh matching for the next call's warm start. The Vec is
286 // indexed by the dense `gac_id`; ids past `CACHE_CAP` fall through uncached,
287 // so the buffer is bounded without a wholesale clear.
288 if let Some(id) = gac_id
289 && (id as usize) < CACHE_CAP
290 {
291 let idx = id as usize;
292 if idx >= s.cache.len() {
293 s.cache.resize_with(idx + 1, || None);
294 }
295 let cvec = s.cache[idx].get_or_insert_with(Vec::new);
296 cvec.clear();
297 cvec.resize(scope.len(), None);
298 for pu in 0..n_vars {
299 if s.match_u[pu] != NONE {
300 cvec[s.participants[pu]] = Some(s.all_vals[s.match_u[pu] as usize].clone());
301 }
302 }
303 }
304
305 // ----- Residual graph (flat CSR) -----
306 // Variable rows `0..n_vars` are filled first, then value rows
307 // `n_vars..total_nodes`, so the CSR fills strictly in index order. A matched
308 // (var,val) edge orients val→var (an in-edge on the value); every other
309 // edge orients var→val. The matching is injective on the value side, so
310 // each value row holds at most the one variable it is matched to
311 // (`match_v[vi]`) — identical row contents to the vector-of-vectors build.
312 let total_nodes = n_vars + n_vals;
313 s.res_adj.begin();
314 for u in 0..n_vars {
315 let matched_vi = s.match_u[u];
316 for &vi in s.adj.row(u) {
317 if vi != matched_vi {
318 s.res_adj.push((n_vars as u32) + vi);
319 }
320 }
321 s.res_adj.finish_row();
322 }
323 for vi in 0..n_vals {
324 let mu = s.match_v[vi];
325 if mu != NONE {
326 s.res_adj.push(mu);
327 }
328 s.res_adj.finish_row();
329 }
330
331 // ----- Reachability from free vertices -----
332 s.reachable.clear();
333 s.reachable.resize(total_nodes, false);
334 s.bfs.clear();
335 for vi in 0..n_vals {
336 if s.match_v[vi] == NONE {
337 let node = n_vars + vi;
338 s.reachable[node] = true;
339 s.bfs.push(node as u32);
340 }
341 }
342 for pu in 0..n_vars {
343 if s.match_u[pu] == NONE && s.has_sentinel[pu] {
344 s.reachable[pu] = true;
345 s.bfs.push(pu as u32);
346 }
347 }
348 let mut head = 0;
349 while head < s.bfs.len() {
350 let node = s.bfs[head] as usize;
351 head += 1;
352 let row = s.res_adj.row(node);
353 for &next_node in row {
354 let next = next_node as usize;
355 if !s.reachable[next] {
356 s.reachable[next] = true;
357 s.bfs.push(next as u32);
358 }
359 }
360 }
361
362 // ----- SCCs -----
363 resize_tarjan(s, total_nodes);
364 tarjan_scc(
365 total_nodes,
366 &s.res_adj,
367 &mut s.t_index,
368 &mut s.t_lowlink,
369 &mut s.t_onstack,
370 &mut s.t_scc,
371 &mut s.t_stack,
372 &mut s.t_call,
373 );
374
375 // ----- Prune -----
376 let mut changed = false;
377
378 // Phase 1: assigned-singleton non-sentinel values were excluded from the
379 // graph; remove them from every participant's live domain.
380 if !s.assigned_ns.is_empty() {
381 for pu in 0..n_vars {
382 let var_id = scope[s.participants[pu]] as usize;
383 for k in 0..s.assigned_ns.len() {
384 let val = s.assigned_ns[k].clone();
385 if variables[var_id].prune(&val, depth) {
386 changed = true;
387 }
388 }
389 if variables[var_id].domain.is_empty() {
390 return Revision::Unsatisfiable;
391 }
392 }
393 }
394
395 // Phase 2: Régin SCC pruning — drop unmatched (var, val) edges crossing SCC
396 // boundaries and not reachable from a free vertex.
397 for pu in 0..n_vars {
398 let var_id = scope[s.participants[pu]] as usize;
399 let matched_vi = s.match_u[pu];
400 for &vi in s.adj.row(pu) {
401 if vi == matched_vi {
402 continue;
403 }
404 let val_node = n_vars + vi as usize;
405 if s.t_scc[pu] == s.t_scc[val_node] || s.reachable[val_node] {
406 continue;
407 }
408 let val = s.all_vals[vi as usize].clone();
409 if variables[var_id].prune(&val, depth) {
410 changed = true;
411 }
412 }
413 if variables[var_id].domain.is_empty() {
414 return Revision::Unsatisfiable;
415 }
416 }
417
418 if changed {
419 Revision::Changed
420 } else {
421 Revision::Unchanged
422 }
423}
424
425/// Handle the degenerate case where the value universe is empty (every
426/// non-sentinel value was consumed by an assigned singleton).
427fn finish_all_consumed<D: Domain>(
428 scope: &[VarId],
429 sentinel: Option<&D::Value>,
430 variables: &mut [Variable<D>],
431 depth: usize,
432 s: &mut GacScratch<D::Value>,
433) -> Revision {
434 // Plain variant: an unassigned variable with no available value is UNSAT.
435 if sentinel.is_none() {
436 return Revision::Unsatisfiable;
437 }
438 // Sentinel variant: participants survive only via the escape valve. Any
439 // participant without a sentinel is unsatisfiable; the rest have their
440 // (now-forbidden) assigned values pruned.
441 for pu in 0..s.participants.len() {
442 if !s.has_sentinel[pu] {
443 return Revision::Unsatisfiable;
444 }
445 }
446 let mut changed = false;
447 for pu in 0..s.participants.len() {
448 let var_id = scope[s.participants[pu]] as usize;
449 for k in 0..s.assigned_ns.len() {
450 let val = s.assigned_ns[k].clone();
451 if variables[var_id].prune(&val, depth) {
452 changed = true;
453 }
454 }
455 if variables[var_id].domain.is_empty() {
456 return Revision::Unsatisfiable;
457 }
458 }
459 if changed {
460 Revision::Changed
461 } else {
462 Revision::Unchanged
463 }
464}