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}