use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use pounce_cli::nl_reader::{
Expr, FuncallArg, NlBody, NlProblem, NlTnlp, collect_vars, parse_nl_text_with_quadratic,
};
use pounce_nl::nl_quadratic::{is_expanded_quadratic, recognize_expr};
use pounce_nlp::tnlp::{SparsityRequest, TNLP};
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
}
fn same_expr(a: &Expr, b: &Expr) -> bool {
match (a, b) {
(Expr::Const(x), Expr::Const(y)) => x.to_bits() == y.to_bits(),
(Expr::Var(i), Expr::Var(j)) => i == j,
(Expr::Binary(o1, a1, b1), Expr::Binary(o2, a2, b2)) => {
o1 == o2 && same_expr(a1, a2) && same_expr(b1, b2)
}
(Expr::Unary(o1, a1), Expr::Unary(o2, a2)) => o1 == o2 && same_expr(a1, a2),
(Expr::Sum(x), Expr::Sum(y))
| (Expr::MinList(x), Expr::MinList(y))
| (Expr::MaxList(x), Expr::MaxList(y)) => {
x.len() == y.len() && x.iter().zip(y).all(|(p, q)| same_expr(p, q))
}
(Expr::Cse(x), Expr::Cse(y)) => same_expr(x, y),
(Expr::Compare(o1, a1, b1), Expr::Compare(o2, a2, b2)) => {
o1 == o2 && same_expr(a1, a2) && same_expr(b1, b2)
}
(Expr::And(a1, b1), Expr::And(a2, b2)) | (Expr::Or(a1, b1), Expr::Or(a2, b2)) => {
same_expr(a1, a2) && same_expr(b1, b2)
}
(Expr::Not(a1), Expr::Not(a2)) => same_expr(a1, a2),
(
Expr::Cond {
cond: c1,
then_: t1,
else_: e1,
},
Expr::Cond {
cond: c2,
then_: t2,
else_: e2,
},
) => same_expr(c1, c2) && same_expr(t1, t2) && same_expr(e1, e2),
(Expr::Funcall { id: i1, args: a1 }, Expr::Funcall { id: i2, args: a2 }) => {
i1 == i2
&& a1.len() == a2.len()
&& a1.iter().zip(a2).all(|(p, q)| match (p, q) {
(FuncallArg::Real(x), FuncallArg::Real(y)) => same_expr(x, y),
(FuncallArg::Str(x), FuncallArg::Str(y)) => x == y,
_ => false,
})
}
_ => false,
}
}
fn same_form(a: &pounce_nl::nl_quadratic::Quad2, b: &pounce_nl::nl_quadratic::Quad2) -> bool {
let bits = |x: &f64| x.to_bits();
a.constant().to_bits() == b.constant().to_bits()
&& a.linear().len() == b.linear().len()
&& a.linear()
.iter()
.zip(b.linear())
.all(|((i, x), (j, y))| i == j && bits(x) == bits(y))
&& a.quadratic().len() == b.quadratic().len()
&& a.quadratic()
.iter()
.zip(b.quadratic())
.all(|((i, x), (j, y))| i == j && bits(x) == bits(y))
}
#[derive(Default)]
struct Report {
files: usize,
bodies: usize,
recognized: usize,
rebuilt_nodes: usize,
}
fn check_body(prob_q: &NlProblem, prob_t: &NlProblem, body: usize, name: &str, rep: &mut Report) {
let (q_body, tree, q_expr) = if body == usize::MAX {
(&prob_q.obj_nonlinear, prob_t.obj_expr(), prob_q.obj_expr())
} else {
(
&prob_q.con_nonlinear[body],
prob_t.con_expr(body),
prob_q.con_expr(body),
)
};
rep.bodies += 1;
assert!(
same_expr(&q_expr, &tree),
"{name}: the rebuilt tree is not the tree a non-recognizing parse builds"
);
assert!(
prob_t
.con_nonlinear
.iter()
.chain(std::iter::once(&prob_t.obj_nonlinear))
.all(|b| b.tree().is_some()),
"{name}: recognition leaked into the POUNCE_DBG_NO_QUAD parse"
);
let want = recognize_expr(&tree)
.filter(|f| !f.quadratic().is_empty())
.filter(|_| is_expanded_quadratic(&tree));
match (q_body.quad(), &want) {
(Some(got), Some(want)) => {
assert!(
same_form(got, want),
"{name}: the parse-time form is not bit-identical to the tree's"
);
rep.recognized += 1;
}
(None, None) => {}
(Some(_), None) => panic!(
"{name}: the parser recognized a body the tree walk refuses — \
this is the direction that puts a factored form on Q4's \
constant-matrix path"
),
(None, Some(_)) => panic!(
"{name}: the parser rewound a body the tree walk admits — reach \
lost, and Q4 would pick it up anyway, so the two are out of step"
),
}
if let NlBody::Quad(q) = q_body {
let mut want_vars: BTreeSet<usize> = BTreeSet::new();
collect_vars(&tree, &mut want_vars);
let got_vars: BTreeSet<usize> = q.vars.iter().map(|&v| v as usize).collect();
assert_eq!(got_vars, want_vars, "{name}: variable support");
assert_eq!(expr_depth(&tree), q.depth, "{name}: recorded tree depth");
rep.rebuilt_nodes += node_count(&tree);
}
}
fn expr_depth(e: &Expr) -> u32 {
let deepest = |kids: &mut dyn Iterator<Item = &Expr>| kids.fold(0, |a, k| a.max(expr_depth(k)));
1 + match e {
Expr::Const(_) | Expr::Var(_) => 0,
Expr::Binary(_, a, b) | Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
deepest(&mut [&**a, &**b].into_iter())
}
Expr::Unary(_, a) | Expr::Not(a) => expr_depth(a),
Expr::Sum(v) | Expr::MinList(v) | Expr::MaxList(v) => deepest(&mut v.iter()),
Expr::Cond { cond, then_, else_ } => {
deepest(&mut [&**cond, &**then_, &**else_].into_iter())
}
Expr::Funcall { args, .. } => deepest(&mut args.iter().filter_map(|a| match a {
FuncallArg::Real(x) => Some(x),
FuncallArg::Str(_) => None,
})),
Expr::Cse(b) => expr_depth(b),
}
}
fn node_count(e: &Expr) -> usize {
1 + match e {
Expr::Const(_) | Expr::Var(_) => 0,
Expr::Binary(_, a, b) | Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
node_count(a) + node_count(b)
}
Expr::Unary(_, a) | Expr::Not(a) => node_count(a),
Expr::Cse(a) => node_count(a),
Expr::Sum(v) | Expr::MinList(v) | Expr::MaxList(v) => v.iter().map(node_count).sum(),
Expr::Cond { cond, then_, else_ } => {
node_count(cond) + node_count(then_) + node_count(else_)
}
Expr::Funcall { args, .. } => args
.iter()
.map(|a| match a {
FuncallArg::Real(x) => node_count(x),
FuncallArg::Str(_) => 0,
})
.sum(),
}
}
#[test]
fn every_fixture_parses_identically_with_and_without_recognition() {
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 {
let Ok(txt) = std::fs::read_to_string(f) else {
continue;
};
let (Ok(prob_q), Ok(prob_t)) = (
parse_nl_text_with_quadratic(&txt, true),
parse_nl_text_with_quadratic(&txt, false),
) else {
continue;
};
let name = f.display().to_string();
rep.files += 1;
assert_eq!(prob_q.n, prob_t.n, "{name}: n");
assert_eq!(prob_q.m, prob_t.m, "{name}: m");
assert_eq!(prob_q.minimize, prob_t.minimize, "{name}: sense");
for (i, (x, y)) in prob_q.g_l.iter().zip(&prob_t.g_l).enumerate() {
assert_eq!(x.to_bits(), y.to_bits(), "{name}: g_l[{i}]");
}
for (i, (x, y)) in prob_q.g_u.iter().zip(&prob_t.g_u).enumerate() {
assert_eq!(x.to_bits(), y.to_bits(), "{name}: g_u[{i}]");
}
assert_eq!(prob_q.con_linear, prob_t.con_linear, "{name}: con_linear");
assert_eq!(prob_q.obj_linear, prob_t.obj_linear, "{name}: obj_linear");
check_body(
&prob_q,
&prob_t,
usize::MAX,
&format!("{name}: obj"),
&mut rep,
);
for k in 0..prob_q.m {
check_body(&prob_q, &prob_t, k, &format!("{name}: row {k}"), &mut rep);
}
}
assert!(rep.files >= 50, "files walked: {}", rep.files);
assert!(rep.bodies >= 1000, "bodies walked: {}", rep.bodies);
assert!(
rep.recognized >= 200,
"bodies recognized at parse time: {}",
rep.recognized
);
eprintln!(
"[quad parse differential] {} files, {} bodies, {} recognized, \
{} Expr nodes not built",
rep.files, rep.bodies, rep.recognized, rep.rebuilt_nodes
);
}
#[test]
fn a_model_with_nothing_recognized_keeps_no_source() {
let nl = "g3 0 1 0\n2 0 1 0 0\n0 1\n0 0\n0 2 0\n0 0 0 1\n0 0 0 0 0\n0 0\n0 0\n\
0 0 0 0 0\nO0 0\no0\no41\nv0\no41\nv1\nb\n3\n3\n";
let p = parse_nl_text_with_quadratic(nl, true).expect("parse");
assert!(
p.src.is_none(),
"no body was recognized, so no text is kept"
);
assert!(p.obj_nonlinear.tree().is_some());
}
#[test]
fn rebuilt_cses_are_the_parses_own() {
let mut checked = 0usize;
for f in all_fixtures() {
let Ok(txt) = std::fs::read_to_string(&f) else {
continue;
};
let Ok(p) = parse_nl_text_with_quadratic(&txt, true) else {
continue;
};
if p.cse_bodies.is_empty() {
continue;
}
let own: BTreeSet<usize> = p
.cse_bodies
.iter()
.map(|b| std::sync::Arc::as_ptr(b) as usize)
.collect();
for k in 0..p.m {
if p.con_nonlinear[k].quad().is_none() {
continue;
}
let mut seen: Vec<usize> = Vec::new();
collect_cse_ptrs(&p.con_expr(k), &mut seen);
for ptr in seen {
assert!(
own.contains(&ptr),
"{}: row {k} rebuilt a CSE body instead of reusing the parse's",
f.display()
);
checked += 1;
}
}
}
eprintln!("[quad parse differential] {checked} rebuilt CSE references checked");
}
fn collect_cse_ptrs(e: &Expr, out: &mut Vec<usize>) {
match e {
Expr::Const(_) | Expr::Var(_) => {}
Expr::Binary(_, a, b) | Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
collect_cse_ptrs(a, out);
collect_cse_ptrs(b, out);
}
Expr::Unary(_, a) | Expr::Not(a) => collect_cse_ptrs(a, out),
Expr::Sum(v) | Expr::MinList(v) | Expr::MaxList(v) => {
v.iter().for_each(|k| collect_cse_ptrs(k, out))
}
Expr::Cond { cond, then_, else_ } => {
collect_cse_ptrs(cond, out);
collect_cse_ptrs(then_, out);
collect_cse_ptrs(else_, out);
}
Expr::Funcall { args, .. } => args.iter().for_each(|a| {
if let FuncallArg::Real(x) = a {
collect_cse_ptrs(x, out)
}
}),
Expr::Cse(b) => out.push(std::sync::Arc::as_ptr(b) as usize),
}
}
const CSE_QUAD: &str = "g3 0 1 0
1 1 1 0 0
1 0
0 0
1 0 0
0 0 0 1
0 0 0 0 0
0 0
0 0
0 1 0 0 0
V1 0 0
o2
v0
v0
C0
o0
v1
v1
O0 0
n0
r
1 5.0
b
3
k0
";
#[test]
fn a_twice_referenced_cse_quadratic_is_recognized() {
let q = parse_nl_text_with_quadratic(CSE_QUAD, true).expect("parse");
let t = parse_nl_text_with_quadratic(CSE_QUAD, false).expect("parse");
let form = q.con_nonlinear[0]
.quad()
.expect("v1 + v1 with v1 = x0² is an expanded quadratic");
assert_eq!(
form.quadratic().get(&(0, 0)).map(|c| c.to_bits()),
Some(2.0_f64.to_bits()),
"x0² + x0² = 2·x0²"
);
let want = recognize_expr(&t.con_expr(0)).expect("the tree walk agrees");
assert!(same_form(form, &want), "parse-time form vs tree walk");
assert!(
is_expanded_quadratic(&t.con_expr(0)),
"and the exactness gate admits it — which it did not before Q5"
);
assert!(
same_expr(&q.con_expr(0), &t.con_expr(0)),
"the rebuilt tree is the tree"
);
let mut ptrs = Vec::new();
collect_cse_ptrs(&q.con_expr(0), &mut ptrs);
assert_eq!(ptrs.len(), 2, "both references survive the rebuild");
let own = std::sync::Arc::as_ptr(&q.cse_bodies[0]) as usize;
assert!(ptrs.iter().all(|p| *p == own));
}
const CSE_FACTORED: &str = "g3 0 1 0
2 1 1 0 0
1 0
0 0
2 0 0
0 0 0 1
0 0 0 0 0
0 0
0 0
0 1 0 0 0
V2 0 0
o0
v0
v1
C0
o5
v2
n2
O0 0
n0
r
1 5.0
b
3
3
k1
0
";
#[test]
fn a_factored_defined_variable_is_not_recognized() {
let q = parse_nl_text_with_quadratic(CSE_FACTORED, true).expect("parse");
let t = parse_nl_text_with_quadratic(CSE_FACTORED, false).expect("parse");
assert!(
q.con_nonlinear[0].quad().is_none(),
"(x0 + x1)² must keep its tree — expanding it is the gh #544 defect"
);
assert!(!is_expanded_quadratic(&t.con_expr(0)));
assert!(recognize_expr(&t.con_expr(0)).is_some());
}
const SUM_ORDER: &str = "g3 0 1 0
1 1 1 0 0
1 0
0 0
1 0 0
0 0 0 1
0 0 0 0 0
0 0
0 0
0 0 0 0 0
C0
o54
3
o2
n1e16
o2
v0
v0
o2
n1
o2
v0
v0
o2
n1
o2
v0
v0
O0 0
n0
r
1 5.0
b
3
k0
";
#[test]
fn a_sumlist_folds_in_the_order_the_tree_walk_folds() {
let q = parse_nl_text_with_quadratic(SUM_ORDER, true).expect("parse");
let t = parse_nl_text_with_quadratic(SUM_ORDER, false).expect("parse");
let form = q.con_nonlinear[0].quad().expect("expanded quadratic");
let want = recognize_expr(&t.con_expr(0)).expect("tree walk");
assert!(same_form(form, &want), "fold order");
let got = form.quadratic()[&(0, 0)];
assert_eq!(
got.to_bits(),
1.0e16_f64.to_bits(),
"expected the front-to-back fold, got {got:e}"
);
assert_ne!(got.to_bits(), (1.0e16_f64 + 2.0).to_bits());
}
#[test]
fn a_sumlist_folds_the_way_the_tape_folds() {
let q = parse_nl_text_with_quadratic(SUM_ORDER, true).expect("parse");
let t = parse_nl_text_with_quadratic(SUM_ORDER, false).expect("parse");
let mut fast = NlTnlp::try_new_with_quadratic(q, true).expect("quad tnlp");
let mut tape = NlTnlp::try_new_with_quadratic(t, false).expect("tape tnlp");
let x = [1.0_f64];
let lam = [1.0_f64];
let mut hf = [0.0_f64; 1];
let mut ht = [0.0_f64; 1];
assert!(
fast.eval_h(
Some(&x),
true,
0.0,
Some(&lam),
true,
SparsityRequest::Values { values: &mut hf }
),
"eval_h (quad)"
);
assert!(
tape.eval_h(
Some(&x),
true,
0.0,
Some(&lam),
true,
SparsityRequest::Values { values: &mut ht }
),
"eval_h (tape)"
);
assert_eq!(
hf[0].to_bits(),
ht[0].to_bits(),
"H(0,0): quad {:e} vs tape {:e} — the sumlist fold order disagrees \
with the tape",
hf[0],
ht[0]
);
assert_eq!(ht[0].to_bits(), (2.0 * 1.0e16_f64).to_bits());
}