use pounce_solve_report::SolveReport;
use std::path::PathBuf;
use std::process::Command;
fn pounce_exe() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_pounce"))
}
fn fixture() -> PathBuf {
fixture_named("convex_qp.nl")
}
fn fixture_named(name: &str) -> PathBuf {
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push("tests");
p.push("fixtures");
p.push(name);
p
}
#[test]
fn infeasible_qp_reports_infeasible() {
let out = Command::new(pounce_exe())
.arg(fixture_named("infeasible_qp.nl"))
.arg("--no-sol")
.arg("solver_selection=qp-ipm")
.output()
.expect("spawn pounce");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.to_lowercase().contains("infeasible"),
"expected infeasible status; stdout=\n{stdout}"
);
assert_ne!(out.status.code(), Some(0), "infeasible must exit non-zero");
}
#[test]
fn ampl_mode_honors_exit_code_contract_on_infeasible_convex_qp() {
let dir = std::env::temp_dir();
let sol = dir.join("pounce_h4_ampl_infeasible.sol");
let _ = std::fs::remove_file(&sol);
let ampl = Command::new(pounce_exe())
.arg(fixture_named("infeasible_qp.nl"))
.arg("-AMPL")
.arg("--sol-output")
.arg(&sol)
.arg("solver_selection=qp-ipm")
.output()
.expect("spawn pounce");
assert_eq!(
ampl.status.code(),
Some(0),
"-AMPL infeasible must exit 0 (verdict travels in the .sol); stdout=\n{}",
String::from_utf8_lossy(&l.stdout)
);
let text = std::fs::read_to_string(&sol).expect("verdict .sol written under -AMPL");
assert!(
text.contains("200"),
"the infeasible solve_result_num (200) must be in the .sol:\n{text}"
);
let _ = std::fs::remove_file(&sol);
let plain = Command::new(pounce_exe())
.arg(fixture_named("infeasible_qp.nl"))
.arg("--no-sol")
.arg("solver_selection=qp-ipm")
.output()
.expect("spawn pounce");
assert_ne!(
plain.status.code(),
Some(0),
"plain-CLI infeasible must still exit non-zero"
);
}
#[test]
fn forced_qp_ipm_on_nonconvex_qp_errors() {
let out = Command::new(pounce_exe())
.arg(fixture_named("nonconvex_qp.nl"))
.arg("--no-sol")
.arg("solver_selection=qp-ipm")
.output()
.expect("spawn pounce");
assert_eq!(out.status.code(), Some(2), "forced mismatch must exit 2");
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
combined.contains("nonconvex QP") && combined.contains("qp-ipm"),
"error must name detected class and forced solver:\n{combined}"
);
assert!(
!combined.contains("Optimal Solution Found"),
"a mismatch must never report a solve:\n{combined}"
);
}
#[test]
fn forced_qp_active_set_on_nonconvex_qp_errors() {
let out = Command::new(pounce_exe())
.arg(fixture_named("nonconvex_qp.nl"))
.arg("--no-sol")
.arg("solver_selection=qp-active-set")
.output()
.expect("spawn pounce");
assert_eq!(out.status.code(), Some(2));
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
combined.contains("nonconvex QP") && combined.contains("qp-active-set"),
"error must name detected class and forced solver:\n{combined}"
);
assert!(!combined.contains("Optimal Solution Found"), "{combined}");
}
#[test]
fn forced_lp_ipm_on_convex_qp_errors() {
let out = Command::new(pounce_exe())
.arg(fixture())
.arg("--no-sol")
.arg("solver_selection=lp-ipm")
.output()
.expect("spawn pounce");
assert_eq!(out.status.code(), Some(2));
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
combined.contains("convex QP") && combined.contains("lp-ipm"),
"error must name detected class and forced solver:\n{combined}"
);
assert!(!combined.contains("Optimal Solution Found"), "{combined}");
}
#[test]
fn auto_routes_nonconvex_qp_to_nlp_safely() {
let out = Command::new(pounce_exe())
.arg(fixture_named("nonconvex_qp.nl"))
.arg("--no-sol")
.arg("solver_selection=auto")
.output()
.expect("spawn pounce");
assert_eq!(out.status.code(), Some(0), "auto should solve via NLP");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("pounce-nlp") && !stdout.contains("pounce-convex"),
"auto must fall back to the NLP path, not the convex IPM:\n{stdout}"
);
assert!(
stdout.contains("Optimal Solution Found"),
"NLP fallback should solve to a local optimum:\n{stdout}"
);
}
#[test]
fn auto_routes_convex_qp_to_pounce_convex() {
let out = Command::new(pounce_exe())
.arg(fixture())
.arg("--no-sol")
.arg("solver_selection=auto")
.output()
.expect("spawn pounce");
assert_eq!(out.status.code(), Some(0), "should solve");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("pounce-convex"),
"auto should route the convex QP to pounce-convex; stdout=\n{stdout}"
);
assert!(
stdout.contains("Optimal Solution Found"),
"should report optimal; stdout=\n{stdout}"
);
}
#[test]
fn forced_qp_ipm_solves() {
let out = Command::new(pounce_exe())
.arg(fixture())
.arg("--no-sol")
.arg("solver_selection=qp-ipm")
.output()
.expect("spawn pounce");
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("pounce-convex"), "stdout=\n{stdout}");
}
#[test]
fn forced_qp_active_set_solves_convex_qp() {
let out = Command::new(pounce_exe())
.arg(fixture())
.arg("--no-sol")
.arg("solver_selection=qp-active-set")
.output()
.expect("spawn pounce");
assert_eq!(out.status.code(), Some(0), "active-set route should solve");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("active-set QP (pounce-qp)"),
"banner must name the active-set solver, not fall through:\n{stdout}"
);
assert!(
stdout.contains("Optimal Solution Found"),
"active-set route should report optimal:\n{stdout}"
);
}
#[test]
fn qp_active_set_sol_matches_known_optimum_and_dual() {
let dir = std::env::temp_dir();
let sol = dir.join("pounce_qp_active_set_test.sol");
let _ = std::fs::remove_file(&sol);
let out = Command::new(pounce_exe())
.arg(fixture())
.arg("--sol-output")
.arg(&sol)
.arg("solver_selection=qp-active-set")
.output()
.expect("spawn pounce");
assert_eq!(out.status.code(), Some(0));
let text = std::fs::read_to_string(&sol).expect("read .sol");
let floats: Vec<f64> = text
.lines()
.filter_map(|l| l.trim().parse::<f64>().ok())
.collect();
let near_one = floats.iter().filter(|v| (**v - 1.0).abs() < 1e-5).count();
assert!(
near_one >= 2,
"active-set .sol must carry the real primal x ≈ (1,1), not zeros:\n{text}"
);
let dual_near = floats
.iter()
.copied()
.min_by(|a, b| (a + 2.0).abs().partial_cmp(&(b + 2.0).abs()).unwrap())
.expect("a float in .sol");
assert!(
(dual_near + 2.0).abs() < 1e-5,
"active-set equality dual {dual_near} != −2:\n{text}"
);
}
#[test]
fn nlp_path_still_solves_same_file() {
let out = Command::new(pounce_exe())
.arg(fixture())
.arg("--no-sol")
.arg("solver_selection=nlp")
.output()
.expect("spawn pounce");
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("Optimal Solution Found"),
"NLP path stdout=\n{stdout}"
);
}
#[test]
fn sol_primal_matches_known_optimum() {
let dir = std::env::temp_dir();
let sol = dir.join("pounce_convex_qp_test.sol");
let _ = std::fs::remove_file(&sol);
let out = Command::new(pounce_exe())
.arg(fixture())
.arg("--sol-output")
.arg(&sol)
.arg("solver_selection=auto")
.output()
.expect("spawn pounce");
assert_eq!(out.status.code(), Some(0));
let text = std::fs::read_to_string(&sol).expect("read .sol");
let near_one = text
.lines()
.filter_map(|l| l.trim().parse::<f64>().ok())
.filter(|v| (v - 1.0).abs() < 1e-5)
.count();
assert!(
near_one >= 2,
"expected two primal values ≈ 1.0 in .sol:\n{text}"
);
}
#[test]
fn qp_and_nlp_duals_agree() {
let dir = std::env::temp_dir();
let run = |sel: &str, out: &std::path::Path| {
let _ = std::fs::remove_file(out);
let status = Command::new(pounce_exe())
.arg(fixture())
.arg("--sol-output")
.arg(out)
.arg(format!("solver_selection={sel}"))
.output()
.expect("spawn pounce");
assert_eq!(status.status.code(), Some(0), "{sel} failed");
std::fs::read_to_string(out).expect("read .sol")
};
let dual_near = |text: &str| -> f64 {
text.lines()
.filter_map(|l| l.trim().parse::<f64>().ok())
.min_by(|a, b| (a - (-2.0)).abs().partial_cmp(&(b - (-2.0)).abs()).unwrap())
.expect("a float in .sol")
};
let qp_sol = run("qp-ipm", &dir.join("pounce_dual_qp.sol"));
let nlp_sol = run("nlp", &dir.join("pounce_dual_nlp.sol"));
let qp_dual = dual_near(&qp_sol);
let nlp_dual = dual_near(&nlp_sol);
assert!((qp_dual - (-2.0)).abs() < 1e-5, "QP dual {qp_dual} != −2");
assert!(
(qp_dual - nlp_dual).abs() < 1e-5,
"QP dual {qp_dual} disagrees with NLP dual {nlp_dual}"
);
}
#[test]
fn qp_path_emits_json_report() {
let dir = std::env::temp_dir();
let json = dir.join("pounce_convex_qp_report.json");
let _ = std::fs::remove_file(&json);
let out = Command::new(pounce_exe())
.arg(fixture())
.arg("--no-sol")
.arg("--json-output")
.arg(&json)
.arg("solver_selection=qp-ipm")
.output()
.expect("spawn pounce");
assert_eq!(out.status.code(), Some(0), "QP solve should succeed");
let text = std::fs::read_to_string(&json).expect("JSON report should be written");
let report: SolveReport = serde_json::from_str(&text).expect("deserialize report");
assert_eq!(report.schema, "pounce.solve-report/v1");
assert!(
(report.solution.objective - 2.0).abs() < 1e-5,
"objective {} != 2",
report.solution.objective
);
assert_eq!(report.solution.solve_result_num, 0, "AMPL srn 0 = solved");
assert_eq!(report.problem.n_variables, 2);
assert_eq!(report.problem.n_constraints, 1);
assert!(report.problem.minimize);
assert!(
report.statistics.iteration_count >= 1,
"iteration_count = {}",
report.statistics.iteration_count
);
assert!(
report.statistics.final_constr_viol < 1e-6,
"constr_viol = {}",
report.statistics.final_constr_viol
);
assert!(
report.statistics.final_dual_inf < 1e-6,
"dual_inf = {}",
report.statistics.final_dual_inf
);
assert!(
report.statistics.final_kkt_error < 1e-6,
"kkt_error = {}",
report.statistics.final_kkt_error
);
assert!(!report.fair_metadata.solver.name.is_empty());
}
#[test]
fn qp_full_report_has_iteration_trace() {
let dir = std::env::temp_dir();
let json = dir.join("pounce_convex_qp_full.json");
let _ = std::fs::remove_file(&json);
let out = Command::new(pounce_exe())
.arg(fixture())
.arg("--no-sol")
.arg("--json-output")
.arg(&json)
.arg("--json-detail")
.arg("full")
.arg("solver_selection=qp-ipm")
.output()
.expect("spawn pounce");
assert_eq!(out.status.code(), Some(0));
let text = std::fs::read_to_string(&json).expect("report written");
let report: SolveReport = serde_json::from_str(&text).expect("deserialize");
assert!(
!report.iterations.is_empty(),
"full-detail QP report should carry an iteration trace"
);
for (k, rec) in report.iterations.iter().enumerate() {
assert_eq!(rec.iter as usize, k, "iteration indices contiguous");
}
let last = report.iterations.last().unwrap();
assert!(
(last.objective - 2.0).abs() < 1e-4,
"final traced objective {} ~ 2",
last.objective
);
}
#[test]
fn qp_presolve_option_on_and_off_agree() {
let run = |presolve: &str| -> i32 {
let out = Command::new(pounce_exe())
.arg(fixture())
.arg("--no-sol")
.arg("solver_selection=qp-ipm")
.arg(format!("qp_presolve={presolve}"))
.output()
.expect("spawn pounce");
assert!(
String::from_utf8_lossy(&out.stdout).contains("Optimal Solution Found"),
"qp_presolve={presolve} should solve"
);
out.status.code().unwrap_or(-1)
};
assert_eq!(run("yes"), 0);
assert_eq!(run("no"), 0);
}
#[test]
fn afiro_active_set_solves_under_default_anti_cycling() {
const AFIRO_OPT: f64 = -4.6475314286e+02;
let run = |extra: Option<&str>| -> SolveReport {
let dir = std::env::temp_dir();
let json = dir.join(format!(
"pounce_afiro_{}.json",
extra.unwrap_or("default").replace(['=', ' '], "_")
));
let _ = std::fs::remove_file(&json);
let mut cmd = Command::new(pounce_exe());
cmd.arg(fixture_named("lp_afiro.nl"))
.arg("--no-sol")
.arg("--json-output")
.arg(&json)
.arg("solver_selection=qp-active-set");
if let Some(e) = extra {
cmd.arg(e);
}
let out = cmd.output().expect("spawn pounce");
assert_eq!(
out.status.code(),
Some(0),
"afiro qp-active-set ({}) should exit 0; stdout=\n{}",
extra.unwrap_or("default"),
String::from_utf8_lossy(&out.stdout)
);
let text = std::fs::read_to_string(&json).expect("JSON report written");
serde_json::from_str(&text).expect("deserialize report")
};
let def = run(None);
assert_eq!(def.solution.solve_result_num, 0, "afiro default = solved");
assert!(
(def.solution.objective - AFIRO_OPT).abs() < 1e-4,
"afiro default objective {} != {AFIRO_OPT}",
def.solution.objective
);
let bland = run(Some("sqp_qp_anti_cycling=bland"));
assert!(
(bland.solution.objective - AFIRO_OPT).abs() < 1e-4,
"afiro bland objective {} != {AFIRO_OPT}",
bland.solution.objective
);
}