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
11struct State {
15 live: Option<HighsModel>,
16 meta: Meta,
17 snap: Option<Snapshot>,
18}
19
20#[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 #[must_use]
52 pub fn new() -> Self {
53 Self::default()
54 }
55
56 pub fn reset(&mut self) {
61 self.state = None;
62 }
63
64 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 fn solve_resident(
81 &mut self,
82 model: &Model,
83 opts: &HighsOptions,
84 ) -> Result<SolverResult, SolverError> {
85 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 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 #[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 #[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 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}