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