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 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 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 #[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 #[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 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}