cobre_solver/backends/highs/solver.rs
1//! The [`HighsSolver`] handle wrapper, its lifecycle/solve primitives, and the
2//! `highs_version` free function.
3//!
4//! Owns the `HighsSolver` struct definition, the `unsafe impl Send`, the
5//! construction/configuration/solve helpers, the warm-start `solve_inner`
6//! orchestration (determinism-sensitive), the `Drop` handle teardown, the
7//! `highs_version` query, and the `test-support` accessor impl. The escalation
8//! ladder (`retry`) and the `SolverInterface` impl (`interface`) are additional
9//! `impl HighsSolver` blocks that reach this struct's fields and helpers via the
10//! child-module hierarchy.
11
12use std::os::raw::c_void;
13use std::time::Instant;
14
15use super::config::{HighsProfile, default_options};
16use crate::{
17 DEFAULT_PROFILE_HEURISTIC_SENTINEL, DEFAULT_PROFILE_IPM_UNBOUNDED_SENTINEL, ffi,
18 types::{SolutionView, SolverError, SolverStatistics},
19};
20
21/// `HiGHS` LP solver instance implementing [`SolverInterface`](crate::SolverInterface).
22///
23/// Owns an opaque `HiGHS` handle and pre-allocated buffers for solution
24/// extraction, scratch i32 index conversion, and statistics accumulation.
25///
26/// Construct with [`HighsSolver::new`]. The handle is destroyed automatically
27/// when the instance is dropped.
28///
29/// # Example
30///
31/// ```rust
32/// use cobre_solver::{HighsSolver, SolverInterface};
33///
34/// let solver = HighsSolver::new().expect("HiGHS initialisation failed");
35/// assert_eq!(solver.name(), "HiGHS");
36/// ```
37pub struct HighsSolver {
38 /// Opaque pointer to the `HiGHS` C++ instance, obtained from `cobre_highs_create()`.
39 pub(super) handle: *mut c_void,
40 /// Pre-allocated buffer for primal column values extracted after each solve.
41 /// Resized in `load_model`; reused across solves to avoid per-solve allocation.
42 pub(super) col_value: Vec<f64>,
43 /// Pre-allocated buffer for column dual values (reduced costs from `HiGHS` perspective).
44 /// Resized in `load_model`.
45 pub(super) col_dual: Vec<f64>,
46 /// Pre-allocated buffer for row primal values (constraint activity).
47 /// Resized in `load_model`.
48 pub(super) row_value: Vec<f64>,
49 /// Pre-allocated buffer for row dual multipliers (shadow prices).
50 /// Resized in `load_model`.
51 pub(super) row_dual: Vec<f64>,
52 /// Scratch buffer for converting `usize` indices to `i32` for the `HiGHS` C API.
53 /// Used by `add_rows`, `set_row_bounds`, and `set_col_bounds`.
54 /// Never shrunk -- only grows -- to prevent reallocation churn on the hot path.
55 pub(super) scratch_i32: Vec<i32>,
56 /// Pre-allocated i32 buffer for column basis status codes.
57 /// Reused across warm-start `solve` and `get_basis` calls to avoid per-call allocation.
58 /// Resized in `load_model` to `num_cols`; never shrunk.
59 pub(super) basis_col_i32: Vec<i32>,
60 /// Pre-allocated i32 buffer for row basis status codes.
61 /// Reused across warm-start `solve` and `get_basis` calls to avoid per-call allocation.
62 /// Resized in `load_model` to `num_rows` and grown in `add_rows`.
63 pub(super) basis_row_i32: Vec<i32>,
64 /// Scratch buffer for dual-ray extraction in `interpret_terminal_status` (dual).
65 /// Grown lazily to `num_rows` via `resize`; contents are discarded after classification.
66 /// Retained across calls so repeated non-optimal solves do not re-allocate.
67 pub(super) terminal_status_dual_scratch: Vec<f64>,
68 /// Scratch buffer for primal-ray extraction in `interpret_terminal_status` (primal).
69 /// Grown lazily to `num_cols` via `resize`; contents are discarded after classification.
70 /// Retained across calls so repeated non-optimal solves do not re-allocate.
71 pub(super) terminal_status_primal_scratch: Vec<f64>,
72 /// Current number of LP columns (decision variables), updated by `load_model` and `add_rows`.
73 pub(super) num_cols: usize,
74 /// Current number of LP rows (constraints), updated by `load_model` and `add_rows`.
75 pub(super) num_rows: usize,
76 /// Whether a model is currently loaded. Set to `true` in `load_model`,
77 /// `false` in `reset` and `new`. Guards `solve`/`get_basis` contract.
78 pub(super) has_model: bool,
79 /// Accumulated solver statistics. Counters grow monotonically from zero;
80 /// not reset by `reset()`.
81 pub(super) stats: SolverStatistics,
82 /// Cached solver profile applied by the last `set_*_profile` call.
83 ///
84 /// Initialised to `HighsProfile::default()` at construction, which
85 /// preserves the historical hardcoded behaviour bit-for-bit. Updated by
86 /// the four `SolverInterface` profile setter methods; read by
87 /// `set_iteration_limits` on every solve attempt.
88 pub(super) current_profile: HighsProfile,
89}
90
91// SAFETY: `HighsSolver` holds a raw pointer to a `HiGHS` C++ object. The `HiGHS`
92// handle is not thread-safe for concurrent access, but exclusive ownership is
93// maintained at all times -- exactly one `HighsSolver` instance owns each
94// handle and no shared references to the handle exist. Transferring the
95// `HighsSolver` to another thread (via `Send`) is safe because there is no
96// concurrent access; the new thread has exclusive ownership. `Sync` is
97// intentionally NOT implemented per `HiGHS` Implementation SS6.3.
98unsafe impl Send for HighsSolver {}
99
100impl HighsSolver {
101 /// Creates a new `HiGHS` solver instance with performance-tuned defaults.
102 ///
103 /// Calls `cobre_highs_create()` to allocate the `HiGHS` handle, then applies
104 /// the thirteen default options defined in `HiGHS` Implementation SS4.1:
105 ///
106 /// | Option | Value | Type |
107 /// |---------------------------------------------|-------------|--------|
108 /// | `solver` | `"simplex"` | string |
109 /// | `simplex_strategy` | `1` | int |
110 /// | `simplex_scale_strategy` | `0` | int |
111 /// | `presolve` | `"on"` | string |
112 /// | `parallel` | `"off"` | string |
113 /// | `output_flag` | `0` | bool |
114 /// | `primal_feasibility_tolerance` | `1e-9` | double |
115 /// | `dual_feasibility_tolerance` | `1e-9` | double |
116 /// | `simplex_dual_edge_weight_strategy` | `1` | int |
117 /// | `dual_simplex_cost_perturbation_multiplier` | `0.0` | double |
118 /// | `simplex_initial_condition_check` | `0` | bool |
119 /// | `simplex_price_strategy` | `1` | int |
120 /// | `rebuild_refactor_solution_error_tolerance` | `1e-6` | double |
121 ///
122 /// # Errors
123 ///
124 /// Returns `Err(SolverError::InternalError { .. })` if:
125 /// - `cobre_highs_create()` returns a null pointer.
126 /// - Any configuration call returns `HIGHS_STATUS_ERROR`.
127 ///
128 /// In both failure cases the `HiGHS` handle is destroyed before returning to
129 /// prevent a resource leak.
130 pub fn new() -> Result<Self, SolverError> {
131 // SAFETY: `cobre_highs_create` is a C function with no preconditions.
132 // It allocates and returns a new `HiGHS` instance, or null on allocation
133 // failure. The returned pointer is opaque and must be passed back to
134 // `HiGHS` API functions.
135 let handle = unsafe { ffi::cobre_highs_create() };
136
137 if handle.is_null() {
138 return Err(SolverError::InternalError {
139 message: "HiGHS instance creation failed: Highs_create() returned null".to_string(),
140 error_code: None,
141 });
142 }
143
144 // Apply performance-tuned configuration. On any failure, destroy the
145 // handle before returning to prevent a resource leak.
146 if let Err(e) = Self::apply_default_config(handle) {
147 // SAFETY: `handle` is a valid, non-null pointer obtained from
148 // `cobre_highs_create()` in this same function. It has not been
149 // passed to `cobre_highs_destroy()` yet. After this call, `handle`
150 // must not be used again -- this function returns immediately with Err.
151 unsafe { ffi::cobre_highs_destroy(handle) };
152 return Err(e);
153 }
154
155 Ok(Self {
156 handle,
157 col_value: Vec::new(),
158 col_dual: Vec::new(),
159 row_value: Vec::new(),
160 row_dual: Vec::new(),
161 scratch_i32: Vec::new(),
162 basis_col_i32: Vec::new(),
163 basis_row_i32: Vec::new(),
164 terminal_status_dual_scratch: Vec::new(),
165 terminal_status_primal_scratch: Vec::new(),
166 num_cols: 0,
167 num_rows: 0,
168 has_model: false,
169 stats: SolverStatistics {
170 retry_level_histogram: vec![0u64; 12],
171 ..SolverStatistics::default()
172 },
173 current_profile: HighsProfile::default(),
174 })
175 }
176
177 /// Applies the thirteen performance-tuned `HiGHS` configuration options.
178 ///
179 /// Called once during construction. Returns `Ok(())` if all options are set
180 /// successfully, or `Err(SolverError::InternalError)` with the failing
181 /// option name if any configuration call returns `HIGHS_STATUS_ERROR`.
182 fn apply_default_config(handle: *mut c_void) -> Result<(), SolverError> {
183 for opt in &default_options() {
184 // SAFETY: `handle` is a valid, non-null HiGHS pointer.
185 let status = unsafe { opt.apply(handle) };
186 if status == ffi::HIGHS_STATUS_ERROR {
187 return Err(SolverError::InternalError {
188 message: format!(
189 "HiGHS configuration failed: {}",
190 opt.name.to_str().unwrap_or("?")
191 ),
192 error_code: Some(status),
193 });
194 }
195 }
196 Ok(())
197 }
198
199 /// Extracts the optimal solution from `HiGHS` into pre-allocated buffers and returns
200 /// a [`SolutionView`] borrowing directly from those buffers.
201 ///
202 /// The returned view borrows solver-internal buffers and is valid until the next
203 /// `&mut self` call. `col_dual` is the reduced cost vector. Row duals follow the
204 /// canonical sign convention (per Solver Abstraction SS8).
205 pub(super) fn extract_solution_view(&mut self, solve_time_seconds: f64) -> SolutionView<'_> {
206 // SAFETY: buffers resized in `load_model`/`add_rows`; HiGHS writes within bounds.
207 let status = unsafe {
208 ffi::cobre_highs_get_solution(
209 self.handle,
210 self.col_value.as_mut_ptr(),
211 self.col_dual.as_mut_ptr(),
212 self.row_value.as_mut_ptr(),
213 self.row_dual.as_mut_ptr(),
214 )
215 };
216 // HiGHS documentation guarantees `cobre_highs_get_solution` returns
217 // non-ERROR status after `OPTIMAL` model status; this is a
218 // debug-build-only invariant check.
219 debug_assert_ne!(
220 status,
221 ffi::HIGHS_STATUS_ERROR,
222 "cobre_highs_get_solution failed after optimal solve; HiGHS invariant violation"
223 );
224
225 // SAFETY: `self.handle` is a valid, non-null HiGHS pointer.
226 let objective = unsafe { ffi::cobre_highs_get_objective_value(self.handle) };
227
228 // SAFETY: iteration count is non-negative so cast is safe.
229 #[allow(clippy::cast_sign_loss)]
230 let iterations =
231 unsafe { ffi::cobre_highs_get_simplex_iteration_count(self.handle) } as u64;
232
233 SolutionView {
234 objective,
235 primal: &self.col_value[..self.num_cols],
236 dual: &self.row_dual[..self.num_rows],
237 reduced_costs: &self.col_dual[..self.num_cols],
238 iterations,
239 solve_time_seconds,
240 }
241 }
242
243 /// Re-applies the current profile's feasibility tolerances to the `HiGHS` instance.
244 ///
245 /// Called immediately after `restore_default_settings()` in the retry-escalation
246 /// finalization path so that `HiGHS` state and `current_profile` remain in sync.
247 /// `restore_default_settings` resets the tolerances to the hardcoded table values
248 /// (`1e-9`); this helper layers the caller's profile values on top.
249 ///
250 /// The iteration limits are not re-applied here because `restore_iteration_limits`
251 /// always follows immediately and sets them to `i32::MAX` (unconstrained for the
252 /// post-retry default-attempt path).
253 pub(super) fn apply_profile_tolerances(&mut self) {
254 // SAFETY: `self.handle` is a valid, non-null HiGHS pointer obtained from
255 // `cobre_highs_create()`. Option names are static C string literals with no
256 // retained pointer after the call returns.
257 unsafe {
258 ffi::cobre_highs_set_double_option(
259 self.handle,
260 c"primal_feasibility_tolerance".as_ptr(),
261 self.current_profile.primal_feasibility_tolerance,
262 );
263 ffi::cobre_highs_set_double_option(
264 self.handle,
265 c"dual_feasibility_tolerance".as_ptr(),
266 self.current_profile.dual_feasibility_tolerance,
267 );
268 // Also re-apply the algorithmic strategy int options. Post-retry
269 // `restore_default_settings` resets these to HiGHS defaults; the
270 // profile values must be reinstalled before the default-attempt
271 // path runs so backward-tuned profiles survive the retry boundary.
272 ffi::cobre_highs_set_int_option(
273 self.handle,
274 c"simplex_dual_edge_weight_strategy".as_ptr(),
275 self.current_profile.simplex_dual_edge_weight_strategy,
276 );
277 ffi::cobre_highs_set_int_option(
278 self.handle,
279 c"simplex_scale_strategy".as_ptr(),
280 self.current_profile.simplex_scale_strategy,
281 );
282 ffi::cobre_highs_set_int_option(
283 self.handle,
284 c"simplex_price_strategy".as_ptr(),
285 self.current_profile.simplex_price_strategy,
286 );
287 }
288 }
289
290 /// Restores default options after retry escalation.
291 ///
292 /// Status codes are checked via `debug_assert!` to catch programming
293 /// errors during development (e.g., invalid option name). In release
294 /// builds, failures are silently ignored since we are already on the
295 /// recovery path.
296 pub(super) fn restore_default_settings(&mut self) {
297 for opt in &default_options() {
298 // SAFETY: `self.handle` is a valid, non-null HiGHS pointer.
299 let status = unsafe { opt.apply(self.handle) };
300 debug_assert_eq!(
301 status,
302 ffi::HIGHS_STATUS_OK,
303 "restore_default_settings: option {:?} failed with status {status}",
304 opt.name,
305 );
306 }
307 }
308
309 /// Runs the solver once and returns the raw `HiGHS` model status.
310 pub(super) fn run_once(&mut self) -> i32 {
311 // SAFETY: `self.handle` is a valid, non-null HiGHS pointer.
312 let run_status = unsafe { ffi::cobre_highs_run(self.handle) };
313 if run_status == ffi::HIGHS_STATUS_ERROR {
314 return ffi::HIGHS_MODEL_STATUS_SOLVE_ERROR;
315 }
316 // SAFETY: same.
317 unsafe { ffi::cobre_highs_get_model_status(self.handle) }
318 }
319
320 /// Sets per-solve iteration limits before a `run_once()` call.
321 ///
322 /// Simplex cap: if `current_profile.simplex_iteration_limit` equals
323 /// [`DEFAULT_PROFILE_HEURISTIC_SENTINEL`] (`0`), the historical heuristic
324 /// `max(100_000, 50 × num_cols)` is used. Any non-zero profile value is
325 /// applied verbatim (clamped to `i32::MAX` for the FFI call).
326 ///
327 /// IPM cap: if `current_profile.ipm_iteration_limit` equals
328 /// [`DEFAULT_PROFILE_IPM_UNBOUNDED_SENTINEL`] (`0`), `i32::MAX` is sent to
329 /// `HiGHS` (no cap). Any positive value is applied verbatim (clamped to
330 /// `i32::MAX` for the FFI call). The `Default` value is `10_000`, so
331 /// existing callers see no behavioural change.
332 ///
333 /// **Note on `time_limit`**: `HiGHS` tracks elapsed time cumulatively from
334 /// instance creation, not per-`run()` call — neither `clear_solver()` nor
335 /// option changes reset the internal timer. This makes `time_limit`
336 /// unusable for the scenario-loop pattern (thousands of solves per
337 /// instance). Wall-clock measurement via `Instant` is used instead for
338 /// time-based budget management.
339 pub(super) fn set_iteration_limits(&mut self) {
340 #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
341 let simplex_iter_limit: i32 =
342 if self.current_profile.simplex_iteration_limit == DEFAULT_PROFILE_HEURISTIC_SENTINEL {
343 // Heuristic fallback: scale with LP size to avoid runaway cycling.
344 let heuristic = self.num_cols.saturating_mul(50).max(100_000);
345 // `heuristic` is bounded by `usize::MAX`, but realistic LP sizes
346 // are well below `i32::MAX` (≈2.1 × 10^9 cols). Clamp defensively.
347 (heuristic.min(i32::MAX as usize)) as i32
348 } else {
349 // Profile literal value: apply verbatim, clamped for FFI cast.
350 // `.min(i32::MAX as u32)` ensures the value fits; the cast cannot wrap.
351 (self
352 .current_profile
353 .simplex_iteration_limit
354 .min(i32::MAX as u32)) as i32
355 };
356
357 // IPM cap: 0 is the "unbounded" sentinel per trait contract; map it to
358 // i32::MAX so HiGHS does not interpret 0 as "no iterations allowed".
359 // Any positive value is applied verbatim (clamped for the FFI cast).
360 #[allow(clippy::cast_possible_wrap)]
361 let ipm_iter_limit: i32 =
362 if self.current_profile.ipm_iteration_limit == DEFAULT_PROFILE_IPM_UNBOUNDED_SENTINEL {
363 i32::MAX // "unbounded" per trait contract
364 } else {
365 (self
366 .current_profile
367 .ipm_iteration_limit
368 .min(i32::MAX as u32)) as i32
369 };
370
371 // SAFETY: handle is valid non-null HiGHS pointer; option names are
372 // static C strings with no retained pointers.
373 unsafe {
374 ffi::cobre_highs_set_int_option(
375 self.handle,
376 c"simplex_iteration_limit".as_ptr(),
377 simplex_iter_limit,
378 );
379 ffi::cobre_highs_set_int_option(
380 self.handle,
381 c"ipm_iteration_limit".as_ptr(),
382 ipm_iter_limit,
383 );
384 }
385 }
386
387 /// Restores iteration limits to their unconstrained defaults.
388 ///
389 /// Called after `retry_escalation` completes (regardless of outcome).
390 pub(super) fn restore_iteration_limits(&mut self) {
391 // SAFETY: handle is valid non-null HiGHS pointer.
392 unsafe {
393 ffi::cobre_highs_set_int_option(
394 self.handle,
395 c"simplex_iteration_limit".as_ptr(),
396 i32::MAX,
397 );
398 ffi::cobre_highs_set_int_option(self.handle, c"ipm_iteration_limit".as_ptr(), i32::MAX);
399 }
400 }
401
402 /// Interprets a non-optimal status as a terminal `SolverError`.
403 ///
404 /// Returns `None` for `SOLVE_ERROR` or `UNKNOWN` (retry continues),
405 /// or `Some(error)` for terminal statuses.
406 pub(super) fn interpret_terminal_status(
407 &mut self,
408 status: i32,
409 solve_time_seconds: f64,
410 ) -> Option<SolverError> {
411 match status {
412 ffi::HIGHS_MODEL_STATUS_OPTIMAL => {
413 // Caller should have handled optimal before reaching here.
414 None
415 }
416 ffi::HIGHS_MODEL_STATUS_INFEASIBLE => Some(SolverError::Infeasible),
417 ffi::HIGHS_MODEL_STATUS_UNBOUNDED_OR_INFEASIBLE => {
418 // Probe for a dual ray to classify as Infeasible, then a primal
419 // ray to classify as Unbounded. The ray values are not stored in
420 // the error -- only the classification matters.
421 //
422 // `num_rows` and `num_cols` are up-to-date because `load_model`
423 // and `add_rows` always update them before any solve that could
424 // reach this branch. The `resize` below matches the exact count
425 // that HiGHS writes into the buffer.
426 let mut has_dual_ray: i32 = 0;
427 self.terminal_status_dual_scratch.resize(self.num_rows, 0.0);
428 // SAFETY: `self.handle` is a valid, non-null HiGHS pointer.
429 // `terminal_status_dual_scratch` has been resized to at least
430 // `self.num_rows` elements; HiGHS writes exactly `num_rows` values.
431 let dual_status = unsafe {
432 ffi::cobre_highs_get_dual_ray(
433 self.handle,
434 &raw mut has_dual_ray,
435 self.terminal_status_dual_scratch.as_mut_ptr(),
436 )
437 };
438 if dual_status != ffi::HIGHS_STATUS_ERROR && has_dual_ray != 0 {
439 return Some(SolverError::Infeasible);
440 }
441 let mut has_primal_ray: i32 = 0;
442 self.terminal_status_primal_scratch
443 .resize(self.num_cols, 0.0);
444 // SAFETY: `self.handle` is a valid, non-null HiGHS pointer.
445 // `terminal_status_primal_scratch` has been resized to at least
446 // `self.num_cols` elements; HiGHS writes exactly `num_cols` values.
447 let primal_status = unsafe {
448 ffi::cobre_highs_get_primal_ray(
449 self.handle,
450 &raw mut has_primal_ray,
451 self.terminal_status_primal_scratch.as_mut_ptr(),
452 )
453 };
454 if primal_status != ffi::HIGHS_STATUS_ERROR && has_primal_ray != 0 {
455 return Some(SolverError::Unbounded);
456 }
457 Some(SolverError::Infeasible)
458 }
459 ffi::HIGHS_MODEL_STATUS_UNBOUNDED => Some(SolverError::Unbounded),
460 ffi::HIGHS_MODEL_STATUS_TIME_LIMIT => Some(SolverError::TimeLimitExceeded {
461 elapsed_seconds: solve_time_seconds,
462 }),
463 ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT => {
464 // SAFETY: handle is valid non-null pointer; iteration count is non-negative.
465 #[allow(clippy::cast_sign_loss)]
466 let iterations =
467 unsafe { ffi::cobre_highs_get_simplex_iteration_count(self.handle) } as u64;
468 Some(SolverError::IterationLimit { iterations })
469 }
470 ffi::HIGHS_MODEL_STATUS_SOLVE_ERROR | ffi::HIGHS_MODEL_STATUS_UNKNOWN => {
471 // Signal to the caller that retry should continue.
472 None
473 }
474 other => Some(SolverError::InternalError {
475 message: format!("HiGHS returned unexpected model status {other}"),
476 error_code: Some(other),
477 }),
478 }
479 }
480
481 /// Converts `usize` indices to `i32` in the internal scratch buffer.
482 ///
483 /// Grows but never shrinks the buffer. Each element is debug-asserted to fit in i32.
484 pub(super) fn convert_to_i32_scratch(&mut self, source: &[usize]) -> &[i32] {
485 if source.len() > self.scratch_i32.len() {
486 self.scratch_i32.resize(source.len(), 0);
487 }
488 for (i, &v) in source.iter().enumerate() {
489 debug_assert!(
490 i32::try_from(v).is_ok(),
491 "usize index {v} overflows i32::MAX at position {i}"
492 );
493 // SAFETY: debug_assert verifies v fits in i32; cast to HiGHS C API i32.
494 #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
495 {
496 self.scratch_i32[i] = v as i32;
497 }
498 }
499 &self.scratch_i32[..source.len()]
500 }
501
502 /// Internal helper: run the simplex and update stats.
503 ///
504 /// Core simplex execution, called after (for warm-start) the basis has been
505 /// installed. `HiGHS` retains its internal simplex basis across consecutive
506 /// `solve_inner` calls on the same LP shape, which is the primary warm-start
507 /// mechanism for the backward pass. No `Highs_clearSolver` call is issued —
508 /// clearing the solver discards the retained basis and forfeits the warm start.
509 pub(super) fn solve_inner(&mut self) -> Result<SolutionView<'_>, SolverError> {
510 // Safeguard: apply iteration limits before the initial attempt.
511 // Time limits are NOT set here — HiGHS tracks time cumulatively from
512 // instance creation, so a per-solve time_limit would fire spuriously
513 // on long-running solver instances. Instead, wall-clock time is checked
514 // after run_once() to detect stuck solves.
515 self.set_iteration_limits();
516
517 let t0 = Instant::now();
518 let model_status = self.run_once();
519 let solve_time = t0.elapsed().as_secs_f64();
520
521 self.stats.solve_count += 1;
522
523 if model_status == ffi::HIGHS_MODEL_STATUS_OPTIMAL {
524 // Read iteration count from FFI BEFORE establishing the shared borrow
525 // via extract_solution_view, so stats can be updated without violating
526 // the aliasing rules.
527 // SAFETY: handle is valid non-null HiGHS pointer.
528 #[allow(clippy::cast_sign_loss)]
529 let iterations =
530 unsafe { ffi::cobre_highs_get_simplex_iteration_count(self.handle) } as u64;
531 self.stats.success_count += 1;
532 self.stats.first_try_successes += 1;
533 self.stats.total_iterations += iterations;
534 self.stats.total_solve_time_seconds += solve_time;
535 self.restore_iteration_limits();
536 return Ok(self.extract_solution_view(solve_time));
537 }
538
539 // Check for a definitive terminal status (not a retry-able error).
540 // UNBOUNDED is retried: HiGHS dual simplex can report spurious UNBOUNDED
541 // on numerically difficult LPs with wide coefficient ranges. The retry
542 // escalation (especially presolve in the core sequence) often resolves these.
543 // ITERATION_LIMIT from the initial attempt is retryable — the retry
544 // sequence uses different strategies that may converge faster.
545 // TIME_LIMIT is retryable — HiGHS tracks time cumulatively from instance
546 // creation; a spurious TIME_LIMIT can fire even with time_limit=Infinity
547 // in edge cases. Retry level 0 (cold restart) recovers from this.
548 // Wall-clock > 15s is also retryable — detects stuck initial solves.
549 let is_unbounded = model_status == ffi::HIGHS_MODEL_STATUS_UNBOUNDED;
550 // INFEASIBLE from the initial attempt is retryable. A warm-started dual
551 // simplex can FALSELY report INFEASIBLE on numerically difficult LPs --
552 // the same warm-start fragility that surfaces as spurious UNBOUNDED or
553 // ITERATION_LIMIT (retried above). Routing INFEASIBLE through the
554 // escalation runs level 0 first, which clears the warm basis
555 // (`cobre_highs_clear_solver`) and re-solves cold. A *genuinely* infeasible
556 // LP is confirmed by that cold solve and the escalation stops immediately
557 // (INFEASIBLE is terminal inside `retry_escalation`), so a true infeasible
558 // still returns `Err(Infeasible)` -- only one extra cold solve. A
559 // warm-start-only false infeasible is rescued (returns optimal). Without
560 // this, a false INFEASIBLE bypassed the escalation entirely and became a
561 // fatal training error. Mirrors the cold-solve escalation on the CLP path.
562 let is_infeasible = model_status == ffi::HIGHS_MODEL_STATUS_INFEASIBLE;
563 let initial_retryable = is_unbounded
564 || is_infeasible
565 || model_status == ffi::HIGHS_MODEL_STATUS_ITERATION_LIMIT
566 || model_status == ffi::HIGHS_MODEL_STATUS_TIME_LIMIT
567 || solve_time > 15.0;
568 if !initial_retryable
569 && let Some(terminal_err) = self.interpret_terminal_status(model_status, solve_time)
570 {
571 self.restore_iteration_limits();
572 self.stats.failure_count += 1;
573 return Err(terminal_err);
574 }
575
576 // Delegate to the retry escalation method (restores limits internally).
577 match self.retry_escalation(is_unbounded) {
578 Ok(outcome) => {
579 self.stats.retry_count += outcome.attempts;
580 self.stats.success_count += 1;
581 self.stats.total_iterations += outcome.iterations;
582 self.stats.total_solve_time_seconds += outcome.solve_time;
583 self.stats.retry_level_histogram[outcome.level as usize] += 1;
584 Ok(self.extract_solution_view(outcome.solve_time))
585 }
586 Err((attempts, err)) => {
587 self.stats.retry_count += attempts;
588 self.stats.failure_count += 1;
589 Err(err)
590 }
591 }
592 }
593}
594
595impl Drop for HighsSolver {
596 fn drop(&mut self) {
597 // SAFETY: valid HiGHS pointer from construction, called once per instance.
598 unsafe { ffi::cobre_highs_destroy(self.handle) };
599 }
600}
601
602/// Returns the `HiGHS` version as a `"major.minor.patch"` string.
603///
604/// This is a free function — no solver instance is required.
605///
606/// # Example
607///
608/// ```rust
609/// # #[cfg(feature = "highs")]
610/// # {
611/// let v = cobre_solver::highs_version();
612/// assert!(v.contains('.'), "version string should be 'major.minor.patch'");
613/// # }
614/// ```
615#[must_use]
616pub fn highs_version() -> String {
617 // SAFETY: These are pure query functions with no arguments. The HiGHS C API
618 // documents them as safe to call without any prior initialisation; they read
619 // only compile-time constants embedded in the library.
620 let major = unsafe { crate::ffi::cobre_highs_version_major() };
621 let minor = unsafe { crate::ffi::cobre_highs_version_minor() };
622 let patch = unsafe { crate::ffi::cobre_highs_version_patch() };
623 format!("{major}.{minor}.{patch}")
624}
625
626/// Test-support accessors for integration tests that need to set raw `HiGHS` options.
627///
628/// Gated behind the `test-support` feature. The raw handle is intentionally not
629/// part of the public API — callers use these methods to configure time/iteration
630/// limits before a solve without going through the safe wrapper.
631#[cfg(feature = "test-support")]
632impl HighsSolver {
633 /// Returns the raw `HiGHS` handle for use with test-support FFI helpers.
634 ///
635 /// # Safety
636 ///
637 /// The returned pointer is valid for the lifetime of `self`. The caller must
638 /// not store the pointer beyond that lifetime, must not call
639 /// `cobre_highs_destroy` on it, and must not alias it across threads.
640 #[must_use]
641 pub fn raw_handle(&self) -> *mut std::os::raw::c_void {
642 self.handle
643 }
644
645 /// Invoke `apply_retry_level_options` for a given level.
646 ///
647 /// Thin test-support wrapper that exposes the private retry-level applier
648 /// so integration tests can verify option composition without driving a
649 /// full solve through the retry ladder.
650 ///
651 /// Only levels 0-4 are routed through this method; levels 5-11 delegate to
652 /// `apply_extended_retry_options_for_test`. Call the appropriate method
653 /// based on the level you want to test.
654 pub fn apply_retry_level_options_for_test(&mut self, level: u32) {
655 self.apply_retry_level_options(level);
656 }
657
658 /// Invoke `apply_extended_retry_options` for a given level (5-11).
659 ///
660 /// Thin test-support wrapper that exposes the private extended retry-level
661 /// applier so integration tests can verify option composition without
662 /// driving a full solve.
663 pub fn apply_extended_retry_options_for_test(&mut self, level: u32) {
664 self.apply_extended_retry_options(level);
665 }
666
667 /// Invoke `restore_default_settings` then `apply_profile_tolerances` in sequence.
668 ///
669 /// Mirrors the finalization path in `retry_escalation` so integration tests
670 /// can verify that profile tolerances survive a defaults-restore without
671 /// driving a full retry through a failing LP.
672 pub fn restore_defaults_then_apply_profile_for_test(&mut self) {
673 self.restore_default_settings();
674 self.apply_profile_tolerances();
675 }
676
677 /// Read a double-valued `HiGHS` option by name.
678 ///
679 /// Returns `None` if the option name is unknown to `HiGHS`; `Some(value)`
680 /// on success.
681 #[must_use]
682 pub fn get_double_option(&self, option: &std::ffi::CStr) -> Option<f64> {
683 let mut out = 0.0_f64;
684 // SAFETY: handle is valid non-null HiGHS pointer; option is a valid
685 // null-terminated C string borrowed for the duration of the call;
686 // `out` is stack-allocated and written by HiGHS on success.
687 let status = unsafe {
688 ffi::cobre_highs_get_double_option(self.handle, option.as_ptr(), &raw mut out)
689 };
690 if status == ffi::HIGHS_STATUS_ERROR {
691 None
692 } else {
693 Some(out)
694 }
695 }
696
697 /// Read an integer-valued `HiGHS` option by name.
698 ///
699 /// Returns `None` if the option name is unknown to `HiGHS`; `Some(value)`
700 /// on success.
701 #[must_use]
702 pub fn get_int_option(&self, option: &std::ffi::CStr) -> Option<i32> {
703 let mut out = 0_i32;
704 // SAFETY: handle is valid non-null HiGHS pointer; option is a valid
705 // null-terminated C string borrowed for the duration of the call;
706 // `out` is stack-allocated and written by HiGHS on success.
707 let status =
708 unsafe { ffi::cobre_highs_get_int_option(self.handle, option.as_ptr(), &raw mut out) };
709 if status == ffi::HIGHS_STATUS_ERROR {
710 None
711 } else {
712 Some(out)
713 }
714 }
715}