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}