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