Skip to main content

csp_solver/builder/
assignment.rs

1//! Bipartite assignment COP builder.
2//!
3//! Tests: `tests/assignment_builder.rs`, `tests/assignment_proptest.rs`.
4//!
5//! Fluent API for the common pattern of "assign N source rows to M
6//! target columns with per-cell costs, role-based AllDifferent groups,
7//! and optional hard pin constraints." Internally constructs a
8//! [`Csp<CostFiniteDomain>`] with one variable per row, an
9//! [`AllDifferentExcept`] per row-group, and `-1` as the unmatched
10//! sentinel; the underlying branch-and-bound search is invoked through
11//! [`Csp::solve_optimized`] with [`OptimizationMode::MinimizeCost`] and
12//! [`Pruning::Ac3`].
13//!
14//! `AssignmentBuilder` is intended for `n ≤ ~100` rows / cols. The
15//! branch-and-bound search degrades super-linearly past that point;
16//! larger problems should prefer a specialized Hungarian algorithm
17//! and feed the resulting permutation back into a Csp only if
18//! additional constraints (groups, pins) make the closed-form
19//! solution infeasible.
20//!
21//! # Example
22//!
23//! ```
24//! use csp_solver::assignment;
25//!
26//! let sol = assignment()
27//!     .rows(3)
28//!     .cols(3)
29//!     .cost(|i, k| if i == k { 0.0 } else { 10.0 })
30//!     .unmatch_penalty(100.0)
31//!     .solve()
32//!     .expect("solvable");
33//!
34//! assert_eq!(sol.assign, vec![0, 1, 2]);
35//! assert_eq!(sol.cost, 0.0);
36//! ```
37
38use crate::constraint::{AllDifferentExcept, ConstraintEnum};
39use crate::domain::CostFiniteDomain;
40use crate::{Csp, OptimizationMode, Pruning, SolveConfig, SolveStats};
41
42/// Sentinel value used in [`AssignmentSolution::assign`] to denote an
43/// unmatched row.
44///
45/// Encoded as a negative `i32` so it can never collide with a valid
46/// 0-indexed column. The internal `CostFiniteDomain` for each row
47/// always carries this value as a real domain entry priced at the
48/// caller-supplied [`AssignmentBuilder::unmatch_penalty`]; the
49/// branch-and-bound search treats it as just another option whose
50/// dominance is decided by total cost.
51pub const SENTINEL: i32 = -1;
52
53/// Default node budget applied to the underlying branch-and-bound
54/// search when the caller does not override it via
55/// [`AssignmentBuilder::node_budget`].
56const DEFAULT_NODE_BUDGET: u64 = 1_000_000;
57
58/// Fluent builder for bipartite assignment COPs.
59///
60/// Construct via [`assignment()`] (preferred) or [`Default::default`].
61/// All setters consume `self` and return `self`, allowing chained
62/// configuration. The terminal [`AssignmentBuilder::solve`] call
63/// validates the configuration, materializes the underlying
64/// [`Csp<CostFiniteDomain>`], runs branch-and-bound, and returns an
65/// [`AssignmentSolution`] (or an [`AssignmentError`] on
66/// mis-configuration / infeasibility).
67#[derive(Debug, Default)]
68pub struct AssignmentBuilder {
69    n_rows: usize,
70    n_cols: usize,
71    /// Row-major `n_rows × n_cols` matrix of per-cell costs. Populated
72    /// eagerly by [`AssignmentBuilder::cost`] so the builder owns no
73    /// closure state.
74    cost_matrix: Vec<f64>,
75    /// Length `n_rows`; defaults to all-zero (single group) if the
76    /// caller never invoked [`AssignmentBuilder::row_group`].
77    row_groups: Vec<u8>,
78    /// Length `n_cols`; defaults to all-zero (single group) if the
79    /// caller never invoked [`AssignmentBuilder::col_group`].
80    col_groups: Vec<u8>,
81    /// Hard `(row, col)` equality pins. Validated against the row's
82    /// computed domain at [`AssignmentBuilder::solve`] time.
83    pins: Vec<(usize, i32)>,
84    /// Per-row cost paid when the assigned column is [`SENTINEL`].
85    unmatch_penalty: f64,
86    /// Optional cap on branch-and-bound nodes; `None` means use the
87    /// crate default of `1_000_000`. See
88    /// [`crate::SolveConfig::node_budget`] for the contract.
89    node_budget: Option<u64>,
90    /// Tracks whether [`AssignmentBuilder::cost`] has been called so
91    /// `.solve()` can return [`AssignmentError::CostNotSet`] without
92    /// guessing from `cost_matrix.is_empty()`.
93    cost_set: bool,
94}
95
96/// Result of a successful [`AssignmentBuilder::solve`] call.
97#[derive(Debug, Clone)]
98pub struct AssignmentSolution {
99    /// Length `n_rows`. Each entry is the assigned column index in
100    /// `0..n_cols`, or [`SENTINEL`] (`-1`) if the row was left
101    /// unmatched.
102    pub assign: Vec<i32>,
103    /// Total cost of the assignment: the sum of `cost_matrix[i][k]`
104    /// for each matched row `i → k`, plus
105    /// [`AssignmentBuilder::unmatch_penalty`] for each unmatched row.
106    pub cost: f64,
107    /// Statistics from the underlying branch-and-bound run. Inspect
108    /// [`SolveStats::budget_exceeded`] to distinguish best-so-far
109    /// from optimal solutions.
110    pub stats: SolveStats,
111}
112
113/// Errors from [`AssignmentBuilder::solve`].
114#[derive(Debug)]
115pub enum AssignmentError {
116    /// `.rows()` or `.cols()` was not called before `.solve()` (or
117    /// either was set to zero).
118    DimensionsNotSet,
119    /// `.cost()` was not called before `.solve()`.
120    CostNotSet,
121    /// A custom `row_group` / `col_group` slice did not match the
122    /// declared dimensions.
123    GroupLengthMismatch,
124    /// A pin references an out-of-range row or a column that is
125    /// neither [`SENTINEL`] nor a valid `0..n_cols` index, or whose
126    /// row-group does not match its target column's group.
127    InvalidPin {
128        /// Row index supplied to [`AssignmentBuilder::pin`].
129        row: usize,
130        /// Column index (or [`SENTINEL`]) supplied to
131        /// [`AssignmentBuilder::pin`].
132        col: i32,
133    },
134    /// The CSP has no feasible solution under the supplied
135    /// constraints. Note that with [`SENTINEL`] always available a
136    /// pure assignment problem is always feasible; this variant
137    /// surfaces when pins or group constraints are mutually
138    /// incompatible.
139    Infeasible,
140    /// The branch-and-bound search hit its
141    /// [`AssignmentBuilder::node_budget`] before scoring a single
142    /// complete assignment, so there is no best-so-far solution to
143    /// return. Distinct from [`Infeasible`](Self::Infeasible): the
144    /// problem may well be satisfiable — the search simply ran out of
145    /// budget. Retry with a larger (or `None`) `node_budget`. When the
146    /// budget is hit *after* at least one complete assignment was
147    /// scored, `.solve()` instead returns `Ok` with
148    /// [`SolveStats::budget_exceeded`] set on the best-so-far solution.
149    BudgetExceeded,
150}
151
152impl std::fmt::Display for AssignmentError {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        match self {
155            Self::DimensionsNotSet => {
156                write!(
157                    f,
158                    "AssignmentBuilder: .rows() and .cols() must both be set to a non-zero value before .solve()"
159                )
160            }
161            Self::CostNotSet => {
162                write!(
163                    f,
164                    "AssignmentBuilder: .cost() must be called before .solve()"
165                )
166            }
167            Self::GroupLengthMismatch => {
168                write!(
169                    f,
170                    "AssignmentBuilder: row_groups / col_groups length does not match the declared dimensions"
171                )
172            }
173            Self::InvalidPin { row, col } => {
174                write!(
175                    f,
176                    "AssignmentBuilder: invalid pin (row={row}, col={col}); col must be SENTINEL or a valid 0..n_cols index sharing the row's group"
177                )
178            }
179            Self::Infeasible => {
180                write!(
181                    f,
182                    "AssignmentBuilder: CSP is infeasible under the supplied constraints"
183                )
184            }
185            Self::BudgetExceeded => {
186                write!(
187                    f,
188                    "AssignmentBuilder: node budget exhausted before any complete assignment was scored; increase node_budget (or pass None) or reduce the problem size"
189                )
190            }
191        }
192    }
193}
194
195impl std::error::Error for AssignmentError {}
196
197/// Top-level constructor for an empty [`AssignmentBuilder`].
198///
199/// Equivalent to [`AssignmentBuilder::default`] but reads more
200/// naturally at the call site:
201///
202/// ```
203/// use csp_solver::assignment;
204///
205/// let sol = assignment()
206///     .rows(2)
207///     .cols(2)
208///     .cost(|i, k| (i + k) as f64)
209///     .solve()
210///     .expect("trivially solvable");
211/// assert_eq!(sol.assign.len(), 2);
212/// ```
213pub fn assignment() -> AssignmentBuilder {
214    AssignmentBuilder::default()
215}
216
217impl AssignmentBuilder {
218    /// Set the number of source rows.
219    pub fn rows(mut self, n: usize) -> Self {
220        self.n_rows = n;
221        self
222    }
223
224    /// Set the number of target columns.
225    pub fn cols(mut self, n: usize) -> Self {
226        self.n_cols = n;
227        self
228    }
229
230    /// Eagerly populate the row-major cost matrix.
231    ///
232    /// Calls `f(i, k)` exactly once per `(row, col)` cell during this
233    /// method, stores the result in an internal `Vec<f64>`, and
234    /// returns `self`. No closure is retained, which keeps the
235    /// builder `Send + Sync` even when constructed from non-`'static`
236    /// captures.
237    ///
238    /// # Panics
239    ///
240    /// Panics if [`AssignmentBuilder::rows`] or
241    /// [`AssignmentBuilder::cols`] has not been called yet — both
242    /// dimensions are required to know how to walk `f`.
243    pub fn cost(mut self, f: impl Fn(usize, usize) -> f64) -> Self {
244        assert!(
245            self.n_rows > 0 && self.n_cols > 0,
246            "AssignmentBuilder::cost() requires .rows() and .cols() to be set first"
247        );
248        let mut matrix = Vec::with_capacity(self.n_rows * self.n_cols);
249        for i in 0..self.n_rows {
250            for k in 0..self.n_cols {
251                matrix.push(f(i, k));
252            }
253        }
254        self.cost_matrix = matrix;
255        self.cost_set = true;
256        self
257    }
258
259    /// Tag each row with a `u8` group identifier.
260    ///
261    /// Rows in different groups are placed in independent
262    /// [`AllDifferentExcept`] scopes, and a row may only be assigned
263    /// to a column whose group identifier matches. Omitting the call
264    /// (or supplying `|_| 0`) puts every row in a single group, which
265    /// is the standard bipartite-assignment shape.
266    pub fn row_group(mut self, f: impl Fn(usize) -> u8) -> Self {
267        self.row_groups = (0..self.n_rows).map(f).collect();
268        self
269    }
270
271    /// Tag each column with a `u8` group identifier.
272    ///
273    /// See [`AssignmentBuilder::row_group`] for the semantics.
274    pub fn col_group(mut self, f: impl Fn(usize) -> u8) -> Self {
275        self.col_groups = (0..self.n_cols).map(f).collect();
276        self
277    }
278
279    /// Hard-pin row `row` to column `col`.
280    ///
281    /// `col` may be [`SENTINEL`] to force the row unmatched. Multiple
282    /// pins are accumulated; conflicting pins on the same row are
283    /// detected at [`AssignmentBuilder::solve`] time as
284    /// [`AssignmentError::Infeasible`].
285    pub fn pin(mut self, row: usize, col: i32) -> Self {
286        self.pins.push((row, col));
287        self
288    }
289
290    /// Set the per-row cost paid when a row is assigned to
291    /// [`SENTINEL`] (left unmatched).
292    pub fn unmatch_penalty(mut self, penalty: f64) -> Self {
293        self.unmatch_penalty = penalty;
294        self
295    }
296
297    /// Override the underlying branch-and-bound node budget.
298    ///
299    /// Passing `None` here is *not* the same as never calling this
300    /// method: `None` requests an unbounded search, while the default
301    /// (no call) installs a `1_000_000` node guard so a pathological
302    /// problem cannot hang the caller. See
303    /// [`crate::SolveConfig::node_budget`].
304    pub fn node_budget(mut self, budget: Option<u64>) -> Self {
305        self.node_budget = budget;
306        self
307    }
308
309    /// Validate the configuration, build the underlying CSP, and run
310    /// branch-and-bound to find the minimum-cost assignment.
311    pub fn solve(self) -> Result<AssignmentSolution, AssignmentError> {
312        // 1. Dimensions + cost must be set.
313        if self.n_rows == 0 || self.n_cols == 0 {
314            return Err(AssignmentError::DimensionsNotSet);
315        }
316        if !self.cost_set {
317            return Err(AssignmentError::CostNotSet);
318        }
319
320        // 2. Default groups to all-zero if the caller did not supply
321        //    them; otherwise verify lengths match the declared
322        //    dimensions.
323        let row_groups: Vec<u8> = if self.row_groups.is_empty() {
324            vec![0; self.n_rows]
325        } else if self.row_groups.len() == self.n_rows {
326            self.row_groups
327        } else {
328            return Err(AssignmentError::GroupLengthMismatch);
329        };
330        let col_groups: Vec<u8> = if self.col_groups.is_empty() {
331            vec![0; self.n_cols]
332        } else if self.col_groups.len() == self.n_cols {
333            self.col_groups
334        } else {
335            return Err(AssignmentError::GroupLengthMismatch);
336        };
337
338        // 3. Pre-validate pins and collapse them into a per-row map.
339        //    Pins are baked directly into each row's CostFiniteDomain
340        //    at construction time so the variable's `original_domain`
341        //    already encodes the singleton; this matters because
342        //    `Csp::solve_optimized` calls `Variable::reset()` at
343        //    search start and would otherwise undo any post-hoc
344        //    domain mutation. Multiple pins on the same row are
345        //    accepted only if they agree.
346        let mut row_pin: Vec<Option<i32>> = vec![None; self.n_rows];
347        for &(row, col) in &self.pins {
348            if row >= self.n_rows {
349                return Err(AssignmentError::InvalidPin { row, col });
350            }
351            if col != SENTINEL && (col < 0 || col as usize >= self.n_cols) {
352                return Err(AssignmentError::InvalidPin { row, col });
353            }
354            // Verify pin is compatible with the row's group: SENTINEL
355            // is always allowed, otherwise the column's group must
356            // match the row's.
357            if col != SENTINEL && col_groups[col as usize] != row_groups[row] {
358                return Err(AssignmentError::InvalidPin { row, col });
359            }
360            match row_pin[row] {
361                None => row_pin[row] = Some(col),
362                Some(prev) if prev == col => {} // duplicate, fine
363                Some(_) => return Err(AssignmentError::Infeasible),
364            }
365        }
366
367        // 4. Build one CostFiniteDomain per row, restricted to columns
368        //    whose group matches the row's group (and to the pinned
369        //    singleton when a pin is present). SENTINEL is always
370        //    available at the unmatch penalty unless overridden by a
371        //    non-SENTINEL pin.
372        let mut csp: Csp<CostFiniteDomain> = Csp::new();
373        let mut row_var_ids: Vec<u32> = Vec::with_capacity(self.n_rows);
374
375        for i in 0..self.n_rows {
376            let row_group = row_groups[i];
377            let row_offset = i * self.n_cols;
378
379            let mut values: Vec<i32> = Vec::with_capacity(self.n_cols + 1);
380            let mut costs: Vec<f64> = Vec::with_capacity(self.n_cols + 1);
381
382            match row_pin[i] {
383                Some(SENTINEL) => {
384                    values.push(SENTINEL);
385                    costs.push(self.unmatch_penalty);
386                }
387                Some(col) => {
388                    // col is guaranteed in 0..n_cols and group-compatible
389                    // by the pin validation above.
390                    values.push(col);
391                    costs.push(self.cost_matrix[row_offset + col as usize]);
392                }
393                None => {
394                    // SENTINEL first; CostFiniteDomain canonicalises to
395                    // ascending value order internally so the order at
396                    // construction is irrelevant for correctness, but
397                    // starting from SENTINEL keeps the (values, costs)
398                    // slices easy to read in a debugger.
399                    values.push(SENTINEL);
400                    costs.push(self.unmatch_penalty);
401                    for (k, &cg) in col_groups.iter().enumerate() {
402                        if cg == row_group {
403                            values.push(k as i32);
404                            costs.push(self.cost_matrix[row_offset + k]);
405                        }
406                    }
407                }
408            }
409
410            let domain = CostFiniteDomain::new(values, costs);
411            row_var_ids.push(csp.add_variable(domain));
412        }
413
414        // 5. Add one AllDifferentExcept per distinct row group.
415        let mut unique_groups: Vec<u8> = row_groups.clone();
416        unique_groups.sort_unstable();
417        unique_groups.dedup();
418        for group in unique_groups {
419            let scope: Vec<u32> = (0..self.n_rows)
420                .filter(|&i| row_groups[i] == group)
421                .map(|i| row_var_ids[i])
422                .collect();
423            // A single-row group still benefits from the constraint
424            // for symmetry — it's a no-op at search time but keeps
425            // the adjacency structure uniform across groups.
426            csp.add_constraint_enum(ConstraintEnum::AllDifferentExcept(AllDifferentExcept::new(
427                scope, SENTINEL,
428            )));
429        }
430
431        // 6. Finalize and run branch-and-bound.
432        csp.finalize();
433
434        let config = SolveConfig {
435            optimization_mode: OptimizationMode::MinimizeCost,
436            max_solutions: 1,
437            pruning: Pruning::Ac3,
438            node_budget: self.node_budget.or(Some(DEFAULT_NODE_BUDGET)),
439            ..SolveConfig::default()
440        };
441
442        let solutions = csp.solve_optimized(&config);
443        let stats = csp.stats().clone();
444
445        let solution = match solutions.into_iter().next() {
446            Some(s) => s,
447            // No complete assignment came back. Two distinct causes share
448            // this branch and must not be conflated: a genuinely infeasible
449            // constraint set, versus a search that aborted on its node
450            // budget before reaching any leaf. `budget_exceeded` is the
451            // discriminator (a partial best-so-far would have returned via
452            // the `Some` arm above with the flag set on its stats).
453            None if stats.budget_exceeded => return Err(AssignmentError::BudgetExceeded),
454            None => return Err(AssignmentError::Infeasible),
455        };
456
457        // 7. Project the Solution<CostFiniteDomain> back into the
458        //    row-indexed `assign` vector and recompute the total cost
459        //    from the cost matrix + unmatch penalty so callers see a
460        //    value that matches their inputs exactly (as opposed to
461        //    the search's running total, which can drift through
462        //    floating-point summation order).
463        let mut assign: Vec<i32> = vec![SENTINEL; self.n_rows];
464        let mut cost: f64 = 0.0;
465        for i in 0..self.n_rows {
466            let v = solution[row_var_ids[i] as usize];
467            assign[i] = v;
468            if v == SENTINEL {
469                cost += self.unmatch_penalty;
470            } else {
471                cost += self.cost_matrix[i * self.n_cols + v as usize];
472            }
473        }
474
475        Ok(AssignmentSolution {
476            assign,
477            cost,
478            stats,
479        })
480    }
481}