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}