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 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 fn solve_resident(
79 &mut self,
80 model: &Model,
81 opts: &HighsOptions,
82 ) -> Result<SolverResult, SolverError> {
83 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 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 #[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 #[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}