Skip to main content

cobre_solver/
types.rs

1//! Core types for the solver abstraction layer.
2//!
3//! Defines the canonical representations of LP solutions, basis management,
4//! and terminal solver errors used throughout the solver interface.
5
6use core::fmt;
7
8/// Simplex basis storing solver-native `i32` status codes for zero-copy round-trip
9/// basis management.
10///
11/// `Basis` stores the raw solver `i32` status codes directly, enabling zero-copy
12/// round-trip warm-starting via `copy_from_slice` (memcpy). This avoids per-element
13/// translation overhead when the caller only needs to save and restore the basis
14/// without inspecting individual statuses.
15///
16/// `HiGHS` uses `HighsInt` (4 bytes) for status codes; CLP uses `unsigned char`
17/// (1 byte, widened to `i32` in this representation). The caller is responsible
18/// for matching the buffer dimensions to the LP model before use.
19///
20/// See Solver Abstraction SS9.
21#[derive(Debug, Clone)]
22pub struct Basis {
23    /// Solver-native `i32` status codes for each column (length must equal `num_cols`).
24    pub col_status: Vec<i32>,
25
26    /// Solver-native `i32` status codes for each row, including structural and dynamic rows.
27    pub row_status: Vec<i32>,
28}
29
30impl Basis {
31    /// Creates a new `Basis` with pre-allocated, zero-filled status code buffers.
32    ///
33    /// Both `col_status` and `row_status` are allocated to the requested lengths
34    /// and filled with `0_i32`. The caller reuses this buffer across solves by
35    /// passing it to [`crate::SolverInterface::get_basis`] on each iteration.
36    #[must_use]
37    pub fn new(num_cols: usize, num_rows: usize) -> Self {
38        Self {
39            col_status: vec![0_i32; num_cols],
40            row_status: vec![0_i32; num_rows],
41        }
42    }
43}
44
45/// Complete solution from a successful LP solve.
46///
47/// All values are in the original (unscaled) problem space. Dual values
48/// are pre-normalized to the canonical sign convention defined in
49/// [Solver Abstraction SS8](../../../cobre-docs/src/specs/architecture/solver-abstraction.md)
50/// before this struct is returned -- solver-specific sign differences are
51/// resolved within the [`crate::SolverInterface`] implementation.
52///
53/// See [Solver Interface Trait SS4.1](../../../cobre-docs/src/specs/architecture/solver-interface-trait.md).
54#[derive(Debug, Clone)]
55pub struct LpSolution {
56    /// Optimal objective value (minimization sense).
57    pub objective: f64,
58
59    /// Primal variable values, indexed by column (length equals `num_cols`).
60    pub primal: Vec<f64>,
61
62    /// Dual multipliers (shadow prices), indexed by row (length equals `num_rows`).
63    /// Normalized to canonical sign convention.
64    pub dual: Vec<f64>,
65
66    /// Reduced costs, indexed by column (length equals `num_cols`).
67    pub reduced_costs: Vec<f64>,
68
69    /// Number of simplex iterations performed for this solve.
70    pub iterations: u64,
71
72    /// Wall-clock solve time in seconds (excluding retry overhead).
73    pub solve_time_seconds: f64,
74}
75
76/// Zero-copy view of an LP solution, borrowing directly from solver-internal buffers.
77///
78/// Valid until the next mutating method call on the solver (any `&mut self` call).
79/// This is enforced at compile time by the Rust borrow checker: the lifetime `'a`
80/// ties the view to the solver instance that produced it.
81///
82/// Use [`SolutionView::to_owned`] to convert to an owned [`LpSolution`] when the
83/// solution data must outlive the current borrow, or when the same data will be
84/// accessed after a subsequent solver call.
85///
86/// See [Solver Interface Trait SS4.1](../../../cobre-docs/src/specs/architecture/solver-interface-trait.md).
87#[derive(Debug, Clone, Copy)]
88pub struct SolutionView<'a> {
89    /// Optimal objective value (minimization sense).
90    pub objective: f64,
91
92    /// Primal variable values, indexed by column (length equals `num_cols`).
93    pub primal: &'a [f64],
94
95    /// Dual multipliers (shadow prices), indexed by row (length equals `num_rows`).
96    /// Normalized to canonical sign convention.
97    pub dual: &'a [f64],
98
99    /// Reduced costs, indexed by column (length equals `num_cols`).
100    pub reduced_costs: &'a [f64],
101
102    /// Number of simplex iterations performed for this solve.
103    pub iterations: u64,
104
105    /// Wall-clock solve time in seconds (excluding retry overhead).
106    pub solve_time_seconds: f64,
107}
108
109impl SolutionView<'_> {
110    /// Clones the borrowed slices into owned [`Vec`]s, producing an [`LpSolution`].
111    ///
112    /// Use this when the solution data must outlive the current solver borrow,
113    /// or when the same solution will be read after a subsequent solver call.
114    #[must_use]
115    pub fn to_owned(&self) -> LpSolution {
116        LpSolution {
117            objective: self.objective,
118            primal: self.primal.to_vec(),
119            dual: self.dual.to_vec(),
120            reduced_costs: self.reduced_costs.to_vec(),
121            iterations: self.iterations,
122            solve_time_seconds: self.solve_time_seconds,
123        }
124    }
125}
126
127/// Accumulated solve metrics for a single solver instance.
128///
129/// Counters grow monotonically from construction. They are thread-local --
130/// each thread owns one solver instance and accumulates its own statistics.
131/// Statistics are aggregated across threads via reduction after training
132/// completes.
133///
134/// Statistics counters persist across model reloads for the lifetime of the
135/// solver instance.
136///
137/// See [Solver Interface Trait SS4.3](../../../cobre-docs/src/specs/architecture/solver-interface-trait.md).
138#[derive(Debug, Clone, Default)]
139pub struct SolverStatistics {
140    /// Total number of `solve` calls (cold-start and warm-start).
141    pub solve_count: u64,
142
143    /// Number of solves that returned `Ok` (optimal solution found).
144    pub success_count: u64,
145
146    /// Number of solves that returned `Err` (terminal failure after retries).
147    pub failure_count: u64,
148
149    /// Total simplex iterations summed across all solves.
150    pub total_iterations: u64,
151
152    /// Total retry attempts summed across all failed solves.
153    pub retry_count: u64,
154
155    /// Cumulative wall-clock time spent in solver calls, in seconds.
156    pub total_solve_time_seconds: f64,
157
158    /// Number of warm-start `solve(Some(&basis))` calls in which
159    /// `cobre_highs_set_basis_non_alien` rejected the offered basis because
160    /// `isBasisConsistent` returned false.
161    /// Incremented once per rejected offer. Replaces two counters removed in v0.5.0
162    /// (see CHANGELOG).
163    pub basis_consistency_failures: u64,
164
165    /// Number of solves that returned optimal on the first attempt (before any retry).
166    ///
167    /// Enables first-try rate computation: `first_try_rate = first_try_successes / solve_count`.
168    /// The complement `success_count - first_try_successes` gives the number of retried solves.
169    pub first_try_successes: u64,
170
171    /// Total number of warm-start `solve(Some(&basis))` calls (basis offers).
172    ///
173    /// Combined with `basis_consistency_failures`, enables acceptance-rate computation:
174    /// `basis_acceptance_rate = 1 - basis_consistency_failures / basis_offered`.
175    pub basis_offered: u64,
176
177    /// Total number of `load_model` calls.
178    pub load_model_count: u64,
179
180    /// Cumulative wall-clock time spent in `load_model` calls, in seconds.
181    pub total_load_model_time_seconds: f64,
182
183    /// Cumulative wall-clock time spent in `set_row_bounds` and `set_col_bounds` calls, in seconds.
184    pub total_set_bounds_time_seconds: f64,
185
186    /// Cumulative wall-clock time spent in `set_basis` FFI calls, in seconds.
187    ///
188    /// Accumulated by `solve(Some(&basis))` around the basis installation step.
189    /// Cold-start `solve(None)` does not increment this counter.
190    pub total_basis_set_time_seconds: f64,
191
192    /// Number of `reconstruct_basis` invocations with a non-empty stored basis.
193    /// Incremented via `record_reconstruction_stats`. A non-zero value indicates
194    /// basis reconstruction is active on this solver instance.
195    pub basis_reconstructions: u64,
196
197    /// Per-level retry success histogram. Length depends on the solver backend
198    /// (e.g. 12 for `HiGHS`). `retry_level_histogram[k]` counts how many solves
199    /// were recovered at retry level `k`. The sum equals
200    /// `success_count - first_try_successes`.
201    pub retry_level_histogram: Vec<u64>,
202}
203
204/// Pre-assembled structural LP for one stage, in CSC (column-major) form.
205///
206/// Built once at initialization from resolved internal structures.
207/// Shared read-only across all threads within an MPI rank.
208/// Passed to [`crate::SolverInterface::load_model`] to bulk-load the LP.
209///
210/// Column and row ordering follows the LP layout convention defined in
211/// [Solver Abstraction SS2](../../../cobre-docs/src/specs/architecture/solver-abstraction.md).
212/// The calling algorithm crate owns construction of this type; `cobre-solver`
213/// treats it as an opaque data holder and does not interpret the LP structure.
214///
215/// See [Solver Interface Trait SS4.4](../../../cobre-docs/src/specs/architecture/solver-interface-trait.md)
216/// and [Solver Abstraction SS11.1](../../../cobre-docs/src/specs/architecture/solver-abstraction.md).
217#[derive(Debug, Clone)]
218pub struct StageTemplate {
219    /// Number of columns (decision variables) in the structural LP.
220    pub num_cols: usize,
221
222    /// Number of static rows (structural constraints, excluding dynamic rows).
223    pub num_rows: usize,
224
225    /// Number of non-zero entries in the structural constraint matrix.
226    pub num_nz: usize,
227
228    /// CSC column start offsets (length: `num_cols + 1`; `col_starts[num_cols] == num_nz`).
229    pub col_starts: Vec<i32>,
230
231    /// CSC row indices for each non-zero entry (length: `num_nz`).
232    pub row_indices: Vec<i32>,
233
234    /// CSC non-zero values (length: `num_nz`).
235    pub values: Vec<f64>,
236
237    /// Column lower bounds (length: `num_cols`; use `f64::NEG_INFINITY` for unbounded).
238    pub col_lower: Vec<f64>,
239
240    /// Column upper bounds (length: `num_cols`; use `f64::INFINITY` for unbounded).
241    pub col_upper: Vec<f64>,
242
243    /// Objective coefficients, minimization sense (length: `num_cols`).
244    pub objective: Vec<f64>,
245
246    /// Row lower bounds (length: `num_rows`; set equal to `row_upper` for equality).
247    pub row_lower: Vec<f64>,
248
249    /// Row upper bounds (length: `num_rows`; set equal to `row_lower` for equality).
250    pub row_upper: Vec<f64>,
251
252    /// Number of state variables (contiguous prefix of columns).
253    pub n_state: usize,
254
255    /// Number of state values transferred between consecutive stages.
256    ///
257    /// Equal to `N * L` per
258    /// [Solver Abstraction SS2.1](../../../cobre-docs/src/specs/architecture/solver-abstraction.md).
259    /// This is the storage volumes plus all AR lags except the oldest
260    /// (which ages out of the lag window).
261    pub n_transfer: usize,
262
263    /// Number of dual-relevant constraint rows (contiguous prefix of rows).
264    ///
265    /// Currently equal to `n_state` (= `N + N*L` where `N` is the number of
266    /// hydros and `L` is the maximum PAR lag order). FPHA and generic variable
267    /// constraint rows are structural and not included in the dual-relevant set.
268    ///
269    /// Gradient coefficients are extracted from `dual[0..n_dual_relevant]`.
270    pub n_dual_relevant: usize,
271
272    /// Number of operating hydros at this stage.
273    pub n_hydro: usize,
274
275    /// Maximum PAR order across all operating hydros at this stage.
276    ///
277    /// Determines the uniform lag stride: all hydros store `max_par_order`
278    /// lag values regardless of their individual PAR order, enabling SIMD
279    /// vectorization with a single contiguous state stride.
280    pub max_par_order: usize,
281
282    /// Per-column scaling factors for numerical conditioning.
283    ///
284    /// When non-empty (length `num_cols`), the constraint matrix, objective
285    /// coefficients, and column bounds have been pre-scaled by these factors.
286    /// The calling algorithm is responsible for unscaling primal values after
287    /// each solve: `x_original[j] = col_scale[j] * x_scaled[j]`.
288    ///
289    /// When empty, no column scaling has been applied and solver results are
290    /// used directly.
291    pub col_scale: Vec<f64>,
292
293    /// Per-row scaling factors for numerical conditioning.
294    ///
295    /// When non-empty (length `num_rows`), the constraint matrix and row bounds
296    /// have been pre-scaled by these factors. The calling algorithm is responsible
297    /// for unscaling dual values after each solve:
298    /// `dual_original[i] = row_scale[i] * dual_scaled[i]`.
299    ///
300    /// When empty, no row scaling has been applied and solver results are
301    /// used directly.
302    pub row_scale: Vec<f64>,
303}
304
305/// Batch of constraint rows for addition to a loaded LP, in CSR (row-major) form.
306///
307/// Assembled from the row-pool activity bitmap before each LP rebuild
308/// and passed to [`crate::SolverInterface::add_rows`] for a single batch call.
309/// Rows are appended at the bottom of the constraint matrix in the dynamic
310/// constraint region per
311/// [Solver Abstraction SS2.2](../../../cobre-docs/src/specs/architecture/solver-abstraction.md).
312///
313/// See [Solver Interface Trait SS4.5](../../../cobre-docs/src/specs/architecture/solver-interface-trait.md)
314/// and the row-pool assembly protocol in
315/// [Solver Abstraction SS5.4](../../../cobre-docs/src/specs/architecture/solver-abstraction.md).
316#[derive(Debug, Clone)]
317pub struct RowBatch {
318    /// Number of active constraint rows in this batch.
319    pub num_rows: usize,
320
321    /// CSR row start offsets (`i32` for `HiGHS` FFI compatibility).
322    ///
323    /// Length: `num_rows + 1`. Entry `row_starts[i]` is the index into
324    /// `col_indices` and `values` where row `i` begins.
325    /// `row_starts[num_rows]` equals the total number of non-zeros.
326    pub row_starts: Vec<i32>,
327
328    /// CSR column indices for each non-zero entry (`i32` for `HiGHS` FFI compatibility).
329    ///
330    /// Length: total non-zeros across all rows. Entry `col_indices[k]` is the
331    /// column of the `k`-th non-zero value.
332    pub col_indices: Vec<i32>,
333
334    /// CSR non-zero values.
335    ///
336    /// Length: total non-zeros across all rows. Entry `values[k]` is the
337    /// coefficient at column `col_indices[k]` in its row.
338    pub values: Vec<f64>,
339
340    /// Row lower bounds (RHS lower bounds for `>=` constraints).
341    ///
342    /// Length: `num_rows`. For `>=` constraints, this is the RHS lower bound.
343    pub row_lower: Vec<f64>,
344
345    /// Row upper bounds.
346    ///
347    /// Length: `num_rows`. Use `f64::INFINITY` for `>=` constraints (no finite upper bound).
348    pub row_upper: Vec<f64>,
349}
350
351impl StageTemplate {
352    /// Creates an empty [`StageTemplate`] with zero-sized fields and empty `Vec`s.
353    ///
354    /// Intended for use as a reusable output buffer passed to
355    /// [`crate::baking::bake_rows_into_template`]. The caller constructs one
356    /// `StageTemplate::empty()` and passes it on every baking call; the function
357    /// clears and refills the buffer without calling `shrink_to_fit`, so the
358    /// allocated capacity grows to its steady-state peak and then stabilises.
359    ///
360    /// An empty template is **not** a valid model for `load_model` (it has
361    /// `num_cols == 0` and `num_rows == 0`). Only pass it to `load_model` after
362    /// a successful `bake_rows_into_template` call has populated it.
363    ///
364    /// A `Default` impl is intentionally omitted: an empty template is a
365    /// surprising default and invites misuse. Use this constructor explicitly.
366    #[must_use]
367    pub fn empty() -> Self {
368        Self {
369            num_cols: 0,
370            num_rows: 0,
371            num_nz: 0,
372            col_starts: Vec::new(),
373            row_indices: Vec::new(),
374            values: Vec::new(),
375            col_lower: Vec::new(),
376            col_upper: Vec::new(),
377            objective: Vec::new(),
378            row_lower: Vec::new(),
379            row_upper: Vec::new(),
380            n_state: 0,
381            n_transfer: 0,
382            n_dual_relevant: 0,
383            n_hydro: 0,
384            max_par_order: 0,
385            col_scale: Vec::new(),
386            row_scale: Vec::new(),
387        }
388    }
389}
390
391impl RowBatch {
392    /// Reset all buffers to empty without deallocating.
393    ///
394    /// After `clear()`, `num_rows` is 0 and all `Vec` fields have length 0
395    /// but retain their allocated capacity for reuse.
396    pub fn clear(&mut self) {
397        self.num_rows = 0;
398        self.row_starts.clear();
399        self.col_indices.clear();
400        self.values.clear();
401        self.row_lower.clear();
402        self.row_upper.clear();
403    }
404}
405
406/// Terminal LP solve error returned after all retry attempts are exhausted.
407///
408/// The calling algorithm uses the variant to determine its response:
409/// hard stop (`Infeasible`, `Unbounded`, `InternalError`) or terminate
410/// with a diagnostic error (`NumericalDifficulty`, `TimeLimitExceeded`,
411/// `IterationLimit`).
412///
413/// The six variants correspond to the error categories defined in
414/// Solver Abstraction SS6. Solver-internal errors (e.g., factorization
415/// failures) are resolved by retry logic before reaching this level.
416#[derive(Debug)]
417pub enum SolverError {
418    /// The LP has no feasible solution.
419    ///
420    /// Indicates a data error (inconsistent bounds or constraints) or a
421    /// modeling error. The calling algorithm should perform a hard stop.
422    Infeasible,
423
424    /// The LP objective is unbounded below.
425    ///
426    /// Indicates a modeling error (missing bounds, incorrect objective sign).
427    /// The calling algorithm should perform a hard stop.
428    Unbounded,
429
430    /// Solver encountered numerical difficulties that persisted through all
431    /// retry attempts.
432    ///
433    /// The calling algorithm should log the error and perform a hard stop.
434    NumericalDifficulty {
435        /// Human-readable description of the numerical issue from the solver.
436        message: String,
437    },
438
439    /// Per-solve wall-clock time budget exhausted.
440    TimeLimitExceeded {
441        /// Elapsed wall-clock time in seconds at the point of termination.
442        elapsed_seconds: f64,
443    },
444
445    /// Solver simplex iteration limit reached.
446    IterationLimit {
447        /// Number of simplex iterations performed before the limit was hit.
448        iterations: u64,
449    },
450
451    /// Unrecoverable solver-internal failure.
452    ///
453    /// Covers FFI panics, memory allocation failures within the solver,
454    /// corrupted internal state, or any error not classifiable into the above
455    /// categories. The calling algorithm should log the error and perform a hard stop.
456    InternalError {
457        /// Human-readable error description.
458        message: String,
459        /// Solver-specific error code, if available.
460        error_code: Option<i32>,
461    },
462
463    /// The backend does not implement the requested operation.
464    ///
465    /// The caller should fall back to an alternate code path (e.g.,
466    /// `reset` + `load_model`).
467    Unsupported(&'static str),
468
469    /// The offered basis was rejected by the solver because the total
470    /// number of basic variables did not match the row count.
471    ///
472    /// Indicates that the reconstructed basis violates the fundamental LP
473    /// basis consistency invariant (`col_basic + row_basic == num_row`).
474    /// The calling algorithm should perform a hard stop; this is not a
475    /// recoverable solver-internal condition.
476    BasisInconsistent {
477        /// The LP row count at the point of rejection.
478        num_row: i64,
479        /// The total basic-variable count in the offered basis (`col_basic + row_basic`).
480        total_basic: i64,
481        /// Number of basic columns in the offered basis.
482        col_basic: i64,
483        /// Number of basic rows in the offered basis.
484        row_basic: i64,
485    },
486}
487
488impl fmt::Display for SolverError {
489    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
490        match self {
491            Self::Infeasible => write!(f, "LP is infeasible"),
492            Self::Unbounded => write!(f, "LP is unbounded"),
493            Self::NumericalDifficulty { message } => {
494                write!(f, "numerical difficulty: {message}")
495            }
496            Self::TimeLimitExceeded { elapsed_seconds } => {
497                write!(f, "time limit exceeded after {elapsed_seconds:.3}s")
498            }
499            Self::IterationLimit { iterations } => {
500                write!(f, "iteration limit reached after {iterations} iterations")
501            }
502            Self::InternalError {
503                message,
504                error_code,
505            } => match error_code {
506                Some(code) => write!(f, "internal solver error (code {code}): {message}"),
507                None => write!(f, "internal solver error: {message}"),
508            },
509            Self::Unsupported(msg) => write!(f, "unsupported operation: {msg}"),
510            Self::BasisInconsistent {
511                num_row,
512                total_basic,
513                col_basic,
514                row_basic,
515            } => write!(
516                f,
517                "basis inconsistent: num_row={num_row}, total_basic={total_basic} (col_basic={col_basic}, row_basic={row_basic})"
518            ),
519        }
520    }
521}
522
523impl std::error::Error for SolverError {}
524
525#[cfg(test)]
526mod tests {
527    use super::{Basis, RowBatch, SolutionView, SolverError, SolverStatistics, StageTemplate};
528
529    #[test]
530    fn test_basis_new_dimensions_and_zero_fill() {
531        let rb = Basis::new(3, 2);
532        assert_eq!(rb.col_status.len(), 3);
533        assert_eq!(rb.row_status.len(), 2);
534        assert!(rb.col_status.iter().all(|&v| v == 0_i32));
535        assert!(rb.row_status.iter().all(|&v| v == 0_i32));
536    }
537
538    #[test]
539    fn test_basis_new_empty() {
540        let rb = Basis::new(0, 0);
541        assert!(rb.col_status.is_empty());
542        assert!(rb.row_status.is_empty());
543    }
544
545    #[test]
546    fn test_basis_debug_and_clone() {
547        let rb = Basis::new(2, 1);
548        assert!(!format!("{rb:?}").is_empty());
549        let cloned = rb.clone();
550        assert_eq!(cloned.col_status, rb.col_status);
551        assert_eq!(cloned.row_status, rb.row_status);
552        let mut cloned2 = rb.clone();
553        cloned2.col_status[0] = 1_i32;
554        assert_eq!(rb.col_status[0], 0_i32);
555    }
556
557    #[test]
558    fn test_solver_error_display_infeasible() {
559        let msg = format!("{}", SolverError::Infeasible);
560        assert!(msg.contains("infeasible"));
561    }
562
563    #[test]
564    fn test_solver_error_display_all_variants() {
565        let variants = [
566            SolverError::Infeasible,
567            SolverError::Unbounded,
568            SolverError::NumericalDifficulty {
569                message: "factorization failed".to_string(),
570            },
571            SolverError::TimeLimitExceeded {
572                elapsed_seconds: 60.0,
573            },
574            SolverError::IterationLimit { iterations: 10_000 },
575            SolverError::InternalError {
576                message: "segfault in HiGHS".to_string(),
577                error_code: Some(-1),
578            },
579            SolverError::BasisInconsistent {
580                num_row: 2,
581                total_basic: 5,
582                col_basic: 3,
583                row_basic: 2,
584            },
585        ];
586
587        let messages: Vec<String> = variants.iter().map(|err| format!("{err}")).collect();
588        for i in 0..messages.len() {
589            for j in (i + 1)..messages.len() {
590                assert_ne!(messages[i], messages[j]);
591            }
592        }
593    }
594
595    #[test]
596    fn test_solver_error_is_std_error() {
597        let err = SolverError::InternalError {
598            message: "test".to_string(),
599            error_code: None,
600        };
601        let _: &dyn std::error::Error = &err;
602    }
603
604    #[test]
605    fn test_solver_statistics_default_all_zero() {
606        let stats = SolverStatistics::default();
607        assert_eq!(stats.solve_count, 0);
608        assert_eq!(stats.success_count, 0);
609        assert_eq!(stats.failure_count, 0);
610        assert_eq!(stats.total_iterations, 0);
611        assert_eq!(stats.retry_count, 0);
612        assert_eq!(stats.total_solve_time_seconds, 0.0);
613        assert_eq!(stats.basis_consistency_failures, 0);
614        assert_eq!(stats.first_try_successes, 0);
615        assert_eq!(stats.basis_offered, 0);
616        assert_eq!(stats.total_load_model_time_seconds, 0.0);
617        assert_eq!(stats.total_set_bounds_time_seconds, 0.0);
618        assert_eq!(stats.basis_reconstructions, 0);
619        assert!(stats.retry_level_histogram.is_empty());
620    }
621
622    fn make_fixture_stage_template() -> StageTemplate {
623        StageTemplate {
624            num_cols: 3,
625            num_rows: 2,
626            num_nz: 3,
627            col_starts: vec![0_i32, 2, 2, 3],
628            row_indices: vec![0_i32, 1, 1],
629            values: vec![1.0, 2.0, 1.0],
630            col_lower: vec![0.0, 0.0, 0.0],
631            col_upper: vec![10.0, f64::INFINITY, 8.0],
632            objective: vec![0.0, 1.0, 50.0],
633            row_lower: vec![6.0, 14.0],
634            row_upper: vec![6.0, 14.0],
635            n_state: 1,
636            n_transfer: 0,
637            n_dual_relevant: 1,
638            n_hydro: 1,
639            max_par_order: 0,
640            col_scale: Vec::new(),
641            row_scale: Vec::new(),
642        }
643    }
644
645    #[test]
646    fn test_stage_template_construction() {
647        let tmpl = make_fixture_stage_template();
648
649        assert_eq!(tmpl.num_cols, 3);
650        assert_eq!(tmpl.num_rows, 2);
651        assert_eq!(tmpl.num_nz, 3);
652        assert_eq!(tmpl.col_starts, vec![0_i32, 2, 2, 3]);
653        assert_eq!(tmpl.row_indices, vec![0_i32, 1, 1]);
654        assert_eq!(tmpl.values, vec![1.0, 2.0, 1.0]);
655
656        assert_eq!(tmpl.col_lower, vec![0.0, 0.0, 0.0]);
657        assert_eq!(tmpl.col_upper[0], 10.0);
658        assert!(tmpl.col_upper[1].is_infinite() && tmpl.col_upper[1] > 0.0);
659        assert_eq!(tmpl.col_upper[2], 8.0);
660
661        assert_eq!(tmpl.objective, vec![0.0, 1.0, 50.0]);
662        assert_eq!(tmpl.row_lower, vec![6.0, 14.0]);
663        assert_eq!(tmpl.row_upper, vec![6.0, 14.0]);
664
665        assert_eq!(tmpl.n_state, 1);
666        assert_eq!(tmpl.n_transfer, 0);
667        assert_eq!(tmpl.n_dual_relevant, 1);
668        assert_eq!(tmpl.n_hydro, 1);
669        assert_eq!(tmpl.max_par_order, 0);
670    }
671
672    #[test]
673    fn test_solver_error_display_all_branches() {
674        let cases = vec![
675            ("Infeasible", SolverError::Infeasible, "infeasible"),
676            ("Unbounded", SolverError::Unbounded, "unbounded"),
677            (
678                "NumericalDifficulty",
679                SolverError::NumericalDifficulty {
680                    message: "singular matrix".to_string(),
681                },
682                "singular matrix",
683            ),
684            (
685                "TimeLimitExceeded",
686                SolverError::TimeLimitExceeded {
687                    elapsed_seconds: 60.0,
688                },
689                "60.000s",
690            ),
691            (
692                "IterationLimit",
693                SolverError::IterationLimit { iterations: 10_000 },
694                "10000 iterations",
695            ),
696            (
697                "InternalError/None",
698                SolverError::InternalError {
699                    message: "unknown failure".to_string(),
700                    error_code: None,
701                },
702                "unknown failure",
703            ),
704            (
705                "InternalError/Some",
706                SolverError::InternalError {
707                    message: "segfault in HiGHS".to_string(),
708                    error_code: Some(-1),
709                },
710                "code -1",
711            ),
712            (
713                "BasisInconsistent",
714                SolverError::BasisInconsistent {
715                    num_row: 2,
716                    total_basic: 5,
717                    col_basic: 3,
718                    row_basic: 2,
719                },
720                "num_row=2",
721            ),
722        ];
723
724        for (name, err, expected_text) in cases {
725            let msg = format!("{err}");
726            assert!(!msg.is_empty());
727            assert!(
728                msg.contains(expected_text),
729                "{name} missing '{expected_text}'"
730            );
731        }
732    }
733
734    #[test]
735    fn test_solver_error_is_std_error_all_variants() {
736        let errors: Vec<SolverError> = vec![
737            SolverError::Infeasible,
738            SolverError::Unbounded,
739            SolverError::NumericalDifficulty {
740                message: "test".to_string(),
741            },
742            SolverError::TimeLimitExceeded {
743                elapsed_seconds: 1.0,
744            },
745            SolverError::IterationLimit { iterations: 1 },
746            SolverError::InternalError {
747                message: "test".to_string(),
748                error_code: None,
749            },
750            SolverError::InternalError {
751                message: "test".to_string(),
752                error_code: Some(-1),
753            },
754            SolverError::BasisInconsistent {
755                num_row: 2,
756                total_basic: 5,
757                col_basic: 3,
758                row_basic: 2,
759            },
760        ];
761
762        for err in &errors {
763            let _: &dyn std::error::Error = err;
764        }
765    }
766
767    #[test]
768    fn test_solution_view_to_owned() {
769        let primal = [1.0, 2.0];
770        let dual = [3.0];
771        let rc = [4.0, 5.0];
772        let view = SolutionView {
773            objective: 42.0,
774            primal: &primal,
775            dual: &dual,
776            reduced_costs: &rc,
777            iterations: 7,
778            solve_time_seconds: 0.5,
779        };
780        let owned = view.to_owned();
781        assert_eq!(owned.objective, 42.0);
782        assert_eq!(owned.primal, vec![1.0, 2.0]);
783        assert_eq!(owned.dual, vec![3.0]);
784        assert_eq!(owned.reduced_costs, vec![4.0, 5.0]);
785        assert_eq!(owned.iterations, 7);
786        assert_eq!(owned.solve_time_seconds, 0.5);
787    }
788
789    #[test]
790    fn test_solution_view_is_copy() {
791        let primal = [1.0];
792        let dual = [2.0];
793        let rc = [3.0];
794        let view = SolutionView {
795            objective: 0.0,
796            primal: &primal,
797            dual: &dual,
798            reduced_costs: &rc,
799            iterations: 0,
800            solve_time_seconds: 0.0,
801        };
802        let copy = view;
803        assert_eq!(view.objective, copy.objective);
804    }
805
806    #[test]
807    fn test_row_batch_construction() {
808        let batch = RowBatch {
809            num_rows: 2,
810            row_starts: vec![0_i32, 2, 4],
811            col_indices: vec![0_i32, 1, 0, 1],
812            values: vec![-5.0, 1.0, 3.0, 1.0],
813            row_lower: vec![20.0, 80.0],
814            row_upper: vec![f64::INFINITY, f64::INFINITY],
815        };
816
817        assert_eq!(batch.num_rows, 2);
818        assert_eq!(batch.row_starts.len(), 3);
819        assert_eq!(batch.row_starts, vec![0_i32, 2, 4]);
820        assert_eq!(batch.col_indices, vec![0_i32, 1, 0, 1]);
821        assert_eq!(batch.values, vec![-5.0, 1.0, 3.0, 1.0]);
822        assert_eq!(batch.row_lower, vec![20.0, 80.0]);
823        assert!(batch.row_upper[0].is_infinite() && batch.row_upper[0] > 0.0);
824        assert!(batch.row_upper[1].is_infinite() && batch.row_upper[1] > 0.0);
825    }
826}