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