use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Command;
fn pounce_exe() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_pounce"))
}
fn fixture() -> PathBuf {
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push("tests");
p.push("fixtures");
p.push("convex_qp_sens.nl");
p
}
fn staged_nl(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("pounce_issue196_{tag}"));
std::fs::create_dir_all(&dir).expect("mkdir temp");
let dst = dir.join("convex_qp_sens.nl");
std::fs::copy(fixture(), &dst).expect("copy fixture");
let _ = std::fs::remove_file(dir.join("convex_qp_sens.sol"));
dst
}
fn parse_sens_sol_state_1(sol: &str) -> Option<HashMap<usize, f64>> {
let mut lines = sol.lines();
while let Some(line) = lines.next() {
if let Some(rest) = line.strip_prefix("suffix ") {
let parts: Vec<&str> = rest.split_whitespace().collect();
if parts.len() < 5 {
continue;
}
let count: usize = parts[1].parse().ok()?;
let tabline: usize = parts[4].parse().ok()?;
let name = lines.next()?.trim().to_string();
if name != "sens_sol_state_1" {
for _ in 0..(tabline + count) {
lines.next();
}
continue;
}
for _ in 0..tabline {
lines.next();
}
let mut out = HashMap::new();
for _ in 0..count {
let l = lines.next()?;
let mut it = l.split_whitespace();
let idx: usize = it.next()?.parse().ok()?;
let val: f64 = it.next()?.parse().ok()?;
out.insert(idx, val);
}
return Some(out);
}
}
None
}
fn run(tag: &str, args: &[&str]) -> (String, String) {
let nl = staged_nl(tag);
let mut cmd = Command::new(pounce_exe());
cmd.arg(&nl);
for a in args {
cmd.arg(a);
}
let out = cmd.output().expect("spawn pounce");
assert_eq!(out.status.code(), Some(0), "solve should succeed");
(
String::from_utf8_lossy(&out.stderr).into_owned(),
std::fs::read_to_string(nl.with_extension("sol")).expect("read .sol"),
)
}
#[test]
fn auto_serves_sens_on_the_convex_path() {
let (stderr, sol) = run("auto", &[]);
assert!(
!stderr.contains("routing to the general NLP"),
"the reroute must no longer fire for a servable request; stderr=\n{stderr}"
);
assert!(
stderr.contains("computes it directly"),
"the routing change should be announced, not silent; stderr=\n{stderr}"
);
let sens = parse_sens_sol_state_1(&sol)
.expect("sens_sol_state_1 must be present — served on the convex path");
let x = *sens.get(&0).expect("perturbed x (index 0)");
assert!(
(x - 1.5).abs() < 1e-6,
"dx*/dp = 1 so p 1.0 -> 1.5 gives x* -> 1.5; got {x}"
);
}
#[test]
fn the_banner_reports_the_convex_engine() {
let nl = staged_nl("banner");
let out = Command::new(pounce_exe())
.arg(&nl)
.output()
.expect("spawn pounce");
let stdout = String::from_utf8_lossy(&out.stdout);
let line = stdout
.lines()
.find(|l| l.starts_with("Problem class:"))
.expect("the routing line is printed");
assert!(
line.contains("convex QP interior-point"),
"the run that answers must be the one named; got: {line}"
);
}
#[test]
fn both_engines_agree_on_the_same_model() {
let (_, convex_sol) = run("parity_convex", &[]);
let (_, nlp_sol) = run("parity_nlp", &["solver_selection=nlp"]);
let convex = parse_sens_sol_state_1(&convex_sol).expect("convex path writes the suffix");
let nlp = parse_sens_sol_state_1(&nlp_sol).expect("NLP path writes the suffix");
assert_eq!(
convex.len(),
nlp.len(),
"the two engines must report the same entries, not merely both report something"
);
for (idx, cx) in &convex {
let nx = nlp
.get(idx)
.unwrap_or_else(|| panic!("NLP path is missing index {idx}"));
assert!(
(cx - nx).abs() < 1e-7,
"the two engines disagree at index {idx}: convex {cx}, nlp {nx}"
);
}
}
#[test]
fn an_explicit_convex_force_is_served_not_skipped() {
let (stderr, sol) = run("qp_ipm", &["solver_selection=qp-ipm"]);
assert!(
!stderr.contains("will be skipped"),
"the forced convex path computes the step now; stderr=\n{stderr}"
);
let sens = parse_sens_sol_state_1(&sol)
.expect("a forced convex solve writes sens_sol_state_1 like any other");
let x = *sens.get(&0).expect("perturbed x (index 0)");
assert!((x - 1.5).abs() < 1e-6, "expected x* -> 1.5; got {x}");
}
#[test]
fn a_reduced_hessian_request_still_routes_to_the_nlp_path() {
let nl = staged_nl("redhess");
let out = Command::new(pounce_exe())
.arg(&nl)
.arg("--compute-red-hessian")
.output()
.expect("spawn pounce");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("routing to the general NLP"),
"a reduced-Hessian request is still the general path\'s; stderr=\n{stderr}"
);
}
#[test]
fn serving_sens_switches_the_convex_presolve_off() {
let nl = staged_nl("presolve");
let out = Command::new(pounce_exe())
.arg(&nl)
.output()
.expect("spawn pounce");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.lines().any(|l| l.starts_with("Presolve:")),
"a run serving a sensitivity request must not presolve; stdout=\n{stdout}"
);
let sol = std::fs::read_to_string(nl.with_extension("sol")).expect("read .sol");
let sens = parse_sens_sol_state_1(&sol).expect("the step is still produced");
let x = *sens.get(&0).expect("perturbed x (index 0)");
assert!(
(x - 1.5).abs() < 1e-12,
"reading the converged KKT rather than a postsolve reconstruction is worth four \
orders here: measured 6.2e-15 with the guard, 5.0e-11 without. got |x - 1.5| = {:e}",
(x - 1.5f64).abs()
);
}
#[test]
fn nlp_path_writes_sens_suffix() {
let (_, sol) = run("nlp", &["solver_selection=nlp"]);
let sens = parse_sens_sol_state_1(&sol).expect("sens_sol_state_1 present on NLP path");
let x = *sens.get(&0).expect("perturbed x (index 0)");
assert!((x - 1.5).abs() < 1e-6, "expected x* -> 1.5; got {x}");
}