Skip to main content

oximo_solver/
incremental.rs

1use std::hash::Hasher;
2
3use oximo_core::{Model, ModelKind, ObjectiveSense, Variable};
4use oximo_expr::VarId;
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    let prepared = crate::prepare::LoweringContext::new(model)?;
50    let kind = prepared.kind();
51    if model.num_soc_constraints() > 0 || matches!(kind, ModelKind::SOCP | ModelKind::MISOCP) {
52        return Err(SolverError::UnsupportedKind(kind));
53    }
54    let vars = prepared.variables();
55    let model_constraints = prepared.constraints();
56    let constraints = model_constraints.algebraic();
57
58    let objective = prepared.objective();
59    let obj = objective.as_ref();
60    let sense = obj.map_or(ObjectiveSense::Minimize, |o| o.sense);
61    let (obj_by_id, obj_constant) = match obj {
62        Some(o) => {
63            let lin = prepared.require_linear(o.expr, || "the objective".into())?;
64            let mut by_id = vec![0.0; vars.len()];
65            for &(v, c) in lin.coeffs.iter() {
66                by_id[v.index()] = c;
67            }
68            (by_id, lin.constant)
69        }
70        None => (vec![0.0; vars.len()], 0.0),
71    };
72
73    let mut obj_costs = Vec::with_capacity(vars.len());
74    let mut lb = Vec::with_capacity(vars.len());
75    let mut ub = Vec::with_capacity(vars.len());
76    let mut hasher = FxHasher::default();
77    hash_header(&mut hasher, vars, sense);
78    for v in vars {
79        obj_costs.push(obj_by_id[v.id.index()]);
80        lb.push(v.lb);
81        ub.push(v.ub);
82    }
83
84    let mut row_terms = Vec::new();
85    for c in constraints {
86        let t = prepared.require_linear(c.lhs, || format!("constraint {:?}", c.name))?;
87        hash_row(
88            &mut hasher,
89            c.lower - t.constant,
90            c.upper - t.constant,
91            &t.coeffs,
92            &mut row_terms,
93        );
94    }
95
96    for sos in prepared.constraints().special_ordered_sets() {
97        if !sos.active {
98            continue;
99        }
100        hasher.write_u8(match sos.sos_type {
101            oximo_core::SosType::Sos1 => 1,
102            oximo_core::SosType::Sos2 => 2,
103        });
104        hasher.write_usize(sos.members.len());
105        for member in &sos.members {
106            hasher.write_u32(member.variable.0);
107            hasher.write_u64(member.weight.to_bits());
108        }
109    }
110
111    for indicator in prepared.constraints().indicators() {
112        if !indicator.active {
113            continue;
114        }
115        let terms = prepared.require_linear(indicator.lhs, || {
116            format!("indicator constraint {:?}", indicator.name)
117        })?;
118        hasher.write_u8(3);
119        hasher.write_u32(indicator.trigger.0);
120        hasher.write_u8(u8::from(indicator.active_value));
121        hash_row(
122            &mut hasher,
123            indicator.lower - terms.constant,
124            indicator.upper - terms.constant,
125            &terms.coeffs,
126            &mut row_terms,
127        );
128    }
129
130    Ok(Snapshot { obj_costs, obj_constant, lb, ub, fingerprint: hasher.finish() })
131}
132
133/// Hash the parts that decide column count, integrality, and objective sense.
134fn hash_header(h: &mut FxHasher, vars: &[Variable], sense: ObjectiveSense) {
135    h.write_usize(vars.len());
136    h.write_u8(match sense {
137        ObjectiveSense::Minimize => 0,
138        ObjectiveSense::Maximize => 1,
139    });
140    for v in vars {
141        h.write_u8(u8::from(v.domain.is_integer()));
142    }
143}
144
145/// Hash one constraint row: its (constant-folded) bounds and its `(column, coeff)`
146/// terms, sorted so the hash is independent of extraction order.
147fn hash_row(
148    h: &mut FxHasher,
149    lower: f64,
150    upper: f64,
151    coeffs: &[(VarId, f64)],
152    terms: &mut Vec<(usize, u64)>,
153) {
154    h.write_u64(lower.to_bits());
155    h.write_u64(upper.to_bits());
156    terms.clear();
157    terms.extend(coeffs.iter().map(|(v, c)| (v.index(), c.to_bits())));
158    terms.sort_unstable();
159    for &(vi, cb) in terms.iter() {
160        h.write_usize(vi);
161        h.write_u64(cb);
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use oximo_core::prelude::*;
168
169    use super::snapshot;
170
171    #[test]
172    fn row_scratch_preserves_fingerprint_bits_across_different_widths() {
173        use oximo_expr::VarId;
174        use rustc_hash::FxHasher;
175        use std::hash::Hasher;
176
177        let rows = [
178            vec![(VarId(9), -0.0), (VarId(1), 2.0), (VarId(9), 0.0)],
179            vec![],
180            vec![(VarId(3), f64::from_bits(0x7ff8_0000_0000_0001))],
181            vec![(VarId(1), 1.0)],
182        ];
183        let mut expected = FxHasher::default();
184        let mut actual = FxHasher::default();
185        let mut scratch = Vec::new();
186        for row in rows {
187            expected.write_u64((-1.0_f64).to_bits());
188            expected.write_u64(2.0_f64.to_bits());
189            let mut sorted: Vec<_> = row.iter().map(|(v, c)| (v.index(), c.to_bits())).collect();
190            sorted.sort_unstable();
191            for (var, bits) in sorted {
192                expected.write_usize(var);
193                expected.write_u64(bits);
194            }
195            super::hash_row(&mut actual, -1.0, 2.0, &row, &mut scratch);
196            assert_eq!(actual.finish(), expected.finish());
197        }
198    }
199
200    #[test]
201    fn objective_coeff_change_keeps_fingerprint() {
202        let m = Model::new("t");
203        param!(m, p = 1.0);
204        variable!(m, x >= 0.0);
205        variable!(m, y >= 0.0);
206        constraint!(m, c, x + y <= 10.0);
207        objective!(m, Max, p * x + 2.0 * y);
208
209        let s1 = snapshot(&m).unwrap();
210        p.set_param_value(5.0);
211        let s2 = snapshot(&m).unwrap();
212        assert_eq!(s1.fingerprint, s2.fingerprint, "structure unchanged");
213        assert_ne!(s1.obj_costs, s2.obj_costs, "coefficient moved");
214    }
215
216    #[test]
217    fn bound_change_keeps_fingerprint() {
218        let m = Model::new("t");
219        variable!(m, x >= 0.0);
220        constraint!(m, c, x <= 10.0);
221        objective!(m, Max, x);
222
223        let s1 = snapshot(&m).unwrap();
224        m.fix(x, 3.0).unwrap();
225        let s2 = snapshot(&m).unwrap();
226        assert_eq!(s1.fingerprint, s2.fingerprint, "structure unchanged");
227        assert_ne!(s1.ub, s2.ub, "bound moved");
228    }
229
230    #[test]
231    fn reformulated_inactive_sos_snapshots_as_linear_structure() {
232        let m = Model::new("reformulated_sos");
233        variable!(m, 0.0 <= x <= 1.0);
234        variable!(m, 0.0 <= y <= 1.0);
235        sos_constraint!(m, choice, SOS1, [x, y]);
236        objective!(m, Max, x + y);
237
238        let transformed = m
239            .to_reformulated_sos_model(SosReformulationOptions::default())
240            .expect("bounded SOS reformulates");
241        assert!(!transformed.has_active_sos_constraints());
242        assert!(snapshot(&transformed).is_ok());
243    }
244
245    #[test]
246    fn constraint_rhs_change_breaks_fingerprint() {
247        let m = Model::new("t");
248        param!(m, cap = 10.0);
249        variable!(m, x >= 0.0);
250        constraint!(m, c, x <= cap);
251        objective!(m, Max, x);
252
253        let s1 = snapshot(&m).unwrap();
254        cap.set_param_value(20.0);
255        let s2 = snapshot(&m).unwrap();
256        assert_ne!(s1.fingerprint, s2.fingerprint, "row bound changed");
257    }
258
259    #[test]
260    fn constraint_coeff_change_breaks_fingerprint() {
261        let m = Model::new("t");
262        param!(m, a = 1.0);
263        variable!(m, x >= 0.0);
264        variable!(m, y >= 0.0);
265        constraint!(m, c, a * x + y <= 10.0);
266        objective!(m, Max, x + y);
267
268        let s1 = snapshot(&m).unwrap();
269        a.set_param_value(3.0);
270        let s2 = snapshot(&m).unwrap();
271        assert_ne!(s1.fingerprint, s2.fingerprint, "matrix coefficient changed");
272    }
273
274    #[test]
275    fn nonlinear_objective_is_rejected() {
276        let m = Model::new("t");
277        variable!(m, x >= 0.0);
278        objective!(m, Min, x.powi(2));
279        assert!(snapshot(&m).is_err());
280    }
281
282    #[test]
283    fn soc_constraint_is_rejected() {
284        let m = Model::new("t");
285        variable!(m, x >= 0.0);
286        variable!(m, t >= 0.0);
287        m.add_soc_constraint("cone", [x], t);
288        objective!(m, Min, t);
289        assert!(matches!(
290            snapshot(&m),
291            Err(crate::status::SolverError::UnsupportedKind(ModelKind::SOCP))
292        ));
293    }
294
295    #[test]
296    fn feasibility_is_a_zero_objective() {
297        let m = Model::new("feas");
298        variable!(m, x >= 0.0);
299        variable!(m, y >= 0.0);
300        constraint!(m, c, x + y == 5.0);
301        objective!(m, Feasibility);
302
303        let s = snapshot(&m).expect("feasibility model snapshots");
304        assert!(s.obj_costs.iter().all(|&c| c.abs() < 1e-12), "costs = {:?}", s.obj_costs);
305        assert!(s.obj_constant.abs() < 1e-12, "constant = {}", s.obj_constant);
306    }
307
308    #[test]
309    fn undeclared_objective_is_rejected() {
310        let m = Model::new("undeclared");
311        variable!(m, x >= 0.0);
312        constraint!(m, c, x <= 5.0);
313        assert!(matches!(
314            snapshot(&m),
315            Err(crate::status::SolverError::Core(oximo_core::Error::NoObjective))
316        ));
317    }
318}