use std::path::PathBuf;
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
use pounce_cli::solve_report::SolveReport;
const RELAX: &str = "bound_relax_factor=1e-8";
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/fixtures/bound_relax_cliff.nl");
p
}
fn tmp_path(suffix: &str) -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let mut p = std::env::temp_dir();
p.push(format!(
"pounce_gh900_{}_{}_{suffix}",
std::process::id(),
n
));
p
}
struct Run {
stdout: String,
report: SolveReport,
}
fn run(engine: &str, extra: &[&str], color: bool) -> Run {
let json_path = tmp_path("report.json");
let sol_path = tmp_path("out.sol");
let mut cmd = Command::new(pounce_exe());
cmd.arg(fixture())
.arg(&sol_path)
.arg("--json-output")
.arg(&json_path)
.arg(format!("solver_selection={engine}"));
for o in extra {
cmd.arg(o);
}
if color {
cmd.env("CLICOLOR_FORCE", "1");
cmd.env_remove("NO_COLOR");
} else {
cmd.env_remove("CLICOLOR_FORCE");
}
let out = cmd.output().expect("spawn pounce");
let text = std::fs::read_to_string(&json_path).expect("read json report");
let _ = std::fs::remove_file(&json_path);
let _ = std::fs::remove_file(&sol_path);
Run {
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
report: serde_json::from_str(&text).expect("deserialize SolveReport"),
}
}
fn row(stdout: &str, label: &str) -> (f64, f64) {
let line = stdout
.lines()
.find(|l| l.starts_with(label))
.unwrap_or_else(|| panic!("no `{label}` row in:\n{stdout}"));
let rest = line.split_once(':').expect("labelled row").1;
let mut it = rest.split_whitespace();
let parse = |s: Option<&str>| {
s.unwrap_or_else(|| panic!("missing column in `{line}`"))
.parse::<f64>()
.unwrap_or_else(|e| panic!("unparsable column in `{line}`: {e}"))
};
(parse(it.next()), parse(it.next()))
}
fn bound_violation_row(stdout: &str) -> (f64, f64) {
row(stdout, "Variable bound violation")
}
const DELTA: f64 = 1e-8;
#[test]
fn the_nlp_arm_reports_the_box_violation_it_incurred() {
let r = run("nlp", &[], false);
let (scaled, unscaled) = bound_violation_row(&r.stdout);
assert!(
(scaled - DELTA).abs() <= 0.01 * DELTA,
"the point sits one widening outside the declared bound `x >= 0`, so \
the row should read ~{DELTA:e}; got {scaled:e}. A `0.0` here is the \
gh#900 defect."
);
assert_eq!(
scaled, unscaled,
"the box violation has no scaled/unscaled distinction"
);
let obj = r.report.solution.objective;
assert!(
obj < -0.9,
"the widened bound should buy `δ · λ = 1e-8 · 1e8 ≈ 1` of objective \
on a problem whose true minimum is 0; got {obj:e}. Without that this \
fixture has stopped exercising the gap."
);
}
#[test]
fn the_convex_arm_reports_the_box_violation_it_incurred() {
let r = run("auto", &[RELAX], false);
assert_eq!(
r.report.solution.engine, "cvx-qp",
"this case exists to cover the convex printer's own path to the row; \
if `auto` stops routing here it is covering the NLP one twice"
);
let (scaled, unscaled) = bound_violation_row(&r.stdout);
assert!(
(scaled - DELTA).abs() <= 0.01 * DELTA,
"expected ~{DELTA:e} on the convex arm too; got {scaled:e}"
);
assert_eq!(scaled, unscaled);
let obj = r.report.solution.objective;
assert!(obj < -0.9, "expected an objective near -1; got {obj:e}");
}
#[test]
fn the_nlp_arm_reports_zero_when_it_did_not_widen() {
let r = run("nlp", &["bound_relax_factor=0"], false);
let (scaled, _) = bound_violation_row(&r.stdout);
assert_eq!(
scaled, 0.0,
"with no widening the point is inside its declared box and the row \
must say so; got {scaled:e}"
);
let obj = r.report.solution.objective;
assert!(
(0.0..1e-3).contains(&obj),
"the unwidened answer must approach `f* = 0` from inside the box, so \
`0 <= f << 1`; got {obj:e}"
);
}
#[test]
fn the_convex_arm_reports_zero_at_its_default() {
let r = run("auto", &[], false);
let (scaled, _) = bound_violation_row(&r.stdout);
assert_eq!(scaled, 0.0, "expected an unviolated box; got {scaled:e}");
let obj = r.report.solution.objective;
assert!(
(0.0..1e-3).contains(&obj),
"the analytic answer `f* = 0`, approached from inside; got {obj:e}"
);
}
#[test]
fn the_json_report_carries_the_same_number_as_the_row() {
for (engine, extra) in [("nlp", &[][..]), ("auto", &[RELAX][..])] {
let r = run(engine, extra, false);
let (printed, _) = bound_violation_row(&r.stdout);
let reported = r.report.statistics.final_declared_box_viol;
let tol = 8.0 * f64::EPSILON * reported.abs().max(printed.abs());
assert!(
(printed - reported).abs() <= tol,
"`final_declared_box_viol` and the printed row must be one \
measurement, not two, on the {engine} arm: {reported:e} vs \
{printed:e}"
);
assert!(
reported > 0.0,
"and both must be the widening, not a zero, on the {engine} arm"
);
}
}
#[test]
fn the_declared_violation_line_is_styled_when_color_is_on() {
let r = run("nlp", &[], true);
let line = r
.stdout
.lines()
.find(|l| l.contains("Violation of the model as declared"))
.unwrap_or_else(|| panic!("no declared-violation line in:\n{}", r.stdout));
assert!(
line.contains("\u{1b}[1m") && line.contains("\u{1b}[31m"),
"expected bold red; got {line:?}"
);
assert!(
line.ends_with("\u{1b}[0m"),
"and a reset at the end of the line; got {line:?}"
);
}
#[test]
fn nothing_is_styled_when_stdout_is_not_a_terminal() {
let r = run("nlp", &[], false);
assert!(
r.stdout.contains(
"Violation of the model as declared (before the bound_relax_factor widening):"
),
"the line itself must still be there, unstyled:\n{}",
r.stdout
);
assert!(
!r.stdout.contains('\u{1b}'),
"no escape byte may reach a redirected stdout"
);
}
fn nlp_fixture() -> PathBuf {
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push("tests/fixtures/hs71_obj1e8.nl");
p
}
fn run_sqp(extra: &[&str]) -> Run {
let json_path = tmp_path("sqp_report.json");
let sol_path = tmp_path("sqp_out.sol");
let mut cmd = Command::new(pounce_exe());
cmd.arg(nlp_fixture())
.arg(&sol_path)
.arg("--json-output")
.arg(&json_path)
.arg("algorithm=active-set-sqp");
for o in extra {
cmd.arg(o);
}
cmd.env_remove("CLICOLOR_FORCE");
let out = cmd.output().expect("spawn pounce");
let text = std::fs::read_to_string(&json_path).expect("read json report");
let _ = std::fs::remove_file(&json_path);
let _ = std::fs::remove_file(&sol_path);
Run {
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
report: serde_json::from_str(&text).expect("deserialize SolveReport"),
}
}
#[test]
fn the_sqp_arm_reports_a_number_on_the_bound_violation_row() {
let r = run_sqp(&[]);
assert_eq!(
r.report.solution.engine, "sqp-active-set",
"expected the active-set SQP arm; got {:?}",
r.report.solution.engine
);
let (scaled, unscaled) = bound_violation_row(&r.stdout);
assert!(
scaled.is_finite() && unscaled.is_finite(),
"the row must be a measurement, not `nan`: {scaled} / {unscaled}"
);
assert!(
r.report.statistics.final_declared_box_viol.is_finite(),
"and the JSON field too: {}",
r.report.statistics.final_declared_box_viol
);
let tol = 8.0 * f64::EPSILON * scaled.abs().max(1.0);
assert!(
(scaled - r.report.statistics.final_declared_box_viol).abs() <= tol,
"row and field must be one measurement: {scaled:e} vs {:e}",
r.report.statistics.final_declared_box_viol
);
}
#[test]
fn the_sqp_bound_violation_is_zero_as_declared_and_nonzero_when_widened() {
let as_declared = run_sqp(&[]);
let (declared_row, _) = bound_violation_row(&as_declared.stdout);
assert_eq!(
declared_row, 0.0,
"unset `bound_relax_factor` solves the model as declared, so the \
returned point is inside the box the caller wrote; got {declared_row:e}"
);
let widened = run_sqp(&[RELAX]);
let (widened_row, _) = bound_violation_row(&widened.stdout);
assert!(
widened_row > 0.0,
"a named `bound_relax_factor` is honoured on this arm, so the answer \
sits outside the declared box and the row must report it; got \
{widened_row:e}"
);
assert!(
(1e-9..1e-7).contains(&widened_row),
"expected a violation of order the 1e-8 widening; got {widened_row:e}"
);
}
#[test]
fn a_named_bound_relax_factor_gives_both_arms_the_same_model() {
let sqp = run_sqp(&[RELAX]);
let (_, sqp_obj) = row(&sqp.stdout, "Objective...............");
let json_path = tmp_path("nlp_relax.json");
let sol_path = tmp_path("nlp_relax.sol");
let out = Command::new(pounce_exe())
.arg(nlp_fixture())
.arg(&sol_path)
.arg("--json-output")
.arg(&json_path)
.arg(RELAX)
.output()
.expect("spawn pounce");
let nlp_stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let _ = std::fs::remove_file(&json_path);
let _ = std::fs::remove_file(&sol_path);
let (_, nlp_obj) = row(&nlp_stdout, "Objective...............");
let rel = (sqp_obj - nlp_obj).abs() / nlp_obj.abs().max(1.0);
assert!(
rel < 1e-6,
"both arms must answer the same widened model: SQP {sqp_obj:e} vs \
NLP {nlp_obj:e} (relative {rel:e})"
);
}
#[test]
fn the_upstream_compatible_residual_table_is_never_styled() {
let r = run("nlp", &[], true);
for label in [
"Objective...............",
"Dual infeasibility......",
"Constraint violation....",
"Variable bound violation",
"Complementarity.........",
"Overall NLP error.......",
] {
let line = r
.stdout
.lines()
.find(|l| l.starts_with(label))
.unwrap_or_else(|| panic!("no `{label}` row in:\n{}", r.stdout));
assert!(
!line.contains('\u{1b}'),
"`{label}` must stay byte-compatible with upstream's block; got \
{line:?}"
);
}
}