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."
8//!
9//! # Two solve paths
10//!
11//! [`AssignmentBuilder::solve`] dispatches on the shape:
12//!
13//! * **Group-free / pin-free** instances are a pure linear assignment
14//! problem, solved in closed form by Kuhn-Munkres (the `hungarian` crate)
15//! in O(n³) — microseconds even at n=200. This path is always
16//! proven-optimal and never budget-blows.
17//! * **Grouped or pinned** instances go through the general CSP: a
18//! [`Csp<CostFiniteDomain>`] with one variable per row, an
19//! [`AllDifferentExcept`] per row-group, and `-1` as the unmatched
20//! sentinel, driven by branch-and-bound via [`Csp::solve_optimized`]
21//! ([`OptimizationMode::MinimizeCost`] + [`Pruning::Ac3`]).
22//!
23//! The B&B path is only proven-optimal to roughly **n ≈ 15–18**; past that it
24//! exhausts its node budget and returns a *best-so-far* assignment with
25//! [`SolveStats::budget_exceeded`] set (n=20 budget-blows at ~1 M nodes). The
26//! closed-form dispatch exists precisely to keep the common group-free/pin-free
27//! shape off that cliff. [`AssignmentBuilder::solve_branch_and_bound`] forces
28//! the CSP path regardless of shape (benchmarking / the B&B node-count gate).
29//!
30//! # Example
31//!
32//! ```
33//! use csp_solver::assignment;
34//!
35//! let sol = assignment()
36//! .rows(3)
37//! .cols(3)
38//! .cost(|i, k| if i == k { 0.0 } else { 10.0 })
39//! .unmatch_penalty(100.0)
40//! .solve()
41//! .expect("solvable");
42//!
43//! assert_eq!(sol.assign, vec![0, 1, 2]);
44//! assert_eq!(sol.cost, 0.0);
45//! ```
46
47use crate::constraint::{AllDifferentExcept, ConstraintEnum};
48use crate::domain::CostFiniteDomain;
49use crate::{Csp, OptimizationMode, Pruning, SolveConfig, SolveStats};
50
51/// Sentinel value used in [`AssignmentSolution::assign`] to denote an
52/// unmatched row.
53///
54/// Encoded as a negative `i32` so it can never collide with a valid
55/// 0-indexed column. The internal `CostFiniteDomain` for each row
56/// always carries this value as a real domain entry priced at the
57/// caller-supplied [`AssignmentBuilder::unmatch_penalty`]; the
58/// branch-and-bound search treats it as just another option whose
59/// dominance is decided by total cost.
60pub const SENTINEL: i32 = -1;
61
62/// Default node budget applied to the underlying branch-and-bound
63/// search when the caller does not override it via
64/// [`AssignmentBuilder::node_budget`].
65const DEFAULT_NODE_BUDGET: u64 = 1_000_000;
66
67/// Fluent builder for bipartite assignment COPs.
68///
69/// Construct via [`assignment()`] (preferred) or [`Default::default`].
70/// All setters consume `self` and return `self`, allowing chained
71/// configuration. The terminal [`AssignmentBuilder::solve`] call
72/// validates the configuration, materializes the underlying
73/// [`Csp<CostFiniteDomain>`], runs branch-and-bound, and returns an
74/// [`AssignmentSolution`] (or an [`AssignmentError`] on
75/// mis-configuration / infeasibility).
76#[derive(Debug, Default)]
77pub struct AssignmentBuilder {
78 n_rows: usize,
79 n_cols: usize,
80 /// Row-major `n_rows × n_cols` matrix of per-cell costs. Populated
81 /// eagerly by [`AssignmentBuilder::cost`] so the builder owns no
82 /// closure state.
83 cost_matrix: Vec<f64>,
84 /// Length `n_rows`; defaults to all-zero (single group) if the
85 /// caller never invoked [`AssignmentBuilder::row_group`].
86 row_groups: Vec<u8>,
87 /// Length `n_cols`; defaults to all-zero (single group) if the
88 /// caller never invoked [`AssignmentBuilder::col_group`].
89 col_groups: Vec<u8>,
90 /// Hard `(row, col)` equality pins. Validated against the row's
91 /// computed domain at [`AssignmentBuilder::solve`] time.
92 pins: Vec<(usize, i32)>,
93 /// Per-row cost paid when the assigned column is [`SENTINEL`].
94 unmatch_penalty: f64,
95 /// Optional cap on branch-and-bound nodes; `None` means use the
96 /// crate default of `1_000_000`. See
97 /// [`crate::SolveConfig::node_budget`] for the contract.
98 node_budget: Option<u64>,
99 /// Tracks whether [`AssignmentBuilder::cost`] has been called so
100 /// `.solve()` can return [`AssignmentError::CostNotSet`] without
101 /// guessing from `cost_matrix.is_empty()`.
102 cost_set: bool,
103}
104
105/// Result of a successful [`AssignmentBuilder::solve`] call.
106#[derive(Debug, Clone)]
107pub struct AssignmentSolution {
108 /// Length `n_rows`. Each entry is the assigned column index in
109 /// `0..n_cols`, or [`SENTINEL`] (`-1`) if the row was left
110 /// unmatched.
111 pub assign: Vec<i32>,
112 /// Total cost of the assignment: the sum of `cost_matrix[i][k]`
113 /// for each matched row `i → k`, plus
114 /// [`AssignmentBuilder::unmatch_penalty`] for each unmatched row.
115 pub cost: f64,
116 /// Statistics from the underlying branch-and-bound run. Inspect
117 /// [`SolveStats::budget_exceeded`] to distinguish best-so-far
118 /// from optimal solutions.
119 pub stats: SolveStats,
120}
121
122/// Errors from [`AssignmentBuilder::solve`].
123#[derive(Debug)]
124pub enum AssignmentError {
125 /// `.rows()` or `.cols()` was not called before `.solve()` (or
126 /// either was set to zero).
127 DimensionsNotSet,
128 /// `.cost()` was not called before `.solve()`.
129 CostNotSet,
130 /// A custom `row_group` / `col_group` slice did not match the
131 /// declared dimensions.
132 GroupLengthMismatch,
133 /// A pin references an out-of-range row or a column that is
134 /// neither [`SENTINEL`] nor a valid `0..n_cols` index, or whose
135 /// row-group does not match its target column's group.
136 InvalidPin {
137 /// Row index supplied to [`AssignmentBuilder::pin`].
138 row: usize,
139 /// Column index (or [`SENTINEL`]) supplied to
140 /// [`AssignmentBuilder::pin`].
141 col: i32,
142 },
143 /// The CSP has no feasible solution under the supplied
144 /// constraints. Note that with [`SENTINEL`] always available a
145 /// pure assignment problem is always feasible; this variant
146 /// surfaces when pins or group constraints are mutually
147 /// incompatible.
148 Infeasible,
149 /// The branch-and-bound search hit its
150 /// [`AssignmentBuilder::node_budget`] before scoring a single
151 /// complete assignment, so there is no best-so-far solution to
152 /// return. Distinct from [`Infeasible`](Self::Infeasible): the
153 /// problem may well be satisfiable — the search simply ran out of
154 /// budget. Retry with a larger (or `None`) `node_budget`. When the
155 /// budget is hit *after* at least one complete assignment was
156 /// scored, `.solve()` instead returns `Ok` with
157 /// [`SolveStats::budget_exceeded`] set on the best-so-far solution.
158 BudgetExceeded,
159}
160
161impl std::fmt::Display for AssignmentError {
162 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163 match self {
164 Self::DimensionsNotSet => {
165 write!(
166 f,
167 "AssignmentBuilder: .rows() and .cols() must both be set to a non-zero value before .solve()"
168 )
169 }
170 Self::CostNotSet => {
171 write!(
172 f,
173 "AssignmentBuilder: .cost() must be called before .solve()"
174 )
175 }
176 Self::GroupLengthMismatch => {
177 write!(
178 f,
179 "AssignmentBuilder: row_groups / col_groups length does not match the declared dimensions"
180 )
181 }
182 Self::InvalidPin { row, col } => {
183 write!(
184 f,
185 "AssignmentBuilder: invalid pin (row={row}, col={col}); col must be SENTINEL or a valid 0..n_cols index sharing the row's group"
186 )
187 }
188 Self::Infeasible => {
189 write!(
190 f,
191 "AssignmentBuilder: CSP is infeasible under the supplied constraints"
192 )
193 }
194 Self::BudgetExceeded => {
195 write!(
196 f,
197 "AssignmentBuilder: node budget exhausted before any complete assignment was scored; increase node_budget (or pass None) or reduce the problem size"
198 )
199 }
200 }
201 }
202}
203
204impl std::error::Error for AssignmentError {}
205
206/// Top-level constructor for an empty [`AssignmentBuilder`].
207///
208/// Equivalent to [`AssignmentBuilder::default`] but reads more
209/// naturally at the call site:
210///
211/// ```
212/// use csp_solver::assignment;
213///
214/// let sol = assignment()
215/// .rows(2)
216/// .cols(2)
217/// .cost(|i, k| (i + k) as f64)
218/// .solve()
219/// .expect("trivially solvable");
220/// assert_eq!(sol.assign.len(), 2);
221/// ```
222pub fn assignment() -> AssignmentBuilder {
223 AssignmentBuilder::default()
224}
225
226impl AssignmentBuilder {
227 /// Set the number of source rows.
228 pub fn rows(mut self, n: usize) -> Self {
229 self.n_rows = n;
230 self
231 }
232
233 /// Set the number of target columns.
234 pub fn cols(mut self, n: usize) -> Self {
235 self.n_cols = n;
236 self
237 }
238
239 /// Eagerly populate the row-major cost matrix.
240 ///
241 /// Calls `f(i, k)` exactly once per `(row, col)` cell during this
242 /// method, stores the result in an internal `Vec<f64>`, and
243 /// returns `self`. No closure is retained, which keeps the
244 /// builder `Send + Sync` even when constructed from non-`'static`
245 /// captures.
246 ///
247 /// # Panics
248 ///
249 /// Panics if [`AssignmentBuilder::rows`] or
250 /// [`AssignmentBuilder::cols`] has not been called yet — both
251 /// dimensions are required to know how to walk `f`.
252 pub fn cost(mut self, f: impl Fn(usize, usize) -> f64) -> Self {
253 assert!(
254 self.n_rows > 0 && self.n_cols > 0,
255 "AssignmentBuilder::cost() requires .rows() and .cols() to be set first"
256 );
257 let mut matrix = Vec::with_capacity(self.n_rows * self.n_cols);
258 for i in 0..self.n_rows {
259 for k in 0..self.n_cols {
260 matrix.push(f(i, k));
261 }
262 }
263 self.cost_matrix = matrix;
264 self.cost_set = true;
265 self
266 }
267
268 /// Tag each row with a `u8` group identifier.
269 ///
270 /// Rows in different groups are placed in independent
271 /// [`AllDifferentExcept`] scopes, and a row may only be assigned
272 /// to a column whose group identifier matches. Omitting the call
273 /// (or supplying `|_| 0`) puts every row in a single group, which
274 /// is the standard bipartite-assignment shape.
275 pub fn row_group(mut self, f: impl Fn(usize) -> u8) -> Self {
276 self.row_groups = (0..self.n_rows).map(f).collect();
277 self
278 }
279
280 /// Tag each column with a `u8` group identifier.
281 ///
282 /// See [`AssignmentBuilder::row_group`] for the semantics.
283 pub fn col_group(mut self, f: impl Fn(usize) -> u8) -> Self {
284 self.col_groups = (0..self.n_cols).map(f).collect();
285 self
286 }
287
288 /// Hard-pin row `row` to column `col`.
289 ///
290 /// `col` may be [`SENTINEL`] to force the row unmatched. Multiple
291 /// pins are accumulated; conflicting pins on the same row are
292 /// detected at [`AssignmentBuilder::solve`] time as
293 /// [`AssignmentError::Infeasible`].
294 pub fn pin(mut self, row: usize, col: i32) -> Self {
295 self.pins.push((row, col));
296 self
297 }
298
299 /// Set the per-row cost paid when a row is assigned to
300 /// [`SENTINEL`] (left unmatched).
301 pub fn unmatch_penalty(mut self, penalty: f64) -> Self {
302 self.unmatch_penalty = penalty;
303 self
304 }
305
306 /// Override the underlying branch-and-bound node budget.
307 ///
308 /// Passing `None` here is *not* the same as never calling this
309 /// method: `None` requests an unbounded search, while the default
310 /// (no call) installs a `1_000_000` node guard so a pathological
311 /// problem cannot hang the caller. See
312 /// [`crate::SolveConfig::node_budget`].
313 pub fn node_budget(mut self, budget: Option<u64>) -> Self {
314 self.node_budget = budget;
315 self
316 }
317
318 /// Validate the configuration and solve for the minimum-cost assignment.
319 ///
320 /// A **group-free, pin-free** instance is dispatched to the closed-form
321 /// Kuhn-Munkres LAP solver (always optimal, microsecond-scale, never
322 /// budget-blows). Grouped or pinned instances fall through to the general
323 /// branch-and-bound CSP path. See the module docs for the n≈15–18 B&B
324 /// ceiling; use [`solve_branch_and_bound`](Self::solve_branch_and_bound) to
325 /// force the CSP path on any shape.
326 pub fn solve(self) -> Result<AssignmentSolution, AssignmentError> {
327 // 1. Dimensions + cost must be set.
328 if self.n_rows == 0 || self.n_cols == 0 {
329 return Err(AssignmentError::DimensionsNotSet);
330 }
331 if !self.cost_set {
332 return Err(AssignmentError::CostNotSet);
333 }
334
335 // Closed-form dispatch: a group-free, pin-free instance is a pure
336 // linear assignment problem — Kuhn-Munkres solves it optimally in
337 // O(n³), sidestepping the exponential B&B that only reaches optimality
338 // to n≈15–18 (n=20 budget-blows). Grouped/pinned instances carry
339 // constraints the LAP cannot express and stay on the CSP path.
340 if self.pins.is_empty() && self.row_groups.is_empty() && self.col_groups.is_empty() {
341 return Ok(self.solve_lap());
342 }
343
344 self.solve_csp()
345 }
346
347 /// Force the branch-and-bound CSP path regardless of shape, bypassing the
348 /// closed-form LAP dispatch in [`solve`](Self::solve).
349 ///
350 /// Exists for benchmarking the general solver and for the node-count
351 /// invariance gate — a group-free/pin-free instance solved here exercises
352 /// the exact same B&B trajectory it did before the LAP dispatch landed, so
353 /// its `nodes_explored` / `backtracks` counts are a stable regression
354 /// tripwire. Prefer [`solve`](Self::solve) in production.
355 pub fn solve_branch_and_bound(self) -> Result<AssignmentSolution, AssignmentError> {
356 if self.n_rows == 0 || self.n_cols == 0 {
357 return Err(AssignmentError::DimensionsNotSet);
358 }
359 if !self.cost_set {
360 return Err(AssignmentError::CostNotSet);
361 }
362 self.solve_csp()
363 }
364
365 /// Closed-form linear-assignment solve (Kuhn-Munkres via the `hungarian`
366 /// crate) for the group-free / pin-free case. Always optimal; the returned
367 /// [`SolveStats`] is the `Default` (no search ran, `budget_exceeded` is
368 /// `false`).
369 fn solve_lap(self) -> AssignmentSolution {
370 let n = self.n_rows;
371 let m = self.n_cols;
372
373 // Augmented integer cost matrix, `n` rows × `m + n` columns:
374 // cols 0..m real per-cell costs
375 // cols m..m+n one "unmatched" sentinel slot per row, every one
376 // priced at `unmatch_penalty`. With `n` such slots any
377 // subset of rows may go unmatched simultaneously and a
378 // perfect matching of all `n` rows always exists, so
379 // the LAP result maps cleanly back onto the CSP's
380 // "sentinel is shareable" semantics.
381 //
382 // Costs are quantized to i64 (the crate's integer API); the scale keeps
383 // six decimal digits, ample for any realistic cost function.
384 const SCALE: f64 = 1_000_000.0;
385 let width = m + n;
386 let pen = (self.unmatch_penalty * SCALE) as i64;
387 let mut matrix: Vec<i64> = Vec::with_capacity(n * width);
388 for i in 0..n {
389 let row_off = i * m;
390 for k in 0..m {
391 matrix.push((self.cost_matrix[row_off + k] * SCALE) as i64);
392 }
393 for _ in 0..n {
394 matrix.push(pen);
395 }
396 }
397
398 // Shift to non-negative. Adding a constant to every cell shifts the
399 // total by a fixed `n × c` (every row is matched exactly once in an
400 // `n × (m+n ≥ n)` assignment), so the argmin — the chosen columns — is
401 // unchanged, while the `hungarian` crate's negative-cost handling is
402 // sidestepped.
403 if let Some(&min) = matrix.iter().min()
404 && min < 0
405 {
406 for c in matrix.iter_mut() {
407 *c -= min;
408 }
409 }
410
411 let assignment = hungarian::minimize(&matrix, n, width);
412
413 // Project back: a real column (< m) is a match at its cost; a sentinel
414 // slot (≥ m) — or an unexpected `None` — is the shared unmatched token
415 // at the penalty. Cost is recomputed from the original f64 matrix so
416 // callers see exact inputs, not the quantized/shifted integers.
417 let mut assign: Vec<i32> = vec![SENTINEL; n];
418 let mut cost = 0.0;
419 for (i, slot) in assign.iter_mut().enumerate() {
420 match assignment.get(i).copied().flatten() {
421 Some(k) if k < m => {
422 *slot = k as i32;
423 cost += self.cost_matrix[i * m + k];
424 }
425 _ => {
426 *slot = SENTINEL;
427 cost += self.unmatch_penalty;
428 }
429 }
430 }
431
432 AssignmentSolution {
433 assign,
434 cost,
435 stats: SolveStats::default(),
436 }
437 }
438
439 /// The general branch-and-bound CSP path. Reached from
440 /// [`solve`](Self::solve) for grouped/pinned instances and unconditionally
441 /// from [`solve_branch_and_bound`](Self::solve_branch_and_bound).
442 fn solve_csp(self) -> Result<AssignmentSolution, AssignmentError> {
443 // 2. Default groups to all-zero if the caller did not supply
444 // them; otherwise verify lengths match the declared
445 // dimensions.
446 let row_groups: Vec<u8> = if self.row_groups.is_empty() {
447 vec![0; self.n_rows]
448 } else if self.row_groups.len() == self.n_rows {
449 self.row_groups
450 } else {
451 return Err(AssignmentError::GroupLengthMismatch);
452 };
453 let col_groups: Vec<u8> = if self.col_groups.is_empty() {
454 vec![0; self.n_cols]
455 } else if self.col_groups.len() == self.n_cols {
456 self.col_groups
457 } else {
458 return Err(AssignmentError::GroupLengthMismatch);
459 };
460
461 // 3. Pre-validate pins and collapse them into a per-row map.
462 // Pins are baked directly into each row's CostFiniteDomain
463 // at construction time so the variable's `original_domain`
464 // already encodes the singleton; this matters because
465 // `Csp::solve_optimized` calls `Variable::reset()` at
466 // search start and would otherwise undo any post-hoc
467 // domain mutation. Multiple pins on the same row are
468 // accepted only if they agree.
469 let mut row_pin: Vec<Option<i32>> = vec![None; self.n_rows];
470 for &(row, col) in &self.pins {
471 if row >= self.n_rows {
472 return Err(AssignmentError::InvalidPin { row, col });
473 }
474 if col != SENTINEL && (col < 0 || col as usize >= self.n_cols) {
475 return Err(AssignmentError::InvalidPin { row, col });
476 }
477 // Verify pin is compatible with the row's group: SENTINEL
478 // is always allowed, otherwise the column's group must
479 // match the row's.
480 if col != SENTINEL && col_groups[col as usize] != row_groups[row] {
481 return Err(AssignmentError::InvalidPin { row, col });
482 }
483 match row_pin[row] {
484 None => row_pin[row] = Some(col),
485 Some(prev) if prev == col => {} // duplicate, fine
486 Some(_) => return Err(AssignmentError::Infeasible),
487 }
488 }
489
490 // 4. Build one CostFiniteDomain per row, restricted to columns
491 // whose group matches the row's group (and to the pinned
492 // singleton when a pin is present). SENTINEL is always
493 // available at the unmatch penalty unless overridden by a
494 // non-SENTINEL pin.
495 let mut csp: Csp<CostFiniteDomain> = Csp::new();
496 let mut row_var_ids: Vec<u32> = Vec::with_capacity(self.n_rows);
497
498 for i in 0..self.n_rows {
499 let row_group = row_groups[i];
500 let row_offset = i * self.n_cols;
501
502 let mut values: Vec<i32> = Vec::with_capacity(self.n_cols + 1);
503 let mut costs: Vec<f64> = Vec::with_capacity(self.n_cols + 1);
504
505 match row_pin[i] {
506 Some(SENTINEL) => {
507 values.push(SENTINEL);
508 costs.push(self.unmatch_penalty);
509 }
510 Some(col) => {
511 // col is guaranteed in 0..n_cols and group-compatible
512 // by the pin validation above.
513 values.push(col);
514 costs.push(self.cost_matrix[row_offset + col as usize]);
515 }
516 None => {
517 // SENTINEL first; CostFiniteDomain canonicalises to
518 // ascending value order internally so the order at
519 // construction is irrelevant for correctness, but
520 // starting from SENTINEL keeps the (values, costs)
521 // slices easy to read in a debugger.
522 values.push(SENTINEL);
523 costs.push(self.unmatch_penalty);
524 for (k, &cg) in col_groups.iter().enumerate() {
525 if cg == row_group {
526 values.push(k as i32);
527 costs.push(self.cost_matrix[row_offset + k]);
528 }
529 }
530 }
531 }
532
533 let domain = CostFiniteDomain::new(values, costs);
534 row_var_ids.push(csp.add_variable(domain));
535 }
536
537 // 5. Add one AllDifferentExcept per distinct row group.
538 let mut unique_groups: Vec<u8> = row_groups.clone();
539 unique_groups.sort_unstable();
540 unique_groups.dedup();
541 for group in unique_groups {
542 let scope: Vec<u32> = (0..self.n_rows)
543 .filter(|&i| row_groups[i] == group)
544 .map(|i| row_var_ids[i])
545 .collect();
546 // A single-row group still benefits from the constraint
547 // for symmetry — it's a no-op at search time but keeps
548 // the adjacency structure uniform across groups.
549 csp.add_constraint_enum(ConstraintEnum::AllDifferentExcept(AllDifferentExcept::new(
550 scope, SENTINEL,
551 )));
552 }
553
554 // 6. Finalize and run branch-and-bound.
555 csp.finalize();
556
557 let config = SolveConfig {
558 optimization_mode: OptimizationMode::MinimizeCost,
559 max_solutions: 1,
560 pruning: Pruning::Ac3,
561 node_budget: self.node_budget.or(Some(DEFAULT_NODE_BUDGET)),
562 ..SolveConfig::default()
563 };
564
565 let solutions = csp.solve_optimized(&config);
566 let stats = csp.stats().clone();
567
568 let solution = match solutions.into_iter().next() {
569 Some(s) => s,
570 // No complete assignment came back. Two distinct causes share
571 // this branch and must not be conflated: a genuinely infeasible
572 // constraint set, versus a search that aborted on its node
573 // budget before reaching any leaf. `budget_exceeded` is the
574 // discriminator (a partial best-so-far would have returned via
575 // the `Some` arm above with the flag set on its stats).
576 None if stats.budget_exceeded => return Err(AssignmentError::BudgetExceeded),
577 None => return Err(AssignmentError::Infeasible),
578 };
579
580 // 7. Project the Solution<CostFiniteDomain> back into the
581 // row-indexed `assign` vector and recompute the total cost
582 // from the cost matrix + unmatch penalty so callers see a
583 // value that matches their inputs exactly (as opposed to
584 // the search's running total, which can drift through
585 // floating-point summation order).
586 let mut assign: Vec<i32> = vec![SENTINEL; self.n_rows];
587 let mut cost: f64 = 0.0;
588 for i in 0..self.n_rows {
589 let v = solution[row_var_ids[i] as usize];
590 assign[i] = v;
591 if v == SENTINEL {
592 cost += self.unmatch_penalty;
593 } else {
594 cost += self.cost_matrix[i * self.n_cols + v as usize];
595 }
596 }
597
598 Ok(AssignmentSolution {
599 assign,
600 cost,
601 stats,
602 })
603 }
604}