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