use std::collections::{BTreeMap, BTreeSet, HashMap};
use crate::interner::NameId;
use crate::model::{Coefficient, Constraint};
use crate::problem::LpProblem;
pub type Normaliser<'a> = &'a dyn Fn(&str) -> String;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DiffTol {
pub abs: f64,
pub rel: f64,
}
impl Default for DiffTol {
fn default() -> Self {
Self { abs: 0.0, rel: 0.0 }
}
}
impl DiffTol {
#[must_use]
pub fn differ(self, a: f64, b: f64) -> bool {
debug_assert!(self.abs.is_finite() && self.abs >= 0.0, "abs tolerance must be finite and non-negative");
debug_assert!(self.rel.is_finite() && self.rel >= 0.0, "rel tolerance must be finite and non-negative");
if a.is_nan() || b.is_nan() {
return a.is_nan() != b.is_nan();
}
let diff = (a - b).abs();
if diff == 0.0 {
return false;
}
let scale = a.abs().max(b.abs());
diff > self.abs && diff > self.rel * scale
}
}
#[derive(Default)]
pub struct DiffOptions<'a> {
pub tol: DiffTol,
pub normalise: Option<Normaliser<'a>>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct LpDiff {
pub vars_added: Vec<String>,
pub vars_removed: Vec<String>,
pub vars_type_changed: Vec<(String, String, String)>,
pub cons_added: Vec<String>,
pub cons_removed: Vec<String>,
pub cons_modified: Vec<(String, Vec<String>)>,
pub objs_added: Vec<String>,
pub objs_removed: Vec<String>,
pub objs_modified: Vec<(String, Vec<String>)>,
}
impl LpDiff {
#[must_use]
pub fn is_empty(&self) -> bool {
self.vars_added.is_empty()
&& self.vars_removed.is_empty()
&& self.vars_type_changed.is_empty()
&& self.cons_added.is_empty()
&& self.cons_removed.is_empty()
&& self.cons_modified.is_empty()
&& self.objs_added.is_empty()
&& self.objs_removed.is_empty()
&& self.objs_modified.is_empty()
}
}
impl LpProblem {
#[must_use]
pub fn diff(&self, other: &LpProblem, options: &DiffOptions) -> LpDiff {
compare(self, other, options)
}
}
fn coeff_map(problem: &LpProblem, coeffs: &[Coefficient], normalise: Normaliser) -> BTreeMap<String, f64> {
coeffs.iter().map(|c| (normalise(problem.resolve(c.name)), c.value)).collect()
}
fn count_coeff_diffs(m1: &BTreeMap<String, f64>, m2: &BTreeMap<String, f64>, tol: DiffTol) -> usize {
let mut diffs = 0usize;
for (k, v1) in m1 {
match m2.get(k) {
Some(v2) if tol.differ(*v1, *v2) => diffs += 1,
None => diffs += 1,
_ => {}
}
}
diffs += m2.keys().filter(|k| !m1.contains_key(*k)).count();
diffs
}
fn diff_modified_constraints(
p1: &LpProblem,
p2: &LpProblem,
ccons1: &HashMap<String, NameId>,
ccons2: &HashMap<String, NameId>,
common: &[String],
normalise: Normaliser,
tol: DiffTol,
) -> Vec<(String, Vec<String>)> {
let mut modified = Vec::new();
for name in common {
let c1 = &p1.constraints[&ccons1[name]];
let c2 = &p2.constraints[&ccons2[name]];
let mut changes = Vec::new();
match (c1, c2) {
(
Constraint::Standard { coefficients: cf1, operator: op1, rhs: r1, .. },
Constraint::Standard { coefficients: cf2, operator: op2, rhs: r2, .. },
) => {
if op1 != op2 {
changes.push(format!("operator {op1} -> {op2}"));
}
if tol.differ(*r1, *r2) {
changes.push(format!("rhs {r1} -> {r2}"));
}
let coef_diffs = count_coeff_diffs(&coeff_map(p1, cf1, normalise), &coeff_map(p2, cf2, normalise), tol);
if coef_diffs > 0 {
changes.push(format!("{coef_diffs} coefficient change(s)"));
}
}
(Constraint::SOS { .. }, Constraint::SOS { .. }) => {
if c1 != c2 {
changes.push("SOS definition changed".to_string());
}
}
_ => changes.push("constraint kind changed (Standard <-> SOS)".to_string()),
}
if !changes.is_empty() {
modified.push((name.clone(), changes));
}
}
modified
}
fn diff_modified_objectives(
p1: &LpProblem,
p2: &LpProblem,
cobjs1: &HashMap<String, NameId>,
cobjs2: &HashMap<String, NameId>,
common: &[String],
normalise: Normaliser,
tol: DiffTol,
) -> Vec<(String, Vec<String>)> {
let mut modified = Vec::new();
for name in common {
let o1 = &p1.objectives[&cobjs1[name]];
let o2 = &p2.objectives[&cobjs2[name]];
let coef_diffs = count_coeff_diffs(&coeff_map(p1, &o1.coefficients, normalise), &coeff_map(p2, &o2.coefficients, normalise), tol);
let mut changes = Vec::new();
if coef_diffs > 0 {
changes.push(format!("{coef_diffs} coefficient change(s)"));
}
if tol.differ(o1.constant, o2.constant) {
changes.push(format!("constant: {} -> {}", o1.constant, o2.constant));
}
if !changes.is_empty() {
modified.push((name.clone(), changes));
}
}
modified
}
#[must_use]
#[allow(clippy::similar_names)]
pub fn compare(p1: &LpProblem, p2: &LpProblem, options: &DiffOptions) -> LpDiff {
let identity = |name: &str| name.to_string();
let normalise: Normaliser = options.normalise.unwrap_or(&identity);
let tol = options.tol;
let canon = |problem: &LpProblem, ids: Vec<NameId>| -> HashMap<String, NameId> {
ids.iter().map(|id| (normalise(problem.resolve(*id)), *id)).collect()
};
let cvars1 = canon(p1, p1.variables.keys().copied().collect());
let cvars2 = canon(p2, p2.variables.keys().copied().collect());
let ccons1: HashMap<String, NameId> = p1.constraints.values().map(|c| (normalise(p1.resolve(c.name())), c.name())).collect();
let ccons2: HashMap<String, NameId> = p2.constraints.values().map(|c| (normalise(p2.resolve(c.name())), c.name())).collect();
let cobjs1 = canon(p1, p1.objectives.keys().copied().collect());
let cobjs2 = canon(p2, p2.objectives.keys().copied().collect());
let set_of = |m: &HashMap<String, NameId>| -> BTreeSet<String> { m.keys().cloned().collect() };
let vars1 = set_of(&cvars1);
let vars2 = set_of(&cvars2);
let cons1 = set_of(&ccons1);
let cons2 = set_of(&ccons2);
let objs1 = set_of(&cobjs1);
let objs2 = set_of(&cobjs2);
let cons_common: Vec<String> = cons1.intersection(&cons2).cloned().collect();
let objs_common: Vec<String> = objs1.intersection(&objs2).cloned().collect();
let mut vars_type_changed = Vec::new();
for name in vars1.intersection(&vars2) {
let t1 = &p1.variables[&cvars1[name]].var_type;
let t2 = &p2.variables[&cvars2[name]].var_type;
if t1 != t2 {
vars_type_changed.push((name.clone(), format!("{t1:?}"), format!("{t2:?}")));
}
}
LpDiff {
vars_added: vars2.difference(&vars1).cloned().collect(),
vars_removed: vars1.difference(&vars2).cloned().collect(),
vars_type_changed,
cons_added: cons2.difference(&cons1).cloned().collect(),
cons_removed: cons1.difference(&cons2).cloned().collect(),
cons_modified: diff_modified_constraints(p1, p2, &ccons1, &ccons2, &cons_common, normalise, tol),
objs_added: objs2.difference(&objs1).cloned().collect(),
objs_removed: objs1.difference(&objs2).cloned().collect(),
objs_modified: diff_modified_objectives(p1, p2, &cobjs1, &cobjs2, &objs_common, normalise, tol),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn strip_index_suffix(name: &str) -> String {
match name.rfind('_') {
Some(idx) if idx + 1 < name.len() && name[idx + 1..].chars().all(|ch| ch.is_ascii_digit()) => name[..idx].to_string(),
_ => name.to_string(),
}
}
fn opts(tol: DiffTol) -> DiffOptions<'static> {
DiffOptions { tol, normalise: None }
}
#[test]
fn tol_zero_reports_any_nonzero_difference() {
let tol = DiffTol::default();
assert!(!tol.differ(1.0, 1.0));
assert!(tol.differ(1.0, 1.0 + 1e-12));
}
#[test]
fn tol_equal_within_absolute() {
let tol = DiffTol { abs: 0.5, rel: 0.0 };
assert!(!tol.differ(1.0, 1.4));
assert!(tol.differ(1.0, 1.6));
}
#[test]
fn tol_relative_scales_with_magnitude() {
let tol = DiffTol { abs: 0.0, rel: 0.01 };
assert!(!tol.differ(1000.0, 1005.0));
assert!(tol.differ(1000.0, 1020.0));
}
#[test]
fn tol_requires_both_tolerances_exceeded() {
let tol = DiffTol { abs: 10.0, rel: 0.5 };
assert!(!tol.differ(10.0, 18.0));
assert!(!tol.differ(12.0, 24.0));
}
#[test]
fn tol_nan_operands() {
let tol = DiffTol::default();
assert!(tol.differ(f64::NAN, 1.0));
assert!(tol.differ(1.0, f64::NAN));
assert!(!tol.differ(f64::NAN, f64::NAN));
}
#[test]
fn tol_zero_baseline() {
let tol = DiffTol { abs: 0.0, rel: 0.5 };
assert!(tol.differ(0.0, 1.0));
assert!(!tol.differ(0.0, 0.0));
}
fn parse(src: &str) -> LpProblem {
LpProblem::parse(src).expect("test LP must parse")
}
#[test]
fn detects_added_and_removed_variables() {
let p1 = parse("Minimize\n obj: 2 x + 3 y\nSubject To\n c1: x + y >= 1\nEnd");
let p2 = parse("Minimize\n obj: 2 x + 3 z\nSubject To\n c1: x + z >= 1\nEnd");
let diff = p1.diff(&p2, &opts(DiffTol::default()));
assert_eq!(diff.vars_added, vec!["z".to_string()]);
assert_eq!(diff.vars_removed, vec!["y".to_string()]);
}
#[test]
fn detects_variable_type_change() {
let p1 = parse("Minimize\n obj: x\nSubject To\n c1: x >= 1\nEnd");
let p2 = parse("Minimize\n obj: x\nSubject To\n c1: x >= 1\nintegers\n x\nEnd");
let diff = p1.diff(&p2, &opts(DiffTol::default()));
assert_eq!(diff.vars_type_changed.len(), 1);
assert_eq!(diff.vars_type_changed[0].0, "x");
}
#[test]
fn detects_added_and_removed_constraints() {
let p1 = parse("Minimize\n obj: x + y\nSubject To\n c1: x + y >= 1\nEnd");
let p2 = parse("Minimize\n obj: x + y\nSubject To\n c2: x + y >= 1\nEnd");
let diff = p1.diff(&p2, &opts(DiffTol::default()));
assert_eq!(diff.cons_added, vec!["c2".to_string()]);
assert_eq!(diff.cons_removed, vec!["c1".to_string()]);
}
#[test]
fn detects_modified_constraint_operator_rhs_and_coefficients() {
let p1 = parse("Minimize\n obj: x + y\nSubject To\n c1: x + y >= 1\nEnd");
let p2 = parse("Minimize\n obj: x + y\nSubject To\n c1: 2 x + y <= 5\nEnd");
let diff = p1.diff(&p2, &opts(DiffTol::default()));
assert_eq!(diff.cons_modified.len(), 1);
let (name, changes) = &diff.cons_modified[0];
assert_eq!(name, "c1");
assert!(changes.iter().any(|c| c.contains("operator")));
assert!(changes.iter().any(|c| c.contains("rhs")));
assert!(changes.iter().any(|c| c.contains("coefficient change")));
}
#[test]
fn detects_added_removed_and_modified_objectives() {
let p1 = parse("Minimize\n obj: 2 x + 3 y\nSubject To\n c1: x + y >= 1\nEnd");
let p2 = parse("Minimize\n obj: 5 x + 3 y\nSubject To\n c1: x + y >= 1\nEnd");
let diff = p1.diff(&p2, &opts(DiffTol::default()));
assert_eq!(diff.objs_modified.len(), 1);
assert_eq!(diff.objs_modified[0].0, "obj");
let single = parse("Minimize\n obj: 2 x + 3 y\nSubject To\n c1: x + y >= 1\nEnd");
let double = parse("Minimize\n obj: 2 x + 3 y\n obj2: x\nSubject To\n c1: x + y >= 1\nEnd");
let diff = single.diff(&double, &opts(DiffTol::default()));
assert_eq!(diff.objs_added, vec!["obj2".to_string()]);
assert!(diff.objs_removed.is_empty());
let diff = double.diff(&single, &opts(DiffTol::default()));
assert_eq!(diff.objs_removed, vec!["obj2".to_string()]);
assert!(diff.objs_added.is_empty());
}
#[test]
fn rhs_change_within_tolerance_is_ignored() {
let p1 = parse("Minimize\n obj: x\nSubject To\n c1: x >= 100\nEnd");
let p2 = parse("Minimize\n obj: x\nSubject To\n c1: x >= 100.4\nEnd");
let diff = p1.diff(&p2, &opts(DiffTol { abs: 0.5, rel: 0.0 }));
assert!(diff.cons_modified.is_empty());
let diff = p1.diff(&p2, &opts(DiffTol::default()));
assert_eq!(diff.cons_modified.len(), 1);
}
#[test]
fn normaliser_applied_on_both_sides() {
let p1 = parse("Minimize\n obj: x_1\nSubject To\n c_1: x_1 >= 1\nEnd");
let p2 = parse("Minimize\n obj: x_2\nSubject To\n c_2: x_2 >= 1\nEnd");
let diff = p1.diff(&p2, &opts(DiffTol::default()));
assert_eq!(diff.vars_added, vec!["x_2".to_string()]);
assert_eq!(diff.vars_removed, vec!["x_1".to_string()]);
let options = DiffOptions { tol: DiffTol::default(), normalise: Some(&strip_index_suffix) };
let diff = p1.diff(&p2, &options);
assert!(diff.vars_added.is_empty());
assert!(diff.vars_removed.is_empty());
assert!(diff.cons_added.is_empty());
assert!(diff.cons_removed.is_empty());
assert!(diff.cons_modified.is_empty());
}
#[test]
fn detects_sos_weight_change() {
let p1 = parse("Minimize\n obj: x + y\nSubject To\n c1: x + y >= 1\nSOS\n sos_a: S1:: x:1 y:2\nEnd");
let p2 = parse("Minimize\n obj: x + y\nSubject To\n c1: x + y >= 1\nSOS\n sos_a: S1:: x:1 y:3\nEnd");
let diff = p1.diff(&p2, &opts(DiffTol::default()));
assert_eq!(diff.cons_modified.len(), 1);
let (name, changes) = &diff.cons_modified[0];
assert_eq!(name, "sos_a");
assert_eq!(changes, &vec!["SOS definition changed".to_string()]);
}
#[test]
fn detects_constraint_kind_change() {
let p1 = parse("Minimize\n obj: x + y\nSubject To\n c1: x + y >= 1\n mix: x + y <= 5\nEnd");
let p2 = parse("Minimize\n obj: x + y\nSubject To\n c1: x + y >= 1\nSOS\n mix: S1:: x:1 y:2\nEnd");
let diff = p1.diff(&p2, &opts(DiffTol::default()));
assert_eq!(diff.cons_modified.len(), 1);
let (name, changes) = &diff.cons_modified[0];
assert_eq!(name, "mix");
assert_eq!(changes, &vec!["constraint kind changed (Standard <-> SOS)".to_string()]);
}
#[test]
fn identical_problems_produce_empty_diff() {
let src = "Minimize\n obj: 2 x + 3 y\nSubject To\n c1: x + y >= 1\nBounds\n x <= 4\nSOS\n sos_a: S1:: x:1 y:2\nEnd";
let p1 = parse(src);
let p2 = parse(src);
let diff = p1.diff(&p2, &opts(DiffTol::default()));
assert!(diff.is_empty(), "identical problems must diff empty: {diff:?}");
}
#[test]
fn objective_coefficient_additions_and_removals_are_counted() {
let p1 = parse("Minimize\n obj: 2 x + 3 y\nSubject To\n c1: x >= 1\nEnd");
let p2 = parse("Minimize\n obj: 2 x + 4 z\nSubject To\n c1: x >= 1\nEnd");
let diff = p1.diff(&p2, &opts(DiffTol::default()));
assert_eq!(diff.objs_modified.len(), 1);
let (name, changes) = &diff.objs_modified[0];
assert_eq!(name, "obj");
assert_eq!(changes, &vec!["2 coefficient change(s)".to_string()]);
}
}