1use std::hash::Hasher;
2
3use oximo_core::{Model, ModelKind, ObjectiveSense, Variable};
4use oximo_expr::{ExprArena, VarId, extract_linear};
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 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 constraints = model.constraints();
57
58 let objective = model.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 = extract_linear(&arena, o.expr).ok_or(SolverError::Nonlinear)?;
64 let mut by_id = vec![0.0; vars.len()];
65 for (v, c) in &lin.coeffs {
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.iter() {
79 obj_costs.push(obj_by_id[v.id.index()]);
80 lb.push(v.lb);
81 ub.push(v.ub);
82 }
83
84 let arena_ref: &ExprArena = &arena;
85 for c in constraints.iter() {
86 let t = extract_linear(arena_ref, c.lhs).ok_or(SolverError::Nonlinear)?;
87 hash_row(&mut hasher, c.lower - t.constant, c.upper - t.constant, &t.coeffs);
88 }
89
90 Ok(Snapshot { obj_costs, obj_constant, lb, ub, fingerprint: hasher.finish() })
91}
92
93fn hash_header(h: &mut FxHasher, vars: &[Variable], sense: ObjectiveSense) {
95 h.write_usize(vars.len());
96 h.write_u8(match sense {
97 ObjectiveSense::Minimize => 0,
98 ObjectiveSense::Maximize => 1,
99 });
100 for v in vars {
101 h.write_u8(u8::from(v.domain.is_integer()));
102 }
103}
104
105fn hash_row(h: &mut FxHasher, lower: f64, upper: f64, coeffs: &[(VarId, f64)]) {
108 h.write_u64(lower.to_bits());
109 h.write_u64(upper.to_bits());
110 let mut terms: Vec<(usize, u64)> =
111 coeffs.iter().map(|(v, c)| (v.index(), c.to_bits())).collect();
112 terms.sort_unstable();
113 for (vi, cb) in terms {
114 h.write_usize(vi);
115 h.write_u64(cb);
116 }
117}
118
119#[cfg(test)]
120mod tests {
121 use oximo_core::prelude::*;
122
123 use super::snapshot;
124
125 #[test]
126 fn objective_coeff_change_keeps_fingerprint() {
127 let m = Model::new("t");
128 param!(m, p = 1.0);
129 variable!(m, x >= 0.0);
130 variable!(m, y >= 0.0);
131 constraint!(m, c, x + y <= 10.0);
132 objective!(m, Max, p * x + 2.0 * y);
133
134 let s1 = snapshot(&m).unwrap();
135 p.set_param_value(5.0);
136 let s2 = snapshot(&m).unwrap();
137 assert_eq!(s1.fingerprint, s2.fingerprint, "structure unchanged");
138 assert_ne!(s1.obj_costs, s2.obj_costs, "coefficient moved");
139 }
140
141 #[test]
142 fn bound_change_keeps_fingerprint() {
143 let m = Model::new("t");
144 variable!(m, x >= 0.0);
145 constraint!(m, c, x <= 10.0);
146 objective!(m, Max, x);
147
148 let s1 = snapshot(&m).unwrap();
149 m.fix(x, 3.0);
150 let s2 = snapshot(&m).unwrap();
151 assert_eq!(s1.fingerprint, s2.fingerprint, "structure unchanged");
152 assert_ne!(s1.ub, s2.ub, "bound moved");
153 }
154
155 #[test]
156 fn constraint_rhs_change_breaks_fingerprint() {
157 let m = Model::new("t");
158 param!(m, cap = 10.0);
159 variable!(m, x >= 0.0);
160 constraint!(m, c, x <= cap);
161 objective!(m, Max, x);
162
163 let s1 = snapshot(&m).unwrap();
164 cap.set_param_value(20.0);
165 let s2 = snapshot(&m).unwrap();
166 assert_ne!(s1.fingerprint, s2.fingerprint, "row bound changed");
167 }
168
169 #[test]
170 fn constraint_coeff_change_breaks_fingerprint() {
171 let m = Model::new("t");
172 param!(m, a = 1.0);
173 variable!(m, x >= 0.0);
174 variable!(m, y >= 0.0);
175 constraint!(m, c, a * x + y <= 10.0);
176 objective!(m, Max, x + y);
177
178 let s1 = snapshot(&m).unwrap();
179 a.set_param_value(3.0);
180 let s2 = snapshot(&m).unwrap();
181 assert_ne!(s1.fingerprint, s2.fingerprint, "matrix coefficient changed");
182 }
183
184 #[test]
185 fn nonlinear_objective_is_rejected() {
186 let m = Model::new("t");
187 variable!(m, x >= 0.0);
188 objective!(m, Min, x.powi(2));
189 assert!(snapshot(&m).is_err());
190 }
191
192 #[test]
193 fn soc_constraint_is_rejected() {
194 let m = Model::new("t");
195 variable!(m, x >= 0.0);
196 variable!(m, t >= 0.0);
197 m.add_soc_constraint("cone", [x], t);
198 objective!(m, Min, t);
199 assert!(matches!(
200 snapshot(&m),
201 Err(crate::status::SolverError::UnsupportedKind(ModelKind::SOCP))
202 ));
203 }
204
205 #[test]
206 fn feasibility_is_a_zero_objective() {
207 let m = Model::new("feas");
208 variable!(m, x >= 0.0);
209 variable!(m, y >= 0.0);
210 constraint!(m, c, x + y == 5.0);
211 objective!(m, Feasibility);
212
213 let s = snapshot(&m).expect("feasibility model snapshots");
214 assert!(s.obj_costs.iter().all(|&c| c.abs() < 1e-12), "costs = {:?}", s.obj_costs);
215 assert!(s.obj_constant.abs() < 1e-12, "constant = {}", s.obj_constant);
216 }
217
218 #[test]
219 fn undeclared_objective_is_rejected() {
220 let m = Model::new("undeclared");
221 variable!(m, x >= 0.0);
222 constraint!(m, c, x <= 5.0);
223 assert!(matches!(
224 snapshot(&m),
225 Err(crate::status::SolverError::Core(oximo_core::Error::NoObjective))
226 ));
227 }
228}