use std::path::PathBuf;
use std::process::Command;
fn pounce_exe() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_pounce"))
}
fn fixture(name: &str) -> PathBuf {
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push("tests");
p.push("fixtures");
p.push(name);
p
}
fn solve_result_num(text: &str) -> i32 {
for line in text.lines() {
if let Some(rest) = line.trim().strip_prefix("objno ") {
if let Some(code) = rest.split_whitespace().nth(1) {
return code.parse().expect("objno code parses");
}
}
}
panic!("no `objno` line in .sol:\n{text}");
}
fn solve(tag: &str, extra: &[&str]) -> String {
let sol = std::env::temp_dir().join(format!("pounce_presolve_cert_{tag}.sol"));
let _ = std::fs::remove_file(&sol);
let out = Command::new(pounce_exe())
.arg(fixture("issue_372_infeasible_bounds.nl"))
.arg("-AMPL")
.arg("--sol-output")
.arg(&sol)
.arg("print_level=0")
.args(extra)
.output()
.expect("spawn pounce");
assert_eq!(out.status.code(), Some(0), "-AMPL must exit 0");
std::fs::read_to_string(&sol).expect("read .sol")
}
fn solve_fixture(model: &str, tag: &str, extra: &[&str]) -> String {
let sol = std::env::temp_dir().join(format!("pounce_presolve_cert_{tag}.sol"));
let _ = std::fs::remove_file(&sol);
let out = Command::new(pounce_exe())
.arg(fixture(model))
.arg("-AMPL")
.arg("--sol-output")
.arg(&sol)
.arg("print_level=0")
.args(extra)
.output()
.expect("spawn pounce");
assert_eq!(out.status.code(), Some(0), "-AMPL must exit 0");
std::fs::read_to_string(&sol).expect("read .sol")
}
#[test]
fn a_feasible_model_is_never_certified_infeasible() {
for (tag, opts) in [
("s223_presolve", vec!["presolve=yes", "presolve_fbbt=yes"]),
("s223_plain", vec!["presolve=no"]),
] {
let text = solve_fixture("feasible_x0_sentinel_bound.nl", tag, &opts);
let srn = solve_result_num(&text);
assert!(
!(200..300).contains(&srn),
"{opts:?}: this model is feasible — `pounce check-x0` reports \
`rows violated: 0 max violation: 0.000e0` at its own starting \
point, and the convex route solves it — so no code in the AMPL \
infeasible band is defensible, least of all the certified 201 \
(got {srn}):\n{text}"
);
}
}
#[test]
fn feasible_sentinel_bound_model_solves() {
for (tag, opts) in [
(
"s223_solves_nlp",
vec!["solver_selection=nlp", "presolve=no"],
),
(
"s223_solves_presolve",
vec!["solver_selection=nlp", "presolve=yes", "presolve_fbbt=yes"],
),
] {
let text = solve_fixture("feasible_x0_sentinel_bound.nl", tag, &opts);
let srn = solve_result_num(&text);
assert_ne!(
srn, 504,
"{opts:?}: a one-sided constraint bound of -5e20 is a legitimate \
finite bound, not a crossed pair — the adapter must not reject \
the model as Invalid_Problem_Definition:\n{text}"
);
assert_eq!(
srn, 0,
"{opts:?}: this model is feasible and the convex QP route solves it \
to optimality; the NLP route must reach Solve_Succeeded too \
(got {srn}):\n{text}"
);
}
}
#[test]
fn presolve_proves_the_contradiction_and_says_so() {
let text = solve("on", &["presolve=yes"]);
let srn = solve_result_num(&text);
assert_eq!(
srn, 201,
"a presolve-proved empty feasible region must report the certified \
sub-code 201, not the generic local-infeasibility 200:\n{text}"
);
assert!(
text.contains("detected by presolve"),
"the message must say the verdict is a proof, not a local \
verdict:\n{text}"
);
assert!(
text.contains("bound propagation"),
"the message must name *how* it was proved, so the claim is \
checkable:\n{text}"
);
}
#[test]
fn without_presolve_the_numerical_verdict_is_unchanged() {
let text = solve("off", &["presolve=no"]);
let srn = solve_result_num(&text);
assert_eq!(
srn, 200,
"without presolve the verdict is the numerical local one (200):\n{text}"
);
assert!(
!text.contains("detected by presolve"),
"the IPM's local verdict must not claim to be a proof:\n{text}"
);
}
#[test]
fn both_paths_stay_in_the_ampl_infeasible_band() {
for (tag, opt) in [("band_on", "presolve=yes"), ("band_off", "presolve=no")] {
let text = solve(tag, &[opt]);
let srn = solve_result_num(&text);
assert!(
(200..300).contains(&srn),
"{opt}: solve_result_num={srn} escaped the AMPL infeasible band \
(200..299); Pyomo and every other band-reading consumer would \
misclassify it:\n{text}"
);
}
}
#[test]
fn certified_infeasibility_skips_the_second_opinion_ladder() {
let sol = std::env::temp_dir().join("pounce_presolve_cert_mc64.sol");
let _ = std::fs::remove_file(&sol);
let out = Command::new(pounce_exe())
.arg(fixture("issue_372_infeasible_bounds.nl"))
.arg("-AMPL")
.arg("--sol-output")
.arg(&sol)
.arg("print_level=0")
.arg("presolve=yes")
.output()
.expect("spawn pounce");
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
!combined.contains("second-opinion ladder"),
"the second-opinion ladder re-derives a verdict that neither scaling \
nor the barrier strategy can affect; it must be skipped for a \
proof:\n{combined}"
);
}
#[test]
fn float_noise_infeasibility_is_never_certified() {
for (tag, opts) in [
("ft_none", vec!["presolve=no"]),
("ft_p", vec!["presolve=yes", "presolve_fbbt=no"]),
("ft_pf", vec!["presolve=yes", "presolve_fbbt=yes"]),
] {
let sol = std::env::temp_dir().join(format!("pounce_{tag}.sol"));
let _ = std::fs::remove_file(&sol);
let out = Command::new(pounce_exe())
.arg(fixture("presolve_float_trap.nl"))
.arg("-AMPL")
.arg("--sol-output")
.arg(&sol)
.arg("print_level=0")
.arg("solver_selection=nlp")
.args(&opts)
.output()
.expect("spawn pounce");
assert_eq!(out.status.code(), Some(0), "-AMPL must exit 0");
let text = std::fs::read_to_string(&sol).expect("read .sol");
let srn = solve_result_num(&text);
assert!(
!text.contains("detected by presolve"),
"{opts:?}: a model infeasible by 5.5e-17 must never be reported as \
*proved* infeasible — POUNCE solves it successfully on every other \
route:\n{text}"
);
assert_eq!(
srn, 0,
"{opts:?}: expected the same Solve_Succeeded (0) every other route \
gives, got solve_result_num={srn}. 504 here means presolve handed \
the solver a crossed box `x_l > x_u`:\n{text}"
);
}
}
#[test]
fn json_report_and_sol_agree_on_the_code() {
for (tag, opt) in [("cmp_on", "presolve=yes"), ("cmp_off", "presolve=no")] {
let sol = std::env::temp_dir().join(format!("pounce_{tag}.sol"));
let json = std::env::temp_dir().join(format!("pounce_{tag}.json"));
let _ = std::fs::remove_file(&sol);
let _ = std::fs::remove_file(&json);
let out = Command::new(pounce_exe())
.arg(fixture("issue_372_infeasible_bounds.nl"))
.arg("-AMPL")
.arg("--sol-output")
.arg(&sol)
.arg("--json-output")
.arg(&json)
.arg("print_level=0")
.arg(opt)
.output()
.expect("spawn pounce");
assert_eq!(out.status.code(), Some(0), "-AMPL must exit 0");
let text = std::fs::read_to_string(&sol).expect("read .sol");
let sol_srn = solve_result_num(&text);
let raw = std::fs::read_to_string(&json).expect("read json");
let json_srn: i32 = raw
.split("\"solve_result_num\"")
.nth(1)
.and_then(|s| s.split(&[':', ',', '}'][..]).nth(1))
.and_then(|s| s.trim().parse().ok())
.expect("solve_result_num in JSON report");
assert_eq!(
sol_srn, json_srn,
"{opt}: the .sol says {sol_srn} and the JSON report says \
{json_srn} for the same run"
);
}
}
#[test]
fn interval_overflow_is_never_mistaken_for_a_proof() {
let sol = std::env::temp_dir().join("pounce_presolve_overflow.sol");
let _ = std::fs::remove_file(&sol);
let out = Command::new(pounce_exe())
.arg(fixture("presolve_overflow_feasible.nl"))
.arg("-AMPL")
.arg("--sol-output")
.arg(&sol)
.arg("print_level=0")
.arg("solver_selection=nlp")
.arg("presolve=yes")
.arg("presolve_fbbt=yes")
.output()
.expect("spawn pounce");
assert_eq!(out.status.code(), Some(0), "-AMPL must exit 0");
let text = std::fs::read_to_string(&sol).expect("read .sol");
let srn = solve_result_num(&text);
assert!(
!text.contains("detected by presolve"),
"a feasible model (x >= 1) was reported as *proved* infeasible because \
1e300*1e300 overflowed to inf:\n{text}"
);
assert!(
!(200..300).contains(&srn),
"feasible model reported in the AMPL infeasible band \
(solve_result_num={srn}):\n{text}"
);
}
#[test]
fn user_declared_crossed_box_is_still_rejected() {
for (tag, opt) in [("xb_on", "presolve=yes"), ("xb_off", "presolve=no")] {
let sol = std::env::temp_dir().join(format!("pounce_{tag}.sol"));
let _ = std::fs::remove_file(&sol);
let out = Command::new(pounce_exe())
.arg(fixture("presolve_crossed_user_box.nl"))
.arg("-AMPL")
.arg("--sol-output")
.arg(&sol)
.arg("print_level=0")
.arg("solver_selection=nlp")
.arg(opt)
.output()
.expect("spawn pounce");
assert_eq!(out.status.code(), Some(0), "-AMPL must exit 0");
let text = std::fs::read_to_string(&sol).expect("read .sol");
let srn = solve_result_num(&text);
assert_ne!(
srn, 0,
"{opt}: a model whose declared box is empty (x in [5, 3]) must \
never report Solve_Succeeded:\n{text}"
);
assert_eq!(
srn, 504,
"{opt}: expected Invalid_Problem_Definition (504), the same \
verdict the presolve=no route gives:\n{text}"
);
}
}