use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use pounce_cli::nl_quadratic::recognize_expr;
use pounce_cli::nl_reader::{
BinOp, Expr, NlProblem, NlProblemParts, NlTnlp, UnaryOp, read_nl_file,
};
use pounce_nlp::tnlp::{SparsityRequest, TNLP};
const REL_TOL: f64 = 1e-12;
const WORST_OBSERVED_REL: f64 = 1e-14;
const MAX_HESS_ULPS: u64 = 1;
const WORST_OBSERVED_HESS_REL: f64 = 1e-15;
const DENSE_MULTI_ROW_HESS_REL: f64 = 4e-15;
struct Rng(u64);
impl Rng {
fn next_f64(&mut self) -> f64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
(self.0 >> 11) as f64 / (1u64 << 52) as f64 * 4.0 - 2.0
}
}
fn probe_points(prob: &NlProblem, k: usize) -> Vec<Vec<f64>> {
let mut out = vec![prob.x0.clone()];
let mut rng = Rng(0x5eed_1234_9e37_79b9);
for _ in 0..k {
out.push(prob.x0.iter().map(|&v| v + rng.next_f64()).collect());
}
out
}
fn as_map(irow: &[i32], jcol: &[i32], values: &[f64]) -> BTreeMap<(i32, i32), f64> {
let mut m = BTreeMap::new();
for k in 0..values.len() {
m.insert((irow[k], jcol[k]), values[k]);
}
m
}
fn rel_dev(a: f64, b: f64, floor: f64) -> f64 {
if a.is_nan() || b.is_nan() {
return if a.is_nan() && b.is_nan() {
0.0
} else {
f64::INFINITY
};
}
if a == b {
return 0.0;
}
if a == 0.0 && floor == 0.0 {
return b.abs();
}
((a - b) / a.abs().max(floor)).abs()
}
fn bit_equal(a: f64, b: f64) -> bool {
if a.is_nan() || b.is_nan() {
return a.is_nan() && b.is_nan();
}
a.to_bits() == b.to_bits()
}
#[derive(Default)]
struct Report {
models_with_quadratic: usize,
quadratic_rows: usize,
worst_rel: f64,
hess_entries: usize,
hess_bit_diffs: usize,
worst_hess_ulps: u64,
worst_hess_where: String,
worst_hess_rel: f64,
worst_hess_rel_where: String,
obj_entries: usize,
obj_bit_diffs: usize,
}
fn ulp_distance(a: f64, b: f64) -> u64 {
if !a.is_finite() || !b.is_finite() || a.is_sign_negative() != b.is_sign_negative() {
return u64::MAX;
}
a.to_bits().abs_diff(b.to_bits())
}
fn compare_model(path: &Path, rep: &mut Report) {
let Ok(prob) = read_nl_file(path) else { return };
compare_problem(&path.display().to_string(), prob, 0.0, rep);
}
fn compare_problem(name: &str, prob: NlProblem, floor: f64, rep: &mut Report) {
let n = prob.n;
let m = prob.m;
if n == 0 {
return;
}
let points = probe_points(&prob, 3);
let Ok(mut fast) = NlTnlp::try_new_with_quadratic(prob.clone(), true) else {
return;
};
let Ok(mut slow) = NlTnlp::try_new_with_quadratic(prob.clone(), false) else {
return;
};
let quad_rows = (0..m).filter(|&i| fast.quadratic_row(i)).count();
let quad_obj = fast.quadratic_objective();
if quad_rows == 0 && !quad_obj {
return;
}
rep.models_with_quadratic += 1;
rep.quadratic_rows += quad_rows;
let (fast_jac, slow_jac) = (
structure(&mut fast, Kind::Jac),
structure(&mut slow, Kind::Jac),
);
let (fast_h, slow_h) = (
structure(&mut fast, Kind::Hess),
structure(&mut slow, Kind::Hess),
);
for (p, x) in points.iter().enumerate() {
let (ff, fs) = (
fast.eval_f(x, true).expect("eval_f (fast)"),
slow.eval_f(x, true).expect("eval_f (tape)"),
);
let d = rel_dev(fs, ff, floor);
rep.worst_rel = rep.worst_rel.max(d);
assert!(
d <= REL_TOL,
"{name}: probe {p}: eval_f disagrees: tape {fs:?} vs quad {ff:?} (rel {d:.3e})"
);
rep.obj_entries += 1;
if !bit_equal(ff, fs) {
rep.obj_bit_diffs += 1;
}
let (mut gradf, mut grads) = (vec![0.0; n], vec![0.0; n]);
assert!(
fast.eval_grad_f(x, true, &mut gradf),
"{name}: eval_grad_f (fast)"
);
assert!(
slow.eval_grad_f(x, true, &mut grads),
"{name}: eval_grad_f (tape)"
);
for j in 0..n {
let d = rel_dev(grads[j], gradf[j], floor);
rep.worst_rel = rep.worst_rel.max(d);
assert!(
d <= REL_TOL,
"{name}: probe {p}: grad_f[{j}] disagrees: tape {:?} vs quad {:?} (rel {d:.3e})",
grads[j],
gradf[j]
);
rep.obj_entries += 1;
if !bit_equal(gradf[j], grads[j]) {
rep.obj_bit_diffs += 1;
}
}
let (mut gf, mut gs) = (vec![0.0; m], vec![0.0; m]);
assert!(fast.eval_g(x, true, &mut gf), "{name}: eval_g (fast)");
assert!(slow.eval_g(x, true, &mut gs), "{name}: eval_g (tape)");
for i in 0..m {
let d = rel_dev(gs[i], gf[i], floor);
rep.worst_rel = rep.worst_rel.max(d);
assert!(
d <= REL_TOL,
"{name}: probe {p}: eval_g row {i} disagrees: tape {:?} vs quad {:?} (rel {d:.3e})",
gs[i],
gf[i]
);
}
let mut vf = vec![0.0; fast_jac.0.len()];
let mut vs = vec![0.0; slow_jac.0.len()];
assert!(
fast.eval_jac_g(Some(x), true, SparsityRequest::Values { values: &mut vf }),
"{name}: eval_jac_g (fast)"
);
assert!(
slow.eval_jac_g(Some(x), true, SparsityRequest::Values { values: &mut vs }),
"{name}: eval_jac_g (tape)"
);
let jf = as_map(&fast_jac.0, &fast_jac.1, &vf);
let js = as_map(&slow_jac.0, &slow_jac.1, &vs);
for key in jf.keys().chain(js.keys()) {
let (a, b) = (
js.get(key).copied().unwrap_or(0.0),
jf.get(key).copied().unwrap_or(0.0),
);
let d = rel_dev(a, b, floor);
rep.worst_rel = rep.worst_rel.max(d);
assert!(
d <= REL_TOL,
"{name}: probe {p}: jac {key:?} disagrees: tape {a:?} vs quad {b:?} (rel {d:.3e})"
);
}
let obj_factor = 0.75;
let lambda: Vec<f64> = (0..m).map(|i| 1.0 + (i % 7) as f64 * 0.5).collect();
let mut hf = vec![0.0; fast_h.0.len()];
let mut hs = vec![0.0; slow_h.0.len()];
assert!(
fast.eval_h(
Some(x),
true,
obj_factor,
Some(&lambda),
true,
SparsityRequest::Values { values: &mut hf }
),
"{name}: eval_h (fast)"
);
assert!(
slow.eval_h(
Some(x),
true,
obj_factor,
Some(&lambda),
true,
SparsityRequest::Values { values: &mut hs }
),
"{name}: eval_h (tape)"
);
let mf = as_map(&fast_h.0, &fast_h.1, &hf);
let ms = as_map(&slow_h.0, &slow_h.1, &hs);
for key in mf.keys().chain(ms.keys()) {
let (a, b) = (
ms.get(key).copied().unwrap_or(0.0),
mf.get(key).copied().unwrap_or(0.0),
);
rep.hess_entries += 1;
if !bit_equal(a, b) {
rep.hess_bit_diffs += 1;
let u = ulp_distance(a, b);
if u > rep.worst_hess_ulps {
rep.worst_hess_ulps = u;
rep.worst_hess_where = format!("{name}: probe {p}: hessian {key:?}");
}
let r = rel_dev(a, b, floor);
if r > rep.worst_hess_rel {
rep.worst_hess_rel = r;
rep.worst_hess_rel_where = format!("{name}: probe {p}: hessian {key:?}");
}
}
}
}
}
enum Kind {
Jac,
Hess,
}
fn structure(t: &mut NlTnlp, kind: Kind) -> (Vec<i32>, Vec<i32>) {
let info = t.get_nlp_info().expect("nlp info");
let nnz = match kind {
Kind::Jac => info.nnz_jac_g,
Kind::Hess => info.nnz_h_lag,
} as usize;
let (mut irow, mut jcol) = (vec![0i32; nnz], vec![0i32; nnz]);
let req = SparsityRequest::Structure {
irow: &mut irow,
jcol: &mut jcol,
};
let ok = match kind {
Kind::Jac => t.eval_jac_g(None, true, req),
Kind::Hess => t.eval_h(None, true, 1.0, None, true, req),
};
assert!(ok, "structure request declined");
(irow, jcol)
}
fn all_fixtures() -> Vec<PathBuf> {
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
walk(&p, out);
} else if p.extension().is_some_and(|x| x == "nl") {
out.push(p);
}
}
}
let base = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests");
let mut out = Vec::new();
walk(&base.join("fixtures"), &mut out);
walk(&base.join("fixtures_issue_49"), &mut out);
out.sort();
out
}
#[test]
fn every_quadratic_fixture_evaluates_the_same_both_ways() {
let fixtures = all_fixtures();
assert!(
fixtures.len() >= 50,
"expected the fixture corpus, found {} files",
fixtures.len()
);
let mut rep = Report::default();
for f in &fixtures {
compare_model(f, &mut rep);
}
assert!(
rep.models_with_quadratic >= 20,
"the corpus should exercise the fast path on many models, got {}",
rep.models_with_quadratic
);
assert!(
rep.hess_entries >= 1_000,
"too few Hessian entries compared: {}",
rep.hess_entries
);
eprintln!(
"[quad differential] {} models, {} quadratic rows, {} hessian entries \
({} not bit-identical, worst {} ulp at {}), worst g/jac rel deviation {:.3e}",
rep.models_with_quadratic,
rep.quadratic_rows,
rep.hess_entries,
rep.hess_bit_diffs,
rep.worst_hess_ulps,
rep.worst_hess_where,
rep.worst_rel
);
eprintln!(
"[quad differential] worst hessian relative deviation {:.3e} at {}",
rep.worst_hess_rel, rep.worst_hess_rel_where
);
assert!(
rep.worst_hess_rel <= WORST_OBSERVED_HESS_REL,
"hessian relative deviation grew past what the corpus produced: \
{:.3e} > {WORST_OBSERVED_HESS_REL:.0e} at {}",
rep.worst_hess_rel,
rep.worst_hess_rel_where
);
eprintln!(
"[quad differential] objective: {} values compared, {} not bit-identical",
rep.obj_entries, rep.obj_bit_diffs
);
assert!(
rep.worst_hess_ulps <= MAX_HESS_ULPS,
"hessian disagreement grew past what the corpus produced: {} ulp at {}",
rep.worst_hess_ulps,
rep.worst_hess_where
);
assert!(
rep.hess_bit_diffs * 100 <= rep.hess_entries,
"too many Hessian entries stopped being bit-identical: {} of {}",
rep.hess_bit_diffs,
rep.hess_entries
);
assert!(
rep.worst_rel <= WORST_OBSERVED_REL,
"g/jac deviation grew past what the corpus produced: {:.3e} > {WORST_OBSERVED_REL:.0e}",
rep.worst_rel
);
}
#[test]
fn a_model_with_no_quadratic_part_is_byte_identical_on_both_paths() {
let mut checked = 0usize;
for f in all_fixtures() {
let Ok(prob) = read_nl_file(&f) else { continue };
if prob.n == 0 {
continue;
}
let Ok(mut fast) = NlTnlp::try_new_with_quadratic(prob.clone(), true) else {
continue;
};
if fast.quadratic_objective() || (0..prob.m).any(|i| fast.quadratic_row(i)) {
continue;
}
let Ok(mut slow) = NlTnlp::try_new_with_quadratic(prob.clone(), false) else {
continue;
};
let (a, b) = (
structure(&mut fast, Kind::Hess),
structure(&mut slow, Kind::Hess),
);
assert_eq!(a, b, "{}: Hessian pattern moved", f.display());
let x = prob.x0.clone();
let (mut gf, mut gs) = (vec![0.0; prob.m], vec![0.0; prob.m]);
assert!(fast.eval_g(&x, true, &mut gf));
assert!(slow.eval_g(&x, true, &mut gs));
for i in 0..prob.m {
assert!(
bit_equal(gf[i], gs[i]),
"{}: row {i} moved on a model with nothing quadratic",
f.display()
);
}
checked += 1;
}
assert!(
checked >= 5,
"expected some non-quadratic models, got {checked}"
);
}
const BATTERY_VARS: usize = 4;
const BATTERY_WORST_REL: f64 = 1e-14;
fn battery_monomial(rng: &mut Rng2) -> Expr {
fn c(rng: &mut Rng2) -> Expr {
const COEFS: [f64; 8] = [1.0, -1.0, 2.0, -3.0, 0.5, -0.25, 4.0, 0.125];
Expr::Const(COEFS[rng_below(rng, 8) as usize])
}
fn v(rng: &mut Rng2) -> Expr {
Expr::Var(rng_below(rng, BATTERY_VARS as u64) as usize)
}
let coef = c(rng);
let body = match rng_below(rng, 6) {
0 => return coef,
1 => Expr::Binary(BinOp::Mul, Box::new(coef), Box::new(v(rng))),
2 => Expr::Binary(
BinOp::Mul,
Box::new(coef),
Box::new(Expr::Binary(BinOp::Mul, Box::new(v(rng)), Box::new(v(rng)))),
),
3 => Expr::Binary(BinOp::Pow, Box::new(v(rng)), Box::new(Expr::Const(2.0))),
4 => Expr::Binary(BinOp::Div, Box::new(v(rng)), Box::new(c(rng))),
_ => Expr::Unary(
UnaryOp::Neg,
Box::new(Expr::Binary(
BinOp::Mul,
Box::new(coef),
Box::new(Expr::Binary(BinOp::Mul, Box::new(v(rng)), Box::new(v(rng)))),
)),
),
};
if rng_below(rng, 4) == 0 {
Expr::Cse(std::sync::Arc::new(body))
} else {
body
}
}
fn battery_square(rng: &mut Rng2) -> Expr {
const WEIGHTS: [f64; 6] = [1.0, -1.0, 2.0, -0.5, 3.0, 0.25];
let terms = 1 + rng_below(rng, 3) as usize;
let base = battery_affine(rng, terms);
let square = Expr::Binary(BinOp::Pow, Box::new(base), Box::new(Expr::Const(2.0)));
match rng_below(rng, 3) {
0 => square,
1 => Expr::Binary(
BinOp::Mul,
Box::new(Expr::Const(WEIGHTS[rng_below(rng, 6) as usize])),
Box::new(square),
),
_ => Expr::Binary(
BinOp::Mul,
Box::new(square),
Box::new(Expr::Const(WEIGHTS[rng_below(rng, 6) as usize])),
),
}
}
fn battery_affine(rng: &mut Rng2, terms: usize) -> Expr {
fn leaf(rng: &mut Rng2) -> Expr {
const COEFS: [f64; 6] = [1.0, -1.0, 2.0, -3.0, 0.5, 4.0];
let c = Expr::Const(COEFS[rng_below(rng, 6) as usize]);
let v = Expr::Var(rng_below(rng, BATTERY_VARS as u64) as usize);
match rng_below(rng, 3) {
0 => c,
1 => v,
_ => Expr::Binary(BinOp::Mul, Box::new(c), Box::new(v)),
}
}
let mut acc = leaf(rng);
for _ in 1..terms {
acc = match rng_below(rng, 3) {
0 => Expr::Binary(BinOp::Add, Box::new(acc), Box::new(leaf(rng))),
1 => Expr::Binary(BinOp::Sub, Box::new(acc), Box::new(leaf(rng))),
_ => Expr::Unary(
UnaryOp::Neg,
Box::new(Expr::Binary(BinOp::Add, Box::new(acc), Box::new(leaf(rng)))),
),
};
}
acc
}
fn battery_body(rng: &mut Rng2, terms: usize) -> Expr {
let squares = rng_below(rng, 4) == 0;
fn leaf(rng: &mut Rng2, squares: bool) -> Expr {
if squares && rng_below(rng, 2) == 0 {
battery_square(rng)
} else {
battery_monomial(rng)
}
}
let mut acc = leaf(rng, squares);
let mut left = terms.saturating_sub(1);
while left > 0 {
acc = match rng_below(rng, 4) {
0 => Expr::Sum(vec![acc, leaf(rng, squares), leaf(rng, squares)]),
1 => Expr::Binary(BinOp::Add, Box::new(acc), Box::new(leaf(rng, squares))),
2 => Expr::Binary(BinOp::Sub, Box::new(acc), Box::new(leaf(rng, squares))),
_ => Expr::Unary(
UnaryOp::Neg,
Box::new(Expr::Binary(
BinOp::Add,
Box::new(acc),
Box::new(leaf(rng, squares)),
)),
),
};
left = left.saturating_sub(if matches!(acc, Expr::Sum(_)) { 2 } else { 1 });
}
acc
}
struct Rng2(u64);
fn rng_below(rng: &mut Rng2, n: u64) -> u64 {
rng.0 ^= rng.0 << 13;
rng.0 ^= rng.0 >> 7;
rng.0 ^= rng.0 << 17;
rng.0 % n
}
#[test]
fn a_synthetic_battery_evaluates_the_same_both_ways() {
let mut rep = Report::default();
let mut skipped = 0usize;
let mut seeds_used = 0usize;
let mut factored_bodies = 0usize;
for seed in 1..=1_500u64 {
let mut rng = Rng2(seed.wrapping_mul(0x9e37_79b9_7f4a_7c15) | 1);
let terms = 1 + rng_below(&mut rng, 5) as usize;
let rows: Vec<Expr> = (0..3).map(|_| battery_body(&mut rng, terms)).collect();
let objective = battery_body(&mut rng, terms);
let lost = |e: &Expr| recognize_expr(e).is_some_and(|q| q.lost_terms());
if lost(&objective) || rows.iter().any(lost) {
skipped += 1;
continue;
}
let n = BATTERY_VARS;
let m = rows.len();
let prob = NlProblem::from_expressions(NlProblemParts {
minimize: true,
objective,
obj_constant: 0.0,
constraints: rows,
x_l: vec![-1e19; n],
x_u: vec![1e19; n],
x0: (0..n).map(|i| 0.7 + i as f64 * 0.3).collect(),
g_l: vec![-1e19; m],
g_u: vec![1.0; m],
var_names: Vec::new(),
con_names: Vec::new(),
})
.expect("assemble battery problem");
for b in std::iter::once(&prob.obj_nonlinear).chain(prob.con_nonlinear.iter()) {
if b.admitted_quad_form().is_none() && b.admitted_factored_form().is_some() {
factored_bodies += 1;
}
}
seeds_used += 1;
compare_problem(&format!("battery seed {seed}"), prob, 1.0, &mut rep);
}
eprintln!(
"[quad differential] battery: {seeds_used} problems built ({skipped} skipped for \
lost terms), {} reached the fast path, {} quadratic rows, {} hessian entries \
({} not bit-identical, worst {} ulp at {}), worst g/jac rel deviation {:.3e}",
rep.models_with_quadratic,
rep.quadratic_rows,
rep.hess_entries,
rep.hess_bit_diffs,
rep.worst_hess_ulps,
rep.worst_hess_where,
rep.worst_rel,
);
eprintln!(
"[quad differential] battery: worst hessian relative deviation {:.3e} at {}",
rep.worst_hess_rel, rep.worst_hess_rel_where
);
assert!(
rep.worst_hess_rel <= WORST_OBSERVED_HESS_REL,
"hessian relative deviation grew past what the battery produced: \
{:.3e} > {WORST_OBSERVED_HESS_REL:.0e} at {}",
rep.worst_hess_rel,
rep.worst_hess_rel_where
);
eprintln!("[quad differential] battery: {factored_bodies} bodies took the factored arm");
assert!(
factored_bodies >= 100,
"the battery stopped covering the factored read-out: only {factored_bodies} bodies \
reached it (gh #673, gh #711)",
);
assert!(
rep.models_with_quadratic >= 500,
"the battery stopped reaching the fast path: {} of {seeds_used}",
rep.models_with_quadratic
);
assert!(
rep.hess_entries >= 1_000,
"too few Hessian entries compared: {}",
rep.hess_entries
);
assert!(
rep.worst_hess_ulps <= MAX_HESS_ULPS,
"hessian disagreement grew past what the battery produced: {} ulp at {}",
rep.worst_hess_ulps,
rep.worst_hess_where
);
assert!(
rep.worst_rel <= BATTERY_WORST_REL,
"g/jac deviation grew past what the battery produced: {:.3e} > {BATTERY_WORST_REL:.0e}",
rep.worst_rel
);
}
fn dense_quad(n: usize, seed: u64) -> Expr {
let mut r = Rng2(seed | 1);
let mut acc: Option<Expr> = None;
for i in 0..n {
for j in i..n {
let c = (rng_below(&mut r, 2001) as f64 - 1000.0) / 97.0;
let t = Expr::Binary(
BinOp::Mul,
Box::new(Expr::Const(c)),
Box::new(Expr::Binary(
BinOp::Mul,
Box::new(Expr::Var(i)),
Box::new(Expr::Var(j)),
)),
);
acc = Some(match acc {
None => t,
Some(a) => Expr::Binary(BinOp::Add, Box::new(a), Box::new(t)),
});
}
}
acc.expect("n >= 1")
}
fn dense_quad_model(rows: usize, n: usize) -> NlProblem {
let cons: Vec<Expr> = (0..rows)
.map(|k| dense_quad(n, 1234 + k as u64 * 7919))
.collect();
let m = cons.len();
NlProblem::from_expressions(NlProblemParts {
minimize: true,
objective: dense_quad(n, 999_983),
obj_constant: 0.0,
constraints: cons,
x_l: vec![-1e19; n],
x_u: vec![1e19; n],
x0: (0..n).map(|i| 0.7 + i as f64 * 0.3).collect(),
g_l: vec![-1e19; m],
g_u: vec![1.0; m],
var_names: Vec::new(),
con_names: Vec::new(),
})
.expect("assemble dense quadratic model")
}
#[test]
fn the_ulp_pin_is_a_corpus_measurement_and_the_relative_bound_is_the_guarantee() {
let mut worst_ulps = 0u64;
let mut worst_rel = 0.0f64;
for rows in 1..=4usize {
let mut rep = Report::default();
compare_problem(
&format!("dense quadratic, {rows} rows"),
dense_quad_model(rows, 6),
0.0,
&mut rep,
);
assert!(
rep.hess_entries >= 20,
"{rows} rows: nothing was compared ({} entries)",
rep.hess_entries
);
eprintln!(
"[quad differential] dense {rows}-row model: {} of {} entries differ, worst {} ulp, worst rel {:.3e}",
rep.hess_bit_diffs, rep.hess_entries, rep.worst_hess_ulps, rep.worst_hess_rel
);
worst_ulps = worst_ulps.max(rep.worst_hess_ulps);
worst_rel = worst_rel.max(rep.worst_hess_rel);
}
assert!(
worst_ulps > MAX_HESS_ULPS,
"the corpus ulp pin ({MAX_HESS_ULPS}) is being read as an evaluator \
guarantee, but a dense multi-row model reached only {worst_ulps} ulp; \
if the fast path really did become bit-identical here, update the \
module note — it currently says the opposite"
);
assert!(
worst_rel <= DENSE_MULTI_ROW_HESS_REL,
"the bound that actually holds moved: {worst_rel:.3e} > \
{DENSE_MULTI_ROW_HESS_REL:.0e}"
);
}