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 now return the blame signal (`Some(ci)` =
115    /// the constraint that emptied a domain — the CHS/dom-wdeg substrate). The
116    /// unified kernel does not yet consume the culprit (the restart/CHS driver
117    /// re-authoring from chs-restarts-nogoods is deferred — see the pass-3
118    /// composition report); it is collapsed to a wipe-out bool here via
119    /// `.is_some()`, keeping search behavior byte-identical.
120    #[inline]
121    fn propagate_from(
122        &mut self,
123        var: VarId,
124        assignment: &mut Vec<Option<D::Value>>,
125        depth: usize,
126    ) -> bool {
127        match self.params.pruning {
128            Pruning::None => false,
129            Pruning::ForwardChecking => propagate::forward_check(
130                var,
131                self.variables,
132                self.constraints,
133                self.adjacency,
134                assignment.as_mut_slice(),
135                self.stats,
136                &mut self.trail,
137                depth,
138            )
139            .is_some(),
140            Pruning::Ac3 => ac3::ac3_from_variable(
141                var,
142                self.variables,
143                self.constraints,
144                self.adjacency,
145                assignment,
146                self.stats,
147                &mut self.trail,
148                &mut self.worklist,
149                depth,
150            )
151            .is_some(),
152            Pruning::AcFc => propagate::ac_fc(
153                var,
154                self.variables,
155                self.constraints,
156                self.adjacency,
157                assignment.as_mut_slice(),
158                self.stats,
159                &mut self.trail,
160                depth,
161            )
162            .is_some(),
163        }
164    }
165}
166
167/// The policy hooks. Defaults cover feasibility; branch-and-bound overrides
168/// `on_leaf`, `node_prune`, and `order_values`.
169trait SearchPolicy<D: Domain> {
170    /// Called at a complete assignment. Returns whether search should stop.
171    fn on_leaf(&mut self, k: &mut Kernel<'_, D>, assignment: &[Option<D::Value>]) -> Step;
172
173    /// Optional node-level prune (bound cutoff). Default: never prune.
174    #[inline]
175    fn node_prune(&mut self, _k: &Kernel<'_, D>, _assignment: &[Option<D::Value>]) -> bool {
176        false
177    }
178
179    /// Value branching order for `var`. Default: raw domain order (no-op).
180    #[inline]
181    fn order_values(&self, _k: &Kernel<'_, D>, _var: VarId, _values: &mut [D::Value]) {}
182}
183
184/// The one tree-search skeleton. Monomorphized per `(D, P)`.
185fn search<D, P>(
186    k: &mut Kernel<'_, D>,
187    p: &mut P,
188    assignment: &mut Vec<Option<D::Value>>,
189    stack: &mut Vec<VarId>,
190    depth: usize,
191) -> Step
192where
193    D: Domain,
194    D::Value: PartialEq + 'static,
195    P: SearchPolicy<D>,
196{
197    if stack.is_empty() {
198        return p.on_leaf(k, assignment);
199    }
200
201    // Budget guard — checked before `nodes_explored += 1` so the post-budget
202    // node is never counted and the flag is set exactly once per search.
203    if let Some(budget) = k.params.node_budget
204        && k.stats.nodes_explored >= budget
205    {
206        k.stats.budget_exceeded = true;
207        return Step::Done;
208    }
209
210    // Cancellation guard — same cadence as the budget guard, but for an
211    // externally requested stop (e.g. a `Python::allow_threads`-released
212    // search whose caller's `asyncio.wait_for` timeout just elapsed). Folds
213    // the pyo3 cancel-token check onto the unified kernel (the deleted
214    // per-mode recurse fns each carried their own copy).
215    if let Some(tok) = &k.params.cancel
216        && tok.is_cancelled()
217    {
218        k.stats.cancelled = true;
219        return Step::Done;
220    }
221
222    k.stats.nodes_explored += 1;
223
224    if p.node_prune(k, assignment) {
225        return Step::Continue;
226    }
227
228    let idx =
229        ordering::select_variable(stack, k.variables, k.params.ordering, k.weights, k.var_cids)
230            .unwrap();
231    let var = stack.swap_remove(idx);
232
233    let mut values: Vec<_> = k.variables[var as usize].domain.iter().collect();
234    p.order_values(k, var, &mut values);
235
236    for val in values {
237        let mark = k.trail.checkpoint();
238        assignment[var as usize] = Some(val.clone());
239
240        // Restrict domain to singleton {val} so revise() sees the decision.
241        k.variables[var as usize].restrict_to(&val, depth);
242        k.trail.push(var);
243
244        if k.is_valid(var, assignment)
245            && !k.propagate_from(var, assignment, depth)
246            && let Step::Done = search(k, p, assignment, stack, depth + 1)
247        {
248            return Step::Done;
249        }
250
251        k.stats.backtracks += 1;
252        assignment[var as usize] = None;
253        k.trail.undo_to(mark, depth, k.variables);
254    }
255
256    stack.push(var);
257    Step::Continue
258}
259
260// ---------------------------------------------------------------------------
261// Feasibility policy + entry point
262// ---------------------------------------------------------------------------
263
264struct Feasibility<D: Domain> {
265    solutions: Vec<Solution<D>>,
266    max_solutions: usize,
267}
268
269impl<D: Domain> SearchPolicy<D> for Feasibility<D>
270where
271    D::Value: PartialEq + 'static,
272{
273    #[inline]
274    fn on_leaf(&mut self, _k: &mut Kernel<'_, D>, assignment: &[Option<D::Value>]) -> Step {
275        self.solutions.push(
276            assignment
277                .iter()
278                .map(|v| v.as_ref().unwrap().clone())
279                .collect(),
280        );
281        if self.solutions.len() >= self.max_solutions {
282            Step::Done
283        } else {
284            Step::Continue
285        }
286    }
287}
288
289/// Run feasibility (satisfaction) search. `given` pre-seeds an assignment and
290/// filters the branch stack; `None` searches all variables from scratch.
291#[allow(clippy::too_many_arguments)]
292pub fn feasibility_search<D: Domain>(
293    variables: &mut [Variable<D>],
294    constraints: &[ConstraintEnum<D>],
295    adjacency: &Adjacency,
296    weights: &mut [f64],
297    var_cids: &[Vec<usize>],
298    params: &SearchParams,
299    stats: &mut SolveStats,
300    given: Option<&[(VarId, D::Value)]>,
301) -> Vec<Solution<D>>
302where
303    D::Value: PartialEq + 'static,
304{
305    let num_vars = variables.len();
306    let mut assignment: Vec<Option<D::Value>> = vec![None; num_vars];
307
308    let mut stack: Vec<VarId> = if let Some(given) = given {
309        for (var, val) in given {
310            assignment[*var as usize] = Some(val.clone());
311        }
312        (0..num_vars as u32)
313            .filter(|v| assignment[*v as usize].is_none())
314            .collect()
315    } else {
316        (0..num_vars as u32).collect()
317    };
318
319    let mut policy = Feasibility {
320        solutions: Vec::new(),
321        max_solutions: params.max_solutions,
322    };
323    let mut kernel = Kernel {
324        variables,
325        constraints,
326        adjacency,
327        weights,
328        var_cids,
329        stats,
330        trail: Trail::default(),
331        worklist: ac3::BitsetWorklist::new(constraints.len()),
332        params,
333    };
334
335    search(
336        &mut kernel,
337        &mut policy,
338        &mut assignment,
339        &mut stack,
340        SEARCH_ROOT_DEPTH,
341    );
342
343    policy.solutions
344}
345
346// ---------------------------------------------------------------------------
347// Branch-and-bound policy + entry point
348// ---------------------------------------------------------------------------
349
350struct ScoredSolution<D: Domain> {
351    solution: Solution<D>,
352    cost: f64,
353}
354
355struct BranchBound<'e, D: Domain> {
356    scored: Vec<ScoredSolution<D>>,
357    best_cost: f64,
358    maximize: bool,
359    eval: &'e dyn DomainCostEval<D>,
360}
361
362impl<D: Domain> BranchBound<'_, D>
363where
364    D::Value: PartialEq + 'static,
365{
366    /// Total cost of a complete assignment: domain costs + soft penalties.
367    fn assignment_cost(&self, k: &Kernel<'_, D>, assignment: &[Option<D::Value>]) -> f64 {
368        let mut cost = 0.0;
369        for (i, val) in assignment.iter().enumerate() {
370            if let Some(v) = val {
371                cost += self.eval.cost(&k.variables[i].domain, v);
372            }
373        }
374        for c in k.constraints {
375            cost += c.soft_penalty(assignment);
376        }
377        cost
378    }
379
380    /// Optimistic bound on any completion (lower bound for minimize, upper for
381    /// maximize). Unassigned vars contribute their best-case domain cost;
382    /// fully-assigned soft-constraint scopes contribute their penalty.
383    fn optimistic_bound(&self, k: &Kernel<'_, D>, assignment: &[Option<D::Value>]) -> f64 {
384        let mut bound = 0.0;
385        for (i, val) in assignment.iter().enumerate() {
386            match val {
387                Some(v) => bound += self.eval.cost(&k.variables[i].domain, v),
388                None if self.maximize => bound += self.eval.max_cost(&k.variables[i].domain),
389                None => bound += self.eval.min_cost(&k.variables[i].domain),
390            }
391        }
392        for c in k.constraints {
393            let scope = c.scope();
394            if scope.iter().all(|&v| assignment[v as usize].is_some()) {
395                bound += c.soft_penalty(assignment);
396            }
397        }
398        bound
399    }
400}
401
402impl<D: Domain> SearchPolicy<D> for BranchBound<'_, D>
403where
404    D::Value: PartialEq + 'static,
405{
406    #[inline]
407    fn on_leaf(&mut self, k: &mut Kernel<'_, D>, assignment: &[Option<D::Value>]) -> Step {
408        let cost = self.assignment_cost(k, assignment);
409        let effective = if self.maximize { -cost } else { cost };
410        if effective < self.best_cost {
411            self.best_cost = effective;
412        }
413        self.scored.push(ScoredSolution {
414            solution: assignment
415                .iter()
416                .map(|v| v.as_ref().unwrap().clone())
417                .collect(),
418            cost,
419        });
420        // Optimization never stops early — keep searching for better solutions.
421        Step::Continue
422    }
423
424    #[inline]
425    fn node_prune(&mut self, k: &Kernel<'_, D>, assignment: &[Option<D::Value>]) -> bool {
426        let bound = self.optimistic_bound(k, assignment);
427        let effective = if self.maximize { -bound } else { bound };
428        effective >= self.best_cost
429    }
430
431    #[inline]
432    fn order_values(&self, k: &Kernel<'_, D>, var: VarId, values: &mut [D::Value]) {
433        let domain = &k.variables[var as usize].domain;
434        // Cheapest-first for minimize, costliest-first for maximize. Cache the
435        // cost key once per value instead of recomputing it per comparison.
436        if self.maximize {
437            values.sort_by(|a, b| {
438                self.eval
439                    .cost(domain, b)
440                    .partial_cmp(&self.eval.cost(domain, a))
441                    .unwrap_or(std::cmp::Ordering::Equal)
442            });
443        } else {
444            values.sort_by(|a, b| {
445                self.eval
446                    .cost(domain, a)
447                    .partial_cmp(&self.eval.cost(domain, b))
448                    .unwrap_or(std::cmp::Ordering::Equal)
449            });
450        }
451    }
452}
453
454/// Run branch-and-bound optimization. Returns up to `max_solutions` solutions,
455/// sorted best-first per the optimization direction.
456#[allow(clippy::too_many_arguments)]
457pub fn branch_and_bound<D: Domain>(
458    variables: &mut [Variable<D>],
459    constraints: &[ConstraintEnum<D>],
460    adjacency: &Adjacency,
461    weights: &mut [f64],
462    var_cids: &[Vec<usize>],
463    params: &SearchParams,
464    stats: &mut SolveStats,
465    maximize: bool,
466    cost_eval: &dyn DomainCostEval<D>,
467) -> Vec<Solution<D>>
468where
469    D::Value: PartialEq + 'static,
470{
471    let num_vars = variables.len();
472    let mut assignment: Vec<Option<D::Value>> = vec![None; num_vars];
473    let mut stack: Vec<VarId> = (0..num_vars as u32).collect();
474
475    let mut policy = BranchBound {
476        scored: Vec::new(),
477        best_cost: f64::INFINITY,
478        maximize,
479        eval: cost_eval,
480    };
481    let mut kernel = Kernel {
482        variables,
483        constraints,
484        adjacency,
485        weights,
486        var_cids,
487        stats,
488        trail: Trail::default(),
489        worklist: ac3::BitsetWorklist::new(constraints.len()),
490        params,
491    };
492
493    search(
494        &mut kernel,
495        &mut policy,
496        &mut assignment,
497        &mut stack,
498        SEARCH_ROOT_DEPTH,
499    );
500
501    // Best-first: lowest effective cost first. `maximize` flips the comparison.
502    if maximize {
503        policy.scored.sort_by(|a, b| {
504            b.cost
505                .partial_cmp(&a.cost)
506                .unwrap_or(std::cmp::Ordering::Equal)
507        });
508    } else {
509        policy.scored.sort_by(|a, b| {
510            a.cost
511                .partial_cmp(&b.cost)
512                .unwrap_or(std::cmp::Ordering::Equal)
513        });
514    }
515    policy.scored.truncate(params.max_solutions);
516    policy.scored.into_iter().map(|s| s.solution).collect()
517}