Skip to main content

oximo_pounce/
persistent.rs

1//! A resident POUNCE handle that keeps the built derivative oracle alive across
2//! solves and warm-starts each solve from the previous iterate.
3
4use std::time::Instant;
5
6use oximo_core::{Model, ModelKind};
7use oximo_solver::{Solver, SolverError, SolverResult};
8
9use crate::convex::{self, Route};
10use crate::options::PounceOptions;
11use crate::translate::{WarmStart, assemble, run_nlp_with_retries, setup};
12
13#[cfg(feature = "enzyme")]
14use crate::exact as backend;
15#[cfg(not(feature = "enzyme"))]
16use crate::stable as backend;
17
18struct NlpState {
19    oracle: backend::Oracle,
20    warm: Option<WarmStart>,
21}
22
23struct ConvexState {
24    route: Route,
25    problem: convex::Problem,
26    warm: Option<pounce_rs::convex::QpWarmStart>,
27    active: Option<convex::ActivePersistent>,
28}
29
30enum State {
31    Nlp(NlpState),
32    Convex(Box<ConvexState>),
33}
34
35struct CachedValidation {
36    options: PounceOptions,
37    result: Result<(), String>,
38}
39
40/// A stateful POUNCE handle that keeps the derivative build resident across
41/// solves. Created by [`Pounce::persistent`](crate::Pounce).
42///
43/// When the next model has the same variables, objective, and constraint
44/// expressions with an unchanged sparsity pattern, the resident oracle
45/// is refreshed in place, reusing the compiled tapes (and, on the `enzyme`
46/// path, the exact jacobians/Hessians structure) instead of rebuilding.
47/// Also, the solve is warm-started from the previous iterate.
48/// Any structural change rebuilds.
49///
50/// A failed solve clears the resident state. The next call rebuilds from scratch.
51#[derive(Default)]
52pub struct PouncePersistent {
53    state: Option<State>,
54    validation: Option<CachedValidation>,
55}
56
57impl std::fmt::Debug for PouncePersistent {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        f.debug_struct("PouncePersistent").field("resident", &self.state.is_some()).finish()
60    }
61}
62
63impl PouncePersistent {
64    /// A fresh handle with no model loaded. The first solve builds it.
65    #[must_use]
66    pub fn new() -> Self {
67        Self::default()
68    }
69
70    /// Drop the resident oracle so the next [`solve`](Solver::solve) rebuilds
71    /// from scratch (and starts from the model's initial point).
72    pub fn reset(&mut self) {
73        self.state = None;
74        self.validation = None;
75    }
76
77    fn solve_resident(
78        &mut self,
79        model: &Model,
80        opts: &PounceOptions,
81    ) -> Result<SolverResult, SolverError> {
82        let route = convex::route(model, opts)?;
83        if route == Route::Nlp {
84            return self.solve_nlp(model, opts);
85        }
86        self.solve_convex(model, opts, route)
87    }
88
89    fn solve_nlp(
90        &mut self,
91        model: &Model,
92        opts: &PounceOptions,
93    ) -> Result<SolverResult, SolverError> {
94        self.solve_nlp_since(model, opts, Instant::now())
95    }
96
97    fn solve_nlp_since(
98        &mut self,
99        model: &Model,
100        opts: &PounceOptions,
101        started: Instant,
102    ) -> Result<SolverResult, SolverError> {
103        let prep = setup(model, opts)?;
104        let state = match &mut self.state {
105            Some(State::Nlp(state)) if backend::try_reuse(&state.oracle, model) => state,
106            slot => {
107                *slot = Some(State::Nlp(NlpState { oracle: backend::build(model)?, warm: None }));
108                let Some(State::Nlp(state)) = slot else { unreachable!() };
109                state
110            }
111        };
112        let mut outcome = run_nlp_with_retries(&state.oracle, &prep, opts, state.warm.as_ref())?;
113        let elapsed = started.elapsed();
114        state.warm = outcome.warm.take();
115        Ok(assemble(prep.sign, outcome, elapsed))
116    }
117
118    fn solve_convex(
119        &mut self,
120        model: &Model,
121        opts: &PounceOptions,
122        route: Route,
123    ) -> Result<SolverResult, SolverError> {
124        self.validate_convex_options(opts)?;
125        let problem = convex::build_problem(model)?;
126        let started = Instant::now();
127        let state = match &mut self.state {
128            Some(State::Convex(state))
129                if state.route == route && state.problem.same_structure(&problem) =>
130            {
131                state.problem = problem;
132                state
133            }
134            slot => {
135                let active = (route == Route::QpActiveSet).then(convex::ActivePersistent::new);
136                *slot = Some(State::Convex(Box::new(ConvexState {
137                    route,
138                    problem,
139                    warm: None,
140                    active,
141                })));
142                let Some(State::Convex(state)) = slot else { unreachable!() };
143                state
144            }
145        };
146        let solution = if route == Route::QpActiveSet {
147            state
148                .active
149                .as_mut()
150                .expect("active-set state exists for active-set route")
151                .solve(&state.problem, opts)?
152        } else {
153            convex::run(&state.problem, opts, route, state.warm.as_ref())
154        };
155        if convex::should_fallback_to_nlp(model, opts, &solution)? {
156            return self.solve_nlp_since(model, opts, started);
157        }
158        let elapsed = started.elapsed();
159        let mut outcome = convex::outcome(&state.problem, opts, route, &solution);
160        state.warm = (route != Route::QpActiveSet && outcome.termination.admits_primal())
161            .then(|| convex::warm_from_solution(route, &state.problem, &solution));
162        let sign = state.problem.sign();
163        outcome.warm = None;
164        Ok(assemble(sign, outcome, elapsed))
165    }
166
167    fn validate_convex_options(&mut self, opts: &PounceOptions) -> Result<(), SolverError> {
168        if let Some(cached) = &self.validation
169            && cached.options == *opts
170        {
171            return cached.result.clone().map_err(SolverError::Backend);
172        }
173        self.validation = None;
174        let result = convex::validate_options(opts);
175        let cached = match &result {
176            Ok(()) => Ok(()),
177            Err(SolverError::Backend(message)) => Err(message.clone()),
178            Err(_) => return result,
179        };
180        self.validation = Some(CachedValidation { options: opts.clone(), result: cached });
181        result
182    }
183}
184
185impl Solver for PouncePersistent {
186    type Options = PounceOptions;
187
188    fn name(&self) -> &str {
189        "pounce"
190    }
191
192    fn supports(&self, kind: ModelKind) -> bool {
193        matches!(
194            kind,
195            ModelKind::LP | ModelKind::QP | ModelKind::QCP | ModelKind::SOCP | ModelKind::NLP
196        )
197    }
198
199    fn solve(&mut self, model: &Model, opts: &PounceOptions) -> Result<SolverResult, SolverError> {
200        match self.solve_resident(model, opts) {
201            Ok(result) => Ok(result),
202            Err(e) => {
203                self.state = None;
204                Err(e)
205            }
206        }
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn convex_validation_cache_tracks_options_and_errors() {
216        let mut solver = PouncePersistent::new();
217        let valid = PounceOptions::default().qp_tau(0.9);
218        solver.validate_convex_options(&valid).unwrap();
219        assert!(
220            solver
221                .validation
222                .as_ref()
223                .is_some_and(|cached| { cached.options == valid && cached.result.is_ok() })
224        );
225        solver.validate_convex_options(&valid).unwrap();
226
227        let invalid = PounceOptions::default().set("not_a_real_option", true);
228        let first = solver.validate_convex_options(&invalid).unwrap_err().to_string();
229        let second = solver.validate_convex_options(&invalid).unwrap_err().to_string();
230        assert_eq!(first, second);
231        assert!(
232            solver
233                .validation
234                .as_ref()
235                .is_some_and(|cached| { cached.options == invalid && cached.result.is_err() })
236        );
237
238        solver.reset();
239        assert!(solver.validation.is_none());
240    }
241}