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