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."
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}