Skip to main content

cobre_solver/backends/highs/
interface.rs

1//! `impl SolverInterface for HighsSolver`.
2//!
3//! Additional `impl` block (the struct and its solve primitives are owned by
4//! `solver`): the public [`SolverInterface`](crate::SolverInterface) surface —
5//! profile application, model loading, row/bound mutation, the warm-start
6//! `solve` entry point (which delegates to `solver`'s `solve_inner`), basis
7//! extraction, and statistics reporting.
8
9use std::time::Instant;
10
11use super::config::HighsProfile;
12use super::solver::{HighsSolver, highs_version};
13use crate::{
14    SolverInterface, ffi,
15    types::{RowBatch, SolutionView, SolverError, SolverStatistics, StageTemplate},
16};
17
18impl SolverInterface for HighsSolver {
19    type Profile = HighsProfile;
20
21    fn apply_profile(&mut self, profile: &HighsProfile) {
22        // SAFETY: `self.handle` is a valid, non-null HiGHS pointer obtained
23        // from `cobre_highs_create()`. The option name is a static C string
24        // literal with no retained pointer after the call returns.
25        unsafe {
26            ffi::cobre_highs_set_double_option(
27                self.handle,
28                c"primal_feasibility_tolerance".as_ptr(),
29                profile.primal_feasibility_tolerance,
30            );
31        }
32        // SAFETY: `self.handle` is a valid, non-null HiGHS pointer obtained
33        // from `cobre_highs_create()`. The option name is a static C string
34        // literal with no retained pointer after the call returns.
35        unsafe {
36            ffi::cobre_highs_set_double_option(
37                self.handle,
38                c"dual_feasibility_tolerance".as_ptr(),
39                profile.dual_feasibility_tolerance,
40            );
41        }
42        // The iteration-limit fields are cache-only (no FFI here); the actual
43        // caps are computed later by `set_iteration_limits`, which reads
44        // `self.current_profile`. The `self.current_profile = *profile` below
45        // covers all field caching.
46        // SAFETY: self.handle is a valid HiGHS pointer; ffi setters accept any i32.
47        unsafe {
48            ffi::cobre_highs_set_int_option(
49                self.handle,
50                c"simplex_dual_edge_weight_strategy".as_ptr(),
51                profile.simplex_dual_edge_weight_strategy,
52            );
53            ffi::cobre_highs_set_int_option(
54                self.handle,
55                c"simplex_scale_strategy".as_ptr(),
56                profile.simplex_scale_strategy,
57            );
58            ffi::cobre_highs_set_int_option(
59                self.handle,
60                c"simplex_price_strategy".as_ptr(),
61                profile.simplex_price_strategy,
62            );
63        }
64        self.current_profile = *profile;
65    }
66
67    fn name(&self) -> &'static str {
68        "HiGHS"
69    }
70
71    fn solver_name_version(&self) -> String {
72        format!("HiGHS {}", highs_version())
73    }
74
75    fn load_model(&mut self, template: &StageTemplate) {
76        let t0 = Instant::now();
77        // SAFETY:
78        // - `self.handle` is a valid, non-null HiGHS pointer from `cobre_highs_create()`.
79        // - All pointer arguments point into owned `Vec` data that remains alive for the
80        //   duration of this call.
81        // - `template.col_starts` and `template.row_indices` are `Vec<i32>` owned by the
82        //   template, alive for the duration of this borrow.
83        // - All slice lengths match the HiGHS API contract:
84        //   `num_col + 1` for a_start, `num_nz` for a_index and a_value,
85        //   `num_col` for col_cost/col_lower/col_upper, `num_row` for row_lower/row_upper.
86        assert!(
87            i32::try_from(template.num_cols).is_ok(),
88            "num_cols {} overflows i32: LP exceeds HiGHS API limit",
89            template.num_cols
90        );
91        assert!(
92            i32::try_from(template.num_rows).is_ok(),
93            "num_rows {} overflows i32: LP exceeds HiGHS API limit",
94            template.num_rows
95        );
96        assert!(
97            i32::try_from(template.num_nz).is_ok(),
98            "num_nz {} overflows i32: LP exceeds HiGHS API limit",
99            template.num_nz
100        );
101        // Length guards: every slice handed to the HiGHS API must match the dimension
102        // it is keyed by. These are internally-constructed buffers, so a mismatch is a
103        // construction bug, not user input -- guard with debug_assert* (no release panic
104        // boundary). CSC column starts carry one extra trailing offset (`num_cols + 1`).
105        debug_assert_eq!(
106            template.col_starts.len(),
107            template.num_cols + 1,
108            "col_starts len {} != num_cols + 1 ({})",
109            template.col_starts.len(),
110            template.num_cols + 1
111        );
112        debug_assert_eq!(
113            template.row_indices.len(),
114            template.num_nz,
115            "row_indices len {} != num_nz {}",
116            template.row_indices.len(),
117            template.num_nz
118        );
119        debug_assert_eq!(
120            template.values.len(),
121            template.num_nz,
122            "values len {} != num_nz {}",
123            template.values.len(),
124            template.num_nz
125        );
126        debug_assert_eq!(
127            template.col_lower.len(),
128            template.num_cols,
129            "col_lower len {} != num_cols {}",
130            template.col_lower.len(),
131            template.num_cols
132        );
133        debug_assert_eq!(
134            template.col_upper.len(),
135            template.num_cols,
136            "col_upper len {} != num_cols {}",
137            template.col_upper.len(),
138            template.num_cols
139        );
140        debug_assert_eq!(
141            template.objective.len(),
142            template.num_cols,
143            "objective len {} != num_cols {}",
144            template.objective.len(),
145            template.num_cols
146        );
147        debug_assert_eq!(
148            template.row_lower.len(),
149            template.num_rows,
150            "row_lower len {} != num_rows {}",
151            template.row_lower.len(),
152            template.num_rows
153        );
154        debug_assert_eq!(
155            template.row_upper.len(),
156            template.num_rows,
157            "row_upper len {} != num_rows {}",
158            template.row_upper.len(),
159            template.num_rows
160        );
161        // Scale vectors are optional: empty means "no scaling", otherwise they must be
162        // keyed by the matching dimension.
163        debug_assert!(
164            template.col_scale.is_empty() || template.col_scale.len() == template.num_cols,
165            "col_scale len {} != num_cols {} (and is non-empty)",
166            template.col_scale.len(),
167            template.num_cols
168        );
169        debug_assert!(
170            template.row_scale.is_empty() || template.row_scale.len() == template.num_rows,
171            "row_scale len {} != num_rows {} (and is non-empty)",
172            template.row_scale.len(),
173            template.num_rows
174        );
175        // SAFETY: All three values have been asserted to fit in i32 above.
176        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
177        let num_col = template.num_cols as i32;
178        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
179        let num_row = template.num_rows as i32;
180        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
181        let num_nz = template.num_nz as i32;
182        let status = unsafe {
183            ffi::cobre_highs_pass_lp(
184                self.handle,
185                num_col,
186                num_row,
187                num_nz,
188                ffi::HIGHS_MATRIX_FORMAT_COLWISE,
189                ffi::HIGHS_OBJ_SENSE_MINIMIZE,
190                0.0, // objective offset
191                template.objective.as_ptr(),
192                template.col_lower.as_ptr(),
193                template.col_upper.as_ptr(),
194                template.row_lower.as_ptr(),
195                template.row_upper.as_ptr(),
196                template.col_starts.as_ptr(),
197                template.row_indices.as_ptr(),
198                template.values.as_ptr(),
199            )
200        };
201
202        assert_ne!(
203            status,
204            ffi::HIGHS_STATUS_ERROR,
205            "cobre_highs_pass_lp failed with status {status}"
206        );
207
208        self.num_cols = template.num_cols;
209        self.num_rows = template.num_rows;
210        self.has_model = true;
211
212        // Resize solution extraction buffers to match the new LP dimensions.
213        // Zero-fill is fine; these are overwritten in full by `cobre_highs_get_solution`.
214        self.col_value.resize(self.num_cols, 0.0);
215        self.col_dual.resize(self.num_cols, 0.0);
216        self.row_value.resize(self.num_rows, 0.0);
217        self.row_dual.resize(self.num_rows, 0.0);
218
219        // Resize basis status i32 buffers. Zero-fill is fine; values are overwritten before
220        // any FFI call. These never shrink -- only grow -- to prevent reallocation on hot path.
221        self.basis_col_i32.resize(self.num_cols, 0);
222        self.basis_row_i32.resize(self.num_rows, 0);
223        self.stats.total_load_model_time_seconds += t0.elapsed().as_secs_f64();
224        self.stats.load_model_count += 1;
225    }
226
227    fn add_rows(&mut self, rows: &RowBatch) {
228        assert!(
229            i32::try_from(rows.num_rows).is_ok(),
230            "rows.num_rows {} overflows i32: RowBatch exceeds HiGHS API limit",
231            rows.num_rows
232        );
233        assert!(
234            i32::try_from(rows.col_indices.len()).is_ok(),
235            "rows nnz {} overflows i32: RowBatch exceeds HiGHS API limit",
236            rows.col_indices.len()
237        );
238        // SAFETY: Both values have been asserted to fit in i32 above.
239        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
240        let num_new_row = rows.num_rows as i32;
241        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
242        let num_new_nz = rows.col_indices.len() as i32;
243
244        // SAFETY:
245        // - `self.handle` is a valid, non-null HiGHS pointer.
246        // - All pointer arguments point into owned data alive for the duration of this call.
247        // - `rows.row_starts` and `rows.col_indices` are `Vec<i32>` owned by the RowBatch,
248        //   alive for the duration of this borrow.
249        // - Slice lengths: `num_rows + 1` for starts, total nnz for index and value,
250        //   `num_rows` for lower/upper bounds.
251        let status = unsafe {
252            ffi::cobre_highs_add_rows(
253                self.handle,
254                num_new_row,
255                rows.row_lower.as_ptr(),
256                rows.row_upper.as_ptr(),
257                num_new_nz,
258                rows.row_starts.as_ptr(),
259                rows.col_indices.as_ptr(),
260                rows.values.as_ptr(),
261            )
262        };
263
264        assert_ne!(
265            status,
266            ffi::HIGHS_STATUS_ERROR,
267            "cobre_highs_add_rows failed with status {status}"
268        );
269
270        self.num_rows += rows.num_rows;
271
272        // Grow row-indexed solution extraction buffers to cover the new rows.
273        self.row_value.resize(self.num_rows, 0.0);
274        self.row_dual.resize(self.num_rows, 0.0);
275
276        // Grow basis row i32 buffer to cover the new rows.
277        self.basis_row_i32.resize(self.num_rows, 0);
278    }
279
280    fn set_row_bounds(&mut self, indices: &[usize], lower: &[f64], upper: &[f64]) {
281        assert!(
282            indices.len() == lower.len() && indices.len() == upper.len(),
283            "set_row_bounds: indices ({}), lower ({}), and upper ({}) must have equal length",
284            indices.len(),
285            lower.len(),
286            upper.len()
287        );
288        if indices.is_empty() {
289            return;
290        }
291
292        assert!(
293            i32::try_from(indices.len()).is_ok(),
294            "set_row_bounds: indices.len() {} overflows i32",
295            indices.len()
296        );
297        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
298        let num_entries = indices.len() as i32;
299
300        let t0 = Instant::now();
301        // SAFETY:
302        // - `self.handle` is a valid, non-null HiGHS pointer.
303        // - `convert_to_i32_scratch()` returns a slice pointing into `self.scratch_i32`,
304        //   alive for `'self`. Pointer is used immediately in the FFI call.
305        // - `lower` and `upper` are borrowed slices alive for the duration of this call.
306        // - `num_entries` equals the lengths of all three arrays.
307        let status = unsafe {
308            ffi::cobre_highs_change_rows_bounds_by_set(
309                self.handle,
310                num_entries,
311                self.convert_to_i32_scratch(indices).as_ptr(),
312                lower.as_ptr(),
313                upper.as_ptr(),
314            )
315        };
316
317        assert_ne!(
318            status,
319            ffi::HIGHS_STATUS_ERROR,
320            "cobre_highs_change_rows_bounds_by_set failed with status {status}"
321        );
322        self.stats.total_set_bounds_time_seconds += t0.elapsed().as_secs_f64();
323    }
324
325    fn set_col_bounds(&mut self, indices: &[usize], lower: &[f64], upper: &[f64]) {
326        assert!(
327            indices.len() == lower.len() && indices.len() == upper.len(),
328            "set_col_bounds: indices ({}), lower ({}), and upper ({}) must have equal length",
329            indices.len(),
330            lower.len(),
331            upper.len()
332        );
333        if indices.is_empty() {
334            return;
335        }
336
337        assert!(
338            i32::try_from(indices.len()).is_ok(),
339            "set_col_bounds: indices.len() {} overflows i32",
340            indices.len()
341        );
342        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
343        let num_entries = indices.len() as i32;
344
345        let t0 = Instant::now();
346        // SAFETY:
347        // - `self.handle` is a valid, non-null HiGHS pointer.
348        // - Converted indices point into `self.scratch_i32`, alive for `'self`.
349        // - `lower` and `upper` are borrowed slices alive for the duration of this call.
350        // - `num_entries` equals the lengths of all three arrays.
351        let status = unsafe {
352            ffi::cobre_highs_change_cols_bounds_by_set(
353                self.handle,
354                num_entries,
355                self.convert_to_i32_scratch(indices).as_ptr(),
356                lower.as_ptr(),
357                upper.as_ptr(),
358            )
359        };
360
361        assert_ne!(
362            status,
363            ffi::HIGHS_STATUS_ERROR,
364            "cobre_highs_change_cols_bounds_by_set failed with status {status}"
365        );
366        self.stats.total_set_bounds_time_seconds += t0.elapsed().as_secs_f64();
367    }
368
369    /// # Preconditions
370    ///
371    /// When `basis` is `Some(b)`, the caller should size `b.row_status` to at
372    /// least `self.num_rows` (the current LP row count). A basis with **fewer**
373    /// row entries than the LP (e.g. one captured before `add_rows` grew the LP)
374    /// cannot be padded soundly — a BASIC pad is wrong for inequality-row slacks
375    /// — so it is rejected with `Err(SolverError::BasisRowCountMismatch)` and
376    /// `basis_consistency_failures` is incremented; the caller should fall back
377    /// to a cold solve. A basis with **more** row entries is tolerated: the
378    /// trailing entries beyond `self.num_rows` are ignored. The column count
379    /// must match exactly (hard `assert!`).
380    ///
381    /// # Errors
382    ///
383    /// Returns `Err(SolverError::BasisRowCountMismatch { lp_rows, basis_rows })`
384    /// when the offered basis has fewer row entries than the LP has rows, and
385    /// `Err(SolverError::BasisInconsistent { .. })` when `HiGHS` rejects the
386    /// offered basis via `isBasisConsistent`.
387    fn solve(
388        &mut self,
389        basis: Option<&crate::types::Basis>,
390    ) -> Result<SolutionView<'_>, SolverError> {
391        assert!(
392            self.has_model,
393            "solve called without a loaded model — call load_model first"
394        );
395
396        if let Some(basis) = basis {
397            assert!(
398                basis.col_status.len() == self.num_cols,
399                "basis column count {} does not match LP column count {}",
400                basis.col_status.len(),
401                self.num_cols
402            );
403            // An undersized row basis (fewer entries than the LP has rows, e.g.
404            // captured before `add_rows` grew the LP) cannot be padded soundly:
405            // a BASIC pad is wrong for newly added inequality rows, whose slacks
406            // should be non-basic at the appropriate bound. Reject it as a
407            // recoverable warm-start failure so the caller can fall back to a
408            // cold solve. This runs *before* `basis_offered` is incremented —
409            // a rejected basis was never offered to the solver.
410            if basis.row_status.len() < self.num_rows {
411                self.stats.basis_consistency_failures += 1;
412                return Err(SolverError::BasisRowCountMismatch {
413                    lp_rows: self.num_rows,
414                    basis_rows: basis.row_status.len(),
415                });
416            }
417
418            // Track every warm-start call as a basis offer for diagnostics.
419            self.stats.basis_offered += 1;
420
421            // Copy raw i32 codes directly into the pre-allocated buffers — no enum
422            // translation. Zero-copy warm-start path.
423            self.basis_col_i32[..self.num_cols].copy_from_slice(&basis.col_status);
424
425            // The undersized case (`basis_rows < lp_rows`) is rejected above, so
426            // here `basis_rows >= lp_rows` always holds:
427            // - `basis_rows == lp_rows`: an exact copy.
428            // - `basis_rows > lp_rows`: truncate the trailing entries. The solver
429            //   ignores any basis entry beyond `num_rows`.
430            let basis_rows = basis.row_status.len();
431            let lp_rows = self.num_rows;
432            let copy_len = basis_rows.min(lp_rows);
433            self.basis_row_i32[..copy_len].copy_from_slice(&basis.row_status[..copy_len]);
434
435            // SAFETY:
436            // - `self.handle` is a valid, non-null HiGHS pointer obtained from
437            //   `cobre_highs_create()` and kept alive by `HighsSolver`.
438            // - `basis_col_i32` was sized to `num_cols` in `load_model` and grown in
439            //   `add_rows`; the slice written above covers exactly `num_cols` entries.
440            // - `basis_row_i32` was sized to `num_rows` in `load_model` and grown in
441            //   `add_rows`; the slice written above covers exactly `num_rows` entries
442            //   (an undersized basis is rejected before reaching this point).
443            let basis_set_start = Instant::now();
444            let set_status = unsafe {
445                ffi::cobre_highs_set_basis_non_alien(
446                    self.handle,
447                    self.basis_col_i32.as_ptr(),
448                    self.basis_row_i32.as_ptr(),
449                )
450            };
451            if set_status == ffi::HIGHS_STATUS_ERROR {
452                // Non-alien rejected: the offered basis failed
453                // `isBasisConsistent` (total_basic != num_row).
454                // Count the rejection and surface it as a hard error.
455                self.stats.basis_consistency_failures += 1;
456                // Count basic entries from the already-populated buffers.
457                //
458                // `usize` -> `i64` is lossless for any basis that fits in memory:
459                // realistic LP sizes are bounded well below 2^63.
460                #[allow(clippy::cast_possible_wrap)]
461                let col_basic = self.basis_col_i32[..self.num_cols]
462                    .iter()
463                    .filter(|&&s| s == ffi::HIGHS_BASIS_STATUS_BASIC)
464                    .count() as i64;
465                #[allow(clippy::cast_possible_wrap)]
466                let row_basic = self.basis_row_i32[..self.num_rows]
467                    .iter()
468                    .filter(|&&s| s == ffi::HIGHS_BASIS_STATUS_BASIC)
469                    .count() as i64;
470                // Accumulate the elapsed time even on early return.
471                self.stats.total_basis_set_time_seconds += basis_set_start.elapsed().as_secs_f64();
472                #[allow(clippy::cast_possible_wrap)]
473                return Err(SolverError::BasisInconsistent {
474                    num_row: self.num_rows as i64,
475                    total_basic: col_basic + row_basic,
476                    col_basic,
477                    row_basic,
478                });
479            }
480            self.stats.total_basis_set_time_seconds += basis_set_start.elapsed().as_secs_f64();
481        }
482
483        // Basis is installed (warm path) or not needed (cold path); run the simplex.
484        // HiGHS retains its internal basis across consecutive solves on the same
485        // LP shape, giving the backward pass ~15x fewer simplex iterations on
486        // repeat solves at the same stage/opening.
487        self.solve_inner()
488    }
489
490    fn get_basis(&mut self, out: &mut crate::types::Basis) {
491        assert!(
492            self.has_model,
493            "get_basis called without a loaded model — call load_model first"
494        );
495
496        out.col_status.resize(self.num_cols, 0);
497        out.row_status.resize(self.num_rows, 0);
498
499        // SAFETY:
500        // - `self.handle` is a valid, non-null HiGHS pointer.
501        // - `out.col_status` has been resized to `num_cols` entries above.
502        // - `out.row_status` has been resized to `num_rows` entries above.
503        // - HiGHS writes exactly `num_cols` col values and `num_rows` row values.
504        let get_status = unsafe {
505            ffi::cobre_highs_get_basis(
506                self.handle,
507                out.col_status.as_mut_ptr(),
508                out.row_status.as_mut_ptr(),
509            )
510        };
511
512        assert_ne!(
513            get_status,
514            ffi::HIGHS_STATUS_ERROR,
515            "cobre_highs_get_basis failed: basis must exist after a successful solve (programming error)"
516        );
517    }
518
519    fn statistics(&self) -> SolverStatistics {
520        self.stats.clone()
521    }
522
523    fn statistics_into(&self, out: &mut SolverStatistics) {
524        out.copy_from(&self.stats);
525    }
526
527    fn record_reconstruction_stats(&mut self) {
528        self.stats.basis_reconstructions += 1;
529    }
530}