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            if let Some(st) = self.state.as_mut() {
91                if let Some(base) = st.snap.as_ref() {
92                    let snap = snapshot(model)?;
93                    if snap.fingerprint == base.fingerprint {
94                        let live = st.live.as_mut().expect("live model present on fast path");
95                        for i in 0..st.meta.cols.len() {
96                            if snap.obj_costs[i].to_bits() != base.obj_costs[i].to_bits() {
97                                live.change_column_cost(st.meta.cols[i], snap.obj_costs[i]);
98                            }
99                            if snap.lb[i].to_bits() != base.lb[i].to_bits()
100                                || snap.ub[i].to_bits() != base.ub[i].to_bits()
101                            {
102                                live.change_column_bounds(st.meta.cols[i], snap.lb[i]..=snap.ub[i]);
103                            }
104                        }
105                        apply_options(live, opts)?;
106                        st.meta.obj_constant = snap.obj_constant;
107                        st.snap = Some(snap);
108                        updated = true;
109                    }
110                }
111            }
112        }
113        if !updated {
114            self.rebuild(model, opts)?;
115        }
116
117        // Solve the resident model, then move it back so the basis is retained for
118        // the next solve.
119        let st = self.state.as_mut().expect("state present before solve");
120        let live = st.live.take().expect("live model present before solve");
121        let started = Instant::now();
122        let solved = live
123            .try_solve()
124            .map_err(|e| SolverError::Backend(format!("HiGHS solve failed: {e:?}")))?;
125        let elapsed = started.elapsed();
126        let result =
127            extract_result(&solved, st.meta.obj_constant, st.meta.num_constraints, elapsed);
128        st.live = Some(HighsModel::from(solved));
129        Ok(result)
130    }
131}
132
133impl Solver for HighsPersistent {
134    type Options = HighsOptions;
135
136    fn name(&self) -> &str {
137        NAME
138    }
139
140    fn supports(&self, kind: ModelKind) -> bool {
141        crate::supported(kind)
142    }
143
144    fn solve(&mut self, model: &Model, opts: &HighsOptions) -> Result<SolverResult, SolverError> {
145        match self.solve_resident(model, opts) {
146            Ok(result) => Ok(result),
147            Err(e) => {
148                self.state = None;
149                Err(e)
150            }
151        }
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use oximo_core::prelude::*;
158    use oximo_solver::{PersistentSolver, Solver, TerminationStatus};
159
160    use crate::{Highs, HighsOptions};
161
162    #[test]
163    fn persistent_matches_cold_solve_on_objective_sweep() {
164        let m = Model::new("pricing");
165        param!(m, p1 = 0.0);
166        variable!(m, x1 >= 0.0);
167        variable!(m, x2 >= 0.0);
168        constraint!(m, labor, 2.0 * x1 + x2 <= 100.0);
169        constraint!(m, material, x1 + 3.0 * x2 <= 90.0);
170        objective!(m, Max, p1 * x1 + 5.0 * x2);
171
172        let mut solver = Highs.persistent();
173        for price in [1.0, 1.6, 2.0, 5.0, 11.0] {
174            p1.set_param_value(price);
175            let s = solver.solve(&m, &HighsOptions::default()).unwrap();
176            let c = Highs.solve(&m, &HighsOptions::default()).unwrap();
177            assert_eq!(s.termination, TerminationStatus::Optimal, "price {price}");
178            assert!(
179                (s.objective().unwrap() - c.objective().unwrap()).abs() < 1e-9,
180                "price {price}"
181            );
182            assert!((s.value_of(x1).unwrap() - c.value_of(x1).unwrap()).abs() < 1e-9);
183            assert!((s.value_of(x2).unwrap() - c.value_of(x2).unwrap()).abs() < 1e-9);
184        }
185    }
186
187    /// A parameter in a constraint right-hand side changes the row bound, which the
188    /// fast path cannot push: the handle must fall back to a rebuild and still match
189    /// the cold solve.
190    #[test]
191    fn persistent_rebuilds_on_constraint_rhs_change() {
192        let m = Model::new("capacity");
193        param!(m, cap = 100.0);
194        variable!(m, x1 >= 0.0);
195        variable!(m, x2 >= 0.0);
196        constraint!(m, labor, 2.0 * x1 + x2 <= cap);
197        constraint!(m, material, x1 + 3.0 * x2 <= 90.0);
198        objective!(m, Max, 3.0 * x1 + 5.0 * x2);
199
200        let mut solver = Highs.persistent();
201        for c in [100.0, 60.0, 140.0] {
202            cap.set_param_value(c);
203            let s = solver.solve(&m, &HighsOptions::default()).unwrap();
204            let cold = Highs.solve(&m, &HighsOptions::default()).unwrap();
205            assert_eq!(s.termination, TerminationStatus::Optimal, "cap {c}");
206            assert!((s.objective().unwrap() - cold.objective().unwrap()).abs() < 1e-9, "cap {c}");
207        }
208    }
209
210    /// A feasibility model (no objective) solves through the persistent handle as
211    /// `minimize 0`, both cold and on the warm fast path.
212    #[test]
213    fn persistent_feasibility_no_objective() {
214        let m = Model::new("feas");
215        variable!(m, 0.0 <= x <= 10.0);
216        variable!(m, 0.0 <= y <= 10.0);
217        constraint!(m, c, x + y == 5.0);
218        objective!(m, Feasibility);
219
220        let mut solver = Highs.persistent();
221        let r = solver.solve(&m, &HighsOptions::default()).unwrap();
222        assert!(r.has_solution(), "termination = {:?}", r.termination);
223        m.fix(x, 2.0);
224        let r2 = solver.solve(&m, &HighsOptions::default()).unwrap();
225        assert!(r2.has_solution());
226        assert!((r2.value_of(x).unwrap() - 2.0).abs() < 1e-9);
227        assert!((r2.value_of(y).unwrap() - 3.0).abs() < 1e-9);
228    }
229
230    #[test]
231    fn persistent_reset_then_solve_ok() {
232        let m = Model::new("pricing");
233        param!(m, p1 = 0.0);
234        variable!(m, x1 >= 0.0);
235        variable!(m, x2 >= 0.0);
236        constraint!(m, labor, 2.0 * x1 + x2 <= 100.0);
237        objective!(m, Max, p1 * x1 + 5.0 * x2);
238
239        let mut solver = Highs.persistent();
240        p1.set_param_value(2.0);
241        let first = solver.solve(&m, &HighsOptions::default()).unwrap();
242        assert_eq!(first.termination, TerminationStatus::Optimal);
243
244        solver.reset();
245        let after = solver.solve(&m, &HighsOptions::default()).unwrap();
246        assert_eq!(after.termination, TerminationStatus::Optimal);
247        assert!((first.objective().unwrap() - after.objective().unwrap()).abs() < 1e-9);
248    }
249
250    #[test]
251    fn persistent_semi_matches_cold_across_bound_change() {
252        // s in {0} U [5, ub], forced on by s >= 3, min s -> s = 5 while ub >= 5.
253        let m = Model::new("sc_persist");
254        variable!(m, s <= 10.0, SemiCont(5.0));
255        constraint!(m, c, s >= 3.0);
256        objective!(m, Min, s);
257        let sid = s.var_id().unwrap();
258
259        let mut solver = Highs.persistent();
260        for ub in [10.0, 8.0, 12.0] {
261            m.unfix_var(sid, 0.0, ub);
262            let warm = solver.solve(&m, &HighsOptions::default()).unwrap();
263            let cold = Highs.solve(&m, &HighsOptions::default()).unwrap();
264            assert_eq!(warm.termination, TerminationStatus::Optimal, "ub {ub}");
265            assert!(
266                (warm.value_of(s).unwrap() - 5.0).abs() < 1e-6,
267                "ub {ub}: s = {:?}",
268                warm.value_of(s)
269            );
270            assert!(
271                (warm.value_of(s).unwrap() - cold.value_of(s).unwrap()).abs() < 1e-9,
272                "ub {ub}"
273            );
274            assert!(
275                (warm.objective().unwrap() - cold.objective().unwrap()).abs() < 1e-9,
276                "ub {ub}"
277            );
278        }
279    }
280
281    #[test]
282    fn persistent_warm_start_not_worse_than_cold() {
283        let m = Model::new("mix");
284        param!(m, bump = 0.0);
285        variable!(m, p1 >= 0.0);
286        variable!(m, p2 >= 0.0);
287        variable!(m, p3 >= 0.0);
288        variable!(m, p4 >= 0.0);
289        variable!(m, p5 >= 0.0);
290        constraint!(m, r1, 2.0 * p1 + p2 + 3.0 * p3 + p4 + p5 <= 100.0);
291        constraint!(m, r2, p1 + 4.0 * p2 + p3 + 2.0 * p4 + p5 <= 120.0);
292        constraint!(m, r3, 3.0 * p1 + p2 + 2.0 * p3 + p4 + 4.0 * p5 <= 150.0);
293        constraint!(m, r4, p1 + p2 + p3 + p4 + p5 <= 80.0);
294        objective!(m, Max, 10.0 * p1 + 8.0 * p2 + 7.0 * p3 + 6.0 * p4 + 9.0 * p5 + bump * p1);
295
296        let mut solver = Highs.persistent();
297        let mut persistent_iters = 0u64;
298        let mut cold_iters = 0u64;
299        for bump_val in [0.0, 1.0, 2.0, 3.0, 4.0] {
300            bump.set_param_value(bump_val);
301            let warm = solver.solve(&m, &HighsOptions::default()).unwrap();
302            let cold = Highs.solve(&m, &HighsOptions::default()).unwrap();
303            assert_eq!(warm.termination, TerminationStatus::Optimal, "bump {bump_val}");
304            assert!(
305                (warm.objective().unwrap() - cold.objective().unwrap()).abs() < 1e-9,
306                "bump {bump_val}"
307            );
308            persistent_iters += warm.iterations;
309            cold_iters += cold.iterations;
310        }
311        assert!(
312            persistent_iters <= cold_iters,
313            "warm-started handle used more iterations ({persistent_iters}) than cold ({cold_iters})"
314        );
315    }
316}