Skip to main content

oximo_highs/
persistent.rs

1use std::time::Instant;
2
3use highs::Model as HighsModel;
4use oximo_core::{Model, ModelKind};
5use oximo_solver::{Snapshot, Solver, SolverError, SolverResult, snapshot};
6
7use crate::options::apply as apply_options;
8use crate::translate::{Meta, build_problem, extract_result, make_live};
9use crate::{HighsOptions, NAME};
10
11/// The resident HiGHS instance plus the [`Meta`] needed to read results and to drive
12/// incremental re-solves. `live` is taken on solve and put back (as a fresh
13/// [`HighsModel`] over the same instance) so the basis carries to the next solve.
14struct State {
15    live: Option<HighsModel>,
16    meta: Meta,
17    snap: Option<Snapshot>,
18}
19
20/// A stateful HiGHS solver handle that keeps the built model resident across solves.
21///
22/// Created by [`Highs::persistent`](crate::Highs).
23/// When only objective coefficients or variable bounds changed since the last call
24/// it pushes those deltas and HiGHS warm-starts from the retained basis.
25/// Any structural change (new rows/columns, changed matrix coefficients or row bounds,
26/// flipped integrality or sense, or a quadratic objective) triggers a transparent
27/// full rebuild, so every result matches a cold [`Highs::solve`](crate::Highs).
28///
29/// Options passed to each `solve` are applied to the resident instance and persist
30/// across calls. Changing a field takes effect on the next solve, but a field you
31/// stop setting keeps its previous value rather than reverting to the HiGHS default.
32/// Call [`reset`](Self::reset) (or build a fresh handle) when you want the next
33/// solve to behave exactly like a cold [`Highs::solve`](crate::Highs),
34/// options and all.
35///
36/// A failed `solve` (HiGHS returning an error) leaves the handle without a resident
37/// model, the next call rebuilds from scratch.
38#[derive(Default)]
39pub struct HighsPersistent {
40    state: Option<State>,
41}
42
43impl std::fmt::Debug for HighsPersistent {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        f.debug_struct("HighsPersistent").field("resident", &self.state.is_some()).finish()
46    }
47}
48
49impl HighsPersistent {
50    /// A fresh handle with no model loaded. The first solve builds it.
51    #[must_use]
52    pub fn new() -> Self {
53        Self::default()
54    }
55
56    /// Drop the resident model so the next [`solve`](Solver::solve) rebuilds from
57    /// scratch, clearing the warm-start basis and any solver options carried on the
58    /// HiGHS instance. After this, the next solve behaves exactly like a cold
59    /// [`Highs::solve`](crate::Highs), regardless of earlier calls.
60    pub fn reset(&mut self) {
61        self.state = None;
62    }
63
64    /// Discard any resident instance and rebuild from the current model state.
65    fn rebuild(&mut self, model: &Model, opts: &HighsOptions) -> Result<(), SolverError> {
66        let (prob, meta) = build_problem(model)?;
67        let live = make_live(prob, opts)?;
68        let has_semi = model.variables().iter().any(|v| v.domain.semi_threshold().is_some());
69        let snap = if matches!(model.kind(), ModelKind::LP | ModelKind::MILP) && !has_semi {
70            Some(snapshot(model)?)
71        } else {
72            None
73        };
74        self.state = Some(State { live: Some(live), meta, snap });
75        Ok(())
76    }
77
78    /// Re-read the model, update the resident instance in place (or rebuild), and
79    /// solve.
80    fn solve_resident(
81        &mut self,
82        model: &Model,
83        opts: &HighsOptions,
84    ) -> Result<SolverResult, SolverError> {
85        // The fast path is sound only for linear models (the snapshot extracts a
86        // linear objective) once a resident model exists. A quadratic objective or
87        // any structural change falls back to a full rebuild.
88        let mut updated = false;
89        if matches!(model.kind(), ModelKind::LP | ModelKind::MILP)
90            && let Some(st) = self.state.as_mut()
91            && let Some(base) = st.snap.as_ref()
92        {
93            let snap = snapshot(model)?;
94            if snap.fingerprint == base.fingerprint {
95                let live = st.live.as_mut().expect("live model present on fast path");
96                for i in 0..st.meta.cols.len() {
97                    if snap.obj_costs[i].to_bits() != base.obj_costs[i].to_bits() {
98                        live.change_column_cost(st.meta.cols[i], snap.obj_costs[i]);
99                    }
100                    if snap.lb[i].to_bits() != base.lb[i].to_bits()
101                        || snap.ub[i].to_bits() != base.ub[i].to_bits()
102                    {
103                        live.change_column_bounds(st.meta.cols[i], snap.lb[i]..=snap.ub[i]);
104                    }
105                }
106                apply_options(live, opts)?;
107                st.meta.obj_constant = snap.obj_constant;
108                st.snap = Some(snap);
109                updated = true;
110            }
111        }
112        if !updated {
113            self.rebuild(model, opts)?;
114        }
115
116        // Solve the resident model, then move it back so the basis is retained for
117        // the next solve.
118        let st = self.state.as_mut().expect("state present before solve");
119        let live = st.live.take().expect("live model present before solve");
120        let started = Instant::now();
121        let solved = live
122            .try_solve()
123            .map_err(|e| SolverError::Backend(format!("HiGHS solve failed: {e:?}")))?;
124        let elapsed = started.elapsed();
125        let result = extract_result(
126            &solved,
127            st.meta.mixed_integer,
128            st.meta.obj_constant,
129            st.meta.num_constraints,
130            st.meta.cols.len(),
131            model.id(),
132            elapsed,
133        );
134        st.live = Some(HighsModel::from(solved));
135        Ok(result)
136    }
137}
138
139impl Solver for HighsPersistent {
140    type Options = HighsOptions;
141
142    fn name(&self) -> &str {
143        NAME
144    }
145
146    fn supports(&self, kind: ModelKind) -> bool {
147        crate::supported(kind)
148    }
149
150    fn solve(&mut self, model: &Model, opts: &HighsOptions) -> Result<SolverResult, SolverError> {
151        if model.has_active_sos_constraints() {
152            return Err(SolverError::UnsupportedSos);
153        }
154        if model.has_active_indicator_constraints() {
155            return Err(SolverError::UnsupportedIndicator);
156        }
157        match self.solve_resident(model, opts) {
158            Ok(result) => Ok(result),
159            Err(e) => {
160                self.state = None;
161                Err(e)
162            }
163        }
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use oximo_core::prelude::*;
170    use oximo_solver::{PersistentSolver, Solver, TerminationStatus};
171
172    use crate::{Highs, HighsOptions};
173
174    #[test]
175    fn persistent_matches_cold_solve_on_objective_sweep() {
176        let m = Model::new("pricing");
177        param!(m, p1 = 0.0);
178        variable!(m, x1 >= 0.0);
179        variable!(m, x2 >= 0.0);
180        constraint!(m, labor, 2.0 * x1 + x2 <= 100.0);
181        constraint!(m, material, x1 + 3.0 * x2 <= 90.0);
182        objective!(m, Max, p1 * x1 + 5.0 * x2);
183
184        let mut solver = Highs.persistent();
185        for price in [1.0, 1.6, 2.0, 5.0, 11.0] {
186            p1.set_param_value(price);
187            let s = solver.solve(&m, &HighsOptions::default()).unwrap();
188            let c = Highs.solve(&m, &HighsOptions::default()).unwrap();
189            assert_eq!(s.termination, TerminationStatus::Optimal, "price {price}");
190            assert!(
191                (s.objective().unwrap() - c.objective().unwrap()).abs() < 1e-9,
192                "price {price}"
193            );
194            assert!(
195                (s.value_of(x1).unwrap().unwrap() - c.value_of(x1).unwrap().unwrap()).abs() < 1e-9
196            );
197            assert!(
198                (s.value_of(x2).unwrap().unwrap() - c.value_of(x2).unwrap().unwrap()).abs() < 1e-9
199            );
200        }
201    }
202
203    /// A parameter in a constraint right-hand side changes the row bound, which the
204    /// fast path cannot push: the handle must fall back to a rebuild and still match
205    /// the cold solve.
206    #[test]
207    fn persistent_rebuilds_on_constraint_rhs_change() {
208        let m = Model::new("capacity");
209        param!(m, cap = 100.0);
210        variable!(m, x1 >= 0.0);
211        variable!(m, x2 >= 0.0);
212        constraint!(m, labor, 2.0 * x1 + x2 <= cap);
213        constraint!(m, material, x1 + 3.0 * x2 <= 90.0);
214        objective!(m, Max, 3.0 * x1 + 5.0 * x2);
215
216        let mut solver = Highs.persistent();
217        for c in [100.0, 60.0, 140.0] {
218            cap.set_param_value(c);
219            let s = solver.solve(&m, &HighsOptions::default()).unwrap();
220            let cold = Highs.solve(&m, &HighsOptions::default()).unwrap();
221            assert_eq!(s.termination, TerminationStatus::Optimal, "cap {c}");
222            assert!((s.objective().unwrap() - cold.objective().unwrap()).abs() < 1e-9, "cap {c}");
223        }
224    }
225
226    /// A feasibility model (no objective) solves through the persistent handle as
227    /// `minimize 0`, both cold and on the warm fast path.
228    #[test]
229    fn persistent_feasibility_no_objective() {
230        let m = Model::new("feas");
231        variable!(m, 0.0 <= x <= 10.0);
232        variable!(m, 0.0 <= y <= 10.0);
233        constraint!(m, c, x + y == 5.0);
234        objective!(m, Feasibility);
235
236        let mut solver = Highs.persistent();
237        let r = solver.solve(&m, &HighsOptions::default()).unwrap();
238        assert!(r.has_solution(), "termination = {:?}", r.termination);
239        m.fix(x, 2.0).unwrap();
240        let r2 = solver.solve(&m, &HighsOptions::default()).unwrap();
241        assert!(r2.has_solution());
242        assert!((r2.value_of(x).unwrap().unwrap() - 2.0).abs() < 1e-9);
243        assert!((r2.value_of(y).unwrap().unwrap() - 3.0).abs() < 1e-9);
244    }
245
246    #[test]
247    fn persistent_reset_then_solve_ok() {
248        let m = Model::new("pricing");
249        param!(m, p1 = 0.0);
250        variable!(m, x1 >= 0.0);
251        variable!(m, x2 >= 0.0);
252        constraint!(m, labor, 2.0 * x1 + x2 <= 100.0);
253        objective!(m, Max, p1 * x1 + 5.0 * x2);
254
255        let mut solver = Highs.persistent();
256        p1.set_param_value(2.0);
257        let first = solver.solve(&m, &HighsOptions::default()).unwrap();
258        assert_eq!(first.termination, TerminationStatus::Optimal);
259
260        solver.reset();
261        let after = solver.solve(&m, &HighsOptions::default()).unwrap();
262        assert_eq!(after.termination, TerminationStatus::Optimal);
263        assert!((first.objective().unwrap() - after.objective().unwrap()).abs() < 1e-9);
264    }
265
266    #[test]
267    fn persistent_semi_matches_cold_across_bound_change() {
268        // s in {0} U [5, ub], forced on by s >= 3, min s -> s = 5 while ub >= 5.
269        let m = Model::new("sc_persist");
270        variable!(m, s <= 10.0, SemiCont(5.0));
271        constraint!(m, c, s >= 3.0);
272        objective!(m, Min, s);
273        let sid = s.var_id().unwrap();
274
275        let mut solver = Highs.persistent();
276        for ub in [10.0, 8.0, 12.0] {
277            m.unfix_var(sid, 0.0, ub);
278            let warm = solver.solve(&m, &HighsOptions::default()).unwrap();
279            let cold = Highs.solve(&m, &HighsOptions::default()).unwrap();
280            assert_eq!(warm.termination, TerminationStatus::Optimal, "ub {ub}");
281            assert!(
282                (warm.value_of(s).unwrap().unwrap() - 5.0).abs() < 1e-6,
283                "ub {ub}: s = {:?}",
284                warm.value_of(s)
285            );
286            assert!(
287                (warm.value_of(s).unwrap().unwrap() - cold.value_of(s).unwrap().unwrap()).abs()
288                    < 1e-9,
289                "ub {ub}"
290            );
291            assert!(
292                (warm.objective().unwrap() - cold.objective().unwrap()).abs() < 1e-9,
293                "ub {ub}"
294            );
295        }
296    }
297
298    #[test]
299    fn persistent_warm_start_not_worse_than_cold() {
300        let m = Model::new("mix");
301        param!(m, bump = 0.0);
302        variable!(m, p1 >= 0.0);
303        variable!(m, p2 >= 0.0);
304        variable!(m, p3 >= 0.0);
305        variable!(m, p4 >= 0.0);
306        variable!(m, p5 >= 0.0);
307        constraint!(m, r1, 2.0 * p1 + p2 + 3.0 * p3 + p4 + p5 <= 100.0);
308        constraint!(m, r2, p1 + 4.0 * p2 + p3 + 2.0 * p4 + p5 <= 120.0);
309        constraint!(m, r3, 3.0 * p1 + p2 + 2.0 * p3 + p4 + 4.0 * p5 <= 150.0);
310        constraint!(m, r4, p1 + p2 + p3 + p4 + p5 <= 80.0);
311        objective!(m, Max, 10.0 * p1 + 8.0 * p2 + 7.0 * p3 + 6.0 * p4 + 9.0 * p5 + bump * p1);
312
313        let mut solver = Highs.persistent();
314        let mut persistent_iters = 0u64;
315        let mut cold_iters = 0u64;
316        for bump_val in [0.0, 1.0, 2.0, 3.0, 4.0] {
317            bump.set_param_value(bump_val);
318            let warm = solver.solve(&m, &HighsOptions::default()).unwrap();
319            let cold = Highs.solve(&m, &HighsOptions::default()).unwrap();
320            assert_eq!(warm.termination, TerminationStatus::Optimal, "bump {bump_val}");
321            assert!(
322                (warm.objective().unwrap() - cold.objective().unwrap()).abs() < 1e-9,
323                "bump {bump_val}"
324            );
325            persistent_iters += warm.iterations;
326            cold_iters += cold.iterations;
327        }
328        assert!(
329            persistent_iters <= cold_iters,
330            "warm-started handle used more iterations ({persistent_iters}) than cold ({cold_iters})"
331        );
332    }
333}