cobre_solver/highs.rs
1//! `HiGHS` LP solver backend implementing [`SolverInterface`].
2//!
3//! This module provides [`HighsSolver`], which wraps the `HiGHS` C API through
4//! the FFI layer in `ffi` and implements the full [`SolverInterface`]
5//! contract for iterative LP solving in power system optimization.
6//!
7//! # Thread Safety
8//!
9//! [`HighsSolver`] is `Send` but not `Sync`. The underlying `HiGHS` handle is
10//! exclusively owned; transferring ownership to a worker thread is safe.
11//! Concurrent access from multiple threads is not permitted (`HiGHS`
12//! Implementation SS6.3).
13//!
14//! # Configuration
15//!
16//! The constructor applies performance-tuned defaults (`HiGHS` Implementation
17//! SS4.1): dual simplex, no presolve, no parallelism, suppressed output, and
18//! tight feasibility tolerances. These defaults are optimised for repeated
19//! solves of small-to-medium LPs. Per-run parameters (time limit, iteration
20//! limit) are not set here -- those are applied by the caller before each solve.
21
22use std::ffi::CStr;
23use std::os::raw::c_void;
24use std::time::Instant;
25
26use crate::{
27 SolverInterface, ffi,
28 types::{RowBatch, SolutionView, SolverError, SolverStatistics, StageTemplate},
29};
30
31// ─── Default HiGHS configuration ─────────────────────────────────────────────
32//
33// The eight performance-tuned options applied at construction and restored after
34// each retry escalation. Keeping them in a single array eliminates per-option
35// error branches that are structurally impossible to trigger in tests (HiGHS
36// never rejects valid static option names).
37
38/// A typed `HiGHS` option value for the configuration table.
39enum OptionValue {
40 /// String option (`cobre_highs_set_string_option`).
41 Str(&'static CStr),
42 /// Integer option (`cobre_highs_set_int_option`).
43 Int(i32),
44 /// Boolean option (`cobre_highs_set_bool_option`).
45 Bool(i32),
46 /// Double option (`cobre_highs_set_double_option`).
47 Double(f64),
48}
49
50/// A named `HiGHS` option with its default value.
51struct DefaultOption {
52 name: &'static CStr,
53 value: OptionValue,
54}
55
56impl DefaultOption {
57 /// Applies this option to a `HiGHS` handle. Returns the `HiGHS` status code.
58 ///
59 /// # Safety
60 ///
61 /// `handle` must be a valid, non-null pointer from `cobre_highs_create()`.
62 unsafe fn apply(&self, handle: *mut c_void) -> i32 {
63 unsafe {
64 match &self.value {
65 OptionValue::Str(val) => {
66 ffi::cobre_highs_set_string_option(handle, self.name.as_ptr(), val.as_ptr())
67 }
68 OptionValue::Int(val) => {
69 ffi::cobre_highs_set_int_option(handle, self.name.as_ptr(), *val)
70 }
71 OptionValue::Bool(val) => {
72 ffi::cobre_highs_set_bool_option(handle, self.name.as_ptr(), *val)
73 }
74 OptionValue::Double(val) => {
75 ffi::cobre_highs_set_double_option(handle, self.name.as_ptr(), *val)
76 }
77 }
78 }
79 }
80}
81
82/// Performance-tuned default options (`HiGHS` Implementation SS4.1).
83///
84/// These thirteen options are applied at construction and restored after each
85/// retry escalation. The values are tuned for master LPs dominated by many
86/// slack rows that are warm-started across consecutive solves.
87///
88/// `simplex_scale_strategy` is set to 0 (off) because the calling algorithm's
89/// prescaler already normalizes matrix entries toward 1.0; the solver's
90/// internal equilibration scaling is redundant and can distort cost ordering
91/// for large-RHS rows. Retry escalation levels 5+ override this to more
92/// aggressive strategies as a fallback for hard problems.
93///
94/// The last four entries diverge from `HiGHS` defaults to suit warm-started
95/// solves on master LPs with tens of thousands of mostly-slack rows: Devex
96/// pricing avoids per-pivot edge-weight maintenance scaling with row count;
97/// disabling cost perturbation removes wasted cleanup pivots when the basis
98/// is already near-optimal (Bland's rule guards against cycling, and retry
99/// level 0 restores perturbation as a fallback); skipping the initial
100/// condition check eliminates O(m)–O(m²) work whose guarantees are already
101/// provided by the caller's slot-tracked basis reconstruction; row-wise
102/// PRICE wins on hyper-sparse basis-inverse rows; loosening the
103/// rebuild-refactor tolerance skips conservative refactorizations that
104/// aren't earning their keep in this numerically benign regime.
105fn default_options() -> [DefaultOption; 13] {
106 [
107 DefaultOption {
108 name: c"solver",
109 value: OptionValue::Str(c"simplex"),
110 },
111 DefaultOption {
112 name: c"simplex_strategy",
113 value: OptionValue::Int(1), // Dual simplex
114 },
115 DefaultOption {
116 name: c"simplex_scale_strategy",
117 value: OptionValue::Int(0), // Off (prescaler handles scaling)
118 },
119 DefaultOption {
120 name: c"presolve",
121 value: OptionValue::Str(c"off"),
122 },
123 DefaultOption {
124 name: c"parallel",
125 value: OptionValue::Str(c"off"),
126 },
127 DefaultOption {
128 name: c"output_flag",
129 value: OptionValue::Bool(0),
130 },
131 DefaultOption {
132 name: c"primal_feasibility_tolerance",
133 value: OptionValue::Double(1e-7),
134 },
135 DefaultOption {
136 name: c"dual_feasibility_tolerance",
137 value: OptionValue::Double(1e-7),
138 },
139 DefaultOption {
140 name: c"simplex_dual_edge_weight_strategy",
141 value: OptionValue::Int(1), // Devex
142 },
143 DefaultOption {
144 name: c"dual_simplex_cost_perturbation_multiplier",
145 value: OptionValue::Double(0.0), // Off (warm-start regime)
146 },
147 DefaultOption {
148 name: c"simplex_initial_condition_check",
149 value: OptionValue::Bool(0), // Off (caller manages basis quality)
150 },
151 DefaultOption {
152 name: c"simplex_price_strategy",
153 value: OptionValue::Int(1), // Row (hyper-sparse master LPs)
154 },
155 DefaultOption {
156 name: c"rebuild_refactor_solution_error_tolerance",
157 value: OptionValue::Double(1e-6), // Loosened from HiGHS default 1e-8
158 },
159 ]
160}
161
162/// `HiGHS` LP solver instance implementing [`SolverInterface`].
163///
164/// Owns an opaque `HiGHS` handle and pre-allocated buffers for solution
165/// extraction, scratch i32 index conversion, and statistics accumulation.
166///
167/// Construct with [`HighsSolver::new`]. The handle is destroyed automatically
168/// when the instance is dropped.
169///
170/// # Example
171///
172/// ```rust
173/// use cobre_solver::{HighsSolver, SolverInterface};
174///
175/// let solver = HighsSolver::new().expect("HiGHS initialisation failed");
176/// assert_eq!(solver.name(), "HiGHS");
177/// ```
178pub struct HighsSolver {
179 /// Opaque pointer to the `HiGHS` C++ instance, obtained from `cobre_highs_create()`.
180 handle: *mut c_void,
181 /// Pre-allocated buffer for primal column values extracted after each solve.
182 /// Resized in `load_model`; reused across solves to avoid per-solve allocation.
183 col_value: Vec<f64>,
184 /// Pre-allocated buffer for column dual values (reduced costs from `HiGHS` perspective).
185 /// Resized in `load_model`.
186 col_dual: Vec<f64>,
187 /// Pre-allocated buffer for row primal values (constraint activity).
188 /// Resized in `load_model`.
189 row_value: Vec<f64>,
190 /// Pre-allocated buffer for row dual multipliers (shadow prices).
191 /// Resized in `load_model`.
192 row_dual: Vec<f64>,
193 /// Scratch buffer for converting `usize` indices to `i32` for the `HiGHS` C API.
194 /// Used by `add_rows`, `set_row_bounds`, and `set_col_bounds`.
195 /// Never shrunk -- only grows -- to prevent reallocation churn on the hot path.
196 scratch_i32: Vec<i32>,
197 /// Pre-allocated i32 buffer for column basis status codes.
198 /// Reused across warm-start `solve` and `get_basis` calls to avoid per-call allocation.
199 /// Resized in `load_model` to `num_cols`; never shrunk.
200 basis_col_i32: Vec<i32>,
201 /// Pre-allocated i32 buffer for row basis status codes.
202 /// Reused across warm-start `solve` and `get_basis` calls to avoid per-call allocation.
203 /// Resized in `load_model` to `num_rows` and grown in `add_rows`.
204 basis_row_i32: Vec<i32>,
205 /// Scratch buffer for dual-ray extraction in `interpret_terminal_status` (dual).
206 /// Grown lazily to `num_rows` via `resize`; contents are discarded after classification.
207 /// Retained across calls so repeated non-optimal solves do not re-allocate.
208 terminal_status_dual_scratch: Vec<f64>,
209 /// Scratch buffer for primal-ray extraction in `interpret_terminal_status` (primal).
210 /// Grown lazily to `num_cols` via `resize`; contents are discarded after classification.
211 /// Retained across calls so repeated non-optimal solves do not re-allocate.
212 terminal_status_primal_scratch: Vec<f64>,
213 /// Current number of LP columns (decision variables), updated by `load_model` and `add_rows`.
214 num_cols: usize,
215 /// Current number of LP rows (constraints), updated by `load_model` and `add_rows`.
216 num_rows: usize,
217 /// Whether a model is currently loaded. Set to `true` in `load_model`,
218 /// `false` in `reset` and `new`. Guards `solve`/`get_basis` contract.
219 has_model: bool,
220 /// Accumulated solver statistics. Counters grow monotonically from zero;
221 /// not reset by `reset()`.
222 stats: SolverStatistics,
223}
224
225// SAFETY: `HighsSolver` holds a raw pointer to a `HiGHS` C++ object. The `HiGHS`
226// handle is not thread-safe for concurrent access, but exclusive ownership is
227// maintained at all times -- exactly one `HighsSolver` instance owns each
228// handle and no shared references to the handle exist. Transferring the
229// `HighsSolver` to another thread (via `Send`) is safe because there is no
230// concurrent access; the new thread has exclusive ownership. `Sync` is
231// intentionally NOT implemented per `HiGHS` Implementation SS6.3.
232unsafe impl Send for HighsSolver {}
233
234/// Outcome of a successful retry escalation in [`HighsSolver::retry_escalation`].
235///
236/// Contains the accumulated attempt count and the solve time / iteration
237/// count from the successful retry level.
238struct RetryOutcome {
239 attempts: u64,
240 solve_time: f64,
241 iterations: u64,
242 /// The retry level (0..11) at which the solve succeeded.
243 level: u32,
244}
245
246impl HighsSolver {
247 /// Creates a new `HiGHS` solver instance with performance-tuned defaults.
248 ///
249 /// Calls `cobre_highs_create()` to allocate the `HiGHS` handle, then applies
250 /// the thirteen default options defined in `HiGHS` Implementation SS4.1:
251 ///
252 /// | Option | Value | Type |
253 /// |---------------------------------------------|-------------|--------|
254 /// | `solver` | `"simplex"` | string |
255 /// | `simplex_strategy` | `1` | int |
256 /// | `simplex_scale_strategy` | `0` | int |
257 /// | `presolve` | `"off"` | string |
258 /// | `parallel` | `"off"` | string |
259 /// | `output_flag` | `0` | bool |
260 /// | `primal_feasibility_tolerance` | `1e-7` | double |
261 /// | `dual_feasibility_tolerance` | `1e-7` | double |
262 /// | `simplex_dual_edge_weight_strategy` | `1` | int |
263 /// | `dual_simplex_cost_perturbation_multiplier` | `0.0` | double |
264 /// | `simplex_initial_condition_check` | `0` | bool |
265 /// | `simplex_price_strategy` | `1` | int |
266 /// | `rebuild_refactor_solution_error_tolerance` | `1e-6` | double |
267 ///
268 /// # Errors
269 ///
270 /// Returns `Err(SolverError::InternalError { .. })` if:
271 /// - `cobre_highs_create()` returns a null pointer.
272 /// - Any configuration call returns `HIGHS_STATUS_ERROR`.
273 ///
274 /// In both failure cases the `HiGHS` handle is destroyed before returning to
275 /// prevent a resource leak.
276 pub fn new() -> Result<Self, SolverError> {
277 // SAFETY: `cobre_highs_create` is a C function with no preconditions.
278 // It allocates and returns a new `HiGHS` instance, or null on allocation
279 // failure. The returned pointer is opaque and must be passed back to
280 // `HiGHS` API functions.
281 let handle = unsafe { ffi::cobre_highs_create() };
282
283 if handle.is_null() {
284 return Err(SolverError::InternalError {
285 message: "HiGHS instance creation failed: Highs_create() returned null".to_string(),
286 error_code: None,
287 });
288 }
289
290 // Apply performance-tuned configuration. On any failure, destroy the
291 // handle before returning to prevent a resource leak.
292 if let Err(e) = Self::apply_default_config(handle) {
293 // SAFETY: `handle` is a valid, non-null pointer obtained from
294 // `cobre_highs_create()` in this same function. It has not been
295 // passed to `cobre_highs_destroy()` yet. After this call, `handle`
296 // must not be used again -- this function returns immediately with Err.
297 unsafe { ffi::cobre_highs_destroy(handle) };
298 return Err(e);
299 }
300
301 Ok(Self {
302 handle,
303 col_value: Vec::new(),
304 col_dual: Vec::new(),
305 row_value: Vec::new(),
306 row_dual: Vec::new(),
307 scratch_i32: Vec::new(),
308 basis_col_i32: Vec::new(),
309 basis_row_i32: Vec::new(),
310 terminal_status_dual_scratch: Vec::new(),
311 terminal_status_primal_scratch: Vec::new(),
312 num_cols: 0,
313 num_rows: 0,
314 has_model: false,
315 stats: SolverStatistics {
316 retry_level_histogram: vec![0u64; 12],
317 ..SolverStatistics::default()
318 },
319 })
320 }
321
322 /// Applies the eight performance-tuned `HiGHS` configuration options.
323 ///
324 /// Called once during construction. Returns `Ok(())` if all options are set
325 /// successfully, or `Err(SolverError::InternalError)` with the failing
326 /// option name if any configuration call returns `HIGHS_STATUS_ERROR`.
327 fn apply_default_config(handle: *mut c_void) -> Result<(), SolverError> {
328 for opt in &default_options() {
329 // SAFETY: `handle` is a valid, non-null HiGHS pointer.
330 let status = unsafe { opt.apply(handle) };
331 if status == ffi::HIGHS_STATUS_ERROR {
332 return Err(SolverError::InternalError {
333 message: format!(
334 "HiGHS configuration failed: {}",
335 opt.name.to_str().unwrap_or("?")
336 ),
337 error_code: Some(status),
338 });
339 }
340 }
341 Ok(())
342 }
343
344 /// Extracts the optimal solution from `HiGHS` into pre-allocated buffers and returns
345 /// a [`SolutionView`] borrowing directly from those buffers.
346 ///
347 /// The returned view borrows solver-internal buffers and is valid until the next
348 /// `&mut self` call. `col_dual` is the reduced cost vector. Row duals follow the
349 /// canonical sign convention (per Solver Abstraction SS8).
350 fn extract_solution_view(&mut self, solve_time_seconds: f64) -> SolutionView<'_> {
351 // SAFETY: buffers resized in `load_model`/`add_rows`; HiGHS writes within bounds.
352 let status = unsafe {
353 ffi::cobre_highs_get_solution(
354 self.handle,
355 self.col_value.as_mut_ptr(),
356 self.col_dual.as_mut_ptr(),
357 self.row_value.as_mut_ptr(),
358 self.row_dual.as_mut_ptr(),
359 )
360 };
361 // HiGHS documentation guarantees `cobre_highs_get_solution` returns
362 // non-ERROR status after `OPTIMAL` model status; this is a
363 // debug-build-only invariant check.
364 debug_assert_ne!(
365 status,
366 ffi::HIGHS_STATUS_ERROR,
367 "cobre_highs_get_solution failed after optimal solve; HiGHS invariant violation"
368 );
369
370 // SAFETY: `self.handle` is a valid, non-null HiGHS pointer.
371 let objective = unsafe { ffi::cobre_highs_get_objective_value(self.handle) };
372
373 // SAFETY: iteration count is non-negative so cast is safe.
374 #[allow(clippy::cast_sign_loss)]
375 let iterations =
376 unsafe { ffi::cobre_highs_get_simplex_iteration_count(self.handle) } as u64;
377
378 SolutionView {
379 objective,
380 primal: &self.col_value[..self.num_cols],
381 dual: &self.row_dual[..self.num_rows],
382 reduced_costs: &self.col_dual[..self.num_cols],
383 iterations,
384 solve_time_seconds,
385 }
386 }
387
388 /// Restores default options after retry escalation.
389 ///
390 /// Status codes are checked via `debug_assert!` to catch programming
391 /// errors during development (e.g., invalid option name). In release
392 /// builds, failures are silently ignored since we are already on the
393 /// recovery path.
394 fn restore_default_settings(&mut self) {
395 for opt in &default_options() {
396 // SAFETY: `self.handle` is a valid, non-null HiGHS pointer.
397 let status = unsafe { opt.apply(self.handle) };
398 debug_assert_eq!(
399 status,
400 ffi::HIGHS_STATUS_OK,
401 "restore_default_settings: option {:?} failed with status {status}",
402 opt.name,
403 );
404 }
405 }
406
407 /// Runs the solver once and returns the raw `HiGHS` model status.
408 fn run_once(&mut self) -> i32 {
409 // SAFETY: `self.handle` is a valid, non-null HiGHS pointer.
410 let run_status = unsafe { ffi::cobre_highs_run(self.handle) };
411 if run_status == ffi::HIGHS_STATUS_ERROR {
412 return ffi::HIGHS_MODEL_STATUS_SOLVE_ERROR;
413 }
414 // SAFETY: same.
415 unsafe { ffi::cobre_highs_get_model_status(self.handle) }
416 }
417
418 /// Sets per-solve iteration limits before a `run_once()` call.
419 ///
420 /// Simplex gets `max(100_000, 50 × num_cols)` and IPM gets 10,000.
421 /// These prevent degenerate cycling without affecting normal convergence.
422 ///
423 /// **Note on `time_limit`**: `HiGHS` tracks elapsed time cumulatively from
424 /// instance creation, not per-`run()` call — neither `clear_solver()` nor
425 /// option changes reset the internal timer. This makes `time_limit`
426 /// unusable for the scenario-loop pattern (thousands of solves per
427 /// instance). Wall-clock measurement via `Instant` is used instead for
428 /// time-based budget management.
429 fn set_iteration_limits(&mut self) {
430 let simplex_iter_limit = self.num_cols.saturating_mul(50).max(100_000);
431 // SAFETY: handle is valid non-null HiGHS pointer; option names are
432 // static C strings with no retained pointers.
433 unsafe {
434 #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
435 ffi::cobre_highs_set_int_option(
436 self.handle,
437 c"simplex_iteration_limit".as_ptr(),
438 simplex_iter_limit as i32,
439 );
440 ffi::cobre_highs_set_int_option(self.handle, c"ipm_iteration_limit".as_ptr(), 10_000);
441 }
442 }
443
444 /// Restores iteration limits to their unconstrained defaults.
445 ///
446 /// Called after `retry_escalation` completes (regardless of outcome).
447 fn restore_iteration_limits(&mut self) {
448 // SAFETY: handle is valid non-null HiGHS pointer.
449 unsafe {
450 ffi::cobre_highs_set_int_option(
451 self.handle,
452 c"simplex_iteration_limit".as_ptr(),
453 i32::MAX,
454 );
455 ffi::cobre_highs_set_int_option(self.handle, c"ipm_iteration_limit".as_ptr(), i32::MAX);
456 }
457 }
458
459 /// Interprets a non-optimal status as a terminal `SolverError`.
460 ///
461 /// Returns `None` for `SOLVE_ERROR` or `UNKNOWN` (retry continues),
462 /// or `Some(error)` for terminal statuses.
463 fn interpret_terminal_status(
464 &mut self,
465 status: i32,
466 solve_time_seconds: f64,
467 ) -> Option<SolverError> {
468 match status {
469 ffi::HIGHS_MODEL_STATUS_OPTIMAL => {
470 // Caller should have handled optimal before reaching here.
471 None
472 }
473 ffi::HIGHS_MODEL_STATUS_INFEASIBLE => Some(SolverError::Infeasible),
474 ffi::HIGHS_MODEL_STATUS_UNBOUNDED_OR_INFEASIBLE => {
475 // Probe for a dual ray to classify as Infeasible, then a primal
476 // ray to classify as Unbounded. The ray values are not stored in
477 // the error -- only the classification matters.
478 //
479 // `num_rows` and `num_cols` are up-to-date because `load_model`
480 // and `add_rows` always update them before any solve that could
481 // reach this branch. The `resize` below matches the exact count
482 // that HiGHS writes into the buffer.
483 let mut has_dual_ray: i32 = 0;
484 self.terminal_status_dual_scratch.resize(self.num_rows, 0.0);
485 // SAFETY: `self.handle` is a valid, non-null HiGHS pointer.
486 // `terminal_status_dual_scratch` has been resized to at least
487 // `self.num_rows` elements; HiGHS writes exactly `num_rows` values.
488 let dual_status = unsafe {
489 ffi::cobre_highs_get_dual_ray(
490 self.handle,
491 &raw mut has_dual_ray,
492 self.terminal_status_dual_scratch.as_mut_ptr(),
493 )
494 };
495 if dual_status != ffi::HIGHS_STATUS_ERROR && has_dual_ray != 0 {
496 return Some(SolverError::Infeasible);
497 }
498 let mut has_primal_ray: i32 = 0;
499 self.terminal_status_primal_scratch
500 .resize(self.num_cols, 0.0);
501 // SAFETY: `self.handle` is a valid, non-null HiGHS pointer.
502 // `terminal_status_primal_scratch` has been resized to at least
503 // `self.num_cols` elements; HiGHS writes exactly `num_cols` values.
504 let primal_status = unsafe {
505 ffi::cobre_highs_get_primal_ray(
506 self.handle,
507 &raw mut has_primal_ray,
508 self.terminal_status_primal_scratch.as_mut_ptr(),
509 )
510 };
511 if primal_status != ffi::HIGHS_STATUS_ERROR && has_primal_ray != 0 {
512 return Some(SolverError::Unbounded);
513 }
514 Some(SolverError::Infeasible)
515 }
516 ffi::HIGHS_MODEL_STATUS_UNBOUNDED => Some(SolverError::Unbounded),
517 ffi::HIGHS_MODEL_STATUS_TIME_LIMIT => Some(SolverError::TimeLimitExceeded {
518 elapsed_seconds: solve_time_seconds,
519 }),
520 ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT => {
521 // SAFETY: handle is valid non-null pointer; iteration count is non-negative.
522 #[allow(clippy::cast_sign_loss)]
523 let iterations =
524 unsafe { ffi::cobre_highs_get_simplex_iteration_count(self.handle) } as u64;
525 Some(SolverError::IterationLimit { iterations })
526 }
527 ffi::HIGHS_MODEL_STATUS_SOLVE_ERROR | ffi::HIGHS_MODEL_STATUS_UNKNOWN => {
528 // Signal to the caller that retry should continue.
529 None
530 }
531 other => Some(SolverError::InternalError {
532 message: format!("HiGHS returned unexpected model status {other}"),
533 error_code: Some(other),
534 }),
535 }
536 }
537
538 /// Converts `usize` indices to `i32` in the internal scratch buffer.
539 ///
540 /// Grows but never shrinks the buffer. Each element is debug-asserted to fit in i32.
541 fn convert_to_i32_scratch(&mut self, source: &[usize]) -> &[i32] {
542 if source.len() > self.scratch_i32.len() {
543 self.scratch_i32.resize(source.len(), 0);
544 }
545 for (i, &v) in source.iter().enumerate() {
546 debug_assert!(
547 i32::try_from(v).is_ok(),
548 "usize index {v} overflows i32::MAX at position {i}"
549 );
550 // SAFETY: debug_assert verifies v fits in i32; cast to HiGHS C API i32.
551 #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
552 {
553 self.scratch_i32[i] = v as i32;
554 }
555 }
556 &self.scratch_i32[..source.len()]
557 }
558
559 /// Run the 12-level retry escalation when the initial solve fails.
560 ///
561 /// Returns `Ok(RetryOutcome)` when a retry level finds optimal, or
562 /// `Err((attempts, SolverError))` when all levels are exhausted or a
563 /// terminal error is encountered. The caller is responsible for
564 /// updating `self.stats` based on the outcome.
565 ///
566 /// Settings are always restored to defaults before returning (regardless
567 /// of outcome).
568 fn retry_escalation(&mut self, is_unbounded: bool) -> Result<RetryOutcome, (u64, SolverError)> {
569 // 12-level retry escalation (HiGHS Implementation SS3). Organised into
570 // two phases:
571 //
572 // Phase 1 (levels 0-4): Core cumulative sequence. Each level adds one
573 // option on top of the previous state. This proven sequence resolves
574 // the vast majority of retry-recoverable failures.
575 // L0: cold restart
576 // L1: + presolve
577 // L2: + dual simplex
578 // L3: + relaxed tolerances 1e-6
579 // L4: + IPM
580 //
581 // Phase 2 (levels 5-11): Extended strategies. Each level starts from
582 // a clean default state with presolve enabled and a time cap, then
583 // applies a specific combination of scaling, tolerances, and solver
584 // type. These address LPs with extreme coefficient ranges that the
585 // core sequence cannot resolve.
586 //
587 // Wall-clock per-level budgets: 15s (Phase 1), 30s (Phase 2), 60s
588 // (Phase 2 extended). Overall 120s wall-clock budget caps the total.
589 //
590 // HiGHS `time_limit` is NOT used because HiGHS tracks elapsed time
591 // cumulatively from instance creation — neither `clear_solver()` nor
592 // option changes reset the internal timer. Iteration limits provide
593 // the primary per-attempt safeguard; wall-clock budgets provide the
594 // secondary time-based guard.
595 let phase1_wall_budget = 15.0_f64;
596 let phase2_wall_budget = 30.0_f64;
597 let overall_budget = 120.0_f64;
598 let num_retry_levels = 12_u32;
599
600 let retry_start = Instant::now();
601 let mut retry_attempts: u64 = 0;
602 let mut terminal_err: Option<SolverError> = None;
603 let mut found_optimal = false;
604 let mut optimal_time = 0.0_f64;
605 let mut optimal_iterations: u64 = 0;
606 let mut optimal_level = 0_u32;
607
608 for level in 0..num_retry_levels {
609 // Check overall wall-clock budget before starting a new level.
610 if retry_start.elapsed().as_secs_f64() >= overall_budget {
611 break;
612 }
613
614 self.apply_retry_level_options(level);
615
616 retry_attempts += 1;
617
618 let t_retry = Instant::now();
619 let retry_status = self.run_once();
620 let retry_time = t_retry.elapsed().as_secs_f64();
621
622 if retry_status == ffi::HIGHS_MODEL_STATUS_OPTIMAL {
623 // Capture stats before establishing the borrow.
624 // SAFETY: handle is valid non-null HiGHS pointer.
625 #[allow(clippy::cast_sign_loss)]
626 let iters =
627 unsafe { ffi::cobre_highs_get_simplex_iteration_count(self.handle) } as u64;
628 found_optimal = true;
629 optimal_time = retry_time;
630 optimal_iterations = iters;
631 optimal_level = level;
632 break;
633 }
634
635 // UNBOUNDED and ITERATION_LIMIT during retry continue to the next
636 // level: UNBOUNDED may be spurious (presolve resolves it);
637 // ITERATION_LIMIT means this strategy is cycling but another may
638 // converge. Wall-clock budget exceeded also continues (strategy
639 // too slow). Other terminal statuses (INFEASIBLE) stop immediately.
640 let level_budget = if level <= 4 {
641 phase1_wall_budget
642 } else {
643 phase2_wall_budget
644 };
645 let budget_exceeded = retry_time > level_budget;
646 let retryable = retry_status == ffi::HIGHS_MODEL_STATUS_UNBOUNDED
647 || retry_status == ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT
648 || budget_exceeded;
649 if !retryable {
650 if let Some(e) = self.interpret_terminal_status(retry_status, retry_time) {
651 terminal_err = Some(e);
652 break;
653 }
654 }
655 // Still SOLVE_ERROR, UNKNOWN, UNBOUNDED, ITERATION_LIMIT, or
656 // wall-clock exceeded -- continue to next level.
657 }
658
659 // Restore default settings and safeguard limits unconditionally.
660 // `restore_default_settings()` covers the 8 defaults. Retry-only
661 // options and safeguard limits need explicit reset.
662 self.restore_default_settings();
663 self.restore_iteration_limits();
664 unsafe {
665 ffi::cobre_highs_set_int_option(self.handle, c"user_objective_scale".as_ptr(), 0);
666 ffi::cobre_highs_set_int_option(self.handle, c"user_bound_scale".as_ptr(), 0);
667 }
668
669 if found_optimal {
670 return Ok(RetryOutcome {
671 attempts: retry_attempts,
672 solve_time: optimal_time,
673 iterations: optimal_iterations,
674 level: optimal_level,
675 });
676 }
677
678 Err((
679 retry_attempts,
680 terminal_err.unwrap_or_else(|| {
681 // All 12 retry levels exhausted or overall budget exceeded.
682 if is_unbounded {
683 SolverError::Unbounded
684 } else {
685 SolverError::NumericalDifficulty {
686 message:
687 "HiGHS failed to reach optimality after all retry escalation levels"
688 .to_string(),
689 }
690 }
691 }),
692 ))
693 }
694
695 /// Apply `HiGHS` options for a specific retry escalation level.
696 ///
697 /// Phase 1 (levels 0-4) is cumulative: each level adds options on top of
698 /// the previous state. Both phases apply `time_limit` and iteration limits
699 /// as safeguards against hanging on hard LPs.
700 ///
701 /// Phase 2 (levels 5-11) starts fresh each time with its own time limit.
702 ///
703 /// # Safety (internal)
704 ///
705 /// All FFI calls use `self.handle` which is a valid non-null `HiGHS` pointer.
706 /// Option names and values are static C strings with no retained pointers.
707 fn apply_retry_level_options(&mut self, level: u32) {
708 match level {
709 // -- Phase 1: Core cumulative sequence (levels 0-4) ---------------
710 //
711 // Level 0: cold restart (clear solver state) and re-enable the
712 // dual-simplex cost perturbation. The default configuration runs
713 // with perturbation off (see `DUAL_SIMPLEX_COST_PERTURBATION_MULTIPLIER`)
714 // for warm-start performance, which can stall on degenerate vertices;
715 // restoring the `HiGHS` default of `1.0` is the cheapest first-line
716 // intervention against cycling. Persists through levels 1-4 because
717 // Phase 1 is cumulative.
718 0 => {
719 unsafe {
720 ffi::cobre_highs_clear_solver(self.handle);
721 ffi::cobre_highs_set_double_option(
722 self.handle,
723 c"dual_simplex_cost_perturbation_multiplier".as_ptr(),
724 1.0,
725 );
726 }
727 self.set_iteration_limits();
728 }
729 // Level 1: + presolve.
730 1 => unsafe {
731 ffi::cobre_highs_set_string_option(
732 self.handle,
733 c"presolve".as_ptr(),
734 c"on".as_ptr(),
735 );
736 },
737 // Level 2: + dual simplex.
738 // Cumulative: presolve + dual simplex.
739 2 => unsafe {
740 ffi::cobre_highs_set_int_option(self.handle, c"simplex_strategy".as_ptr(), 1);
741 },
742 // Level 3: + relaxed tolerances 1e-6.
743 // Cumulative: presolve + dual simplex + relaxed tolerances.
744 3 => unsafe {
745 ffi::cobre_highs_set_double_option(
746 self.handle,
747 c"primal_feasibility_tolerance".as_ptr(),
748 1e-6,
749 );
750 ffi::cobre_highs_set_double_option(
751 self.handle,
752 c"dual_feasibility_tolerance".as_ptr(),
753 1e-6,
754 );
755 },
756 // Level 4: + IPM.
757 // Cumulative: presolve + relaxed tolerances + IPM.
758 4 => unsafe {
759 ffi::cobre_highs_set_string_option(
760 self.handle,
761 c"solver".as_ptr(),
762 c"ipm".as_ptr(),
763 );
764 },
765
766 // -- Phase 2: Extended strategies (levels 5-11) -------------------
767 // Each level starts from a clean default state with presolve
768 // and iteration limits, then applies specific options.
769 _ => self.apply_extended_retry_options(level),
770 }
771 }
772
773 /// Apply Phase 2 extended retry strategy options for levels 5-11.
774 ///
775 /// Each level starts from restored defaults with presolve and iteration
776 /// limits, then applies level-specific scaling, tolerance, and solver
777 /// options. Wall-clock budgets are managed by the caller.
778 fn apply_extended_retry_options(&mut self, level: u32) {
779 self.restore_default_settings();
780 self.set_iteration_limits();
781 // SAFETY: handle is valid non-null HiGHS pointer; option names/values
782 // are static C strings; no retained pointers after call.
783 unsafe {
784 ffi::cobre_highs_set_string_option(self.handle, c"presolve".as_ptr(), c"on".as_ptr());
785 }
786 match level {
787 5 => unsafe {
788 ffi::cobre_highs_set_int_option(self.handle, c"simplex_scale_strategy".as_ptr(), 3);
789 },
790 6 => unsafe {
791 ffi::cobre_highs_set_int_option(self.handle, c"simplex_strategy".as_ptr(), 1);
792 ffi::cobre_highs_set_int_option(self.handle, c"simplex_scale_strategy".as_ptr(), 4);
793 },
794 7 => unsafe {
795 ffi::cobre_highs_set_int_option(self.handle, c"simplex_scale_strategy".as_ptr(), 3);
796 ffi::cobre_highs_set_double_option(
797 self.handle,
798 c"primal_feasibility_tolerance".as_ptr(),
799 1e-6,
800 );
801 ffi::cobre_highs_set_double_option(
802 self.handle,
803 c"dual_feasibility_tolerance".as_ptr(),
804 1e-6,
805 );
806 },
807 8 => unsafe {
808 ffi::cobre_highs_set_int_option(self.handle, c"user_objective_scale".as_ptr(), -10);
809 },
810 9 => unsafe {
811 ffi::cobre_highs_set_int_option(self.handle, c"simplex_strategy".as_ptr(), 1);
812 ffi::cobre_highs_set_int_option(self.handle, c"user_objective_scale".as_ptr(), -10);
813 ffi::cobre_highs_set_int_option(self.handle, c"user_bound_scale".as_ptr(), -5);
814 },
815 10 => unsafe {
816 ffi::cobre_highs_set_int_option(self.handle, c"user_objective_scale".as_ptr(), -13);
817 ffi::cobre_highs_set_int_option(self.handle, c"user_bound_scale".as_ptr(), -8);
818 ffi::cobre_highs_set_double_option(
819 self.handle,
820 c"primal_feasibility_tolerance".as_ptr(),
821 1e-6,
822 );
823 ffi::cobre_highs_set_double_option(
824 self.handle,
825 c"dual_feasibility_tolerance".as_ptr(),
826 1e-6,
827 );
828 },
829 11 => unsafe {
830 ffi::cobre_highs_set_string_option(
831 self.handle,
832 c"solver".as_ptr(),
833 c"ipm".as_ptr(),
834 );
835 ffi::cobre_highs_set_int_option(self.handle, c"user_objective_scale".as_ptr(), -10);
836 ffi::cobre_highs_set_int_option(self.handle, c"user_bound_scale".as_ptr(), -5);
837 ffi::cobre_highs_set_double_option(
838 self.handle,
839 c"primal_feasibility_tolerance".as_ptr(),
840 1e-6,
841 );
842 ffi::cobre_highs_set_double_option(
843 self.handle,
844 c"dual_feasibility_tolerance".as_ptr(),
845 1e-6,
846 );
847 },
848 _ => unreachable!(),
849 }
850 }
851
852 /// Internal helper: run the simplex and update stats.
853 ///
854 /// Core simplex execution, called after (for warm-start) the basis has been
855 /// installed. `HiGHS` retains its internal simplex basis across consecutive
856 /// `solve_inner` calls on the same LP shape, which is the primary warm-start
857 /// mechanism for the backward pass. No `Highs_clearSolver` call is issued —
858 /// that behavior was removed in commit `25f1351` to recover a 4.7× perf regression.
859 fn solve_inner(&mut self) -> Result<SolutionView<'_>, SolverError> {
860 // Safeguard: apply iteration limits before the initial attempt.
861 // Time limits are NOT set here — HiGHS tracks time cumulatively from
862 // instance creation, so a per-solve time_limit would fire spuriously
863 // on long-running solver instances. Instead, wall-clock time is checked
864 // after run_once() to detect stuck solves.
865 self.set_iteration_limits();
866
867 let t0 = Instant::now();
868 let model_status = self.run_once();
869 let solve_time = t0.elapsed().as_secs_f64();
870
871 self.stats.solve_count += 1;
872
873 if model_status == ffi::HIGHS_MODEL_STATUS_OPTIMAL {
874 // Read iteration count from FFI BEFORE establishing the shared borrow
875 // via extract_solution_view, so stats can be updated without violating
876 // the aliasing rules.
877 // SAFETY: handle is valid non-null HiGHS pointer.
878 #[allow(clippy::cast_sign_loss)]
879 let iterations =
880 unsafe { ffi::cobre_highs_get_simplex_iteration_count(self.handle) } as u64;
881 self.stats.success_count += 1;
882 self.stats.first_try_successes += 1;
883 self.stats.total_iterations += iterations;
884 self.stats.total_solve_time_seconds += solve_time;
885 self.restore_iteration_limits();
886 return Ok(self.extract_solution_view(solve_time));
887 }
888
889 // Check for a definitive terminal status (not a retry-able error).
890 // UNBOUNDED is retried: HiGHS dual simplex can report spurious UNBOUNDED
891 // on numerically difficult LPs with wide coefficient ranges. The retry
892 // escalation (especially presolve in the core sequence) often resolves these.
893 // ITERATION_LIMIT from the initial attempt is retryable — the retry
894 // sequence uses different strategies that may converge faster.
895 // TIME_LIMIT is retryable — HiGHS tracks time cumulatively from instance
896 // creation; a spurious TIME_LIMIT can fire even with time_limit=Infinity
897 // in edge cases. Retry level 0 (cold restart) recovers from this.
898 // Wall-clock > 15s is also retryable — detects stuck initial solves.
899 let is_unbounded = model_status == ffi::HIGHS_MODEL_STATUS_UNBOUNDED;
900 let initial_retryable = is_unbounded
901 || model_status == ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT
902 || model_status == ffi::HIGHS_MODEL_STATUS_TIME_LIMIT
903 || solve_time > 15.0;
904 if !initial_retryable {
905 if let Some(terminal_err) = self.interpret_terminal_status(model_status, solve_time) {
906 self.restore_iteration_limits();
907 self.stats.failure_count += 1;
908 return Err(terminal_err);
909 }
910 }
911
912 // Delegate to the retry escalation method (restores limits internally).
913 match self.retry_escalation(is_unbounded) {
914 Ok(outcome) => {
915 self.stats.retry_count += outcome.attempts;
916 self.stats.success_count += 1;
917 self.stats.total_iterations += outcome.iterations;
918 self.stats.total_solve_time_seconds += outcome.solve_time;
919 self.stats.retry_level_histogram[outcome.level as usize] += 1;
920 Ok(self.extract_solution_view(outcome.solve_time))
921 }
922 Err((attempts, err)) => {
923 self.stats.retry_count += attempts;
924 self.stats.failure_count += 1;
925 Err(err)
926 }
927 }
928 }
929}
930
931impl Drop for HighsSolver {
932 fn drop(&mut self) {
933 // SAFETY: valid HiGHS pointer from construction, called once per instance.
934 unsafe { ffi::cobre_highs_destroy(self.handle) };
935 }
936}
937
938/// Returns the `HiGHS` version as a `"major.minor.patch"` string.
939///
940/// This is a free function — no solver instance is required.
941///
942/// # Example
943///
944/// ```rust
945/// # #[cfg(feature = "highs")]
946/// # {
947/// let v = cobre_solver::highs_version();
948/// assert!(v.contains('.'), "version string should be 'major.minor.patch'");
949/// # }
950/// ```
951#[must_use]
952pub fn highs_version() -> String {
953 // SAFETY: These are pure query functions with no arguments. The HiGHS C API
954 // documents them as safe to call without any prior initialisation; they read
955 // only compile-time constants embedded in the library.
956 let major = unsafe { crate::ffi::cobre_highs_version_major() };
957 let minor = unsafe { crate::ffi::cobre_highs_version_minor() };
958 let patch = unsafe { crate::ffi::cobre_highs_version_patch() };
959 format!("{major}.{minor}.{patch}")
960}
961
962impl SolverInterface for HighsSolver {
963 fn name(&self) -> &'static str {
964 "HiGHS"
965 }
966
967 fn solver_name_version(&self) -> String {
968 format!("HiGHS {}", highs_version())
969 }
970
971 fn load_model(&mut self, template: &StageTemplate) {
972 let t0 = Instant::now();
973 // SAFETY:
974 // - `self.handle` is a valid, non-null HiGHS pointer from `cobre_highs_create()`.
975 // - All pointer arguments point into owned `Vec` data that remains alive for the
976 // duration of this call.
977 // - `template.col_starts` and `template.row_indices` are `Vec<i32>` owned by the
978 // template, alive for the duration of this borrow.
979 // - All slice lengths match the HiGHS API contract:
980 // `num_col + 1` for a_start, `num_nz` for a_index and a_value,
981 // `num_col` for col_cost/col_lower/col_upper, `num_row` for row_lower/row_upper.
982 assert!(
983 i32::try_from(template.num_cols).is_ok(),
984 "num_cols {} overflows i32: LP exceeds HiGHS API limit",
985 template.num_cols
986 );
987 assert!(
988 i32::try_from(template.num_rows).is_ok(),
989 "num_rows {} overflows i32: LP exceeds HiGHS API limit",
990 template.num_rows
991 );
992 assert!(
993 i32::try_from(template.num_nz).is_ok(),
994 "num_nz {} overflows i32: LP exceeds HiGHS API limit",
995 template.num_nz
996 );
997 // SAFETY: All three values have been asserted to fit in i32 above.
998 #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
999 let num_col = template.num_cols as i32;
1000 #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
1001 let num_row = template.num_rows as i32;
1002 #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
1003 let num_nz = template.num_nz as i32;
1004 let status = unsafe {
1005 ffi::cobre_highs_pass_lp(
1006 self.handle,
1007 num_col,
1008 num_row,
1009 num_nz,
1010 ffi::HIGHS_MATRIX_FORMAT_COLWISE,
1011 ffi::HIGHS_OBJ_SENSE_MINIMIZE,
1012 0.0, // objective offset
1013 template.objective.as_ptr(),
1014 template.col_lower.as_ptr(),
1015 template.col_upper.as_ptr(),
1016 template.row_lower.as_ptr(),
1017 template.row_upper.as_ptr(),
1018 template.col_starts.as_ptr(),
1019 template.row_indices.as_ptr(),
1020 template.values.as_ptr(),
1021 )
1022 };
1023
1024 assert_ne!(
1025 status,
1026 ffi::HIGHS_STATUS_ERROR,
1027 "cobre_highs_pass_lp failed with status {status}"
1028 );
1029
1030 self.num_cols = template.num_cols;
1031 self.num_rows = template.num_rows;
1032 self.has_model = true;
1033
1034 // Resize solution extraction buffers to match the new LP dimensions.
1035 // Zero-fill is fine; these are overwritten in full by `cobre_highs_get_solution`.
1036 self.col_value.resize(self.num_cols, 0.0);
1037 self.col_dual.resize(self.num_cols, 0.0);
1038 self.row_value.resize(self.num_rows, 0.0);
1039 self.row_dual.resize(self.num_rows, 0.0);
1040
1041 // Resize basis status i32 buffers. Zero-fill is fine; values are overwritten before
1042 // any FFI call. These never shrink -- only grow -- to prevent reallocation on hot path.
1043 self.basis_col_i32.resize(self.num_cols, 0);
1044 self.basis_row_i32.resize(self.num_rows, 0);
1045 self.stats.total_load_model_time_seconds += t0.elapsed().as_secs_f64();
1046 self.stats.load_model_count += 1;
1047 }
1048
1049 fn add_rows(&mut self, rows: &RowBatch) {
1050 assert!(
1051 i32::try_from(rows.num_rows).is_ok(),
1052 "rows.num_rows {} overflows i32: RowBatch exceeds HiGHS API limit",
1053 rows.num_rows
1054 );
1055 assert!(
1056 i32::try_from(rows.col_indices.len()).is_ok(),
1057 "rows nnz {} overflows i32: RowBatch exceeds HiGHS API limit",
1058 rows.col_indices.len()
1059 );
1060 // SAFETY: Both values have been asserted to fit in i32 above.
1061 #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
1062 let num_new_row = rows.num_rows as i32;
1063 #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
1064 let num_new_nz = rows.col_indices.len() as i32;
1065
1066 // SAFETY:
1067 // - `self.handle` is a valid, non-null HiGHS pointer.
1068 // - All pointer arguments point into owned data alive for the duration of this call.
1069 // - `rows.row_starts` and `rows.col_indices` are `Vec<i32>` owned by the RowBatch,
1070 // alive for the duration of this borrow.
1071 // - Slice lengths: `num_rows + 1` for starts, total nnz for index and value,
1072 // `num_rows` for lower/upper bounds.
1073 let status = unsafe {
1074 ffi::cobre_highs_add_rows(
1075 self.handle,
1076 num_new_row,
1077 rows.row_lower.as_ptr(),
1078 rows.row_upper.as_ptr(),
1079 num_new_nz,
1080 rows.row_starts.as_ptr(),
1081 rows.col_indices.as_ptr(),
1082 rows.values.as_ptr(),
1083 )
1084 };
1085
1086 assert_ne!(
1087 status,
1088 ffi::HIGHS_STATUS_ERROR,
1089 "cobre_highs_add_rows failed with status {status}"
1090 );
1091
1092 self.num_rows += rows.num_rows;
1093
1094 // Grow row-indexed solution extraction buffers to cover the new rows.
1095 self.row_value.resize(self.num_rows, 0.0);
1096 self.row_dual.resize(self.num_rows, 0.0);
1097
1098 // Grow basis row i32 buffer to cover the new rows.
1099 self.basis_row_i32.resize(self.num_rows, 0);
1100 }
1101
1102 fn set_row_bounds(&mut self, indices: &[usize], lower: &[f64], upper: &[f64]) {
1103 assert!(
1104 indices.len() == lower.len() && indices.len() == upper.len(),
1105 "set_row_bounds: indices ({}), lower ({}), and upper ({}) must have equal length",
1106 indices.len(),
1107 lower.len(),
1108 upper.len()
1109 );
1110 if indices.is_empty() {
1111 return;
1112 }
1113
1114 assert!(
1115 i32::try_from(indices.len()).is_ok(),
1116 "set_row_bounds: indices.len() {} overflows i32",
1117 indices.len()
1118 );
1119 #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
1120 let num_entries = indices.len() as i32;
1121
1122 let t0 = Instant::now();
1123 // SAFETY:
1124 // - `self.handle` is a valid, non-null HiGHS pointer.
1125 // - `convert_to_i32_scratch()` returns a slice pointing into `self.scratch_i32`,
1126 // alive for `'self`. Pointer is used immediately in the FFI call.
1127 // - `lower` and `upper` are borrowed slices alive for the duration of this call.
1128 // - `num_entries` equals the lengths of all three arrays.
1129 let status = unsafe {
1130 ffi::cobre_highs_change_rows_bounds_by_set(
1131 self.handle,
1132 num_entries,
1133 self.convert_to_i32_scratch(indices).as_ptr(),
1134 lower.as_ptr(),
1135 upper.as_ptr(),
1136 )
1137 };
1138
1139 assert_ne!(
1140 status,
1141 ffi::HIGHS_STATUS_ERROR,
1142 "cobre_highs_change_rows_bounds_by_set failed with status {status}"
1143 );
1144 self.stats.total_set_bounds_time_seconds += t0.elapsed().as_secs_f64();
1145 }
1146
1147 fn set_col_bounds(&mut self, indices: &[usize], lower: &[f64], upper: &[f64]) {
1148 assert!(
1149 indices.len() == lower.len() && indices.len() == upper.len(),
1150 "set_col_bounds: indices ({}), lower ({}), and upper ({}) must have equal length",
1151 indices.len(),
1152 lower.len(),
1153 upper.len()
1154 );
1155 if indices.is_empty() {
1156 return;
1157 }
1158
1159 assert!(
1160 i32::try_from(indices.len()).is_ok(),
1161 "set_col_bounds: indices.len() {} overflows i32",
1162 indices.len()
1163 );
1164 #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
1165 let num_entries = indices.len() as i32;
1166
1167 let t0 = Instant::now();
1168 // SAFETY:
1169 // - `self.handle` is a valid, non-null HiGHS pointer.
1170 // - Converted indices point into `self.scratch_i32`, alive for `'self`.
1171 // - `lower` and `upper` are borrowed slices alive for the duration of this call.
1172 // - `num_entries` equals the lengths of all three arrays.
1173 let status = unsafe {
1174 ffi::cobre_highs_change_cols_bounds_by_set(
1175 self.handle,
1176 num_entries,
1177 self.convert_to_i32_scratch(indices).as_ptr(),
1178 lower.as_ptr(),
1179 upper.as_ptr(),
1180 )
1181 };
1182
1183 assert_ne!(
1184 status,
1185 ffi::HIGHS_STATUS_ERROR,
1186 "cobre_highs_change_cols_bounds_by_set failed with status {status}"
1187 );
1188 self.stats.total_set_bounds_time_seconds += t0.elapsed().as_secs_f64();
1189 }
1190
1191 /// # Preconditions
1192 ///
1193 /// When `basis` is `Some(b)`, the caller must size
1194 /// `b.row_status` to exactly `self.num_rows` (the current LP
1195 /// row count). Callers that grow the LP by adding rows are
1196 /// responsible for reconciling their basis to the new row
1197 /// count before invoking this method.
1198 fn solve(
1199 &mut self,
1200 basis: Option<&crate::types::Basis>,
1201 ) -> Result<SolutionView<'_>, SolverError> {
1202 assert!(
1203 self.has_model,
1204 "solve called without a loaded model — call load_model first"
1205 );
1206
1207 if let Some(basis) = basis {
1208 assert!(
1209 basis.col_status.len() == self.num_cols,
1210 "basis column count {} does not match LP column count {}",
1211 basis.col_status.len(),
1212 self.num_cols
1213 );
1214 debug_assert!(
1215 basis.row_status.len() >= self.num_rows,
1216 "solve(Some(&basis)): basis.row_status.len() ({}) < self.num_rows ({}); \
1217 callers introducing new rows must reconcile basis (e.g. extend with \
1218 NONBASIC_AT_LOWER for fresh inequality rows) before calling solve. \
1219 The defensive BASIC padding below is incorrect for inequality slacks.",
1220 basis.row_status.len(),
1221 self.num_rows
1222 );
1223
1224 // Track every warm-start call as a basis offer for diagnostics.
1225 self.stats.basis_offered += 1;
1226
1227 // Copy raw i32 codes directly into the pre-allocated buffers — no enum
1228 // translation. Zero-copy warm-start path.
1229 self.basis_col_i32[..self.num_cols].copy_from_slice(&basis.col_status);
1230
1231 // Precondition: the caller must size `basis.row_status` to
1232 // exactly `self.num_rows`. The production caller reconciles
1233 // the basis size to the current row count before invoking
1234 // `solve(Some(&basis))`, so `basis_rows == lp_rows` always
1235 // holds in practice.
1236 //
1237 // For defensive robustness if a future caller offers a
1238 // mismatched basis:
1239 // - `basis_rows < lp_rows`: pad missing tail rows with BASIC.
1240 // This is incorrect for newly added inequality rows, whose
1241 // slacks should be non-basic at the appropriate bound;
1242 // callers introducing new rows must reconcile the basis
1243 // themselves before calling solve.
1244 // - `basis_rows > lp_rows`: truncate the trailing entries.
1245 // The solver ignores any basis entry beyond `num_rows`.
1246 let basis_rows = basis.row_status.len();
1247 let lp_rows = self.num_rows;
1248 let copy_len = basis_rows.min(lp_rows);
1249 self.basis_row_i32[..copy_len].copy_from_slice(&basis.row_status[..copy_len]);
1250 if lp_rows > basis_rows {
1251 self.basis_row_i32[basis_rows..lp_rows].fill(ffi::HIGHS_BASIS_STATUS_BASIC);
1252 }
1253
1254 // SAFETY:
1255 // - `self.handle` is a valid, non-null HiGHS pointer obtained from
1256 // `cobre_highs_create()` and kept alive by `HighsSolver`.
1257 // - `basis_col_i32` was sized to `num_cols` in `load_model` and grown in
1258 // `add_rows`; the slice written above covers exactly `num_cols` entries.
1259 // - `basis_row_i32` was sized to `num_rows` in `load_model` and grown in
1260 // `add_rows`; the slice written above covers exactly `num_rows` entries
1261 // (with missing rows extended to BASIC).
1262 let basis_set_start = Instant::now();
1263 let set_status = unsafe {
1264 ffi::cobre_highs_set_basis_non_alien(
1265 self.handle,
1266 self.basis_col_i32.as_ptr(),
1267 self.basis_row_i32.as_ptr(),
1268 )
1269 };
1270 if set_status == ffi::HIGHS_STATUS_ERROR {
1271 // Non-alien rejected: the offered basis failed
1272 // `isBasisConsistent` (total_basic != num_row).
1273 // Count the rejection and surface it as a hard error.
1274 self.stats.basis_consistency_failures += 1;
1275 // Count basic entries from the already-populated buffers.
1276 //
1277 // `usize` -> `i64` is lossless for any basis that fits in memory:
1278 // realistic LP sizes are bounded well below 2^63.
1279 #[allow(clippy::cast_possible_wrap)]
1280 let col_basic = self.basis_col_i32[..self.num_cols]
1281 .iter()
1282 .filter(|&&s| s == ffi::HIGHS_BASIS_STATUS_BASIC)
1283 .count() as i64;
1284 #[allow(clippy::cast_possible_wrap)]
1285 let row_basic = self.basis_row_i32[..self.num_rows]
1286 .iter()
1287 .filter(|&&s| s == ffi::HIGHS_BASIS_STATUS_BASIC)
1288 .count() as i64;
1289 // Accumulate the elapsed time even on early return.
1290 self.stats.total_basis_set_time_seconds += basis_set_start.elapsed().as_secs_f64();
1291 #[allow(clippy::cast_possible_wrap)]
1292 return Err(SolverError::BasisInconsistent {
1293 num_row: self.num_rows as i64,
1294 total_basic: col_basic + row_basic,
1295 col_basic,
1296 row_basic,
1297 });
1298 }
1299 self.stats.total_basis_set_time_seconds += basis_set_start.elapsed().as_secs_f64();
1300 }
1301
1302 // Basis is installed (warm path) or not needed (cold path); run the simplex.
1303 // HiGHS retains its internal basis across consecutive solves on the same
1304 // LP shape, giving the backward pass ~15x fewer simplex iterations on
1305 // repeat solves at the same stage/opening.
1306 self.solve_inner()
1307 }
1308
1309 fn get_basis(&mut self, out: &mut crate::types::Basis) {
1310 assert!(
1311 self.has_model,
1312 "get_basis called without a loaded model — call load_model first"
1313 );
1314
1315 out.col_status.resize(self.num_cols, 0);
1316 out.row_status.resize(self.num_rows, 0);
1317
1318 // SAFETY:
1319 // - `self.handle` is a valid, non-null HiGHS pointer.
1320 // - `out.col_status` has been resized to `num_cols` entries above.
1321 // - `out.row_status` has been resized to `num_rows` entries above.
1322 // - HiGHS writes exactly `num_cols` col values and `num_rows` row values.
1323 let get_status = unsafe {
1324 ffi::cobre_highs_get_basis(
1325 self.handle,
1326 out.col_status.as_mut_ptr(),
1327 out.row_status.as_mut_ptr(),
1328 )
1329 };
1330
1331 assert_ne!(
1332 get_status,
1333 ffi::HIGHS_STATUS_ERROR,
1334 "cobre_highs_get_basis failed: basis must exist after a successful solve (programming error)"
1335 );
1336 }
1337
1338 fn statistics(&self) -> SolverStatistics {
1339 self.stats.clone()
1340 }
1341
1342 fn record_reconstruction_stats(&mut self) {
1343 self.stats.basis_reconstructions += 1;
1344 }
1345}
1346
1347/// Test-support accessors for integration tests that need to set raw `HiGHS` options.
1348///
1349/// Gated behind the `test-support` feature. The raw handle is intentionally not
1350/// part of the public API — callers use these methods to configure time/iteration
1351/// limits before a solve without going through the safe wrapper.
1352#[cfg(feature = "test-support")]
1353impl HighsSolver {
1354 /// Returns the raw `HiGHS` handle for use with test-support FFI helpers.
1355 ///
1356 /// # Safety
1357 ///
1358 /// The returned pointer is valid for the lifetime of `self`. The caller must
1359 /// not store the pointer beyond that lifetime, must not call
1360 /// `cobre_highs_destroy` on it, and must not alias it across threads.
1361 #[must_use]
1362 pub fn raw_handle(&self) -> *mut std::os::raw::c_void {
1363 self.handle
1364 }
1365}
1366
1367#[cfg(test)]
1368mod tests {
1369 use super::HighsSolver;
1370 use crate::{
1371 SolverInterface,
1372 types::{Basis, RowBatch, StageTemplate},
1373 };
1374
1375 // Shared LP fixture from Solver Interface Testing SS1.1:
1376 // 3 variables, 2 structural constraints, 3 non-zeros.
1377 //
1378 // min 0*x0 + 1*x1 + 50*x2
1379 // s.t. x0 = 6 (state-fixing)
1380 // 2*x0 + x2 = 14 (power balance)
1381 // x0 in [0, 10], x1 in [0, +inf), x2 in [0, 8]
1382 //
1383 // CSC matrix A = [[1, 0, 0], [2, 0, 1]]:
1384 // col_starts = [0, 2, 2, 3]
1385 // row_indices = [0, 1, 1]
1386 // values = [1.0, 2.0, 1.0]
1387 fn make_fixture_stage_template() -> StageTemplate {
1388 StageTemplate {
1389 num_cols: 3,
1390 num_rows: 2,
1391 num_nz: 3,
1392 col_starts: vec![0_i32, 2, 2, 3],
1393 row_indices: vec![0_i32, 1, 1],
1394 values: vec![1.0, 2.0, 1.0],
1395 col_lower: vec![0.0, 0.0, 0.0],
1396 col_upper: vec![10.0, f64::INFINITY, 8.0],
1397 objective: vec![0.0, 1.0, 50.0],
1398 row_lower: vec![6.0, 14.0],
1399 row_upper: vec![6.0, 14.0],
1400 n_state: 1,
1401 n_transfer: 0,
1402 n_dual_relevant: 1,
1403 n_hydro: 1,
1404 max_par_order: 0,
1405 col_scale: Vec::new(),
1406 row_scale: Vec::new(),
1407 }
1408 }
1409
1410 // Valid-inequality fixture from Solver Interface Testing SS1.2:
1411 // Row 1: -5*x0 + x1 >= 20 (col_indices [0,1], values [-5, 1])
1412 // Row 2: 3*x0 + x1 >= 80 (col_indices [0,1], values [ 3, 1])
1413 fn make_fixture_row_batch() -> RowBatch {
1414 RowBatch {
1415 num_rows: 2,
1416 row_starts: vec![0_i32, 2, 4],
1417 col_indices: vec![0_i32, 1, 0, 1],
1418 values: vec![-5.0, 1.0, 3.0, 1.0],
1419 row_lower: vec![20.0, 80.0],
1420 row_upper: vec![f64::INFINITY, f64::INFINITY],
1421 }
1422 }
1423
1424 #[test]
1425 fn test_highs_solver_create_and_name() {
1426 let solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1427 assert_eq!(solver.name(), "HiGHS");
1428 // Drop occurs here; verifies cobre_highs_destroy is called without crash.
1429 }
1430
1431 #[test]
1432 fn test_highs_solver_send_bound() {
1433 fn assert_send<T: Send>() {}
1434 assert_send::<HighsSolver>();
1435 }
1436
1437 #[test]
1438 fn test_highs_solver_statistics_initial() {
1439 let solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1440 let stats = solver.statistics();
1441 assert_eq!(stats.solve_count, 0);
1442 assert_eq!(stats.success_count, 0);
1443 assert_eq!(stats.failure_count, 0);
1444 assert_eq!(stats.total_iterations, 0);
1445 assert_eq!(stats.retry_count, 0);
1446 assert_eq!(stats.total_solve_time_seconds, 0.0);
1447 }
1448
1449 #[test]
1450 fn test_highs_load_model_updates_dimensions() {
1451 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1452 let template = make_fixture_stage_template();
1453
1454 solver.load_model(&template);
1455
1456 assert_eq!(solver.num_cols, 3, "num_cols must be 3 after load_model");
1457 assert_eq!(solver.num_rows, 2, "num_rows must be 2 after load_model");
1458 assert_eq!(
1459 solver.col_value.len(),
1460 3,
1461 "col_value buffer must be resized to num_cols"
1462 );
1463 assert_eq!(
1464 solver.col_dual.len(),
1465 3,
1466 "col_dual buffer must be resized to num_cols"
1467 );
1468 assert_eq!(
1469 solver.row_value.len(),
1470 2,
1471 "row_value buffer must be resized to num_rows"
1472 );
1473 assert_eq!(
1474 solver.row_dual.len(),
1475 2,
1476 "row_dual buffer must be resized to num_rows"
1477 );
1478 }
1479
1480 #[test]
1481 fn test_highs_add_rows_updates_dimensions() {
1482 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1483 let template = make_fixture_stage_template();
1484 let cuts = make_fixture_row_batch();
1485
1486 solver.load_model(&template);
1487 solver.add_rows(&cuts);
1488
1489 // 2 structural rows + 2 appended rows = 4
1490 assert_eq!(solver.num_rows, 4, "num_rows must be 4 after add_rows");
1491 assert_eq!(
1492 solver.row_dual.len(),
1493 4,
1494 "row_dual buffer must be resized to 4 after add_rows"
1495 );
1496 assert_eq!(
1497 solver.row_value.len(),
1498 4,
1499 "row_value buffer must be resized to 4 after add_rows"
1500 );
1501 // Columns unchanged
1502 assert_eq!(solver.num_cols, 3, "num_cols must be unchanged by add_rows");
1503 }
1504
1505 #[test]
1506 fn test_highs_set_row_bounds_no_panic() {
1507 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1508 let template = make_fixture_stage_template();
1509 solver.load_model(&template);
1510
1511 // Patch row 0 to equality at 4.0. Must complete without panic.
1512 solver.set_row_bounds(&[0], &[4.0], &[4.0]);
1513 }
1514
1515 #[test]
1516 fn test_highs_set_col_bounds_no_panic() {
1517 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1518 let template = make_fixture_stage_template();
1519 solver.load_model(&template);
1520
1521 // Patch column 1 lower bound to 10.0. Must complete without panic.
1522 solver.set_col_bounds(&[1], &[10.0], &[f64::INFINITY]);
1523 }
1524
1525 #[test]
1526 fn test_highs_set_bounds_empty_no_panic() {
1527 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1528 let template = make_fixture_stage_template();
1529 solver.load_model(&template);
1530
1531 // Empty patch slices should be short-circuited without any FFI call.
1532 solver.set_row_bounds(&[], &[], &[]);
1533 solver.set_col_bounds(&[], &[], &[]);
1534 }
1535
1536 /// SS1.1 fixture: min 0*x0 + 1*x1 + 50*x2, s.t. x0=6, 2*x0+x2=14, x>=0.
1537 /// Optimal: x0=6, x1=0, x2=2, objective=100.
1538 #[test]
1539 fn test_highs_solve_basic_lp() {
1540 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1541 let template = make_fixture_stage_template();
1542 solver.load_model(&template);
1543
1544 let solution = solver
1545 .solve(None)
1546 .expect("solve() must succeed on a feasible LP");
1547
1548 assert!(
1549 (solution.objective - 100.0).abs() < 1e-8,
1550 "objective must be 100.0, got {}",
1551 solution.objective
1552 );
1553 assert_eq!(solution.primal.len(), 3, "primal must have 3 elements");
1554 assert!(
1555 (solution.primal[0] - 6.0).abs() < 1e-8,
1556 "primal[0] (x0) must be 6.0, got {}",
1557 solution.primal[0]
1558 );
1559 assert!(
1560 (solution.primal[1] - 0.0).abs() < 1e-8,
1561 "primal[1] (x1) must be 0.0, got {}",
1562 solution.primal[1]
1563 );
1564 assert!(
1565 (solution.primal[2] - 2.0).abs() < 1e-8,
1566 "primal[2] (x2) must be 2.0, got {}",
1567 solution.primal[2]
1568 );
1569 }
1570
1571 /// SS1.2: after adding two valid inequalities to SS1.1, optimal objective = 162.
1572 /// Cuts: -5*x0+x1>=20 and 3*x0+x1>=80. With x0=6: x1>=max(50,62)=62.
1573 /// Obj = 0*6 + 1*62 + 50*2 = 162.
1574 #[test]
1575 fn test_highs_solve_with_cuts() {
1576 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1577 let template = make_fixture_stage_template();
1578 let cuts = make_fixture_row_batch();
1579 solver.load_model(&template);
1580 solver.add_rows(&cuts);
1581
1582 let solution = solver
1583 .solve(None)
1584 .expect("solve() must succeed on a feasible LP with cuts");
1585
1586 assert!(
1587 (solution.objective - 162.0).abs() < 1e-8,
1588 "objective must be 162.0, got {}",
1589 solution.objective
1590 );
1591 assert!(
1592 (solution.primal[0] - 6.0).abs() < 1e-8,
1593 "primal[0] must be 6.0, got {}",
1594 solution.primal[0]
1595 );
1596 assert!(
1597 (solution.primal[1] - 62.0).abs() < 1e-8,
1598 "primal[1] must be 62.0, got {}",
1599 solution.primal[1]
1600 );
1601 assert!(
1602 (solution.primal[2] - 2.0).abs() < 1e-8,
1603 "primal[2] must be 2.0, got {}",
1604 solution.primal[2]
1605 );
1606 }
1607
1608 /// SS1.3: after adding cuts and patching row 0 RHS to 4.0 (x0=4).
1609 /// x2=14-2*4=6. cut2: 3*4+x1>=80 => x1>=68. Obj = 0*4+1*68+50*6 = 368.
1610 #[test]
1611 fn test_highs_solve_after_rhs_patch() {
1612 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1613 let template = make_fixture_stage_template();
1614 let cuts = make_fixture_row_batch();
1615 solver.load_model(&template);
1616 solver.add_rows(&cuts);
1617
1618 // Patch row 0 (x0=6 equality) to x0=4.
1619 solver.set_row_bounds(&[0], &[4.0], &[4.0]);
1620
1621 let solution = solver
1622 .solve(None)
1623 .expect("solve() must succeed after RHS patch");
1624
1625 assert!(
1626 (solution.objective - 368.0).abs() < 1e-8,
1627 "objective must be 368.0, got {}",
1628 solution.objective
1629 );
1630 }
1631
1632 /// After two successful solves, statistics must reflect both.
1633 #[test]
1634 fn test_highs_solve_statistics_increment() {
1635 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1636 let template = make_fixture_stage_template();
1637 solver.load_model(&template);
1638
1639 solver.solve(None).expect("first solve must succeed");
1640 solver.solve(None).expect("second solve must succeed");
1641
1642 let stats = solver.statistics();
1643 assert_eq!(stats.solve_count, 2, "solve_count must be 2");
1644 assert_eq!(stats.success_count, 2, "success_count must be 2");
1645 assert_eq!(stats.failure_count, 0, "failure_count must be 0");
1646 assert!(
1647 stats.total_iterations > 0,
1648 "total_iterations must be positive"
1649 );
1650 }
1651
1652 /// After a cold solve, statistics counters must reflect the single solve.
1653 #[test]
1654 fn test_highs_solve_preserves_stats() {
1655 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1656 let template = make_fixture_stage_template();
1657 solver.load_model(&template);
1658 solver.solve(None).expect("solve must succeed");
1659
1660 let stats = solver.statistics();
1661 assert_eq!(
1662 stats.solve_count, 1,
1663 "solve_count must be 1 after one solve"
1664 );
1665 assert_eq!(
1666 stats.success_count, 1,
1667 "success_count must be 1 after one successful solve"
1668 );
1669 assert!(
1670 stats.total_iterations > 0,
1671 "total_iterations must be positive after a successful solve"
1672 );
1673 }
1674
1675 /// The first solve must report a positive iteration count.
1676 #[test]
1677 fn test_highs_solve_iterations_positive() {
1678 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1679 let template = make_fixture_stage_template();
1680 solver.load_model(&template);
1681
1682 let solution = solver.solve(None).expect("solve must succeed");
1683 assert!(
1684 solution.iterations > 0,
1685 "iterations must be positive, got {}",
1686 solution.iterations
1687 );
1688 }
1689
1690 /// The first solve must report a positive wall-clock time.
1691 #[test]
1692 fn test_highs_solve_time_positive() {
1693 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1694 let template = make_fixture_stage_template();
1695 solver.load_model(&template);
1696
1697 let solution = solver.solve(None).expect("solve must succeed");
1698 assert!(
1699 solution.solve_time_seconds > 0.0,
1700 "solve_time_seconds must be positive, got {}",
1701 solution.solve_time_seconds
1702 );
1703 }
1704
1705 /// After one solve, `statistics()` must report `solve_count==1`, `success_count==1`,
1706 /// `failure_count==0`, and `total_iterations` > 0.
1707 #[test]
1708 fn test_highs_solve_statistics_single() {
1709 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1710 let template = make_fixture_stage_template();
1711 solver.load_model(&template);
1712
1713 solver.solve(None).expect("solve must succeed");
1714
1715 let stats = solver.statistics();
1716 assert_eq!(stats.solve_count, 1, "solve_count must be 1");
1717 assert_eq!(stats.success_count, 1, "success_count must be 1");
1718 assert_eq!(stats.failure_count, 0, "failure_count must be 0");
1719 assert!(
1720 stats.total_iterations > 0,
1721 "total_iterations must be positive after a successful solve"
1722 );
1723 }
1724
1725 /// After `load_model` + `solve()`, `get_basis` must return i32 codes
1726 /// that are all valid `HiGHS` basis status values (0..=4).
1727 #[test]
1728 fn test_get_basis_valid_status_codes() {
1729 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1730 let template = make_fixture_stage_template();
1731 solver.load_model(&template);
1732 solver
1733 .solve(None)
1734 .expect("solve must succeed before get_basis");
1735
1736 let mut basis = Basis::new(0, 0);
1737 solver.get_basis(&mut basis);
1738
1739 for &code in &basis.col_status {
1740 assert!(
1741 (0..=4).contains(&code),
1742 "col_status code {code} is outside valid HiGHS range 0..=4"
1743 );
1744 }
1745 for &code in &basis.row_status {
1746 assert!(
1747 (0..=4).contains(&code),
1748 "row_status code {code} is outside valid HiGHS range 0..=4"
1749 );
1750 }
1751 }
1752
1753 /// Starting from an empty `Basis`, `get_basis` must resize the output
1754 /// buffers to match the current LP dimensions (3 cols, 2 rows for SS1.1).
1755 #[test]
1756 fn test_get_basis_resizes_output() {
1757 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1758 let template = make_fixture_stage_template();
1759 solver.load_model(&template);
1760 solver
1761 .solve(None)
1762 .expect("solve must succeed before get_basis");
1763
1764 let mut basis = Basis::new(0, 0);
1765 assert_eq!(
1766 basis.col_status.len(),
1767 0,
1768 "initial col_status must be empty"
1769 );
1770 assert_eq!(
1771 basis.row_status.len(),
1772 0,
1773 "initial row_status must be empty"
1774 );
1775
1776 solver.get_basis(&mut basis);
1777
1778 assert_eq!(
1779 basis.col_status.len(),
1780 3,
1781 "col_status must be resized to 3 (num_cols of SS1.1)"
1782 );
1783 assert_eq!(
1784 basis.row_status.len(),
1785 2,
1786 "row_status must be resized to 2 (num_rows of SS1.1)"
1787 );
1788 }
1789
1790 /// Warm-start via `solve(Some(&basis))` on the same LP must reproduce
1791 /// the optimal objective and complete in at most 1 simplex iteration.
1792 #[test]
1793 fn test_solve_warm_start_reproduces_cold_objective() {
1794 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1795 let template = make_fixture_stage_template();
1796 solver.load_model(&template);
1797 solver.solve(None).expect("cold-start solve must succeed");
1798
1799 let mut basis = Basis::new(0, 0);
1800 solver.get_basis(&mut basis);
1801
1802 // Reload the same model to reset HiGHS internal state.
1803 solver.load_model(&template);
1804 let result = solver
1805 .solve(Some(&basis))
1806 .expect("warm-start solve must succeed");
1807
1808 assert!(
1809 (result.objective - 100.0).abs() < 1e-8,
1810 "warm-start objective must be 100.0, got {}",
1811 result.objective
1812 );
1813 assert!(
1814 result.iterations <= 1,
1815 "warm-start from exact basis must use at most 1 iteration, got {}",
1816 result.iterations
1817 );
1818
1819 let stats = solver.statistics();
1820 assert_eq!(
1821 stats.basis_consistency_failures, 0,
1822 "basis_consistency_failures must be 0 when raw basis is accepted, got {}",
1823 stats.basis_consistency_failures
1824 );
1825 assert_eq!(
1826 stats.basis_offered, 1,
1827 "basis_offered must be 1 after one warm-start call"
1828 );
1829 }
1830
1831 /// When the basis has fewer rows than the current LP (2 vs 4 after `add_rows`),
1832 /// `solve(Some(&basis))` must extend missing rows as Basic and solve correctly.
1833 /// SS1.2 objective with both cuts active is 162.0.
1834 ///
1835 /// This test exercises the defensive BASIC-padding fallback path,
1836 /// which the production caller never hits because it reconciles the
1837 /// basis to the LP row count before invoking `solve`. The
1838 /// `debug_assert!` in `solve` would fire on this fallback path, so
1839 /// the test runs only when `debug_assertions` is disabled.
1840 #[cfg(not(debug_assertions))]
1841 #[test]
1842 fn test_solve_warm_start_extends_missing_rows_as_basic() {
1843 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1844 let template = make_fixture_stage_template();
1845 let cuts = make_fixture_row_batch();
1846
1847 // First solve on 2-row LP to capture a 2-row basis.
1848 solver.load_model(&template);
1849 solver.solve(None).expect("SS1.1 solve must succeed");
1850 let mut basis = Basis::new(0, 0);
1851 solver.get_basis(&mut basis);
1852 assert_eq!(
1853 basis.row_status.len(),
1854 2,
1855 "captured basis must have 2 row statuses"
1856 );
1857
1858 // Reload model and add 2 cuts to get a 4-row LP.
1859 solver.load_model(&template);
1860 solver.add_rows(&cuts);
1861 assert_eq!(solver.num_rows, 4, "LP must have 4 rows after add_rows");
1862
1863 // Warm-start with the 2-row basis; extra rows are extended as Basic.
1864 let result = solver
1865 .solve(Some(&basis))
1866 .expect("solve with dimension-mismatched basis must succeed");
1867
1868 assert!(
1869 (result.objective - 162.0).abs() < 1e-8,
1870 "objective with both cuts active must be 162.0, got {}",
1871 result.objective
1872 );
1873 }
1874
1875 /// Non-alien path accepts a self-extracted basis: counter must stay at zero.
1876 ///
1877 /// Solves SS1.1 cold, extracts the optimal basis, reloads the model, and
1878 /// warm-starts via `solve(Some(&basis))`. The non-alien FFI call
1879 /// (`cobre_highs_set_basis_non_alien`) should accept a basis that was just
1880 /// produced by `HiGHS` itself, so `basis_consistency_failures` must not
1881 /// increase.
1882 #[test]
1883 fn test_solve_warm_start_non_alien_success() {
1884 // Arrange
1885 let template = make_fixture_stage_template();
1886 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1887 solver.load_model(&template);
1888 let _ = solver.solve(None).expect("cold-start solve must succeed");
1889 let mut basis = Basis::new(template.num_cols, template.num_rows);
1890 solver.get_basis(&mut basis);
1891
1892 // Reload model so HiGHS internal state is fresh, then warm-start.
1893 solver.load_model(&template);
1894 let before = solver.statistics();
1895
1896 // Act
1897 let _ = solver
1898 .solve(Some(&basis))
1899 .expect("warm-start solve must succeed with self-extracted basis");
1900
1901 // Assert
1902 let after = solver.statistics();
1903 assert_eq!(
1904 after.basis_consistency_failures - before.basis_consistency_failures,
1905 0,
1906 "non-alien path should accept a self-extracted basis; consistency failures delta must be 0"
1907 );
1908 }
1909
1910 /// `solve(Some(&basis))` returns `Err(SolverError::BasisInconsistent)` when given
1911 /// an inconsistent basis instead of silently falling back to the alien setter.
1912 ///
1913 /// Builds a deliberately inconsistent basis (all column statuses set to
1914 /// `HIGHS_BASIS_STATUS_BASIC`, all row statuses `HIGHS_BASIS_STATUS_BASIC`).
1915 /// For the 3-column, 2-row SS1.1 LP this yields 5 basic variables against a
1916 /// rank of 2, which `cobre_highs_set_basis_non_alien` rejects with
1917 /// `HIGHS_STATUS_ERROR`. The error is surfaced as a hard `Err` and
1918 /// `basis_consistency_failures` increments by 1.
1919 ///
1920 /// After the call:
1921 /// - `basis_consistency_failures` increments by 1.
1922 /// - The result is `Err(SolverError::BasisInconsistent { num_row: 2,
1923 /// total_basic: 5, col_basic: 3, row_basic: 2 })`.
1924 #[test]
1925 fn test_solve_warm_start_rejects_inconsistent_basis() {
1926 use crate::ffi;
1927 use crate::types::SolverError;
1928
1929 // Arrange: non-alien setter is now the only warm-start path.
1930 let template = make_fixture_stage_template();
1931 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
1932
1933 solver.load_model(&template);
1934
1935 // Build a deliberately inconsistent basis: all BASIC (5 basics, rank 2).
1936 let mut bad_basis = Basis::new(template.num_cols, template.num_rows);
1937 bad_basis
1938 .col_status
1939 .iter_mut()
1940 .for_each(|v| *v = ffi::HIGHS_BASIS_STATUS_BASIC);
1941 bad_basis
1942 .row_status
1943 .iter_mut()
1944 .for_each(|v| *v = ffi::HIGHS_BASIS_STATUS_BASIC);
1945
1946 let before = solver.statistics();
1947
1948 // Act — convert result to a form that does not borrow `solver`.
1949 // `SolutionView<'_>` borrows `solver`'s internal buffers; calling
1950 // `statistics()` afterwards would overlap borrows. On the error path
1951 // `SolverError` contains no solver references, so mapping Ok → () breaks
1952 // the mutable borrow before the statistics call.
1953 let err_variant: Result<(), SolverError> = solver.solve(Some(&bad_basis)).map(|_| ());
1954
1955 // Assert counters — the mutable borrow from solve(Some(&bad_basis)) is gone.
1956 let after = solver.statistics();
1957 assert_eq!(
1958 after.basis_consistency_failures - before.basis_consistency_failures,
1959 1,
1960 "basis_consistency_failures must increment by 1 for an overcounted basis"
1961 );
1962
1963 // Assert the returned error.
1964 match err_variant {
1965 Err(SolverError::BasisInconsistent {
1966 num_row,
1967 total_basic,
1968 col_basic,
1969 row_basic,
1970 }) => {
1971 assert_eq!(num_row, 2, "num_row must match LP row count");
1972 assert_eq!(total_basic, 5, "total_basic must be col_basic + row_basic");
1973 assert_eq!(col_basic, 3, "col_basic must count BASIC columns");
1974 assert_eq!(row_basic, 2, "row_basic must count BASIC rows");
1975 }
1976 other => panic!(
1977 "expected Err(SolverError::BasisInconsistent {{ num_row: 2, total_basic: 5, \
1978 col_basic: 3, row_basic: 2 }}), got {other:?}"
1979 ),
1980 }
1981 }
1982
1983 /// `terminal_status_dual_scratch` and `terminal_status_primal_scratch` are
1984 /// initialized as empty `Vec`s in the constructor and retain their capacity
1985 /// across repeated `resize` calls, matching the pattern used by
1986 /// `scratch_i32`, `basis_col_i32`, and `basis_row_i32`.
1987 ///
1988 /// This test directly exercises the `resize`-reuse invariant without depending
1989 /// on a specific `HiGHS` model status. The `UNBOUNDED_OR_INFEASIBLE` branch in
1990 /// `interpret_terminal_status` calls `self.terminal_status_dual_scratch.resize(num_rows, 0.0)`;
1991 /// we verify here that repeated `resize` calls grow but never shrink capacity.
1992 ///
1993 /// The LP: 3-column, 2-row SS1.1 fixture. After `load_model`, `num_rows=2` and
1994 /// `num_cols=3`. We simulate two scratch-buffer resize cycles and verify capacity
1995 /// is monotonically non-decreasing.
1996 #[test]
1997 fn interpret_terminal_status_reuses_scratch() {
1998 let template = make_fixture_stage_template();
1999 let mut solver = HighsSolver::new().expect("HighsSolver::new() must succeed");
2000
2001 // Verify that scratch fields start empty (Vec::new() in constructor).
2002 assert_eq!(
2003 solver.terminal_status_dual_scratch.capacity(),
2004 0,
2005 "dual scratch must start with capacity 0 (Vec::new() in constructor)"
2006 );
2007 assert_eq!(
2008 solver.terminal_status_primal_scratch.capacity(),
2009 0,
2010 "primal scratch must start with capacity 0 (Vec::new() in constructor)"
2011 );
2012
2013 // Load model to establish num_rows=2 and num_cols=3.
2014 solver.load_model(&template);
2015
2016 // Simulate what interpret_terminal_status does in UNBOUNDED_OR_INFEASIBLE branch:
2017 // resize dual scratch to num_rows, primal scratch to num_cols.
2018 solver
2019 .terminal_status_dual_scratch
2020 .resize(solver.num_rows, 0.0);
2021 solver
2022 .terminal_status_primal_scratch
2023 .resize(solver.num_cols, 0.0);
2024
2025 let cap_dual_after_first = solver.terminal_status_dual_scratch.capacity();
2026 let cap_primal_after_first = solver.terminal_status_primal_scratch.capacity();
2027
2028 assert!(
2029 cap_dual_after_first >= solver.num_rows,
2030 "dual scratch capacity {cap_dual_after_first} must be >= num_rows {} after first resize",
2031 solver.num_rows,
2032 );
2033 assert!(
2034 cap_primal_after_first >= solver.num_cols,
2035 "primal scratch capacity {cap_primal_after_first} must be >= num_cols {} after first resize",
2036 solver.num_cols,
2037 );
2038
2039 // Second resize to the same size: capacity must not decrease (heap retained).
2040 solver
2041 .terminal_status_dual_scratch
2042 .resize(solver.num_rows, 0.0);
2043 solver
2044 .terminal_status_primal_scratch
2045 .resize(solver.num_cols, 0.0);
2046
2047 let cap_dual_after_second = solver.terminal_status_dual_scratch.capacity();
2048 let cap_primal_after_second = solver.terminal_status_primal_scratch.capacity();
2049
2050 assert!(
2051 cap_dual_after_second >= cap_dual_after_first,
2052 "dual scratch capacity must not decrease: {cap_dual_after_second} < {cap_dual_after_first}",
2053 );
2054 assert!(
2055 cap_primal_after_second >= cap_primal_after_first,
2056 "primal scratch capacity must not decrease: {cap_primal_after_second} < {cap_primal_after_first}",
2057 );
2058 }
2059}
2060
2061// ─── Research verification tests for non-optimal HiGHS model statuses ────
2062//
2063// These tests verify LP formulations that reliably trigger non-optimal
2064// HiGHS model statuses. They use the raw FFI layer to set options not
2065// exposed through SolverInterface and confirm the expected model status.
2066//
2067// The SS1.1 LP (3-variable, 2-constraint) is too small: HiGHS's crash
2068// heuristic solves it without entering the simplex loop, so time/iteration
2069// limits never fire. A 5-variable, 4-constraint "larger_lp" is required.
2070#[cfg(test)]
2071#[allow(clippy::doc_markdown)]
2072mod research_tests {
2073 // LP used: 3-variable, 2-constraint fixture from SS1.1 (same as other tests).
2074 // This LP requires at least 2 simplex iterations, so iteration_limit=1 will
2075 // produce ITERATION_LIMIT.
2076
2077 // ─── Helper: load the SS1.1 LP onto an existing HiGHS handle ────────────
2078 //
2079 // 3 columns (x0, x1, x2), 2 equality rows, 3 non-zeros.
2080 // Optimal: x0=6, x1=0, x2=2, obj=100. Requires 2 simplex iterations.
2081 //
2082 // SAFETY: caller must guarantee `highs` is a valid, non-null HiGHS handle.
2083 unsafe fn research_load_ss11_lp(highs: *mut std::os::raw::c_void) {
2084 use crate::ffi;
2085 let col_cost: [f64; 3] = [0.0, 1.0, 50.0];
2086 let col_lower: [f64; 3] = [0.0, 0.0, 0.0];
2087 let col_upper: [f64; 3] = [10.0, f64::INFINITY, 8.0];
2088 let row_lower: [f64; 2] = [6.0, 14.0];
2089 let row_upper: [f64; 2] = [6.0, 14.0];
2090 let a_start: [i32; 4] = [0, 2, 2, 3];
2091 let a_index: [i32; 3] = [0, 1, 1];
2092 let a_value: [f64; 3] = [1.0, 2.0, 1.0];
2093 // SAFETY: all pointers are valid, aligned, non-null, and live for the call duration.
2094 let status = unsafe {
2095 ffi::cobre_highs_pass_lp(
2096 highs,
2097 3,
2098 2,
2099 3,
2100 ffi::HIGHS_MATRIX_FORMAT_COLWISE,
2101 ffi::HIGHS_OBJ_SENSE_MINIMIZE,
2102 0.0,
2103 col_cost.as_ptr(),
2104 col_lower.as_ptr(),
2105 col_upper.as_ptr(),
2106 row_lower.as_ptr(),
2107 row_upper.as_ptr(),
2108 a_start.as_ptr(),
2109 a_index.as_ptr(),
2110 a_value.as_ptr(),
2111 )
2112 };
2113 assert_eq!(
2114 status,
2115 ffi::HIGHS_STATUS_OK,
2116 "research_load_ss11_lp pass_lp failed"
2117 );
2118 }
2119
2120 /// Probe: what do time_limit=0.0 and iteration_limit=0 actually return on SS1.1?
2121 ///
2122 /// This test is OBSERVATIONAL -- it captures actual HiGHS behavior. The SS1.1 LP
2123 /// (2 constraints, 3 variables) is solved by presolve/crash before the simplex
2124 /// loop, making limits ineffective. This test documents that behavior.
2125 #[test]
2126 fn test_research_probe_limit_status_on_ss11_lp() {
2127 use crate::ffi;
2128
2129 // SS1.1 with time_limit=0.0: presolve/crash solves before time check fires.
2130 let highs = unsafe { ffi::cobre_highs_create() };
2131 assert!(!highs.is_null());
2132 unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
2133 unsafe { research_load_ss11_lp(highs) };
2134 let _ = unsafe { ffi::cobre_highs_set_double_option(highs, c"time_limit".as_ptr(), 0.0) };
2135 let run_status = unsafe { ffi::cobre_highs_run(highs) };
2136 let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
2137 let obj = unsafe { ffi::cobre_highs_get_objective_value(highs) };
2138 eprintln!(
2139 "SS1.1 + time_limit=0: run_status={run_status}, model_status={model_status}, obj={obj}"
2140 );
2141 unsafe { ffi::cobre_highs_destroy(highs) };
2142
2143 // SS1.1 with iteration_limit=0: same result, need a larger LP.
2144 let highs = unsafe { ffi::cobre_highs_create() };
2145 assert!(!highs.is_null());
2146 unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
2147 unsafe { research_load_ss11_lp(highs) };
2148 let _ = unsafe {
2149 ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), 0)
2150 };
2151 let run_status = unsafe { ffi::cobre_highs_run(highs) };
2152 let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
2153 let obj = unsafe { ffi::cobre_highs_get_objective_value(highs) };
2154 eprintln!(
2155 "SS1.1 + iteration_limit=0: run_status={run_status}, model_status={model_status}, obj={obj}"
2156 );
2157 unsafe { ffi::cobre_highs_destroy(highs) };
2158 }
2159
2160 /// Helper: load a 5-variable, 4-constraint LP that requires multiple simplex
2161 /// iterations and cannot be solved by crash alone.
2162 ///
2163 /// LP (larger_lp):
2164 /// min x0 + x1 + x2 + x3 + x4
2165 /// s.t. x0 + x1 >= 10
2166 /// x1 + x2 >= 8
2167 /// x2 + x3 >= 6
2168 /// x3 + x4 >= 4
2169 /// x_i in [0, 100], i = 0..4
2170 ///
2171 /// CSC matrix (5 cols, 4 rows, 8 non-zeros):
2172 /// col 0: rows [0] -> a_start[0]=0, a_start[1]=1
2173 /// col 1: rows [0,1] -> a_start[2]=3
2174 /// col 2: rows [1,2] -> a_start[3]=5
2175 /// col 3: rows [2,3] -> a_start[4]=7
2176 /// col 4: rows [3] -> a_start[5]=8
2177 ///
2178 /// SAFETY: caller must guarantee `highs` is a valid, non-null HiGHS handle.
2179 unsafe fn research_load_larger_lp(highs: *mut std::os::raw::c_void) {
2180 use crate::ffi;
2181 let col_cost: [f64; 5] = [1.0, 1.0, 1.0, 1.0, 1.0];
2182 let col_lower: [f64; 5] = [0.0; 5];
2183 let col_upper: [f64; 5] = [100.0; 5];
2184 let row_lower: [f64; 4] = [10.0, 8.0, 6.0, 4.0];
2185 let row_upper: [f64; 4] = [f64::INFINITY; 4];
2186 // CSC: col 0 -> row 0; col 1 -> rows 0,1; col 2 -> rows 1,2; col 3 -> rows 2,3; col 4 -> row 3
2187 let a_start: [i32; 6] = [0, 1, 3, 5, 7, 8];
2188 let a_index: [i32; 8] = [0, 0, 1, 1, 2, 2, 3, 3];
2189 let a_value: [f64; 8] = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0];
2190 // SAFETY: all pointers are valid, aligned, non-null, and live for the call duration.
2191 let status = unsafe {
2192 ffi::cobre_highs_pass_lp(
2193 highs,
2194 5,
2195 4,
2196 8,
2197 ffi::HIGHS_MATRIX_FORMAT_COLWISE,
2198 ffi::HIGHS_OBJ_SENSE_MINIMIZE,
2199 0.0,
2200 col_cost.as_ptr(),
2201 col_lower.as_ptr(),
2202 col_upper.as_ptr(),
2203 row_lower.as_ptr(),
2204 row_upper.as_ptr(),
2205 a_start.as_ptr(),
2206 a_index.as_ptr(),
2207 a_value.as_ptr(),
2208 )
2209 };
2210 assert_eq!(
2211 status,
2212 ffi::HIGHS_STATUS_OK,
2213 "research_load_larger_lp pass_lp failed"
2214 );
2215 }
2216
2217 /// Verify time_limit=0.0 triggers HIGHS_MODEL_STATUS_TIME_LIMIT (13).
2218 ///
2219 /// Uses a 5-variable, 4-constraint LP that cannot be trivially solved by
2220 /// crash. HiGHS checks the time limit at entry to the simplex loop.
2221 /// time_limit=0.0 is always exceeded by wall-clock time before any pivot.
2222 ///
2223 /// Observed: run_status=WARNING (1), model_status=TIME_LIMIT (13).
2224 /// Confirmed in HiGHS check/TestQpSolver.cpp line 1083-1085.
2225 #[test]
2226 fn test_research_time_limit_zero_triggers_time_limit_status() {
2227 use crate::ffi;
2228
2229 let highs = unsafe { ffi::cobre_highs_create() };
2230 assert!(!highs.is_null());
2231 unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
2232 unsafe { research_load_larger_lp(highs) };
2233
2234 let opt_status =
2235 unsafe { ffi::cobre_highs_set_double_option(highs, c"time_limit".as_ptr(), 0.0) };
2236 assert_eq!(opt_status, ffi::HIGHS_STATUS_OK);
2237
2238 let run_status = unsafe { ffi::cobre_highs_run(highs) };
2239 let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
2240
2241 eprintln!(
2242 "time_limit=0 on larger LP: run_status={run_status}, model_status={model_status}"
2243 );
2244
2245 assert_eq!(
2246 run_status,
2247 ffi::HIGHS_STATUS_WARNING,
2248 "time_limit=0 must return HIGHS_STATUS_WARNING (1), got {run_status}"
2249 );
2250 assert_eq!(
2251 model_status,
2252 ffi::HIGHS_MODEL_STATUS_TIME_LIMIT,
2253 "time_limit=0 must give MODEL_STATUS_TIME_LIMIT (13), got {model_status}"
2254 );
2255
2256 unsafe { ffi::cobre_highs_destroy(highs) };
2257 }
2258
2259 /// Verify simplex_iteration_limit=0 triggers HIGHS_MODEL_STATUS_ITERATION_LIMIT (14).
2260 ///
2261 /// Uses the 5-variable, 4-constraint LP with presolve disabled so that
2262 /// the crash phase does not solve it, and the iteration limit check fires.
2263 ///
2264 /// Confirmed pattern from HiGHS check/TestLpSolversIterations.cpp
2265 /// lines 145-165: iteration_limit=0 -> HighsStatus::kWarning +
2266 /// HighsModelStatus::kIterationLimit, iteration count = 0.
2267 #[test]
2268 fn test_research_iteration_limit_zero_triggers_iteration_limit_status() {
2269 use crate::ffi;
2270
2271 let highs = unsafe { ffi::cobre_highs_create() };
2272 assert!(!highs.is_null());
2273 unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
2274 // Disable presolve so crash cannot solve LP without simplex iterations.
2275 unsafe { ffi::cobre_highs_set_string_option(highs, c"presolve".as_ptr(), c"off".as_ptr()) };
2276 unsafe { research_load_larger_lp(highs) };
2277
2278 let opt_status = unsafe {
2279 ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), 0)
2280 };
2281 assert_eq!(opt_status, ffi::HIGHS_STATUS_OK);
2282
2283 let run_status = unsafe { ffi::cobre_highs_run(highs) };
2284 let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
2285
2286 eprintln!(
2287 "iteration_limit=0 on larger LP: run_status={run_status}, model_status={model_status}"
2288 );
2289
2290 assert_eq!(
2291 run_status,
2292 ffi::HIGHS_STATUS_WARNING,
2293 "iteration_limit=0 must return HIGHS_STATUS_WARNING (1), got {run_status}"
2294 );
2295 assert_eq!(
2296 model_status,
2297 ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT,
2298 "iteration_limit=0 must give MODEL_STATUS_ITERATION_LIMIT (14), got {model_status}"
2299 );
2300
2301 unsafe { ffi::cobre_highs_destroy(highs) };
2302 }
2303
2304 /// Observe partial solution availability after TIME_LIMIT and ITERATION_LIMIT.
2305 ///
2306 /// With time_limit=0.0, HiGHS halts before pivots. With iteration_limit=0
2307 /// and presolve disabled, HiGHS halts at the crash-point solution.
2308 /// Both tests record objective availability for documentation.
2309 #[test]
2310 fn test_research_partial_solution_availability() {
2311 use crate::ffi;
2312
2313 // TIME_LIMIT: observe objective after halting at time check
2314 {
2315 let highs = unsafe { ffi::cobre_highs_create() };
2316 assert!(!highs.is_null());
2317 unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
2318 unsafe { research_load_larger_lp(highs) };
2319 unsafe { ffi::cobre_highs_set_double_option(highs, c"time_limit".as_ptr(), 0.0) };
2320 unsafe { ffi::cobre_highs_run(highs) };
2321
2322 let obj = unsafe { ffi::cobre_highs_get_objective_value(highs) };
2323 let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
2324 assert_eq!(model_status, ffi::HIGHS_MODEL_STATUS_TIME_LIMIT);
2325 eprintln!("TIME_LIMIT: obj={obj}, finite={}", obj.is_finite());
2326 unsafe { ffi::cobre_highs_destroy(highs) };
2327 }
2328
2329 // ITERATION_LIMIT: observe objective at crash point
2330 {
2331 let highs = unsafe { ffi::cobre_highs_create() };
2332 assert!(!highs.is_null());
2333 unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
2334 unsafe {
2335 ffi::cobre_highs_set_string_option(highs, c"presolve".as_ptr(), c"off".as_ptr())
2336 };
2337 unsafe { research_load_larger_lp(highs) };
2338 unsafe {
2339 ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), 0)
2340 };
2341 unsafe { ffi::cobre_highs_run(highs) };
2342
2343 let obj = unsafe { ffi::cobre_highs_get_objective_value(highs) };
2344 let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
2345 assert_eq!(model_status, ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT);
2346 eprintln!("ITERATION_LIMIT: obj={obj}, finite={}", obj.is_finite());
2347 unsafe { ffi::cobre_highs_destroy(highs) };
2348 }
2349 }
2350
2351 /// Verify restore_default_settings: solve with iteration_limit=0, then solve
2352 /// without limit after restoring defaults. The second solve must succeed optimally.
2353 #[test]
2354 fn test_research_restore_defaults_allows_subsequent_optimal_solve() {
2355 use crate::ffi;
2356
2357 let highs = unsafe { ffi::cobre_highs_create() };
2358 assert!(!highs.is_null());
2359
2360 unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
2361
2362 // Apply cobre defaults (mirror HighsSolver::new() configuration).
2363 unsafe {
2364 ffi::cobre_highs_set_string_option(highs, c"solver".as_ptr(), c"simplex".as_ptr());
2365 ffi::cobre_highs_set_int_option(highs, c"simplex_strategy".as_ptr(), 1);
2366 ffi::cobre_highs_set_string_option(highs, c"presolve".as_ptr(), c"off".as_ptr());
2367 ffi::cobre_highs_set_string_option(highs, c"parallel".as_ptr(), c"off".as_ptr());
2368 ffi::cobre_highs_set_double_option(
2369 highs,
2370 c"primal_feasibility_tolerance".as_ptr(),
2371 1e-7,
2372 );
2373 ffi::cobre_highs_set_double_option(highs, c"dual_feasibility_tolerance".as_ptr(), 1e-7);
2374 }
2375
2376 let col_cost: [f64; 3] = [0.0, 1.0, 50.0];
2377 let col_lower: [f64; 3] = [0.0, 0.0, 0.0];
2378 let col_upper: [f64; 3] = [10.0, f64::INFINITY, 8.0];
2379 let row_lower: [f64; 2] = [6.0, 14.0];
2380 let row_upper: [f64; 2] = [6.0, 14.0];
2381 let a_start: [i32; 4] = [0, 2, 2, 3];
2382 let a_index: [i32; 3] = [0, 1, 1];
2383 let a_value: [f64; 3] = [1.0, 2.0, 1.0];
2384
2385 // First solve: with iteration_limit = 0 -> ITERATION_LIMIT.
2386 unsafe {
2387 ffi::cobre_highs_pass_lp(
2388 highs,
2389 3,
2390 2,
2391 3,
2392 ffi::HIGHS_MATRIX_FORMAT_COLWISE,
2393 ffi::HIGHS_OBJ_SENSE_MINIMIZE,
2394 0.0,
2395 col_cost.as_ptr(),
2396 col_lower.as_ptr(),
2397 col_upper.as_ptr(),
2398 row_lower.as_ptr(),
2399 row_upper.as_ptr(),
2400 a_start.as_ptr(),
2401 a_index.as_ptr(),
2402 a_value.as_ptr(),
2403 );
2404 ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), 0);
2405 ffi::cobre_highs_run(highs);
2406 }
2407 let status1 = unsafe { ffi::cobre_highs_get_model_status(highs) };
2408 assert_eq!(status1, ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT);
2409
2410 // Restore default settings (mirror restore_default_settings()).
2411 unsafe {
2412 ffi::cobre_highs_set_string_option(highs, c"solver".as_ptr(), c"simplex".as_ptr());
2413 ffi::cobre_highs_set_int_option(highs, c"simplex_strategy".as_ptr(), 1);
2414 ffi::cobre_highs_set_string_option(highs, c"presolve".as_ptr(), c"off".as_ptr());
2415 ffi::cobre_highs_set_double_option(
2416 highs,
2417 c"primal_feasibility_tolerance".as_ptr(),
2418 1e-7,
2419 );
2420 ffi::cobre_highs_set_double_option(highs, c"dual_feasibility_tolerance".as_ptr(), 1e-7);
2421 ffi::cobre_highs_set_string_option(highs, c"parallel".as_ptr(), c"off".as_ptr());
2422 ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0);
2423 // simplex_iteration_limit is NOT in restore_default_settings -- reset explicitly.
2424 ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), i32::MAX);
2425 }
2426
2427 // Second solve on the same model: must reach OPTIMAL.
2428 unsafe { ffi::cobre_highs_clear_solver(highs) };
2429 unsafe { ffi::cobre_highs_run(highs) };
2430 let status2 = unsafe { ffi::cobre_highs_get_model_status(highs) };
2431 let obj = unsafe { ffi::cobre_highs_get_objective_value(highs) };
2432 assert_eq!(
2433 status2,
2434 ffi::HIGHS_MODEL_STATUS_OPTIMAL,
2435 "after restoring defaults, second solve must be OPTIMAL, got {status2}"
2436 );
2437 assert!(
2438 (obj - 100.0).abs() < 1e-8,
2439 "objective after restore must be 100.0, got {obj}"
2440 );
2441
2442 unsafe { ffi::cobre_highs_destroy(highs) };
2443 }
2444
2445 /// Verify iteration_limit=1 also triggers ITERATION_LIMIT for SS1.1 LP.
2446 ///
2447 /// This verifies that limiting to a small but non-zero number of iterations
2448 /// also works, providing an alternative formulation for triggering the same status.
2449 #[test]
2450 fn test_research_iteration_limit_one_triggers_iteration_limit_status() {
2451 use crate::ffi;
2452
2453 let highs = unsafe { ffi::cobre_highs_create() };
2454 assert!(!highs.is_null());
2455
2456 unsafe { ffi::cobre_highs_set_bool_option(highs, c"output_flag".as_ptr(), 0) };
2457
2458 let col_cost: [f64; 3] = [0.0, 1.0, 50.0];
2459 let col_lower: [f64; 3] = [0.0, 0.0, 0.0];
2460 let col_upper: [f64; 3] = [10.0, f64::INFINITY, 8.0];
2461 let row_lower: [f64; 2] = [6.0, 14.0];
2462 let row_upper: [f64; 2] = [6.0, 14.0];
2463 let a_start: [i32; 4] = [0, 2, 2, 3];
2464 let a_index: [i32; 3] = [0, 1, 1];
2465 let a_value: [f64; 3] = [1.0, 2.0, 1.0];
2466
2467 unsafe {
2468 ffi::cobre_highs_pass_lp(
2469 highs,
2470 3,
2471 2,
2472 3,
2473 ffi::HIGHS_MATRIX_FORMAT_COLWISE,
2474 ffi::HIGHS_OBJ_SENSE_MINIMIZE,
2475 0.0,
2476 col_cost.as_ptr(),
2477 col_lower.as_ptr(),
2478 col_upper.as_ptr(),
2479 row_lower.as_ptr(),
2480 row_upper.as_ptr(),
2481 a_start.as_ptr(),
2482 a_index.as_ptr(),
2483 a_value.as_ptr(),
2484 );
2485 ffi::cobre_highs_set_int_option(highs, c"simplex_iteration_limit".as_ptr(), 1);
2486 ffi::cobre_highs_run(highs);
2487 }
2488
2489 let model_status = unsafe { ffi::cobre_highs_get_model_status(highs) };
2490 eprintln!("iteration_limit=1 model_status: {model_status}");
2491 // If the LP solves in 1 iteration it may be OPTIMAL; otherwise ITERATION_LIMIT.
2492 // We record both possibilities for the research document.
2493 assert!(
2494 model_status == ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT
2495 || model_status == ffi::HIGHS_MODEL_STATUS_OPTIMAL,
2496 "expected ITERATION_LIMIT or OPTIMAL, got {model_status}"
2497 );
2498
2499 unsafe { ffi::cobre_highs_destroy(highs) };
2500 }
2501
2502 /// Verify that `HighsSolver` correctly maps unbounded and infeasible statuses.
2503 ///
2504 /// With presolve=off and dual simplex (the default `HighsSolver` configuration),
2505 /// HiGHS returns `HIGHS_MODEL_STATUS_UNBOUNDED` (10) for unbounded LPs and
2506 /// `HIGHS_MODEL_STATUS_INFEASIBLE` (8) for infeasible LPs. Both are mapped to
2507 /// the appropriate `SolverError` variants without entering the
2508 /// `UNBOUNDED_OR_INFEASIBLE` probe branch.
2509 ///
2510 /// Note: `HIGHS_MODEL_STATUS_UNBOUNDED_OR_INFEASIBLE` (9) is returned only by
2511 /// IPM (`IpxWrapper.cpp:317`) when it detects dual infeasibility, or when
2512 /// `allow_unbounded_or_infeasible=true` is set with presolve=on. Neither
2513 /// condition occurs in the default `HighsSolver` configuration, so the
2514 /// `UNBOUNDED_OR_INFEASIBLE` branch serves as a safe fallback for retry paths
2515 /// that switch to IPM.
2516 #[test]
2517 fn test_research_verify_non_optimal_highs_status_mapping() {
2518 use super::super::HighsSolver;
2519 use crate::SolverInterface;
2520 use crate::types::SolverError;
2521 use crate::types::StageTemplate;
2522
2523 // Unbounded LP: min -x0 - x1, x0 + x1 >= 1, x0/x1 in [0, +inf).
2524 // With presolve=off and dual simplex, HiGHS returns UNBOUNDED (10).
2525 let unbounded_template = StageTemplate {
2526 num_cols: 2,
2527 num_rows: 1,
2528 num_nz: 2,
2529 col_starts: vec![0_i32, 1, 2],
2530 row_indices: vec![0_i32, 0],
2531 values: vec![1.0, 1.0],
2532 col_lower: vec![0.0, 0.0],
2533 col_upper: vec![f64::INFINITY, f64::INFINITY],
2534 objective: vec![-1.0, -1.0],
2535 row_lower: vec![1.0],
2536 row_upper: vec![f64::INFINITY],
2537 n_state: 1,
2538 n_transfer: 0,
2539 n_dual_relevant: 1,
2540 n_hydro: 0,
2541 max_par_order: 0,
2542 col_scale: Vec::new(),
2543 row_scale: Vec::new(),
2544 };
2545 let mut solver_unb = HighsSolver::new().expect("HighsSolver::new() must succeed");
2546 solver_unb.load_model(&unbounded_template);
2547 let result_unb = solver_unb.solve(None).map(|_| ());
2548 assert!(
2549 matches!(result_unb, Err(SolverError::Unbounded)),
2550 "unbounded LP must return Err(SolverError::Unbounded), got {result_unb:?}"
2551 );
2552
2553 // Infeasible LP: x0 must equal 99 but is bounded to [0, 10].
2554 // HiGHS returns INFEASIBLE (8) directly; mapped to Err(SolverError::Infeasible).
2555 let infeasible_template = StageTemplate {
2556 num_cols: 1,
2557 num_rows: 1,
2558 num_nz: 1,
2559 col_starts: vec![0_i32, 1],
2560 row_indices: vec![0_i32],
2561 values: vec![1.0],
2562 col_lower: vec![0.0],
2563 col_upper: vec![10.0],
2564 objective: vec![0.0],
2565 row_lower: vec![99.0],
2566 row_upper: vec![99.0],
2567 n_state: 1,
2568 n_transfer: 0,
2569 n_dual_relevant: 1,
2570 n_hydro: 0,
2571 max_par_order: 0,
2572 col_scale: Vec::new(),
2573 row_scale: Vec::new(),
2574 };
2575 let mut solver_inf = HighsSolver::new().expect("HighsSolver::new() must succeed");
2576 solver_inf.load_model(&infeasible_template);
2577 let result_inf = solver_inf.solve(None).map(|_| ());
2578 assert!(
2579 matches!(result_inf, Err(SolverError::Infeasible)),
2580 "infeasible LP must return Err(SolverError::Infeasible), got {result_inf:?}"
2581 );
2582 }
2583}