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/// `reset()` does **not** zero statistics counters. They persist across
135/// model reloads for the lifetime of the 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` and `solve_with_basis` calls.
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 times `solve_with_basis` fell back to cold-start due to basis rejection.
159    pub basis_rejections: u64,
160
161    /// Number of solves that returned optimal on the first attempt (before any retry).
162    ///
163    /// Enables first-try rate computation: `first_try_rate = first_try_successes / solve_count`.
164    /// The complement `success_count - first_try_successes` gives the number of retried solves.
165    pub first_try_successes: u64,
166
167    /// Total number of `solve_with_basis` calls (basis offers).
168    ///
169    /// Combined with `basis_rejections`, enables basis hit rate computation:
170    /// `basis_hit_rate = 1 - basis_rejections / basis_offered`.
171    pub basis_offered: u64,
172
173    /// Total number of `load_model` calls.
174    pub load_model_count: u64,
175
176    /// Total number of `add_rows` calls.
177    pub add_rows_count: u64,
178
179    /// Cumulative wall-clock time spent in `load_model` calls, in seconds.
180    pub total_load_model_time_seconds: f64,
181
182    /// Cumulative wall-clock time spent in `add_rows` calls, in seconds.
183    pub total_add_rows_time_seconds: f64,
184
185    /// Cumulative wall-clock time spent in `set_row_bounds` and `set_col_bounds` calls, in seconds.
186    pub total_set_bounds_time_seconds: f64,
187
188    /// Cumulative wall-clock time spent in `set_basis` FFI calls, in seconds.
189    ///
190    /// Accumulated by `solve_with_basis` around the basis installation step.
191    /// `solve()` (without basis) does not increment this counter.
192    pub total_basis_set_time_seconds: f64,
193
194    /// Per-level retry success histogram (12 levels, indexed 0..11).
195    ///
196    /// `retry_level_histogram[k]` counts how many solves were recovered at
197    /// retry level `k`. The sum equals `success_count - first_try_successes`.
198    pub retry_level_histogram: [u64; 12],
199}
200
201/// Pre-assembled structural LP for one stage, in CSC (column-major) form.
202///
203/// Built once at initialization from resolved internal structures.
204/// Shared read-only across all threads within an MPI rank.
205/// Passed to [`crate::SolverInterface::load_model`] to bulk-load the LP.
206///
207/// Column and row ordering follows the LP layout convention defined in
208/// [Solver Abstraction SS2](../../../cobre-docs/src/specs/architecture/solver-abstraction.md).
209/// The calling algorithm crate owns construction of this type; `cobre-solver`
210/// treats it as an opaque data holder and does not interpret the LP structure.
211///
212/// See [Solver Interface Trait SS4.4](../../../cobre-docs/src/specs/architecture/solver-interface-trait.md)
213/// and [Solver Abstraction SS11.1](../../../cobre-docs/src/specs/architecture/solver-abstraction.md).
214#[derive(Debug, Clone)]
215pub struct StageTemplate {
216    /// Number of columns (decision variables) in the structural LP.
217    pub num_cols: usize,
218
219    /// Number of static rows (structural constraints, excluding dynamic rows).
220    pub num_rows: usize,
221
222    /// Number of non-zero entries in the structural constraint matrix.
223    pub num_nz: usize,
224
225    /// CSC column start offsets (length: `num_cols + 1`; `col_starts[num_cols] == num_nz`).
226    pub col_starts: Vec<i32>,
227
228    /// CSC row indices for each non-zero entry (length: `num_nz`).
229    pub row_indices: Vec<i32>,
230
231    /// CSC non-zero values (length: `num_nz`).
232    pub values: Vec<f64>,
233
234    /// Column lower bounds (length: `num_cols`; use `f64::NEG_INFINITY` for unbounded).
235    pub col_lower: Vec<f64>,
236
237    /// Column upper bounds (length: `num_cols`; use `f64::INFINITY` for unbounded).
238    pub col_upper: Vec<f64>,
239
240    /// Objective coefficients, minimization sense (length: `num_cols`).
241    pub objective: Vec<f64>,
242
243    /// Row lower bounds (length: `num_rows`; set equal to `row_upper` for equality).
244    pub row_lower: Vec<f64>,
245
246    /// Row upper bounds (length: `num_rows`; set equal to `row_lower` for equality).
247    pub row_upper: Vec<f64>,
248
249    /// Number of state variables (contiguous prefix of columns).
250    pub n_state: usize,
251
252    /// Number of state values transferred between consecutive stages.
253    ///
254    /// Equal to `N * L` per
255    /// [Solver Abstraction SS2.1](../../../cobre-docs/src/specs/architecture/solver-abstraction.md).
256    /// This is the storage volumes plus all AR lags except the oldest
257    /// (which ages out of the lag window).
258    pub n_transfer: usize,
259
260    /// Number of dual-relevant constraint rows (contiguous prefix of rows).
261    ///
262    /// Currently equal to `n_state` (= `N + N*L` where `N` is the number of
263    /// hydros and `L` is the maximum PAR lag order). FPHA and generic variable
264    /// constraint rows are structural and not included in the dual-relevant set.
265    ///
266    /// Cut coefficients are extracted from `dual[0..n_dual_relevant]`.
267    pub n_dual_relevant: usize,
268
269    /// Number of operating hydros at this stage.
270    pub n_hydro: usize,
271
272    /// Maximum PAR order across all operating hydros at this stage.
273    ///
274    /// Determines the uniform lag stride: all hydros store `max_par_order`
275    /// lag values regardless of their individual PAR order, enabling SIMD
276    /// vectorization with a single contiguous state stride.
277    pub max_par_order: usize,
278
279    /// Per-column scaling factors for numerical conditioning.
280    ///
281    /// When non-empty (length `num_cols`), the constraint matrix, objective
282    /// coefficients, and column bounds have been pre-scaled by these factors.
283    /// The calling algorithm is responsible for unscaling primal values after
284    /// each solve: `x_original[j] = col_scale[j] * x_scaled[j]`.
285    ///
286    /// When empty, no column scaling has been applied and solver results are
287    /// used directly.
288    pub col_scale: Vec<f64>,
289
290    /// Per-row scaling factors for numerical conditioning.
291    ///
292    /// When non-empty (length `num_rows`), the constraint matrix and row bounds
293    /// have been pre-scaled by these factors. The calling algorithm is responsible
294    /// for unscaling dual values after each solve:
295    /// `dual_original[i] = row_scale[i] * dual_scaled[i]`.
296    ///
297    /// When empty, no row scaling has been applied and solver results are
298    /// used directly.
299    pub row_scale: Vec<f64>,
300}
301
302/// Batch of constraint rows for addition to a loaded LP, in CSR (row-major) form.
303///
304/// Assembled from the cut pool activity bitmap before each LP rebuild
305/// and passed to [`crate::SolverInterface::add_rows`] for a single batch call.
306/// Cuts are appended at the bottom of the constraint matrix in the dynamic
307/// constraint region per
308/// [Solver Abstraction SS2.2](../../../cobre-docs/src/specs/architecture/solver-abstraction.md).
309///
310/// See [Solver Interface Trait SS4.5](../../../cobre-docs/src/specs/architecture/solver-interface-trait.md)
311/// and the cut pool assembly protocol in
312/// [Solver Abstraction SS5.4](../../../cobre-docs/src/specs/architecture/solver-abstraction.md).
313#[derive(Debug, Clone)]
314pub struct RowBatch {
315    /// Number of active constraint rows (cuts) in this batch.
316    pub num_rows: usize,
317
318    /// CSR row start offsets (`i32` for `HiGHS` FFI compatibility).
319    ///
320    /// Length: `num_rows + 1`. Entry `row_starts[i]` is the index into
321    /// `col_indices` and `values` where row `i` begins.
322    /// `row_starts[num_rows]` equals the total number of non-zeros.
323    pub row_starts: Vec<i32>,
324
325    /// CSR column indices for each non-zero entry (`i32` for `HiGHS` FFI compatibility).
326    ///
327    /// Length: total non-zeros across all rows. Entry `col_indices[k]` is the
328    /// column of the `k`-th non-zero value.
329    pub col_indices: Vec<i32>,
330
331    /// CSR non-zero values.
332    ///
333    /// Length: total non-zeros across all rows. Entry `values[k]` is the
334    /// coefficient at column `col_indices[k]` in its row.
335    pub values: Vec<f64>,
336
337    /// Row lower bounds (cut intercepts for cutting-plane cuts).
338    ///
339    /// Length: `num_rows`. For `>=` cuts, this is the RHS lower bound.
340    pub row_lower: Vec<f64>,
341
342    /// Row upper bounds.
343    ///
344    /// Length: `num_rows`. Use `f64::INFINITY` for `>=` cuts (cutting-plane cuts
345    /// have no finite upper bound).
346    pub row_upper: Vec<f64>,
347}
348
349impl RowBatch {
350    /// Reset all buffers to empty without deallocating.
351    ///
352    /// After `clear()`, `num_rows` is 0 and all `Vec` fields have length 0
353    /// but retain their allocated capacity for reuse.
354    pub fn clear(&mut self) {
355        self.num_rows = 0;
356        self.row_starts.clear();
357        self.col_indices.clear();
358        self.values.clear();
359        self.row_lower.clear();
360        self.row_upper.clear();
361    }
362}
363
364/// Terminal LP solve error returned after all retry attempts are exhausted.
365///
366/// The calling algorithm uses the variant to determine its response:
367/// hard stop (`Infeasible`, `Unbounded`, `InternalError`) or terminate
368/// with a diagnostic error (`NumericalDifficulty`, `TimeLimitExceeded`,
369/// `IterationLimit`).
370///
371/// The six variants correspond to the error categories defined in
372/// Solver Abstraction SS6. Solver-internal errors (e.g., factorization
373/// failures) are resolved by retry logic before reaching this level.
374#[derive(Debug)]
375pub enum SolverError {
376    /// The LP has no feasible solution.
377    ///
378    /// Indicates a data error (inconsistent bounds or constraints) or a
379    /// modeling error. The calling algorithm should perform a hard stop.
380    Infeasible,
381
382    /// The LP objective is unbounded below.
383    ///
384    /// Indicates a modeling error (missing bounds, incorrect objective sign).
385    /// The calling algorithm should perform a hard stop.
386    Unbounded,
387
388    /// Solver encountered numerical difficulties that persisted through all
389    /// retry attempts.
390    ///
391    /// The calling algorithm should log the error and perform a hard stop.
392    NumericalDifficulty {
393        /// Human-readable description of the numerical issue from the solver.
394        message: String,
395    },
396
397    /// Per-solve wall-clock time budget exhausted.
398    TimeLimitExceeded {
399        /// Elapsed wall-clock time in seconds at the point of termination.
400        elapsed_seconds: f64,
401    },
402
403    /// Solver simplex iteration limit reached.
404    IterationLimit {
405        /// Number of simplex iterations performed before the limit was hit.
406        iterations: u64,
407    },
408
409    /// Unrecoverable solver-internal failure.
410    ///
411    /// Covers FFI panics, memory allocation failures within the solver,
412    /// corrupted internal state, or any error not classifiable into the above
413    /// categories. The calling algorithm should log the error and perform a hard stop.
414    InternalError {
415        /// Human-readable error description.
416        message: String,
417        /// Solver-specific error code, if available.
418        error_code: Option<i32>,
419    },
420}
421
422impl fmt::Display for SolverError {
423    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
424        match self {
425            Self::Infeasible => write!(f, "LP is infeasible"),
426            Self::Unbounded => write!(f, "LP is unbounded"),
427            Self::NumericalDifficulty { message } => {
428                write!(f, "numerical difficulty: {message}")
429            }
430            Self::TimeLimitExceeded { elapsed_seconds } => {
431                write!(f, "time limit exceeded after {elapsed_seconds:.3}s")
432            }
433            Self::IterationLimit { iterations } => {
434                write!(f, "iteration limit reached after {iterations} iterations")
435            }
436            Self::InternalError {
437                message,
438                error_code,
439            } => match error_code {
440                Some(code) => write!(f, "internal solver error (code {code}): {message}"),
441                None => write!(f, "internal solver error: {message}"),
442            },
443        }
444    }
445}
446
447impl std::error::Error for SolverError {}
448
449#[cfg(test)]
450mod tests {
451    use super::{Basis, RowBatch, SolutionView, SolverError, SolverStatistics, StageTemplate};
452
453    #[test]
454    fn test_basis_new_dimensions_and_zero_fill() {
455        let rb = Basis::new(3, 2);
456        assert_eq!(rb.col_status.len(), 3);
457        assert_eq!(rb.row_status.len(), 2);
458        assert!(rb.col_status.iter().all(|&v| v == 0_i32));
459        assert!(rb.row_status.iter().all(|&v| v == 0_i32));
460    }
461
462    #[test]
463    fn test_basis_new_empty() {
464        let rb = Basis::new(0, 0);
465        assert!(rb.col_status.is_empty());
466        assert!(rb.row_status.is_empty());
467    }
468
469    #[test]
470    fn test_basis_debug_and_clone() {
471        let rb = Basis::new(2, 1);
472        assert!(!format!("{rb:?}").is_empty());
473        let cloned = rb.clone();
474        assert_eq!(cloned.col_status, rb.col_status);
475        assert_eq!(cloned.row_status, rb.row_status);
476        let mut cloned2 = rb.clone();
477        cloned2.col_status[0] = 1_i32;
478        assert_eq!(rb.col_status[0], 0_i32);
479    }
480
481    #[test]
482    fn test_solver_error_display_infeasible() {
483        let msg = format!("{}", SolverError::Infeasible);
484        assert!(msg.contains("infeasible"));
485    }
486
487    #[test]
488    fn test_solver_error_display_all_variants() {
489        let variants = [
490            SolverError::Infeasible,
491            SolverError::Unbounded,
492            SolverError::NumericalDifficulty {
493                message: "factorization failed".to_string(),
494            },
495            SolverError::TimeLimitExceeded {
496                elapsed_seconds: 60.0,
497            },
498            SolverError::IterationLimit { iterations: 10_000 },
499            SolverError::InternalError {
500                message: "segfault in HiGHS".to_string(),
501                error_code: Some(-1),
502            },
503        ];
504
505        let messages: Vec<String> = variants.iter().map(|err| format!("{err}")).collect();
506        for i in 0..messages.len() {
507            for j in (i + 1)..messages.len() {
508                assert_ne!(messages[i], messages[j]);
509            }
510        }
511    }
512
513    #[test]
514    fn test_solver_error_is_std_error() {
515        let err = SolverError::InternalError {
516            message: "test".to_string(),
517            error_code: None,
518        };
519        let _: &dyn std::error::Error = &err;
520    }
521
522    #[test]
523    fn test_solver_statistics_default_all_zero() {
524        let stats = SolverStatistics::default();
525        assert_eq!(stats.solve_count, 0);
526        assert_eq!(stats.success_count, 0);
527        assert_eq!(stats.failure_count, 0);
528        assert_eq!(stats.total_iterations, 0);
529        assert_eq!(stats.retry_count, 0);
530        assert_eq!(stats.total_solve_time_seconds, 0.0);
531        assert_eq!(stats.basis_rejections, 0);
532        assert_eq!(stats.first_try_successes, 0);
533        assert_eq!(stats.basis_offered, 0);
534        assert_eq!(stats.total_load_model_time_seconds, 0.0);
535        assert_eq!(stats.total_add_rows_time_seconds, 0.0);
536        assert_eq!(stats.total_set_bounds_time_seconds, 0.0);
537        assert_eq!(stats.retry_level_histogram, [0u64; 12]);
538    }
539
540    fn make_fixture_stage_template() -> StageTemplate {
541        StageTemplate {
542            num_cols: 3,
543            num_rows: 2,
544            num_nz: 3,
545            col_starts: vec![0_i32, 2, 2, 3],
546            row_indices: vec![0_i32, 1, 1],
547            values: vec![1.0, 2.0, 1.0],
548            col_lower: vec![0.0, 0.0, 0.0],
549            col_upper: vec![10.0, f64::INFINITY, 8.0],
550            objective: vec![0.0, 1.0, 50.0],
551            row_lower: vec![6.0, 14.0],
552            row_upper: vec![6.0, 14.0],
553            n_state: 1,
554            n_transfer: 0,
555            n_dual_relevant: 1,
556            n_hydro: 1,
557            max_par_order: 0,
558            col_scale: Vec::new(),
559            row_scale: Vec::new(),
560        }
561    }
562
563    #[test]
564    fn test_stage_template_construction() {
565        let tmpl = make_fixture_stage_template();
566
567        assert_eq!(tmpl.num_cols, 3);
568        assert_eq!(tmpl.num_rows, 2);
569        assert_eq!(tmpl.num_nz, 3);
570        assert_eq!(tmpl.col_starts, vec![0_i32, 2, 2, 3]);
571        assert_eq!(tmpl.row_indices, vec![0_i32, 1, 1]);
572        assert_eq!(tmpl.values, vec![1.0, 2.0, 1.0]);
573
574        assert_eq!(tmpl.col_lower, vec![0.0, 0.0, 0.0]);
575        assert_eq!(tmpl.col_upper[0], 10.0);
576        assert!(tmpl.col_upper[1].is_infinite() && tmpl.col_upper[1] > 0.0);
577        assert_eq!(tmpl.col_upper[2], 8.0);
578
579        assert_eq!(tmpl.objective, vec![0.0, 1.0, 50.0]);
580        assert_eq!(tmpl.row_lower, vec![6.0, 14.0]);
581        assert_eq!(tmpl.row_upper, vec![6.0, 14.0]);
582
583        assert_eq!(tmpl.n_state, 1);
584        assert_eq!(tmpl.n_transfer, 0);
585        assert_eq!(tmpl.n_dual_relevant, 1);
586        assert_eq!(tmpl.n_hydro, 1);
587        assert_eq!(tmpl.max_par_order, 0);
588    }
589
590    #[test]
591    fn test_solver_error_display_all_branches() {
592        let cases = vec![
593            ("Infeasible", SolverError::Infeasible, "infeasible"),
594            ("Unbounded", SolverError::Unbounded, "unbounded"),
595            (
596                "NumericalDifficulty",
597                SolverError::NumericalDifficulty {
598                    message: "singular matrix".to_string(),
599                },
600                "singular matrix",
601            ),
602            (
603                "TimeLimitExceeded",
604                SolverError::TimeLimitExceeded {
605                    elapsed_seconds: 60.0,
606                },
607                "60.000s",
608            ),
609            (
610                "IterationLimit",
611                SolverError::IterationLimit { iterations: 10_000 },
612                "10000 iterations",
613            ),
614            (
615                "InternalError/None",
616                SolverError::InternalError {
617                    message: "unknown failure".to_string(),
618                    error_code: None,
619                },
620                "unknown failure",
621            ),
622            (
623                "InternalError/Some",
624                SolverError::InternalError {
625                    message: "segfault in HiGHS".to_string(),
626                    error_code: Some(-1),
627                },
628                "code -1",
629            ),
630        ];
631
632        for (name, err, expected_text) in cases {
633            let msg = format!("{err}");
634            assert!(!msg.is_empty());
635            assert!(
636                msg.contains(expected_text),
637                "{name} missing '{expected_text}'"
638            );
639        }
640    }
641
642    #[test]
643    fn test_solver_error_is_std_error_all_variants() {
644        let errors: Vec<SolverError> = vec![
645            SolverError::Infeasible,
646            SolverError::Unbounded,
647            SolverError::NumericalDifficulty {
648                message: "test".to_string(),
649            },
650            SolverError::TimeLimitExceeded {
651                elapsed_seconds: 1.0,
652            },
653            SolverError::IterationLimit { iterations: 1 },
654            SolverError::InternalError {
655                message: "test".to_string(),
656                error_code: None,
657            },
658            SolverError::InternalError {
659                message: "test".to_string(),
660                error_code: Some(-1),
661            },
662        ];
663
664        for err in &errors {
665            let _: &dyn std::error::Error = err;
666        }
667    }
668
669    #[test]
670    fn test_solution_view_to_owned() {
671        let primal = [1.0, 2.0];
672        let dual = [3.0];
673        let rc = [4.0, 5.0];
674        let view = SolutionView {
675            objective: 42.0,
676            primal: &primal,
677            dual: &dual,
678            reduced_costs: &rc,
679            iterations: 7,
680            solve_time_seconds: 0.5,
681        };
682        let owned = view.to_owned();
683        assert_eq!(owned.objective, 42.0);
684        assert_eq!(owned.primal, vec![1.0, 2.0]);
685        assert_eq!(owned.dual, vec![3.0]);
686        assert_eq!(owned.reduced_costs, vec![4.0, 5.0]);
687        assert_eq!(owned.iterations, 7);
688        assert_eq!(owned.solve_time_seconds, 0.5);
689    }
690
691    #[test]
692    fn test_solution_view_is_copy() {
693        let primal = [1.0];
694        let dual = [2.0];
695        let rc = [3.0];
696        let view = SolutionView {
697            objective: 0.0,
698            primal: &primal,
699            dual: &dual,
700            reduced_costs: &rc,
701            iterations: 0,
702            solve_time_seconds: 0.0,
703        };
704        let copy = view;
705        assert_eq!(view.objective, copy.objective);
706    }
707
708    #[test]
709    fn test_row_batch_construction() {
710        let batch = RowBatch {
711            num_rows: 2,
712            row_starts: vec![0_i32, 2, 4],
713            col_indices: vec![0_i32, 1, 0, 1],
714            values: vec![-5.0, 1.0, 3.0, 1.0],
715            row_lower: vec![20.0, 80.0],
716            row_upper: vec![f64::INFINITY, f64::INFINITY],
717        };
718
719        assert_eq!(batch.num_rows, 2);
720        assert_eq!(batch.row_starts.len(), 3);
721        assert_eq!(batch.row_starts, vec![0_i32, 2, 4]);
722        assert_eq!(batch.col_indices, vec![0_i32, 1, 0, 1]);
723        assert_eq!(batch.values, vec![-5.0, 1.0, 3.0, 1.0]);
724        assert_eq!(batch.row_lower, vec![20.0, 80.0]);
725        assert!(batch.row_upper[0].is_infinite() && batch.row_upper[0] > 0.0);
726        assert!(batch.row_upper[1].is_infinite() && batch.row_upper[1] > 0.0);
727    }
728}