Skip to main content

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}