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