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