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}