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}