Skip to main content

oximo_clarabel/
persistent.rs

1use std::time::Instant;
2
3use clarabel::solver::{DefaultSolver, IPSolver};
4use oximo_core::{Model, ModelKind};
5use oximo_solver::{Solver, SolverError, SolverResult};
6
7use crate::translate::{Problem, build_problem, build_settings, read_result};
8use crate::{ClarabelOptions, NAME};
9
10/// The resident Clarabel solver plus the [`Problem`] it was built from. The
11/// stored problem carries the sparsity pattern / cone layout used to decide
12/// whether the next solve can update in place, and the [`crate::translate::Meta`]
13/// used to read results back.
14struct State {
15    solver: DefaultSolver<f64>,
16    problem: Problem,
17}
18
19/// A stateful Clarabel handle that keeps the built solver resident across
20/// solves.
21///
22/// Created by [`Clarabel::persistent`](crate::Clarabel). When the next model
23/// has the same dimensions, cone layout and `P`/`A` sparsity pattern, only the
24/// numeric data (`P`, `q`, `A`, `b`) is overwritten in place via Clarabel's
25/// [`update_data`](clarabel::solver::DefaultSolver::update_data), reusing the
26/// KKT symbolic factorization instead of rebuilding it. Any structural change
27/// — added/removed rows or columns, a changed sparsity pattern, a new cone, a
28/// flipped constraint sense that moves a row between cones — triggers a
29/// transparent full rebuild, so every result matches a cold
30/// [`Clarabel::solve`](crate::Clarabel).
31///
32/// Clarabel is an interior-point method and does not warm-start from the
33/// previous iterate, so iteration counts are unchanged; the saving is the
34/// skipped setup (equilibration structure, symbolic factorization, allocations).
35///
36/// In-place updates are rejected by Clarabel when presolve or structural-zero
37/// dropping altered the problem, in which case the handle falls back to a
38/// rebuild. Set `presolve_enable(false)` and `input_sparse_dropzeros(false)`
39/// (the defaults leave presolve on) to keep the fast path available.
40///
41/// A failed `solve` leaves the handle without a resident model; the next call
42/// rebuilds from scratch.
43#[derive(Default)]
44pub struct ClarabelPersistent {
45    state: Option<State>,
46}
47
48impl std::fmt::Debug for ClarabelPersistent {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        f.debug_struct("ClarabelPersistent").field("resident", &self.state.is_some()).finish()
51    }
52}
53
54impl ClarabelPersistent {
55    /// A fresh handle with no model loaded. The first solve builds it.
56    #[must_use]
57    pub fn new() -> Self {
58        Self::default()
59    }
60
61    /// Drop the resident solver so the next [`solve`](Solver::solve) rebuilds
62    /// from scratch. After this, the next solve behaves exactly like a cold
63    /// [`Clarabel::solve`](crate::Clarabel).
64    pub fn reset(&mut self) {
65        self.state = None;
66    }
67
68    /// Translate the model, update the resident solver in place when the
69    /// structure is unchanged (else rebuild), then solve.
70    fn solve_resident(
71        &mut self,
72        model: &Model,
73        opts: &ClarabelOptions,
74    ) -> Result<SolverResult, SolverError> {
75        let new = build_problem(model)?;
76        let settings = build_settings(opts);
77
78        // Fast path: same structure, and Clarabel accepts both the settings
79        // update (no immutable setting changed) and the data update (structure
80        // not altered by presolve / dropped zeros). Any failure means rebuild.
81        let mut updated = false;
82        if let Some(state) = self.state.as_mut() {
83            if state.problem.same_structure(&new)
84                && state.solver.update_settings(settings.clone()).is_ok()
85                && state.solver.update_data(&new.p_mat, &new.q, &new.a_mat, &new.b).is_ok()
86            {
87                updated = true;
88            }
89        }
90
91        if updated {
92            // Refresh the readback metadata (objective sign/constant, dual map).
93            self.state.as_mut().expect("resident on fast path").problem = new;
94        } else {
95            let solver =
96                DefaultSolver::new(&new.p_mat, &new.q, &new.a_mat, &new.b, &new.cones, settings)
97                    .map_err(|e| SolverError::Backend(format!("Clarabel setup: {e:?}")))?;
98            self.state = Some(State { solver, problem: new });
99        }
100
101        let state = self.state.as_mut().expect("state present before solve");
102        let started = Instant::now();
103        state.solver.solve();
104        let elapsed = started.elapsed();
105        Ok(read_result(&state.solver, &state.problem.meta, elapsed))
106    }
107}
108
109impl Solver for ClarabelPersistent {
110    type Options = ClarabelOptions;
111
112    fn name(&self) -> &str {
113        NAME
114    }
115
116    fn supports(&self, kind: ModelKind) -> bool {
117        crate::supported(kind)
118    }
119
120    fn solve(
121        &mut self,
122        model: &Model,
123        opts: &ClarabelOptions,
124    ) -> Result<SolverResult, SolverError> {
125        match self.solve_resident(model, opts) {
126            Ok(result) => Ok(result),
127            Err(e) => {
128                self.state = None;
129                Err(e)
130            }
131        }
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use oximo_core::prelude::*;
138    use oximo_solver::{PersistentSolver, Solver, SolverError, TerminationStatus};
139
140    use crate::{Clarabel, ClarabelOptions};
141
142    /// Relative closeness: two independent interior-point solves stop within
143    /// the solver tolerance, not bit-for-bit, so scale by magnitude.
144    fn close(a: f64, b: f64) -> bool {
145        (a - b).abs() <= 1e-5 * a.abs().max(b.abs()).max(1.0)
146    }
147
148    /// A parameter in an objective coefficient changes `q` only: the sparsity
149    /// pattern is untouched, so the handle updates in place and must match the
150    /// cold solve across the sweep.
151    #[test]
152    fn persistent_matches_cold_on_objective_sweep() {
153        let m = Model::new("pricing");
154        param!(m, p1 = 0.0);
155        variable!(m, x1 >= 0.0);
156        variable!(m, x2 >= 0.0);
157        constraint!(m, labor, 2.0 * x1 + x2 <= 100.0);
158        constraint!(m, material, x1 + 3.0 * x2 <= 90.0);
159        objective!(m, Max, p1 * x1 + 5.0 * x2);
160
161        let mut solver = Clarabel.persistent();
162        for price in [1.0, 1.6, 2.0, 5.0, 11.0] {
163            p1.set_param_value(price);
164            let s = solver.solve(&m, &ClarabelOptions::default()).unwrap();
165            let c = Clarabel.solve(&m, &ClarabelOptions::default()).unwrap();
166            assert_eq!(s.termination, TerminationStatus::Optimal, "price {price}");
167            assert!(close(s.objective().unwrap(), c.objective().unwrap()), "price {price}");
168            assert!(close(s.value_of(x1).unwrap(), c.value_of(x1).unwrap()), "price {price}");
169        }
170    }
171
172    /// A parameter in a right-hand side changes `b` only. Unlike the HiGHS
173    /// handle (which rebuilds on rhs changes), Clarabel can push this through
174    /// the in-place update; results must still match the cold solve.
175    #[test]
176    fn persistent_matches_cold_on_rhs_sweep() {
177        let m = Model::new("capacity");
178        param!(m, cap = 100.0);
179        variable!(m, x1 >= 0.0);
180        variable!(m, x2 >= 0.0);
181        constraint!(m, labor, 2.0 * x1 + x2 <= cap);
182        constraint!(m, material, x1 + 3.0 * x2 <= 90.0);
183        objective!(m, Max, 3.0 * x1 + 5.0 * x2);
184
185        let mut solver = Clarabel.persistent();
186        for c in [100.0, 60.0, 140.0] {
187            cap.set_param_value(c);
188            let s = solver.solve(&m, &ClarabelOptions::default()).unwrap();
189            let cold = Clarabel.solve(&m, &ClarabelOptions::default()).unwrap();
190            assert_eq!(s.termination, TerminationStatus::Optimal, "cap {c}");
191            assert!(close(s.objective().unwrap(), cold.objective().unwrap()), "cap {c}");
192        }
193    }
194
195    /// A parameter in a matrix coefficient changes an `A` value, keeping the
196    /// sparsity pattern: still the in-place path.
197    #[test]
198    fn persistent_matches_cold_on_matrix_coeff_sweep() {
199        let m = Model::new("coeff");
200        param!(m, a = 2.0);
201        variable!(m, x1 >= 0.0);
202        variable!(m, x2 >= 0.0);
203        constraint!(m, labor, a * x1 + x2 <= 100.0);
204        objective!(m, Max, 3.0 * x1 + 5.0 * x2);
205
206        let mut solver = Clarabel.persistent();
207        for av in [2.0, 1.0, 4.0] {
208            a.set_param_value(av);
209            let s = solver.solve(&m, &ClarabelOptions::default()).unwrap();
210            let cold = Clarabel.solve(&m, &ClarabelOptions::default()).unwrap();
211            assert_eq!(s.termination, TerminationStatus::Optimal, "a {av}");
212            assert!(close(s.objective().unwrap(), cold.objective().unwrap()), "a {av}");
213        }
214    }
215
216    /// Fixing a variable moves it from bound rows to a zero-cone row: a
217    /// structural change the handle must absorb via a rebuild, still matching
218    /// the cold solve.
219    #[test]
220    fn persistent_rebuilds_on_structural_change() {
221        let m = Model::new("feas");
222        variable!(m, 0.0 <= x <= 10.0);
223        variable!(m, 0.0 <= y <= 10.0);
224        constraint!(m, c, x + y == 5.0);
225        objective!(m, Min, x + 2.0 * y);
226
227        let mut solver = Clarabel.persistent();
228        let r = solver.solve(&m, &ClarabelOptions::default()).unwrap();
229        assert!(r.has_solution(), "termination = {:?}", r.termination);
230
231        m.fix(x, 2.0);
232        let r2 = solver.solve(&m, &ClarabelOptions::default()).unwrap();
233        let cold = Clarabel.solve(&m, &ClarabelOptions::default()).unwrap();
234        assert!(close(r2.value_of(x).unwrap(), 2.0));
235        assert!(close(r2.value_of(y).unwrap(), 3.0));
236        assert!(close(r2.objective().unwrap(), cold.objective().unwrap()));
237    }
238
239    /// An SOCP with a swept objective coefficient exercises the fast path with
240    /// a second-order cone in the layout.
241    #[test]
242    fn persistent_socp_objective_sweep() {
243        let m = Model::new("socp");
244        param!(m, wt = 1.0);
245        variable!(m, x);
246        variable!(m, y);
247        variable!(m, t >= 0.0);
248        m.fix(t, 1.0);
249        m.add_soc_constraint("disk", [x, y], t); // ||(x, y)|| <= 1
250        objective!(m, Min, wt * x + y);
251
252        let mut solver = Clarabel.persistent();
253        for wv in [1.0, 2.0, 0.5] {
254            wt.set_param_value(wv);
255            let warm = solver.solve(&m, &ClarabelOptions::default()).unwrap();
256            let cold = Clarabel.solve(&m, &ClarabelOptions::default()).unwrap();
257            assert_eq!(warm.termination, TerminationStatus::Optimal, "wt {wv}");
258            assert!(close(warm.objective().unwrap(), cold.objective().unwrap()), "wt {wv}");
259        }
260    }
261
262    #[test]
263    fn persistent_reset_then_solve_ok() {
264        let m = Model::new("pricing");
265        param!(m, p1 = 0.0);
266        variable!(m, x1 >= 0.0);
267        variable!(m, x2 >= 0.0);
268        constraint!(m, labor, 2.0 * x1 + x2 <= 100.0);
269        objective!(m, Max, p1 * x1 + 5.0 * x2);
270
271        let mut solver = Clarabel.persistent();
272        p1.set_param_value(2.0);
273        let first = solver.solve(&m, &ClarabelOptions::default()).unwrap();
274        assert_eq!(first.termination, TerminationStatus::Optimal);
275
276        solver.reset();
277        let after = solver.solve(&m, &ClarabelOptions::default()).unwrap();
278        assert_eq!(after.termination, TerminationStatus::Optimal);
279        assert!(close(first.objective().unwrap(), after.objective().unwrap()));
280    }
281
282    #[test]
283    fn persistent_unsupported_kind_errors_and_clears() {
284        let m = Model::new("milp");
285        variable!(m, 0.0 <= x <= 5.0, Int);
286        objective!(m, Min, x);
287        let mut solver = Clarabel.persistent();
288        let err = solver.solve(&m, &ClarabelOptions::default()).unwrap_err();
289        assert!(matches!(err, SolverError::UnsupportedKind(ModelKind::MILP)));
290    }
291}