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
204impl SolverStatistics {
205    /// Overwrite `self` from `src` in place, reusing `self.retry_level_histogram`'s
206    /// allocation (resize + `copy_from_slice`; no allocation when capacity suffices).
207    ///
208    /// All scalar fields are overwritten by value. The histogram `Vec<u64>` is
209    /// `resize`d to `src`'s length, then overwritten with `copy_from_slice`. When
210    /// `self` already holds a histogram of the same length (the steady-state case),
211    /// no heap allocation occurs. After this call `*self` equals `src.clone()`
212    /// field-for-field.
213    pub fn copy_from(&mut self, src: &SolverStatistics) {
214        self.solve_count = src.solve_count;
215        self.success_count = src.success_count;
216        self.failure_count = src.failure_count;
217        self.total_iterations = src.total_iterations;
218        self.retry_count = src.retry_count;
219        self.total_solve_time_seconds = src.total_solve_time_seconds;
220        self.basis_consistency_failures = src.basis_consistency_failures;
221        self.first_try_successes = src.first_try_successes;
222        self.basis_offered = src.basis_offered;
223        self.load_model_count = src.load_model_count;
224        self.total_load_model_time_seconds = src.total_load_model_time_seconds;
225        self.total_set_bounds_time_seconds = src.total_set_bounds_time_seconds;
226        self.total_basis_set_time_seconds = src.total_basis_set_time_seconds;
227        self.basis_reconstructions = src.basis_reconstructions;
228        self.retry_level_histogram
229            .resize(src.retry_level_histogram.len(), 0);
230        self.retry_level_histogram
231            .copy_from_slice(&src.retry_level_histogram);
232    }
233}
234
235/// Pre-assembled structural LP for one stage, in CSC (column-major) form.
236///
237/// Built once at initialization from resolved internal structures.
238/// Shared read-only across all threads within an MPI rank.
239/// Passed to [`crate::SolverInterface::load_model`] to bulk-load the LP.
240///
241/// Column and row ordering follows the LP layout convention defined in
242/// [Solver Abstraction SS2](../../../cobre-docs/src/specs/architecture/solver-abstraction.md).
243/// The calling algorithm crate owns construction of this type; `cobre-solver`
244/// treats it as an opaque data holder and does not interpret the LP structure.
245///
246/// See [Solver Interface Trait SS4.4](../../../cobre-docs/src/specs/architecture/solver-interface-trait.md)
247/// and [Solver Abstraction SS11.1](../../../cobre-docs/src/specs/architecture/solver-abstraction.md).
248#[derive(Debug, Clone)]
249pub struct StageTemplate {
250    /// Number of columns (decision variables) in the structural LP.
251    pub num_cols: usize,
252
253    /// Number of static rows (structural constraints, excluding dynamic rows).
254    pub num_rows: usize,
255
256    /// Number of non-zero entries in the structural constraint matrix.
257    pub num_nz: usize,
258
259    /// CSC column start offsets (length: `num_cols + 1`; `col_starts[num_cols] == num_nz`).
260    pub col_starts: Vec<i32>,
261
262    /// CSC row indices for each non-zero entry (length: `num_nz`).
263    pub row_indices: Vec<i32>,
264
265    /// CSC non-zero values (length: `num_nz`).
266    pub values: Vec<f64>,
267
268    /// Column lower bounds (length: `num_cols`; use `f64::NEG_INFINITY` for unbounded).
269    pub col_lower: Vec<f64>,
270
271    /// Column upper bounds (length: `num_cols`; use `f64::INFINITY` for unbounded).
272    pub col_upper: Vec<f64>,
273
274    /// Objective coefficients, minimization sense (length: `num_cols`).
275    pub objective: Vec<f64>,
276
277    /// Row lower bounds (length: `num_rows`; set equal to `row_upper` for equality).
278    pub row_lower: Vec<f64>,
279
280    /// Row upper bounds (length: `num_rows`; set equal to `row_lower` for equality).
281    pub row_upper: Vec<f64>,
282
283    /// Number of state variables (contiguous prefix of columns).
284    pub n_state: usize,
285
286    /// Number of state values transferred between consecutive stages.
287    ///
288    /// Equal to `N * L` per
289    /// [Solver Abstraction SS2.1](../../../cobre-docs/src/specs/architecture/solver-abstraction.md).
290    /// This is the storage volumes plus all AR lags except the oldest
291    /// (which ages out of the lag window).
292    pub n_transfer: usize,
293
294    /// Number of dual-relevant constraint rows (contiguous prefix of rows).
295    ///
296    /// Currently equal to `n_state` (= `N + N*L` where `N` is the number of
297    /// hydros and `L` is the maximum PAR lag order). FPHA and generic variable
298    /// constraint rows are structural and not included in the dual-relevant set.
299    ///
300    /// Gradient coefficients are extracted from `dual[0..n_dual_relevant]`.
301    pub n_dual_relevant: usize,
302
303    /// Number of operating hydros at this stage.
304    pub n_hydro: usize,
305
306    /// Maximum PAR order across all operating hydros at this stage.
307    ///
308    /// Determines the uniform lag stride: all hydros store `max_par_order`
309    /// lag values regardless of their individual PAR order, enabling SIMD
310    /// vectorization with a single contiguous state stride.
311    pub max_par_order: usize,
312
313    /// Per-column scaling factors for numerical conditioning.
314    ///
315    /// When non-empty (length `num_cols`), the constraint matrix, objective
316    /// coefficients, and column bounds have been pre-scaled by these factors.
317    /// The calling algorithm is responsible for unscaling primal values after
318    /// each solve: `x_original[j] = col_scale[j] * x_scaled[j]`.
319    ///
320    /// When empty, no column scaling has been applied and solver results are
321    /// used directly.
322    pub col_scale: Vec<f64>,
323
324    /// Per-row scaling factors for numerical conditioning.
325    ///
326    /// When non-empty (length `num_rows`), the constraint matrix and row bounds
327    /// have been pre-scaled by these factors. The calling algorithm is responsible
328    /// for unscaling dual values after each solve:
329    /// `dual_original[i] = row_scale[i] * dual_scaled[i]`.
330    ///
331    /// When empty, no row scaling has been applied and solver results are
332    /// used directly.
333    pub row_scale: Vec<f64>,
334}
335
336/// Batch of constraint rows for addition to a loaded LP, in CSR (row-major) form.
337///
338/// Assembled from the row-pool activity bitmap before each LP rebuild
339/// and passed to [`crate::SolverInterface::add_rows`] for a single batch call.
340/// Rows are appended at the bottom of the constraint matrix in the dynamic
341/// constraint region per
342/// [Solver Abstraction SS2.2](../../../cobre-docs/src/specs/architecture/solver-abstraction.md).
343///
344/// See [Solver Interface Trait SS4.5](../../../cobre-docs/src/specs/architecture/solver-interface-trait.md)
345/// and the row-pool assembly protocol in
346/// [Solver Abstraction SS5.4](../../../cobre-docs/src/specs/architecture/solver-abstraction.md).
347#[derive(Debug, Clone)]
348pub struct RowBatch {
349    /// Number of active constraint rows in this batch.
350    pub num_rows: usize,
351
352    /// CSR row start offsets (`i32` for `HiGHS` FFI compatibility).
353    ///
354    /// Length: `num_rows + 1`. Entry `row_starts[i]` is the index into
355    /// `col_indices` and `values` where row `i` begins.
356    /// `row_starts[num_rows]` equals the total number of non-zeros.
357    pub row_starts: Vec<i32>,
358
359    /// CSR column indices for each non-zero entry (`i32` for `HiGHS` FFI compatibility).
360    ///
361    /// Length: total non-zeros across all rows. Entry `col_indices[k]` is the
362    /// column of the `k`-th non-zero value.
363    pub col_indices: Vec<i32>,
364
365    /// CSR non-zero values.
366    ///
367    /// Length: total non-zeros across all rows. Entry `values[k]` is the
368    /// coefficient at column `col_indices[k]` in its row.
369    pub values: Vec<f64>,
370
371    /// Row lower bounds (RHS lower bounds for `>=` constraints).
372    ///
373    /// Length: `num_rows`. For `>=` constraints, this is the RHS lower bound.
374    pub row_lower: Vec<f64>,
375
376    /// Row upper bounds.
377    ///
378    /// Length: `num_rows`. Use `f64::INFINITY` for `>=` constraints (no finite upper bound).
379    pub row_upper: Vec<f64>,
380}
381
382impl StageTemplate {
383    /// Creates an empty [`StageTemplate`] with zero-sized fields and empty `Vec`s.
384    ///
385    /// Intended for use as a reusable output buffer passed to
386    /// [`crate::baking::bake_rows_into_template`]. The caller constructs one
387    /// `StageTemplate::empty()` and passes it on every baking call; the function
388    /// clears and refills the buffer without calling `shrink_to_fit`, so the
389    /// allocated capacity grows to its steady-state peak and then stabilises.
390    ///
391    /// An empty template is **not** a valid model for `load_model` (it has
392    /// `num_cols == 0` and `num_rows == 0`). Only pass it to `load_model` after
393    /// a successful `bake_rows_into_template` call has populated it.
394    ///
395    /// A `Default` impl is intentionally omitted: an empty template is a
396    /// surprising default and invites misuse. Use this constructor explicitly.
397    #[must_use]
398    pub fn empty() -> Self {
399        Self {
400            num_cols: 0,
401            num_rows: 0,
402            num_nz: 0,
403            col_starts: Vec::new(),
404            row_indices: Vec::new(),
405            values: Vec::new(),
406            col_lower: Vec::new(),
407            col_upper: Vec::new(),
408            objective: Vec::new(),
409            row_lower: Vec::new(),
410            row_upper: Vec::new(),
411            n_state: 0,
412            n_transfer: 0,
413            n_dual_relevant: 0,
414            n_hydro: 0,
415            max_par_order: 0,
416            col_scale: Vec::new(),
417            row_scale: Vec::new(),
418        }
419    }
420}
421
422impl RowBatch {
423    /// Reset all buffers to empty without deallocating.
424    ///
425    /// After `clear()`, `num_rows` is 0 and all `Vec` fields have length 0
426    /// but retain their allocated capacity for reuse.
427    pub fn clear(&mut self) {
428        self.num_rows = 0;
429        self.row_starts.clear();
430        self.col_indices.clear();
431        self.values.clear();
432        self.row_lower.clear();
433        self.row_upper.clear();
434    }
435}
436
437/// Terminal LP solve error returned after all retry attempts are exhausted.
438///
439/// The calling algorithm uses the variant to determine its response:
440/// hard stop (`Infeasible`, `Unbounded`, `InternalError`) or terminate
441/// with a diagnostic error (`NumericalDifficulty`, `TimeLimitExceeded`,
442/// `IterationLimit`).
443///
444/// The six variants correspond to the error categories defined in
445/// Solver Abstraction SS6. Solver-internal errors (e.g., factorization
446/// failures) are resolved by retry logic before reaching this level.
447#[derive(Debug)]
448pub enum SolverError {
449    /// The LP has no feasible solution.
450    ///
451    /// Indicates a data error (inconsistent bounds or constraints) or a
452    /// modeling error. The calling algorithm should perform a hard stop.
453    Infeasible,
454
455    /// The LP objective is unbounded below.
456    ///
457    /// Indicates a modeling error (missing bounds, incorrect objective sign).
458    /// The calling algorithm should perform a hard stop.
459    Unbounded,
460
461    /// Solver encountered numerical difficulties that persisted through all
462    /// retry attempts.
463    ///
464    /// The calling algorithm should log the error and perform a hard stop.
465    NumericalDifficulty {
466        /// Human-readable description of the numerical issue from the solver.
467        message: String,
468    },
469
470    /// Per-solve wall-clock time budget exhausted.
471    TimeLimitExceeded {
472        /// Elapsed wall-clock time in seconds at the point of termination.
473        elapsed_seconds: f64,
474    },
475
476    /// Solver simplex iteration limit reached.
477    IterationLimit {
478        /// Number of simplex iterations performed before the limit was hit.
479        iterations: u64,
480    },
481
482    /// Unrecoverable solver-internal failure.
483    ///
484    /// Covers FFI panics, memory allocation failures within the solver,
485    /// corrupted internal state, or any error not classifiable into the above
486    /// categories. The calling algorithm should log the error and perform a hard stop.
487    InternalError {
488        /// Human-readable error description.
489        message: String,
490        /// Solver-specific error code, if available.
491        error_code: Option<i32>,
492    },
493
494    /// The backend does not implement the requested operation.
495    ///
496    /// The caller should fall back to an alternate code path (e.g.,
497    /// `reset` + `load_model`).
498    Unsupported(&'static str),
499
500    /// The offered basis was rejected by the solver because the total
501    /// number of basic variables did not match the row count.
502    ///
503    /// Indicates that the reconstructed basis violates the fundamental LP
504    /// basis consistency invariant (`col_basic + row_basic == num_row`).
505    /// The calling algorithm should perform a hard stop; this is not a
506    /// recoverable solver-internal condition.
507    BasisInconsistent {
508        /// The LP row count at the point of rejection.
509        num_row: i64,
510        /// The total basic-variable count in the offered basis (`col_basic + row_basic`).
511        total_basic: i64,
512        /// Number of basic columns in the offered basis.
513        col_basic: i64,
514        /// Number of basic rows in the offered basis.
515        row_basic: i64,
516    },
517
518    /// The offered warm-start basis has fewer row entries than the loaded LP
519    /// has rows. The basis predates an `add_rows` growth and cannot be padded
520    /// soundly (a BASIC pad is wrong for inequality-row slacks), so it is
521    /// rejected; the caller should fall back to a cold solve.
522    BasisRowCountMismatch {
523        /// The LP row count at the point of rejection.
524        lp_rows: usize,
525        /// The row-entry count in the offered basis.
526        basis_rows: usize,
527    },
528}
529
530impl fmt::Display for SolverError {
531    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
532        match self {
533            Self::Infeasible => write!(f, "LP is infeasible"),
534            Self::Unbounded => write!(f, "LP is unbounded"),
535            Self::NumericalDifficulty { message } => {
536                write!(f, "numerical difficulty: {message}")
537            }
538            Self::TimeLimitExceeded { elapsed_seconds } => {
539                write!(f, "time limit exceeded after {elapsed_seconds:.3}s")
540            }
541            Self::IterationLimit { iterations } => {
542                write!(f, "iteration limit reached after {iterations} iterations")
543            }
544            Self::InternalError {
545                message,
546                error_code,
547            } => match error_code {
548                Some(code) => write!(f, "internal solver error (code {code}): {message}"),
549                None => write!(f, "internal solver error: {message}"),
550            },
551            Self::Unsupported(msg) => write!(f, "unsupported operation: {msg}"),
552            Self::BasisInconsistent {
553                num_row,
554                total_basic,
555                col_basic,
556                row_basic,
557            } => write!(
558                f,
559                "basis inconsistent: num_row={num_row}, total_basic={total_basic} (col_basic={col_basic}, row_basic={row_basic})"
560            ),
561            Self::BasisRowCountMismatch {
562                lp_rows,
563                basis_rows,
564            } => write!(
565                f,
566                "basis row count mismatch: lp_rows={lp_rows}, basis_rows={basis_rows}"
567            ),
568        }
569    }
570}
571
572impl std::error::Error for SolverError {}
573
574#[cfg(test)]
575mod tests {
576    use super::{Basis, RowBatch, SolutionView, SolverError, SolverStatistics, StageTemplate};
577
578    #[test]
579    fn test_basis_new_dimensions_and_zero_fill() {
580        let rb = Basis::new(3, 2);
581        assert_eq!(rb.col_status.len(), 3);
582        assert_eq!(rb.row_status.len(), 2);
583        assert!(rb.col_status.iter().all(|&v| v == 0_i32));
584        assert!(rb.row_status.iter().all(|&v| v == 0_i32));
585    }
586
587    #[test]
588    fn test_basis_new_empty() {
589        let rb = Basis::new(0, 0);
590        assert!(rb.col_status.is_empty());
591        assert!(rb.row_status.is_empty());
592    }
593
594    #[test]
595    fn test_basis_debug_and_clone() {
596        let rb = Basis::new(2, 1);
597        assert!(!format!("{rb:?}").is_empty());
598        let cloned = rb.clone();
599        assert_eq!(cloned.col_status, rb.col_status);
600        assert_eq!(cloned.row_status, rb.row_status);
601        let mut cloned2 = rb.clone();
602        cloned2.col_status[0] = 1_i32;
603        assert_eq!(rb.col_status[0], 0_i32);
604    }
605
606    #[test]
607    fn test_solver_error_display_infeasible() {
608        let msg = format!("{}", SolverError::Infeasible);
609        assert!(msg.contains("infeasible"));
610    }
611
612    #[test]
613    fn test_solver_error_display_all_variants() {
614        let variants = [
615            SolverError::Infeasible,
616            SolverError::Unbounded,
617            SolverError::NumericalDifficulty {
618                message: "factorization failed".to_string(),
619            },
620            SolverError::TimeLimitExceeded {
621                elapsed_seconds: 60.0,
622            },
623            SolverError::IterationLimit { iterations: 10_000 },
624            SolverError::InternalError {
625                message: "segfault in HiGHS".to_string(),
626                error_code: Some(-1),
627            },
628            SolverError::BasisInconsistent {
629                num_row: 2,
630                total_basic: 5,
631                col_basic: 3,
632                row_basic: 2,
633            },
634            SolverError::BasisRowCountMismatch {
635                lp_rows: 3,
636                basis_rows: 2,
637            },
638        ];
639
640        let messages: Vec<String> = variants.iter().map(|err| format!("{err}")).collect();
641        for i in 0..messages.len() {
642            for j in (i + 1)..messages.len() {
643                assert_ne!(messages[i], messages[j]);
644            }
645        }
646    }
647
648    #[test]
649    fn test_solver_error_is_std_error() {
650        let err = SolverError::InternalError {
651            message: "test".to_string(),
652            error_code: None,
653        };
654        let _: &dyn std::error::Error = &err;
655    }
656
657    #[test]
658    fn test_solver_statistics_default_all_zero() {
659        let stats = SolverStatistics::default();
660        assert_eq!(stats.solve_count, 0);
661        assert_eq!(stats.success_count, 0);
662        assert_eq!(stats.failure_count, 0);
663        assert_eq!(stats.total_iterations, 0);
664        assert_eq!(stats.retry_count, 0);
665        assert_eq!(stats.total_solve_time_seconds, 0.0);
666        assert_eq!(stats.basis_consistency_failures, 0);
667        assert_eq!(stats.first_try_successes, 0);
668        assert_eq!(stats.basis_offered, 0);
669        assert_eq!(stats.total_load_model_time_seconds, 0.0);
670        assert_eq!(stats.total_set_bounds_time_seconds, 0.0);
671        assert_eq!(stats.basis_reconstructions, 0);
672        assert!(stats.retry_level_histogram.is_empty());
673    }
674
675    fn make_fixture_statistics() -> SolverStatistics {
676        SolverStatistics {
677            solve_count: 11,
678            success_count: 9,
679            failure_count: 2,
680            total_iterations: 3456,
681            retry_count: 5,
682            total_solve_time_seconds: 1.25,
683            basis_consistency_failures: 1,
684            first_try_successes: 7,
685            basis_offered: 8,
686            load_model_count: 4,
687            total_load_model_time_seconds: 0.5,
688            total_set_bounds_time_seconds: 0.25,
689            total_basis_set_time_seconds: 0.125,
690            basis_reconstructions: 6,
691            retry_level_histogram: vec![1, 0, 2, 0, 3, 0, 0, 0, 4, 0, 0, 5],
692        }
693    }
694
695    fn assert_statistics_eq(a: &SolverStatistics, b: &SolverStatistics) {
696        assert_eq!(a.solve_count, b.solve_count);
697        assert_eq!(a.success_count, b.success_count);
698        assert_eq!(a.failure_count, b.failure_count);
699        assert_eq!(a.total_iterations, b.total_iterations);
700        assert_eq!(a.retry_count, b.retry_count);
701        assert_eq!(a.total_solve_time_seconds, b.total_solve_time_seconds);
702        assert_eq!(a.basis_consistency_failures, b.basis_consistency_failures);
703        assert_eq!(a.first_try_successes, b.first_try_successes);
704        assert_eq!(a.basis_offered, b.basis_offered);
705        assert_eq!(a.load_model_count, b.load_model_count);
706        assert_eq!(
707            a.total_load_model_time_seconds,
708            b.total_load_model_time_seconds
709        );
710        assert_eq!(
711            a.total_set_bounds_time_seconds,
712            b.total_set_bounds_time_seconds
713        );
714        assert_eq!(
715            a.total_basis_set_time_seconds,
716            b.total_basis_set_time_seconds
717        );
718        assert_eq!(a.basis_reconstructions, b.basis_reconstructions);
719        assert_eq!(a.retry_level_histogram, b.retry_level_histogram);
720    }
721
722    #[test]
723    fn test_solver_statistics_copy_from_equals_clone() {
724        let src = make_fixture_statistics();
725
726        // Buffer starts empty: first copy resizes the histogram from scratch.
727        let mut buf = SolverStatistics::default();
728        buf.copy_from(&src);
729        assert_statistics_eq(&buf, &src);
730
731        // Buffer with a differently-sized histogram is also overwritten exactly.
732        let mut buf2 = SolverStatistics {
733            retry_level_histogram: vec![99; 5],
734            ..Default::default()
735        };
736        buf2.copy_from(&src);
737        assert_statistics_eq(&buf2, &src);
738    }
739
740    #[test]
741    fn test_solver_statistics_copy_from_no_realloc_second_call() {
742        let src = make_fixture_statistics();
743
744        // Pre-size the buffer's histogram to the source length so the first
745        // copy stabilizes capacity; the second copy must not reallocate.
746        let mut buf = SolverStatistics {
747            retry_level_histogram: vec![0; src.retry_level_histogram.len()],
748            ..Default::default()
749        };
750        buf.copy_from(&src);
751
752        let ptr_before = buf.retry_level_histogram.as_ptr();
753        buf.copy_from(&src);
754        let ptr_after = buf.retry_level_histogram.as_ptr();
755
756        assert_eq!(
757            ptr_before, ptr_after,
758            "second copy_from must not reallocate the histogram"
759        );
760        assert_statistics_eq(&buf, &src);
761    }
762
763    fn make_fixture_stage_template() -> StageTemplate {
764        StageTemplate {
765            num_cols: 3,
766            num_rows: 2,
767            num_nz: 3,
768            col_starts: vec![0_i32, 2, 2, 3],
769            row_indices: vec![0_i32, 1, 1],
770            values: vec![1.0, 2.0, 1.0],
771            col_lower: vec![0.0, 0.0, 0.0],
772            col_upper: vec![10.0, f64::INFINITY, 8.0],
773            objective: vec![0.0, 1.0, 50.0],
774            row_lower: vec![6.0, 14.0],
775            row_upper: vec![6.0, 14.0],
776            n_state: 1,
777            n_transfer: 0,
778            n_dual_relevant: 1,
779            n_hydro: 1,
780            max_par_order: 0,
781            col_scale: Vec::new(),
782            row_scale: Vec::new(),
783        }
784    }
785
786    #[test]
787    fn test_stage_template_construction() {
788        let tmpl = make_fixture_stage_template();
789
790        assert_eq!(tmpl.num_cols, 3);
791        assert_eq!(tmpl.num_rows, 2);
792        assert_eq!(tmpl.num_nz, 3);
793        assert_eq!(tmpl.col_starts, vec![0_i32, 2, 2, 3]);
794        assert_eq!(tmpl.row_indices, vec![0_i32, 1, 1]);
795        assert_eq!(tmpl.values, vec![1.0, 2.0, 1.0]);
796
797        assert_eq!(tmpl.col_lower, vec![0.0, 0.0, 0.0]);
798        assert_eq!(tmpl.col_upper[0], 10.0);
799        assert!(tmpl.col_upper[1].is_infinite() && tmpl.col_upper[1] > 0.0);
800        assert_eq!(tmpl.col_upper[2], 8.0);
801
802        assert_eq!(tmpl.objective, vec![0.0, 1.0, 50.0]);
803        assert_eq!(tmpl.row_lower, vec![6.0, 14.0]);
804        assert_eq!(tmpl.row_upper, vec![6.0, 14.0]);
805
806        assert_eq!(tmpl.n_state, 1);
807        assert_eq!(tmpl.n_transfer, 0);
808        assert_eq!(tmpl.n_dual_relevant, 1);
809        assert_eq!(tmpl.n_hydro, 1);
810        assert_eq!(tmpl.max_par_order, 0);
811    }
812
813    #[test]
814    fn test_solver_error_display_all_branches() {
815        let cases = vec![
816            ("Infeasible", SolverError::Infeasible, "infeasible"),
817            ("Unbounded", SolverError::Unbounded, "unbounded"),
818            (
819                "NumericalDifficulty",
820                SolverError::NumericalDifficulty {
821                    message: "singular matrix".to_string(),
822                },
823                "singular matrix",
824            ),
825            (
826                "TimeLimitExceeded",
827                SolverError::TimeLimitExceeded {
828                    elapsed_seconds: 60.0,
829                },
830                "60.000s",
831            ),
832            (
833                "IterationLimit",
834                SolverError::IterationLimit { iterations: 10_000 },
835                "10000 iterations",
836            ),
837            (
838                "InternalError/None",
839                SolverError::InternalError {
840                    message: "unknown failure".to_string(),
841                    error_code: None,
842                },
843                "unknown failure",
844            ),
845            (
846                "InternalError/Some",
847                SolverError::InternalError {
848                    message: "segfault in HiGHS".to_string(),
849                    error_code: Some(-1),
850                },
851                "code -1",
852            ),
853            (
854                "BasisInconsistent",
855                SolverError::BasisInconsistent {
856                    num_row: 2,
857                    total_basic: 5,
858                    col_basic: 3,
859                    row_basic: 2,
860                },
861                "num_row=2",
862            ),
863            (
864                "BasisRowCountMismatch",
865                SolverError::BasisRowCountMismatch {
866                    lp_rows: 3,
867                    basis_rows: 2,
868                },
869                "lp_rows=3",
870            ),
871        ];
872
873        for (name, err, expected_text) in cases {
874            let msg = format!("{err}");
875            assert!(!msg.is_empty());
876            assert!(
877                msg.contains(expected_text),
878                "{name} missing '{expected_text}'"
879            );
880        }
881    }
882
883    #[test]
884    fn test_solver_error_is_std_error_all_variants() {
885        let errors: Vec<SolverError> = vec![
886            SolverError::Infeasible,
887            SolverError::Unbounded,
888            SolverError::NumericalDifficulty {
889                message: "test".to_string(),
890            },
891            SolverError::TimeLimitExceeded {
892                elapsed_seconds: 1.0,
893            },
894            SolverError::IterationLimit { iterations: 1 },
895            SolverError::InternalError {
896                message: "test".to_string(),
897                error_code: None,
898            },
899            SolverError::InternalError {
900                message: "test".to_string(),
901                error_code: Some(-1),
902            },
903            SolverError::BasisInconsistent {
904                num_row: 2,
905                total_basic: 5,
906                col_basic: 3,
907                row_basic: 2,
908            },
909            SolverError::BasisRowCountMismatch {
910                lp_rows: 3,
911                basis_rows: 2,
912            },
913        ];
914
915        for err in &errors {
916            let _: &dyn std::error::Error = err;
917        }
918    }
919
920    #[test]
921    fn test_solution_view_to_owned() {
922        let primal = [1.0, 2.0];
923        let dual = [3.0];
924        let rc = [4.0, 5.0];
925        let view = SolutionView {
926            objective: 42.0,
927            primal: &primal,
928            dual: &dual,
929            reduced_costs: &rc,
930            iterations: 7,
931            solve_time_seconds: 0.5,
932        };
933        let owned = view.to_owned();
934        assert_eq!(owned.objective, 42.0);
935        assert_eq!(owned.primal, vec![1.0, 2.0]);
936        assert_eq!(owned.dual, vec![3.0]);
937        assert_eq!(owned.reduced_costs, vec![4.0, 5.0]);
938        assert_eq!(owned.iterations, 7);
939        assert_eq!(owned.solve_time_seconds, 0.5);
940    }
941
942    #[test]
943    fn test_solution_view_is_copy() {
944        let primal = [1.0];
945        let dual = [2.0];
946        let rc = [3.0];
947        let view = SolutionView {
948            objective: 0.0,
949            primal: &primal,
950            dual: &dual,
951            reduced_costs: &rc,
952            iterations: 0,
953            solve_time_seconds: 0.0,
954        };
955        let copy = view;
956        assert_eq!(view.objective, copy.objective);
957    }
958
959    #[test]
960    fn test_row_batch_construction() {
961        let batch = RowBatch {
962            num_rows: 2,
963            row_starts: vec![0_i32, 2, 4],
964            col_indices: vec![0_i32, 1, 0, 1],
965            values: vec![-5.0, 1.0, 3.0, 1.0],
966            row_lower: vec![20.0, 80.0],
967            row_upper: vec![f64::INFINITY, f64::INFINITY],
968        };
969
970        assert_eq!(batch.num_rows, 2);
971        assert_eq!(batch.row_starts.len(), 3);
972        assert_eq!(batch.row_starts, vec![0_i32, 2, 4]);
973        assert_eq!(batch.col_indices, vec![0_i32, 1, 0, 1]);
974        assert_eq!(batch.values, vec![-5.0, 1.0, 3.0, 1.0]);
975        assert_eq!(batch.row_lower, vec![20.0, 80.0]);
976        assert!(batch.row_upper[0].is_infinite() && batch.row_upper[0] > 0.0);
977        assert!(batch.row_upper[1].is_infinite() && batch.row_upper[1] > 0.0);
978    }
979}