Skip to main content

csp_solver/
lib.rs

1//! A generalized CSP (Constraint Satisfaction Problem) solver.
2//!
3//! Isomorphic to the Python CSP solver. Supports:
4//! - Backtracking search with configurable pruning and variable ordering
5//! - AC-3 (Maintaining Arc Consistency) propagation
6//! - Forward checking
7//! - AC-FC hybrid
8//! - Conflict-directed backjumping
9//! - Lattice domains for monotonic fixed-point propagation
10
11pub mod adjacency;
12pub mod builder;
13pub mod constraint;
14pub mod domain;
15pub mod ordering;
16#[cfg(feature = "py")]
17pub mod py;
18pub mod puzzles;
19pub mod solver;
20pub mod variable;
21
22pub use builder::assignment::{
23    AssignmentBuilder, AssignmentError, AssignmentSolution, SENTINEL, assignment,
24};
25pub use puzzles::sudoku;
26
27use adjacency::Adjacency;
28use constraint::{AllDifferent, Constraint, ConstraintEnum, NotEqual, SoftLambdaConstraint, VarId};
29use domain::Domain;
30use ordering::Ordering;
31use solver::backjump::{self, BackjumpConfig};
32use solver::backtrack::{self, BacktrackConfig, Solution};
33use solver::optimize;
34use variable::Variable;
35
36/// Pruning strategy for backtracking search.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Pruning {
39    /// No pruning — pure backtracking.
40    None,
41    /// Forward checking: prune neighbors of the assigned variable.
42    ForwardChecking,
43    /// MAC: Maintaining Arc Consistency (AC-3 after each assignment).
44    Ac3,
45    /// Hybrid: forward checking + singleton propagation.
46    AcFc,
47}
48
49/// Propagation strategy for `propagate_with()`.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum PropagationStrategy {
52    /// Auto-select: AC-3 if finalize() was called, sweep otherwise.
53    Auto,
54    /// AC-3 worklist with adjacency graph. Requires finalize().
55    Ac3,
56    /// Fixed-point sweep over all constraints. No adjacency needed.
57    Sweep,
58}
59
60/// Optimization mode for the solver.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum OptimizationMode {
63    /// Find any feasible solution (existing behavior).
64    Feasibility,
65    /// Find the solution minimizing total cost (domain costs + soft penalties).
66    MinimizeCost,
67    /// Find the solution maximizing total cost.
68    MaximizeCost,
69}
70
71/// Solve configuration, isomorphic to Python's CSP constructor arguments.
72#[derive(Debug, Clone)]
73pub struct SolveConfig {
74    pub pruning: Pruning,
75    pub ordering: Ordering,
76    pub max_solutions: usize,
77    /// Whether to use conflict-directed backjumping instead of chronological backtracking.
78    pub backjumping: bool,
79    /// Optimization mode. Defaults to `Feasibility` (pure constraint satisfaction).
80    pub optimization_mode: OptimizationMode,
81    /// Maximum number of search nodes (backtrack / branch-and-bound
82    /// recursions) before the solver aborts early and returns whatever
83    /// solutions it has found so far. `None` disables the budget.
84    ///
85    /// Defaults to `Some(1_000_000)` so an unbounded pathological
86    /// search cannot hang a caller. When the budget is hit,
87    /// [`SolveStats::budget_exceeded`] is set to `true` on the
88    /// returning `Csp::stats()`. Callers that care about optimality
89    /// should branch on this flag and either accept the best-so-far
90    /// solution or fall back to a trivial per-variable pick.
91    pub node_budget: Option<u64>,
92}
93
94impl Default for SolveConfig {
95    fn default() -> Self {
96        Self {
97            pruning: Pruning::ForwardChecking,
98            ordering: Ordering::Chronological,
99            max_solutions: 1,
100            backjumping: false,
101            optimization_mode: OptimizationMode::Feasibility,
102            node_budget: Some(1_000_000),
103        }
104    }
105}
106
107/// Solver statistics.
108#[derive(Debug, Clone, Default)]
109pub struct SolveStats {
110    pub backtracks: u64,
111    pub nodes_explored: u64,
112    pub propagations: u64,
113    /// Set to `true` when the last search hit its
114    /// [`SolveConfig::node_budget`] and aborted early.
115    /// Solutions returned alongside this flag are best-so-far, not
116    /// necessarily optimal.
117    pub budget_exceeded: bool,
118}
119
120/// The main CSP solver struct.
121///
122/// Generic over the domain type `D`. Build a problem by adding variables and
123/// constraints, call `finalize()` to build the adjacency graph, then `solve()`.
124pub struct Csp<D: Domain> {
125    pub variables: Vec<Variable<D>>,
126    constraints: Vec<ConstraintEnum<D>>,
127    adjacency: Option<Adjacency>,
128    stats: SolveStats,
129    /// Per-constraint weights for dom/wdeg ordering.
130    constraint_weights: Vec<f64>,
131    /// For each variable, the indices of constraints involving it.
132    var_constraint_ids: Vec<Vec<usize>>,
133}
134
135impl<D: Domain> Csp<D> {
136    /// Create a new empty CSP.
137    pub fn new() -> Self {
138        Self {
139            variables: Vec::new(),
140            constraints: Vec::new(),
141            adjacency: None,
142            stats: SolveStats::default(),
143            constraint_weights: Vec::new(),
144            var_constraint_ids: Vec::new(),
145        }
146    }
147
148    /// Add a variable with the given domain. Returns its VarId.
149    pub fn add_variable(&mut self, domain: D) -> VarId {
150        let id = self.variables.len() as VarId;
151        self.variables.push(Variable::new(domain));
152        id
153    }
154
155    /// Add multiple variables sharing the same domain. Returns their VarIds.
156    pub fn add_variables(&mut self, domain: &D, count: usize) -> Vec<VarId> {
157        (0..count)
158            .map(|_| self.add_variable(domain.clone()))
159            .collect()
160    }
161
162    /// Add a custom constraint (wrapped in the `Custom` enum variant).
163    pub fn add_constraint(&mut self, c: impl Constraint<D> + 'static) {
164        self.constraints.push(ConstraintEnum::Custom(Box::new(c)));
165    }
166
167    /// Add a pre-typed constraint enum directly (avoids boxing for built-in types).
168    pub fn add_constraint_enum(&mut self, c: ConstraintEnum<D>) {
169        self.constraints.push(c);
170    }
171
172    /// Add a soft constraint (contributes penalty cost when violated, never prunes).
173    pub fn add_soft_constraint(&mut self, c: SoftLambdaConstraint<D>) {
174        self.constraints.push(ConstraintEnum::Soft(c));
175    }
176
177    /// Add a not-equal constraint (devirtualized fast path).
178    pub fn add_not_equal(&mut self, x: VarId, y: VarId) {
179        self.constraints.push(ConstraintEnum::NotEqual(NotEqual::new(x, y)));
180    }
181
182    /// Add an all-different constraint (devirtualized fast path).
183    pub fn add_all_different(&mut self, vars: Vec<VarId>) {
184        self.constraints.push(ConstraintEnum::AllDifferent(AllDifferent::new(vars)));
185    }
186
187    /// Fix a variable to a specific value.
188    pub fn add_equals(&mut self, var: VarId, value: D::Value)
189    where
190        D: 'static,
191    {
192        self.add_constraint(constraint::LambdaConstraint::new(
193            vec![var],
194            move |assignment| match &assignment[var as usize] {
195                Some(v) => *v == value,
196                None => true,
197            },
198            format!("equals({var})"),
199        ));
200    }
201
202    /// Constrain x < y (for Ord-comparable values).
203    pub fn add_less_than(&mut self, x: VarId, y: VarId)
204    where
205        D: 'static, D::Value: PartialOrd,
206    {
207        self.add_constraint(constraint::LambdaConstraint::new(
208            vec![x, y],
209            move |assignment| match (&assignment[x as usize], &assignment[y as usize]) {
210                (Some(a), Some(b)) => a < b,
211                _ => true,
212            },
213            format!("less_than({x},{y})"),
214        ));
215    }
216
217    /// Constrain x > y (for Ord-comparable values).
218    pub fn add_greater_than(&mut self, x: VarId, y: VarId)
219    where
220        D: 'static, D::Value: PartialOrd,
221    {
222        self.add_constraint(constraint::LambdaConstraint::new(
223            vec![x, y],
224            move |assignment| match (&assignment[x as usize], &assignment[y as usize]) {
225                (Some(a), Some(b)) => a > b,
226                _ => true,
227            },
228            format!("greater_than({x},{y})"),
229        ));
230    }
231
232    /// Build the adjacency graph. Must be called after all variables and
233    /// constraints have been added, before calling `solve()`.
234    pub fn finalize(&mut self)
235    where
236        D::Value: PartialEq,
237    {
238        let num_vars = self.variables.len();
239        self.adjacency = Some(Adjacency::build(num_vars, &self.constraints));
240
241        self.constraint_weights = vec![1.0; self.constraints.len()];
242        self.var_constraint_ids = vec![Vec::new(); num_vars];
243        for (ci, c) in self.constraints.iter().enumerate() {
244            for &v in c.scope() {
245                self.var_constraint_ids[v as usize].push(ci);
246            }
247        }
248    }
249
250    /// Propagate constraints to a fixed point (auto-select strategy).
251    pub fn propagate(&mut self) -> Result<(), Unsatisfiable>
252    where
253        D::Value: PartialEq,
254    {
255        self.propagate_with(PropagationStrategy::Auto)
256    }
257
258    /// Propagate constraints with an explicit strategy.
259    pub fn propagate_with(&mut self, strategy: PropagationStrategy) -> Result<(), Unsatisfiable>
260    where
261        D::Value: PartialEq,
262    {
263        match strategy {
264            PropagationStrategy::Auto => {
265                if self.adjacency.is_some() {
266                    self.propagate_with(PropagationStrategy::Ac3)
267                } else {
268                    self.propagate_with(PropagationStrategy::Sweep)
269                }
270            }
271            PropagationStrategy::Ac3 => {
272                let adjacency = self.adjacency.as_ref().ok_or(Unsatisfiable)?.clone();
273                solver::ac3::ac3_full(
274                    &mut self.variables,
275                    &self.constraints,
276                    &adjacency,
277                    &mut self.stats,
278                    0,
279                )
280            }
281            PropagationStrategy::Sweep => {
282                solver::monotonic::propagate_monotonic(
283                    &mut self.variables,
284                    &self.constraints,
285                    &mut self.stats,
286                )
287            }
288        }
289    }
290
291    /// Run backtracking (or backjumping) search with the given configuration.
292    ///
293    /// Returns up to `config.max_solutions` solutions.
294    /// When `optimization_mode` is `MinimizeCost` or `MaximizeCost`, uses
295    /// branch-and-bound search and returns solutions sorted by cost (best first).
296    pub fn solve(&mut self, config: &SolveConfig) -> Vec<Solution<D>>
297    where
298        D::Value: PartialEq,
299    {
300        let adjacency = self
301            .adjacency
302            .as_ref()
303            .expect("call finalize() before solve()")
304            .clone();
305
306        self.stats = SolveStats::default();
307
308        // Reset all variables to their original domains
309        for v in &mut self.variables {
310            v.reset();
311        }
312
313        match config.optimization_mode {
314            OptimizationMode::Feasibility => {
315                if config.backjumping {
316                    let bj_config = BackjumpConfig {
317                        pruning: config.pruning,
318                        ordering: config.ordering,
319                        max_solutions: config.max_solutions,
320                        constraint_weights: self.constraint_weights.clone(),
321                        var_constraint_ids: self.var_constraint_ids.clone(),
322                        node_budget: config.node_budget,
323                    };
324                    backjump::backjump_search(
325                        &mut self.variables,
326                        &self.constraints,
327                        &adjacency,
328                        &bj_config,
329                        &mut self.stats,
330                    )
331                } else {
332                    let bt_config = BacktrackConfig {
333                        pruning: config.pruning,
334                        ordering: config.ordering,
335                        max_solutions: config.max_solutions,
336                        constraint_weights: self.constraint_weights.clone(),
337                        var_constraint_ids: self.var_constraint_ids.clone(),
338                        node_budget: config.node_budget,
339                    };
340                    backtrack::backtrack_search(
341                        &mut self.variables,
342                        &self.constraints,
343                        &adjacency,
344                        &bt_config,
345                        &mut self.stats,
346                    )
347                }
348            }
349            mode @ (OptimizationMode::MinimizeCost | OptimizationMode::MaximizeCost) => {
350                let opt_config = optimize::OptimizeConfig {
351                    pruning: config.pruning,
352                    ordering: config.ordering,
353                    max_solutions: config.max_solutions,
354                    constraint_weights: self.constraint_weights.clone(),
355                    var_constraint_ids: self.var_constraint_ids.clone(),
356                    maximize: mode == OptimizationMode::MaximizeCost,
357                    node_budget: config.node_budget,
358                };
359                // Use ZeroCost evaluator — domain costs are 0.
360                // For CostDomain-aware optimization, use solve_optimized().
361                optimize::branch_and_bound(
362                    &mut self.variables,
363                    &self.constraints,
364                    &adjacency,
365                    &opt_config,
366                    &mut self.stats,
367                    &optimize::ZeroCost,
368                )
369            }
370        }
371    }
372
373    /// Solve with initial propagation for pre-assigned ("given") values.
374    ///
375    /// Analogous to Python's `solve_with_initial_propagation`.
376    /// Pre-assigns the given values, propagates constraints, then searches.
377    pub fn solve_with_given(
378        &mut self,
379        config: &SolveConfig,
380        given: &[(VarId, D::Value)],
381    ) -> Vec<Solution<D>>
382    where
383        D::Value: PartialEq,
384    {
385        let adjacency = self
386            .adjacency
387            .as_ref()
388            .expect("call finalize() before solve_with_given()")
389            .clone();
390
391        self.stats = SolveStats::default();
392
393        // Reset all variables to their original domains
394        for v in &mut self.variables {
395            v.reset();
396        }
397
398        // Apply given values: restrict domain to singleton
399        for (var, val) in given {
400            let v = &mut self.variables[*var as usize];
401            let vals: Vec<_> = v.domain.iter().collect();
402            for dv in &vals {
403                if dv != val {
404                    v.domain.remove(dv);
405                }
406            }
407        }
408
409        // One-hop propagation: for each given variable, remove its value from neighbors
410        for (var, val) in given {
411            for &neighbor in adjacency.neighbors_of_var(*var) {
412                let is_given = given.iter().any(|(gv, _)| *gv == neighbor);
413                if !is_given {
414                    self.variables[neighbor as usize].domain.remove(val);
415                }
416            }
417        }
418
419        // Initial AC-3 propagation from given cells
420        let _ = solver::ac3::ac3_full(
421            &mut self.variables,
422            &self.constraints,
423            &adjacency,
424            &mut self.stats,
425            0, // depth 0 = permanent reductions (no undo needed)
426        );
427
428        let bt_config = BacktrackConfig {
429            pruning: config.pruning,
430            ordering: config.ordering,
431            max_solutions: config.max_solutions,
432            constraint_weights: self.constraint_weights.clone(),
433            var_constraint_ids: self.var_constraint_ids.clone(),
434            node_budget: config.node_budget,
435        };
436
437        backtrack::backtrack_search_with_given(
438            &mut self.variables,
439            &self.constraints,
440            &adjacency,
441            &bt_config,
442            &mut self.stats,
443            given,
444        )
445    }
446
447    /// Run optimization search with a custom cost evaluator.
448    ///
449    /// This is the most flexible entry point: you supply a `DomainCostEval`
450    /// that defines how domain values are costed. Use `solve()` with
451    /// `OptimizationMode::MinimizeCost` if you only need soft constraint
452    /// penalties (zero domain cost), or `solve_optimized()` if your domain
453    /// implements `CostDomain`.
454    pub fn solve_with_cost_eval(
455        &mut self,
456        config: &SolveConfig,
457        cost_eval: &dyn optimize::DomainCostEval<D>,
458    ) -> Vec<Solution<D>>
459    where
460        D::Value: PartialEq,
461    {
462        let adjacency = self
463            .adjacency
464            .as_ref()
465            .expect("call finalize() before solve_with_cost_eval()")
466            .clone();
467
468        self.stats = SolveStats::default();
469        for v in &mut self.variables {
470            v.reset();
471        }
472
473        let mode = config.optimization_mode;
474        let opt_config = optimize::OptimizeConfig {
475            pruning: config.pruning,
476            ordering: config.ordering,
477            max_solutions: config.max_solutions,
478            constraint_weights: self.constraint_weights.clone(),
479            var_constraint_ids: self.var_constraint_ids.clone(),
480            maximize: mode == OptimizationMode::MaximizeCost,
481            node_budget: config.node_budget,
482        };
483        optimize::branch_and_bound(
484            &mut self.variables,
485            &self.constraints,
486            &adjacency,
487            &opt_config,
488            &mut self.stats,
489            cost_eval,
490        )
491    }
492
493    /// Get solver statistics from the last run.
494    pub fn stats(&self) -> &SolveStats {
495        &self.stats
496    }
497
498    /// Get a reference to the adjacency graph (available after `finalize()`).
499    pub fn adjacency(&self) -> Option<&Adjacency> {
500        self.adjacency.as_ref()
501    }
502}
503
504impl<D: Domain> Default for Csp<D> {
505    fn default() -> Self {
506        Self::new()
507    }
508}
509
510impl<D: domain::CostDomain> Csp<D> {
511    /// Run optimization search using the domain's `CostDomain` implementation
512    /// for value costing. This is the ergonomic entry point when your domain
513    /// type implements `CostDomain`.
514    pub fn solve_optimized(&mut self, config: &SolveConfig) -> Vec<Solution<D>>
515    where
516        D::Value: PartialEq,
517    {
518        self.solve_with_cost_eval(config, &optimize::CostDomainEval)
519    }
520}
521
522/// Error type for unsatisfiable constraints.
523#[derive(Debug, Clone)]
524pub struct Unsatisfiable;
525
526impl std::fmt::Display for Unsatisfiable {
527    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
528        write!(f, "CSP is unsatisfiable")
529    }
530}
531
532impl std::error::Error for Unsatisfiable {}