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#[derive(Clone, Debug, PartialEq)]
23pub struct Snapshot {
24 pub obj_costs: Vec<f64>,
26 pub obj_constant: f64,
28 pub lb: Vec<f64>,
30 pub ub: Vec<f64>,
32 pub fingerprint: u64,
34}
35
36pub 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
133fn 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
145fn 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}