Skip to main content

oximo_solver/
incremental.rs

1use std::hash::Hasher;
2
3use oximo_core::{Model, ModelKind, ObjectiveSense, Variable, var_name};
4use oximo_expr::{ExprArena, VarId, describe_nonlinear_term, extract_linear};
5use rustc_hash::FxHasher;
6
7use crate::status::SolverError;
8
9/// Column-aligned snapshot of the quantities a persistent backend can push to a
10/// resident model without rebuilding it (objective linear coefficients, the
11/// objective constant, and variable bounds) plus a structural `fingerprint` of
12/// everything it cannot push.
13///
14/// Two snapshots of the same model whose `fingerprint`s match differ only in
15/// pushable quantities, so a backend may update those in place and warm-start. A
16/// fingerprint mismatch means the structure changed and the model must be rebuilt.
17/// All vectors are indexed in [`Model::variables`] (column) order.
18///
19/// This is solver-agnostic, each backend's persistent handle computes a baseline
20/// snapshot when it (re)builds and a fresh one on each re-solve, pushes the diffs
21/// its API supports, and rebuilds on a fingerprint mismatch.
22#[derive(Clone, Debug, PartialEq)]
23pub struct Snapshot {
24    /// Objective linear coefficient per variable, in column order.
25    pub obj_costs: Vec<f64>,
26    /// Constant term of the (linear) objective.
27    pub obj_constant: f64,
28    /// Lower bound per variable, in column order.
29    pub lb: Vec<f64>,
30    /// Upper bound per variable, in column order.
31    pub ub: Vec<f64>,
32    /// Hash of the structural parts that the fast path cannot push.
33    pub fingerprint: u64,
34}
35
36/// Compute the incremental [`Snapshot`] of a linear model (`LP`/`MILP`).
37///
38/// The objective and every constraint must be linear, the snapshot is the basis
39/// for a persistent backend's warm re-solve fast path and is only meaningful for
40/// linear models (a quadratic/nonlinear model always rebuilds).
41///
42/// # Errors
43///
44/// Returns [`SolverError::Nonlinear`] if the objective or any constraint is not
45/// linear, or [`SolverError::UnsupportedKind`] if the model is a second-order
46/// cone program (explicit [`oximo_core::SocConstraint`]s or SOC-shaped
47/// quadratic constraints detected by [`Model::kind`]).
48pub fn snapshot(model: &Model) -> Result<Snapshot, SolverError> {
49    model.ensure_objective_declared().map_err(SolverError::Core)?;
50    let kind = model.kind();
51    if model.num_soc_constraints() > 0 || matches!(kind, ModelKind::SOCP | ModelKind::MISOCP) {
52        return Err(SolverError::UnsupportedKind(kind));
53    }
54    let arena = model.arena();
55    let vars = model.variables();
56    let model_constraints = model.constraints();
57    let constraints = model_constraints.algebraic();
58
59    let objective = model.objective();
60    let obj = objective.as_ref();
61    let sense = obj.map_or(ObjectiveSense::Minimize, |o| o.sense);
62    let (obj_by_id, obj_constant) = match obj {
63        Some(o) => {
64            let lin = extract_linear(&arena, o.expr).ok_or_else(|| SolverError::Nonlinear {
65                location: "the objective".into(),
66                term: describe_nonlinear_term(&arena, o.expr, &|v| var_name(&vars, v))
67                    .unwrap_or_else(|| "<nonlinear>".into()),
68            })?;
69            let mut by_id = vec![0.0; vars.len()];
70            for (v, c) in &lin.coeffs {
71                by_id[v.index()] = *c;
72            }
73            (by_id, lin.constant)
74        }
75        None => (vec![0.0; vars.len()], 0.0),
76    };
77
78    let mut obj_costs = Vec::with_capacity(vars.len());
79    let mut lb = Vec::with_capacity(vars.len());
80    let mut ub = Vec::with_capacity(vars.len());
81    let mut hasher = FxHasher::default();
82    hash_header(&mut hasher, &vars, sense);
83    for v in vars.iter() {
84        obj_costs.push(obj_by_id[v.id.index()]);
85        lb.push(v.lb);
86        ub.push(v.ub);
87    }
88
89    let arena_ref: &ExprArena = &arena;
90    for c in constraints {
91        let t = extract_linear(arena_ref, c.lhs).ok_or_else(|| SolverError::Nonlinear {
92            location: format!("constraint {:?}", c.name),
93            term: describe_nonlinear_term(arena_ref, c.lhs, &|v| var_name(&vars, v))
94                .unwrap_or_else(|| "<nonlinear>".into()),
95        })?;
96        hash_row(&mut hasher, c.lower - t.constant, c.upper - t.constant, &t.coeffs);
97    }
98
99    Ok(Snapshot { obj_costs, obj_constant, lb, ub, fingerprint: hasher.finish() })
100}
101
102/// Hash the parts that decide column count, integrality, and objective sense.
103fn hash_header(h: &mut FxHasher, vars: &[Variable], sense: ObjectiveSense) {
104    h.write_usize(vars.len());
105    h.write_u8(match sense {
106        ObjectiveSense::Minimize => 0,
107        ObjectiveSense::Maximize => 1,
108    });
109    for v in vars {
110        h.write_u8(u8::from(v.domain.is_integer()));
111    }
112}
113
114/// Hash one constraint row: its (constant-folded) bounds and its `(column, coeff)`
115/// terms, sorted so the hash is independent of extraction order.
116fn hash_row(h: &mut FxHasher, lower: f64, upper: f64, coeffs: &[(VarId, f64)]) {
117    h.write_u64(lower.to_bits());
118    h.write_u64(upper.to_bits());
119    let mut terms: Vec<(usize, u64)> =
120        coeffs.iter().map(|(v, c)| (v.index(), c.to_bits())).collect();
121    terms.sort_unstable();
122    for (vi, cb) in terms {
123        h.write_usize(vi);
124        h.write_u64(cb);
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use oximo_core::prelude::*;
131
132    use super::snapshot;
133
134    #[test]
135    fn objective_coeff_change_keeps_fingerprint() {
136        let m = Model::new("t");
137        param!(m, p = 1.0);
138        variable!(m, x >= 0.0);
139        variable!(m, y >= 0.0);
140        constraint!(m, c, x + y <= 10.0);
141        objective!(m, Max, p * x + 2.0 * y);
142
143        let s1 = snapshot(&m).unwrap();
144        p.set_param_value(5.0);
145        let s2 = snapshot(&m).unwrap();
146        assert_eq!(s1.fingerprint, s2.fingerprint, "structure unchanged");
147        assert_ne!(s1.obj_costs, s2.obj_costs, "coefficient moved");
148    }
149
150    #[test]
151    fn bound_change_keeps_fingerprint() {
152        let m = Model::new("t");
153        variable!(m, x >= 0.0);
154        constraint!(m, c, x <= 10.0);
155        objective!(m, Max, x);
156
157        let s1 = snapshot(&m).unwrap();
158        m.fix(x, 3.0);
159        let s2 = snapshot(&m).unwrap();
160        assert_eq!(s1.fingerprint, s2.fingerprint, "structure unchanged");
161        assert_ne!(s1.ub, s2.ub, "bound moved");
162    }
163
164    #[test]
165    fn constraint_rhs_change_breaks_fingerprint() {
166        let m = Model::new("t");
167        param!(m, cap = 10.0);
168        variable!(m, x >= 0.0);
169        constraint!(m, c, x <= cap);
170        objective!(m, Max, x);
171
172        let s1 = snapshot(&m).unwrap();
173        cap.set_param_value(20.0);
174        let s2 = snapshot(&m).unwrap();
175        assert_ne!(s1.fingerprint, s2.fingerprint, "row bound changed");
176    }
177
178    #[test]
179    fn constraint_coeff_change_breaks_fingerprint() {
180        let m = Model::new("t");
181        param!(m, a = 1.0);
182        variable!(m, x >= 0.0);
183        variable!(m, y >= 0.0);
184        constraint!(m, c, a * x + y <= 10.0);
185        objective!(m, Max, x + y);
186
187        let s1 = snapshot(&m).unwrap();
188        a.set_param_value(3.0);
189        let s2 = snapshot(&m).unwrap();
190        assert_ne!(s1.fingerprint, s2.fingerprint, "matrix coefficient changed");
191    }
192
193    #[test]
194    fn nonlinear_objective_is_rejected() {
195        let m = Model::new("t");
196        variable!(m, x >= 0.0);
197        objective!(m, Min, x.powi(2));
198        assert!(snapshot(&m).is_err());
199    }
200
201    #[test]
202    fn soc_constraint_is_rejected() {
203        let m = Model::new("t");
204        variable!(m, x >= 0.0);
205        variable!(m, t >= 0.0);
206        m.add_soc_constraint("cone", [x], t);
207        objective!(m, Min, t);
208        assert!(matches!(
209            snapshot(&m),
210            Err(crate::status::SolverError::UnsupportedKind(ModelKind::SOCP))
211        ));
212    }
213
214    #[test]
215    fn feasibility_is_a_zero_objective() {
216        let m = Model::new("feas");
217        variable!(m, x >= 0.0);
218        variable!(m, y >= 0.0);
219        constraint!(m, c, x + y == 5.0);
220        objective!(m, Feasibility);
221
222        let s = snapshot(&m).expect("feasibility model snapshots");
223        assert!(s.obj_costs.iter().all(|&c| c.abs() < 1e-12), "costs = {:?}", s.obj_costs);
224        assert!(s.obj_constant.abs() < 1e-12, "constant = {}", s.obj_constant);
225    }
226
227    #[test]
228    fn undeclared_objective_is_rejected() {
229        let m = Model::new("undeclared");
230        variable!(m, x >= 0.0);
231        constraint!(m, c, x <= 5.0);
232        assert!(matches!(
233            snapshot(&m),
234            Err(crate::status::SolverError::Core(oximo_core::Error::NoObjective))
235        ));
236    }
237}