Skip to main content

csp_solver/csp/
solve.rs

1//! Solve / propagate dispatch — routes a finalized [`Csp<D>`] into the
2//! propagation engines (AC-3, monotonic sweep) and the unified search kernel
3//! (feasibility / branch-and-bound). Also home to the [`Unsatisfiable`]
4//! control-flow marker, whose sole consumers are `propagate` / `propagate_with`.
5//!
6//! Tests: `tests/solver.rs` (general solve correctness),
7//! `tests/solution_set_invariance.rs` (dispatch parity property test).
8
9use crate::config::{Csp, OptimizationMode, PropagationStrategy, Pruning, SolveConfig, SolveStats};
10use crate::constraint::VarId;
11use crate::domain::{self, Domain};
12use crate::solver::search::{self, SearchParams};
13use crate::solver::{self, Solution, optimize};
14
15impl<D: Domain> Csp<D> {
16    /// Propagate constraints to a fixed point (auto-select strategy).
17    pub fn propagate(&mut self) -> Result<(), Unsatisfiable>
18    where
19        D::Value: PartialEq + 'static,
20    {
21        self.propagate_with(PropagationStrategy::Auto)
22    }
23
24    /// Propagate constraints with an explicit strategy.
25    pub fn propagate_with(&mut self, strategy: PropagationStrategy) -> Result<(), Unsatisfiable>
26    where
27        D::Value: PartialEq + 'static,
28    {
29        match strategy {
30            PropagationStrategy::Auto => {
31                if self.adjacency.is_some() {
32                    self.propagate_with(PropagationStrategy::Ac3)
33                } else {
34                    self.propagate_with(PropagationStrategy::Sweep)
35                }
36            }
37            PropagationStrategy::Ac3 => {
38                // Disjoint field borrows — no adjacency clone (kernel S5).
39                let adjacency = self.adjacency.as_ref().ok_or(Unsatisfiable)?;
40                // Caller-owned reusable AC-3 worklist scratch (zero-alloc P2-2).
41                let mut worklist = solver::ac3::BitsetWorklist::new(self.constraints.len());
42                solver::ac3::ac3_full(
43                    &mut self.variables,
44                    &self.constraints,
45                    adjacency,
46                    &mut self.stats,
47                    &mut worklist,
48                    search::PERMANENT_DEPTH,
49                )
50            }
51            PropagationStrategy::Sweep => solver::monotonic::propagate_monotonic(
52                &mut self.variables,
53                &self.constraints,
54                &mut self.stats,
55            ),
56        }
57    }
58
59    /// Run backtracking search with the given configuration.
60    ///
61    /// Returns up to `config.max_solutions` solutions.
62    /// When `optimization_mode` is `MinimizeCost` or `MaximizeCost`, uses
63    /// branch-and-bound search and returns solutions sorted by cost (best first).
64    pub fn solve(&mut self, config: &SolveConfig) -> Vec<Solution<D>>
65    where
66        D::Value: PartialEq + 'static,
67    {
68        assert!(self.adjacency.is_some(), "call finalize() before solve()");
69
70        self.stats = SolveStats::default();
71
72        // Reset all variables to their original domains.
73        for v in &mut self.variables {
74            v.reset();
75        }
76
77        // Root propagation, symmetric with `solve_with_given`'s initial AC-3.
78        // Only the MAC (`Ac3`) strategy establishes global arc-consistency, so
79        // it is the one whose contract calls for propagating the root before
80        // search; the weaker forward-checking strategies prune relative to an
81        // assignment and do no root work (`forward_check` on an empty
82        // assignment is a no-op). Runs at `PERMANENT_DEPTH` so search's
83        // depth-keyed undo never reverts it.
84        if config.pruning == Pruning::Ac3 {
85            let adjacency = self.adjacency.as_ref().unwrap();
86            let mut worklist = solver::ac3::BitsetWorklist::new(self.constraints.len());
87            let _ = solver::ac3::ac3_full(
88                &mut self.variables,
89                &self.constraints,
90                adjacency,
91                &mut self.stats,
92                &mut worklist,
93                search::PERMANENT_DEPTH,
94            );
95        }
96
97        let params = SearchParams {
98            pruning: config.pruning,
99            ordering: config.ordering,
100            max_solutions: config.max_solutions,
101            node_budget: config.node_budget,
102            cancel: config.cancel.clone(),
103        };
104        let adjacency = self.adjacency.as_ref().unwrap();
105
106        match config.optimization_mode {
107            OptimizationMode::Feasibility => search::feasibility_search(
108                &mut self.variables,
109                &self.constraints,
110                adjacency,
111                &mut self.constraint_weights,
112                &self.var_constraint_ids,
113                &params,
114                &mut self.stats,
115                None,
116            ),
117            mode @ (OptimizationMode::MinimizeCost | OptimizationMode::MaximizeCost) => {
118                // ZeroCost evaluator — domain costs are 0. For CostDomain-aware
119                // optimization, use `solve_optimized()`.
120                search::branch_and_bound(
121                    &mut self.variables,
122                    &self.constraints,
123                    adjacency,
124                    &mut self.constraint_weights,
125                    &self.var_constraint_ids,
126                    &params,
127                    &mut self.stats,
128                    mode == OptimizationMode::MaximizeCost,
129                    &optimize::ZeroCost,
130                )
131            }
132        }
133    }
134
135    /// Solve with initial propagation for pre-assigned ("given") values.
136    ///
137    /// Analogous to Python's `solve_with_initial_propagation`.
138    /// Pre-assigns the given values, propagates constraints, then searches.
139    pub fn solve_with_given(
140        &mut self,
141        config: &SolveConfig,
142        given: &[(VarId, D::Value)],
143    ) -> Vec<Solution<D>>
144    where
145        D::Value: PartialEq + 'static,
146    {
147        assert!(
148            self.adjacency.is_some(),
149            "call finalize() before solve_with_given()"
150        );
151
152        self.stats = SolveStats::default();
153
154        // Reset all variables to their original domains.
155        for v in &mut self.variables {
156            v.reset();
157        }
158
159        // Apply given values: restrict domain to singleton. Uses
160        // `Domain::restrict_to` directly (not `Variable::restrict_to`) —
161        // these reductions are permanent, not undo-logged (zero-alloc: O(1)
162        // bitmask restrict for `BitsetDomain`, not a collect-then-remove loop).
163        for (var, val) in given {
164            let _ = self.variables[*var as usize].domain.restrict_to(val);
165        }
166
167        // One-hop propagation: remove each given value from its non-given
168        // neighbors. Also permanent (direct `remove`).
169        {
170            let adjacency = self.adjacency.as_ref().unwrap();
171            for (var, val) in given {
172                for &neighbor in adjacency.neighbors_of_var(*var) {
173                    let is_given = given.iter().any(|(gv, _)| *gv == neighbor);
174                    if !is_given {
175                        self.variables[neighbor as usize].domain.remove(val);
176                    }
177                }
178            }
179        }
180
181        // Initial AC-3 propagation from the given cells, at `PERMANENT_DEPTH`.
182        // The kernel searches from a strictly deeper frame, so its depth-keyed
183        // undo can never revert these reductions — closing the depth-0 seam
184        // where the first failed root candidate un-pruned this AC-3 via
185        // `restore(0)`. Worklist is caller-owned reusable scratch (zero-alloc).
186        {
187            let adjacency = self.adjacency.as_ref().unwrap();
188            let mut worklist = solver::ac3::BitsetWorklist::new(self.constraints.len());
189            let _ = solver::ac3::ac3_full(
190                &mut self.variables,
191                &self.constraints,
192                adjacency,
193                &mut self.stats,
194                &mut worklist,
195                search::PERMANENT_DEPTH,
196            );
197        }
198
199        let params = SearchParams {
200            pruning: config.pruning,
201            ordering: config.ordering,
202            max_solutions: config.max_solutions,
203            node_budget: config.node_budget,
204            cancel: config.cancel.clone(),
205        };
206        let adjacency = self.adjacency.as_ref().unwrap();
207
208        search::feasibility_search(
209            &mut self.variables,
210            &self.constraints,
211            adjacency,
212            &mut self.constraint_weights,
213            &self.var_constraint_ids,
214            &params,
215            &mut self.stats,
216            Some(given),
217        )
218    }
219
220    /// Run optimization search with a custom cost evaluator.
221    ///
222    /// This is the most flexible entry point: you supply a `DomainCostEval`
223    /// that defines how domain values are costed. Use `solve()` with
224    /// `OptimizationMode::MinimizeCost` if you only need soft constraint
225    /// penalties (zero domain cost), or `solve_optimized()` if your domain
226    /// implements `CostDomain`.
227    pub fn solve_with_cost_eval(
228        &mut self,
229        config: &SolveConfig,
230        cost_eval: &dyn optimize::DomainCostEval<D>,
231    ) -> Vec<Solution<D>>
232    where
233        D::Value: PartialEq + 'static,
234    {
235        assert!(
236            self.adjacency.is_some(),
237            "call finalize() before solve_with_cost_eval()"
238        );
239
240        self.stats = SolveStats::default();
241        for v in &mut self.variables {
242            v.reset();
243        }
244
245        let params = SearchParams {
246            pruning: config.pruning,
247            ordering: config.ordering,
248            max_solutions: config.max_solutions,
249            node_budget: config.node_budget,
250            cancel: config.cancel.clone(),
251        };
252        let adjacency = self.adjacency.as_ref().unwrap();
253        search::branch_and_bound(
254            &mut self.variables,
255            &self.constraints,
256            adjacency,
257            &mut self.constraint_weights,
258            &self.var_constraint_ids,
259            &params,
260            &mut self.stats,
261            config.optimization_mode == OptimizationMode::MaximizeCost,
262            cost_eval,
263        )
264    }
265
266    /// Get solver statistics from the last run.
267    pub fn stats(&self) -> &SolveStats {
268        &self.stats
269    }
270}
271
272impl<D: Domain> Default for Csp<D> {
273    fn default() -> Self {
274        Self::new()
275    }
276}
277
278impl<D: domain::CostDomain> Csp<D> {
279    /// Run optimization search using the domain's `CostDomain` implementation
280    /// for value costing. This is the ergonomic entry point when your domain
281    /// type implements `CostDomain`.
282    pub fn solve_optimized(&mut self, config: &SolveConfig) -> Vec<Solution<D>>
283    where
284        D::Value: PartialEq + 'static,
285    {
286        self.solve_with_cost_eval(config, &optimize::CostDomainEval)
287    }
288}
289
290/// Error type for unsatisfiable constraints.
291#[derive(Debug, Clone)]
292pub struct Unsatisfiable;
293
294impl std::fmt::Display for Unsatisfiable {
295    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
296        write!(f, "CSP is unsatisfiable")
297    }
298}
299
300impl std::error::Error for Unsatisfiable {}