Skip to main content

csp_solver/solver/
search.rs

1//! Unified monomorphized search kernel.
2//!
3//! Tests: `tests/solver.rs` (general solve correctness),
4//! `tests/solution_set_invariance.rs` (solution-set property test).
5//!
6//! One tree-search skeleton — [`search`] — parameterized by a zero-sized
7//! [`SearchPolicy`]. The policy decides the four things that actually differ
8//! between the crate's search modes:
9//!
10//! | axis            | [`Feasibility`]        | [`BranchBound`]              |
11//! |-----------------|------------------------|------------------------------|
12//! | leaf action     | record, stop at `max`  | score, keep best, never stop |
13//! | node prune      | none                   | optimistic-bound cutoff      |
14//! | value order     | raw domain order       | cost-sorted                  |
15//!
16//! Every hook is `#[inline]` and every implementor is (near) zero-sized, so
17//! monomorphization inlines the whole policy — no dispatch cost, the same
18//! devirtualization the crate already applies to `ConstraintEnum`.
19//!
20//! This kernel replaces the three near-verbatim recursive DFS functions that
21//! preceded it (`backtrack_recurse`, `backjump_recurse`, `bb_recurse`) whose
22//! propagate `match`, validity-check loop, and restore sweep were byte-identical.
23//! Undo now runs off the shared [`Trail`] (touched-variable list) rather than an
24//! O(num_vars) per-node sweep, and read-only state (`weights`, `var_cids`,
25//! `adjacency`) is borrowed rather than deep-cloned per solve.
26//!
27//! # Waiver: this file stays whole (CLOSED)
28//!
29//! At 504 LOC the module sits four lines over the file budget, and the two
30//! policies are visually fenced — a `BranchBound`-out split looks free. It
31//! isn't. `BranchBound` impls [`SearchPolicy`] and calls [`search`], so
32//! extracting it to a sibling module forces `trait SearchPolicy`, `fn search`,
33//! and likely `Step` — all currently private kernel internals — to widen to
34//! `pub(super)`. That re-widens three internals to buy a cosmetic file cut: an
35//! encapsulation regression inside an encapsulation pass. The single reason to
36//! change here is the one search skeleton; [`Feasibility`] and [`BranchBound`]
37//! are its co-designed leaves, not separable concerns. Waiver recorded, closed.
38
39use smallvec::SmallVec;
40
41use crate::constraint::{ConstraintEnum, VarId};
42use crate::domain::Domain;
43use crate::ordering::{self, Ordering};
44use crate::solver::adjacency::Adjacency;
45use crate::solver::optimize::DomainCostEval;
46use crate::solver::{Solution, Trail, ac3, propagate};
47use crate::variable::Variable;
48use crate::{Pruning, SolveStats};
49
50/// Depth reserved for permanent pre-search propagation (root AC-3, given-cell
51/// propagation). Search recursion begins at [`SEARCH_ROOT_DEPTH`] so its
52/// depth-keyed undo can never target these permanent reductions — fixing the
53/// depth-0 seam where the first failed root candidate un-pruned the initial
54/// AC-3 via `restore(0)`.
55pub(crate) const PERMANENT_DEPTH: usize = 0;
56/// First depth used by search recursion frames.
57const SEARCH_ROOT_DEPTH: usize = 1;
58
59/// Shared, immutable search parameters. Collapses the three former per-mode
60/// config structs (`BacktrackConfig` / `BackjumpConfig` / `OptimizeConfig`),
61/// which differed only in a `maximize` bool and each carried its own *cloned*
62/// copy of `constraint_weights` + `var_constraint_ids`. Those read-only vectors
63/// are passed to the entry point, which folds them into the kernel's
64/// precomputed `var_wdeg` at search entry, so they live nowhere in this struct.
65#[derive(Debug, Clone)]
66pub(crate) struct SearchParams {
67    pub(crate) pruning: Pruning,
68    pub(crate) ordering: Ordering,
69    pub(crate) max_solutions: usize,
70    /// Node budget (search-frame count). `None` disables. See
71    /// [`crate::SolveConfig::node_budget`].
72    pub(crate) node_budget: Option<u64>,
73    /// Cooperative cancellation handle, checked at the same cadence as
74    /// `node_budget`. `None` disables. See [`crate::SolveConfig::cancel`].
75    pub(crate) cancel: Option<crate::CancelToken>,
76}
77
78/// Outcome of one recursion step. Feasibility and branch-and-bound only ever
79/// produce `Continue`/`Done`; a jump variant is intentionally absent (CBJ was
80/// excised — see the module-level notes in `lib.rs`).
81enum Step {
82    Continue,
83    Done,
84}
85
86/// The single mutable spine of the search: problem references, the undo trail,
87/// and stats. `constraints` / `adjacency` are borrowed (no per-solve clone).
88/// `var_wdeg` is the frozen per-variable weighted degree, precomputed once at
89/// entry by [`ordering::precompute_var_wdeg`] for [`Ordering::Mrv`] (empty for
90/// every other ordering), so the Mrv score's denominator is a single lookup
91/// rather than a per-node re-sum. The entry points still take
92/// `constraint_weights` as `&mut` so a later tranche can bump dom/wdeg on
93/// wipe-out — such a tranche must recompute `var_wdeg` from the bumped weights.
94pub(crate) struct Kernel<'a, D: Domain> {
95    variables: &'a mut [Variable<D>],
96    constraints: &'a [ConstraintEnum<D>],
97    adjacency: &'a Adjacency,
98    var_wdeg: Vec<f64>,
99    stats: &'a mut SolveStats,
100    trail: Trail,
101    /// Reusable AC-3 worklist scratch, sized once to `constraints.len()` at
102    /// search entry. `Pruning::Ac3`'s per-candidate `ac3_from_variable` call
103    /// clears and reseeds this instead of allocating a fresh `Vec<u64>`-backed
104    /// worklist on every attempt (zero-alloc P2-2). Folds the scratch the
105    /// deleted `SearchContext` carried into the unified kernel spine.
106    worklist: ac3::BitsetWorklist,
107    params: &'a SearchParams,
108}
109
110impl<D: Domain> Kernel<'_, D>
111where
112    D::Value: PartialEq + 'static,
113{
114    /// Validity check: every fully-assigned constraint incident to `var` holds.
115    #[inline]
116    fn is_valid(&self, var: VarId, assignment: &[Option<D::Value>]) -> bool {
117        for &ci in self.adjacency.constraints_for(var) {
118            let ci = ci as usize;
119            let scope = self.constraints[ci].scope();
120            if scope.iter().all(|&v| assignment[v as usize].is_some())
121                && !self.constraints[ci].check(assignment)
122            {
123                return false;
124            }
125        }
126        true
127    }
128
129    /// Propagate from a freshly-assigned `var`. Returns `true` on domain
130    /// wipe-out. All prunes are streamed onto `self.trail` for O(removed) undo.
131    ///
132    /// The propagation primitives return a blame signal (`Some(ci)` = the
133    /// constraint that emptied a domain). The unified kernel does not consume
134    /// the culprit; it is collapsed to a wipe-out bool here via `.is_some()`.
135    #[inline]
136    fn propagate_from(
137        &mut self,
138        var: VarId,
139        assignment: &mut Vec<Option<D::Value>>,
140        depth: usize,
141    ) -> bool {
142        match self.params.pruning {
143            Pruning::None => false,
144            Pruning::ForwardChecking => propagate::forward_check(
145                var,
146                self.variables,
147                self.constraints,
148                self.adjacency,
149                assignment.as_mut_slice(),
150                self.stats,
151                &mut self.trail,
152                depth,
153            )
154            .is_some(),
155            Pruning::Ac3 => ac3::ac3_from_variable(
156                var,
157                self.variables,
158                self.constraints,
159                self.adjacency,
160                assignment,
161                self.stats,
162                &mut self.trail,
163                &mut self.worklist,
164                depth,
165            )
166            .is_some(),
167            Pruning::AcFc => propagate::ac_fc(
168                var,
169                self.variables,
170                self.constraints,
171                self.adjacency,
172                assignment.as_mut_slice(),
173                self.stats,
174                &mut self.trail,
175                depth,
176            )
177            .is_some(),
178        }
179    }
180}
181
182/// The policy hooks. Defaults cover feasibility; branch-and-bound overrides
183/// `on_leaf`, `node_prune`, and `order_values`.
184trait SearchPolicy<D: Domain> {
185    /// Called at a complete assignment. Returns whether search should stop.
186    fn on_leaf(&mut self, k: &mut Kernel<'_, D>, assignment: &[Option<D::Value>]) -> Step;
187
188    /// Optional node-level prune (bound cutoff). Default: never prune.
189    #[inline]
190    fn node_prune(&mut self, _k: &Kernel<'_, D>, _assignment: &[Option<D::Value>]) -> bool {
191        false
192    }
193
194    /// Value branching order for `var`. Default: raw domain order (no-op).
195    #[inline]
196    fn order_values(&self, _k: &Kernel<'_, D>, _var: VarId, _values: &mut [D::Value]) {}
197}
198
199/// The one tree-search skeleton. Monomorphized per `(D, P)`.
200fn search<D, P>(
201    k: &mut Kernel<'_, D>,
202    p: &mut P,
203    assignment: &mut Vec<Option<D::Value>>,
204    stack: &mut Vec<VarId>,
205    depth: usize,
206) -> Step
207where
208    D: Domain,
209    D::Value: PartialEq + 'static,
210    P: SearchPolicy<D>,
211{
212    if stack.is_empty() {
213        return p.on_leaf(k, assignment);
214    }
215
216    // Budget guard — checked before `nodes_explored += 1` so the post-budget
217    // node is never counted and the flag is set exactly once per search.
218    if let Some(budget) = k.params.node_budget
219        && k.stats.nodes_explored >= budget
220    {
221        k.stats.budget_exceeded = true;
222        return Step::Done;
223    }
224
225    // Cancellation guard — same cadence as the budget guard, but for an
226    // externally requested stop (e.g. a `Python::allow_threads`-released
227    // search whose caller's `asyncio.wait_for` timeout just elapsed). Folds
228    // the pyo3 cancel-token check onto the unified kernel (the deleted
229    // per-mode recurse fns each carried their own copy).
230    if let Some(tok) = &k.params.cancel
231        && tok.is_cancelled()
232    {
233        k.stats.cancelled = true;
234        return Step::Done;
235    }
236
237    k.stats.nodes_explored += 1;
238
239    if p.node_prune(k, assignment) {
240        return Step::Continue;
241    }
242
243    let idx =
244        ordering::select_variable(stack, k.variables, k.params.ordering, &k.var_wdeg).unwrap();
245    let var = stack.swap_remove(idx);
246
247    // Per-node value snapshot: taken inline (no heap) for any domain that fits
248    // the 16-slot buffer, which covers every shipped puzzle (sudoku's 16×16 is
249    // the largest at 16 values); larger domains spill to the heap exactly as the
250    // former `Vec` did. Same values in the same iteration order, so the branch
251    // trajectory is byte-identical (`order_values` derefs to the slice; the
252    // feasibility policy leaves it untouched). Removes the ~1 alloc/node the
253    // `Vec` collect cost (P2-solver-backend VALUES).
254    let mut values: SmallVec<[D::Value; 16]> = k.variables[var as usize].domain.iter().collect();
255    p.order_values(k, var, &mut values);
256
257    for val in values {
258        let mark = k.trail.checkpoint();
259        assignment[var as usize] = Some(val.clone());
260
261        // Restrict domain to singleton {val} so revise() sees the decision.
262        k.variables[var as usize].restrict_to(&val, depth);
263        k.trail.push(var);
264
265        if k.is_valid(var, assignment)
266            && !k.propagate_from(var, assignment, depth)
267            && let Step::Done = search(k, p, assignment, stack, depth + 1)
268        {
269            return Step::Done;
270        }
271
272        k.stats.backtracks += 1;
273        assignment[var as usize] = None;
274        k.trail.undo_to(mark, depth, k.variables);
275    }
276
277    stack.push(var);
278    Step::Continue
279}
280
281// ---------------------------------------------------------------------------
282// Feasibility policy + entry point
283// ---------------------------------------------------------------------------
284
285struct Feasibility<D: Domain> {
286    solutions: Vec<Solution<D>>,
287    max_solutions: usize,
288}
289
290impl<D: Domain> SearchPolicy<D> for Feasibility<D>
291where
292    D::Value: PartialEq + 'static,
293{
294    #[inline]
295    fn on_leaf(&mut self, _k: &mut Kernel<'_, D>, assignment: &[Option<D::Value>]) -> Step {
296        self.solutions.push(
297            assignment
298                .iter()
299                .map(|v| v.as_ref().unwrap().clone())
300                .collect(),
301        );
302        if self.solutions.len() >= self.max_solutions {
303            Step::Done
304        } else {
305            Step::Continue
306        }
307    }
308}
309
310/// Run feasibility (satisfaction) search. `given` pre-seeds an assignment and
311/// filters the branch stack; `None` searches all variables from scratch.
312#[allow(clippy::too_many_arguments)]
313pub(crate) fn feasibility_search<D: Domain>(
314    variables: &mut [Variable<D>],
315    constraints: &[ConstraintEnum<D>],
316    adjacency: &Adjacency,
317    weights: &mut [f64],
318    var_cids: &[Vec<usize>],
319    params: &SearchParams,
320    stats: &mut SolveStats,
321    given: Option<&[(VarId, D::Value)]>,
322) -> Vec<Solution<D>>
323where
324    D::Value: PartialEq + 'static,
325{
326    let num_vars = variables.len();
327    let mut assignment: Vec<Option<D::Value>> = vec![None; num_vars];
328
329    let mut stack: Vec<VarId> = if let Some(given) = given {
330        for (var, val) in given {
331            assignment[*var as usize] = Some(val.clone());
332        }
333        (0..num_vars as u32)
334            .filter(|v| assignment[*v as usize].is_none())
335            .collect()
336    } else {
337        (0..num_vars as u32).collect()
338    };
339
340    let var_wdeg = ordering::precompute_var_wdeg(params.ordering, weights, var_cids);
341    let mut policy = Feasibility {
342        solutions: Vec::new(),
343        max_solutions: params.max_solutions,
344    };
345    let mut kernel = Kernel {
346        variables,
347        constraints,
348        adjacency,
349        var_wdeg,
350        stats,
351        trail: Trail::default(),
352        worklist: ac3::BitsetWorklist::new(constraints.len()),
353        params,
354    };
355
356    search(
357        &mut kernel,
358        &mut policy,
359        &mut assignment,
360        &mut stack,
361        SEARCH_ROOT_DEPTH,
362    );
363
364    policy.solutions
365}
366
367// ---------------------------------------------------------------------------
368// Branch-and-bound policy + entry point
369// ---------------------------------------------------------------------------
370
371struct ScoredSolution<D: Domain> {
372    solution: Solution<D>,
373    cost: f64,
374}
375
376struct BranchBound<'e, D: Domain> {
377    scored: Vec<ScoredSolution<D>>,
378    best_cost: f64,
379    maximize: bool,
380    eval: &'e dyn DomainCostEval<D>,
381}
382
383impl<D: Domain> BranchBound<'_, D>
384where
385    D::Value: PartialEq + 'static,
386{
387    /// Total cost of a complete assignment: the sum of domain costs.
388    fn assignment_cost(&self, k: &Kernel<'_, D>, assignment: &[Option<D::Value>]) -> f64 {
389        let mut cost = 0.0;
390        for (i, val) in assignment.iter().enumerate() {
391            if let Some(v) = val {
392                cost += self.eval.cost(&k.variables[i].domain, v);
393            }
394        }
395        cost
396    }
397
398    /// Optimistic bound on any completion (lower bound for minimize, upper for
399    /// maximize). Unassigned vars contribute their best-case domain cost.
400    fn optimistic_bound(&self, k: &Kernel<'_, D>, assignment: &[Option<D::Value>]) -> f64 {
401        let mut bound = 0.0;
402        for (i, val) in assignment.iter().enumerate() {
403            match val {
404                Some(v) => bound += self.eval.cost(&k.variables[i].domain, v),
405                None if self.maximize => bound += self.eval.max_cost(&k.variables[i].domain),
406                None => bound += self.eval.min_cost(&k.variables[i].domain),
407            }
408        }
409        bound
410    }
411}
412
413impl<D: Domain> SearchPolicy<D> for BranchBound<'_, D>
414where
415    D::Value: PartialEq + 'static,
416{
417    #[inline]
418    fn on_leaf(&mut self, k: &mut Kernel<'_, D>, assignment: &[Option<D::Value>]) -> Step {
419        let cost = self.assignment_cost(k, assignment);
420        let effective = if self.maximize { -cost } else { cost };
421        if effective < self.best_cost {
422            self.best_cost = effective;
423        }
424        self.scored.push(ScoredSolution {
425            solution: assignment
426                .iter()
427                .map(|v| v.as_ref().unwrap().clone())
428                .collect(),
429            cost,
430        });
431        // Optimization never stops early — keep searching for better solutions.
432        Step::Continue
433    }
434
435    #[inline]
436    fn node_prune(&mut self, k: &Kernel<'_, D>, assignment: &[Option<D::Value>]) -> bool {
437        let bound = self.optimistic_bound(k, assignment);
438        let effective = if self.maximize { -bound } else { bound };
439        effective >= self.best_cost
440    }
441
442    #[inline]
443    fn order_values(&self, k: &Kernel<'_, D>, var: VarId, values: &mut [D::Value]) {
444        let domain = &k.variables[var as usize].domain;
445        // Cheapest-first for minimize, costliest-first for maximize. Cache the
446        // cost key once per value instead of recomputing it per comparison.
447        if self.maximize {
448            values.sort_by(|a, b| {
449                self.eval
450                    .cost(domain, b)
451                    .partial_cmp(&self.eval.cost(domain, a))
452                    .unwrap_or(std::cmp::Ordering::Equal)
453            });
454        } else {
455            values.sort_by(|a, b| {
456                self.eval
457                    .cost(domain, a)
458                    .partial_cmp(&self.eval.cost(domain, b))
459                    .unwrap_or(std::cmp::Ordering::Equal)
460            });
461        }
462    }
463}
464
465/// Run branch-and-bound optimization. Returns up to `max_solutions` solutions,
466/// sorted best-first per the optimization direction.
467#[allow(clippy::too_many_arguments)]
468pub(crate) fn branch_and_bound<D: Domain>(
469    variables: &mut [Variable<D>],
470    constraints: &[ConstraintEnum<D>],
471    adjacency: &Adjacency,
472    weights: &mut [f64],
473    var_cids: &[Vec<usize>],
474    params: &SearchParams,
475    stats: &mut SolveStats,
476    maximize: bool,
477    cost_eval: &dyn DomainCostEval<D>,
478) -> Vec<Solution<D>>
479where
480    D::Value: PartialEq + 'static,
481{
482    let num_vars = variables.len();
483    let mut assignment: Vec<Option<D::Value>> = vec![None; num_vars];
484    let mut stack: Vec<VarId> = (0..num_vars as u32).collect();
485
486    let var_wdeg = ordering::precompute_var_wdeg(params.ordering, weights, var_cids);
487    let mut policy = BranchBound {
488        scored: Vec::new(),
489        best_cost: f64::INFINITY,
490        maximize,
491        eval: cost_eval,
492    };
493    let mut kernel = Kernel {
494        variables,
495        constraints,
496        adjacency,
497        var_wdeg,
498        stats,
499        trail: Trail::default(),
500        worklist: ac3::BitsetWorklist::new(constraints.len()),
501        params,
502    };
503
504    search(
505        &mut kernel,
506        &mut policy,
507        &mut assignment,
508        &mut stack,
509        SEARCH_ROOT_DEPTH,
510    );
511
512    // Best-first: lowest effective cost first. `maximize` flips the comparison.
513    if maximize {
514        policy.scored.sort_by(|a, b| {
515            b.cost
516                .partial_cmp(&a.cost)
517                .unwrap_or(std::cmp::Ordering::Equal)
518        });
519    } else {
520        policy.scored.sort_by(|a, b| {
521            a.cost
522                .partial_cmp(&b.cost)
523                .unwrap_or(std::cmp::Ordering::Equal)
524        });
525    }
526    policy.scored.truncate(params.max_solutions);
527    policy.scored.into_iter().map(|s| s.solution).collect()
528}