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