use std::path::PathBuf;
use std::process::Command;
use pounce_cli::solve_report::SolveReport;
fn pounce_exe() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_pounce"))
}
fn pounce_sens_exe() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_pounce_sens"))
}
fn fixture_nl() -> PathBuf {
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push("tests");
p.push("fixtures");
p.push("parametric.nl");
p
}
fn tmp_path(suffix: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("pounce_json_{}_{suffix}", std::process::id()));
p
}
#[test]
fn pounce_emits_summary_report_without_iterations() {
let json_path = tmp_path("pounce_sum.json");
let status = Command::new(pounce_exe())
.arg(fixture_nl())
.arg("--json-output")
.arg(&json_path)
.arg("--json-detail")
.arg("summary")
.status()
.expect("spawn pounce");
assert!(status.success(), "pounce exited with {status:?}");
let text = std::fs::read_to_string(&json_path).unwrap();
let report: SolveReport = serde_json::from_str(&text).expect("deserialize");
assert_eq!(report.schema, "pounce.solve-report/v1");
assert_eq!(
report.fair_metadata.solver.name, "pounce",
"FAIR metadata identifies solver"
);
assert!(
!report.fair_metadata.result_id.is_empty(),
"result_id present"
);
assert_eq!(report.problem.n_variables, 5);
assert_eq!(report.problem.n_constraints, 4);
assert_eq!(report.solution.x.len(), 5);
assert_eq!(report.solution.lambda.len(), 4);
assert!(report.solution.objective.is_finite());
assert_eq!(
report.statistics.iteration_count,
report.statistics.iteration_count
); assert!(
report.iterations.is_empty(),
"summary should drop iter history, got {}",
report.iterations.len()
);
assert!(!text.contains("\"iterations\""), "json: {text}");
let _ = std::fs::remove_file(&json_path);
}
#[test]
fn pounce_emits_full_report_with_iterations() {
let json_path = tmp_path("pounce_full.json");
let status = Command::new(pounce_exe())
.arg(fixture_nl())
.arg("--json-output")
.arg(&json_path)
.arg("--json-detail")
.arg("full")
.status()
.expect("spawn pounce");
assert!(status.success());
let text = std::fs::read_to_string(&json_path).unwrap();
let report: SolveReport = serde_json::from_str(&text).expect("deserialize");
assert_eq!(report.schema, "pounce.solve-report/v1");
assert!(
!report.iterations.is_empty(),
"full mode should capture iter rows"
);
let it0 = &report.iterations[0];
assert_eq!(it0.iter, 0, "first row is iter 0");
assert!(it0.inf_pr >= 0.0, "inf_pr is non-negative");
let _ = std::fs::remove_file(&json_path);
}
#[test]
fn pounce_sens_emits_report_with_sens_sol_state_suffix() {
let sol_path = tmp_path("ps.sol");
let json_path = tmp_path("ps.json");
let status = Command::new(pounce_sens_exe())
.arg(fixture_nl())
.arg(&sol_path)
.arg("--json-output")
.arg(&json_path)
.arg("--json-detail")
.arg("full")
.status()
.expect("spawn pounce_sens");
assert!(status.success());
let text = std::fs::read_to_string(&json_path).unwrap();
let report: SolveReport = serde_json::from_str(&text).expect("deserialize");
let sens = report
.solution
.suffixes
.iter()
.find(|s| s.name == "sens_sol_state_1")
.expect("sens_sol_state_1 suffix present");
assert_eq!(sens.target, "var");
assert_eq!(sens.kind, "real");
assert_eq!(sens.values.len(), 5);
assert!(
(sens.values[3] - 4.5).abs() < 1e-8,
"perturbed x[3] = {} (expected 4.5)",
sens.values[3],
);
let _ = std::fs::remove_file(&sol_path);
let _ = std::fs::remove_file(&json_path);
}
#[test]
fn json_schema_is_uniform_across_solver_paths() {
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
}
let cases: &[(&str, PathBuf, &str)] = &[
("nlp", fixture_nl(), "nlp"),
("convex-qp-ipm", fixture_named("convex_qp.nl"), "qp-ipm"),
("convex-lp-ipm", fixture_named("lp_afiro.nl"), "lp-ipm"),
];
for (label, fixture, sel) in cases {
let json_path = tmp_path(&format!("uniform_{label}.json"));
let _ = std::fs::remove_file(&json_path);
let out = Command::new(pounce_exe())
.arg(fixture)
.arg("--no-sol")
.arg("--json-output")
.arg(&json_path)
.arg(format!("solver_selection={sel}"))
.output()
.unwrap_or_else(|e| panic!("spawn pounce ({label}): {e}"));
assert_eq!(
out.status.code(),
Some(0),
"{label} solve should succeed; stderr=\n{}",
String::from_utf8_lossy(&out.stderr)
);
let text = std::fs::read_to_string(&json_path)
.unwrap_or_else(|e| panic!("read report ({label}): {e}"));
let report: SolveReport = serde_json::from_str(&text)
.unwrap_or_else(|e| panic!("deserialize report ({label}): {e}\n{text}"));
assert_eq!(
report.schema, "pounce.solve-report/v1",
"{label}: schema tag"
);
assert_eq!(
report.fair_metadata.solver.name, "pounce",
"{label}: solver name"
);
assert!(
!report.fair_metadata.result_id.is_empty(),
"{label}: result_id present"
);
assert!(!report.solution.x.is_empty(), "{label}: primal x populated");
assert!(
report.solution.x.iter().all(|v| v.is_finite()),
"{label}: primal x all finite"
);
assert!(
report.solution.objective.is_finite(),
"{label}: objective finite"
);
assert!(
(report.solution.objective - report.statistics.final_objective).abs()
<= 1e-9 * report.solution.objective.abs().max(1.0),
"{label}: solution.objective {} != statistics.final_objective {}",
report.solution.objective,
report.statistics.final_objective
);
assert_eq!(
report.problem.n_variables as usize,
report.solution.x.len(),
"{label}: n_variables matches x length"
);
let _ = std::fs::remove_file(&json_path);
}
}
#[test]
fn schema_field_is_stable_across_runs() {
let p1 = tmp_path("schema_a.json");
let p2 = tmp_path("schema_b.json");
for p in [&p1, &p2] {
Command::new(pounce_exe())
.arg(fixture_nl())
.arg("--json-output")
.arg(p)
.status()
.expect("spawn pounce");
}
let r1: SolveReport = serde_json::from_str(&std::fs::read_to_string(&p1).unwrap()).unwrap();
let r2: SolveReport = serde_json::from_str(&std::fs::read_to_string(&p2).unwrap()).unwrap();
assert_eq!(r1.schema, r2.schema);
assert_eq!(
r1.fair_metadata.solver.version,
r2.fair_metadata.solver.version
);
assert_ne!(r1.fair_metadata.result_id, r2.fair_metadata.result_id);
let _ = std::fs::remove_file(&p1);
let _ = std::fs::remove_file(&p2);
}
#[test]
fn lambda_is_in_original_g_order_not_cd_split_order() {
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
}
let json_path = tmp_path("dual_order.json");
let _ = std::fs::remove_file(&json_path);
let out = Command::new(pounce_exe())
.arg(fixture_named("dual_order.nl"))
.arg("--no-sol")
.arg("--json-output")
.arg(&json_path)
.arg("solver_selection=nlp")
.output()
.expect("spawn pounce");
assert_eq!(
out.status.code(),
Some(0),
"solve should succeed; stderr=\n{}",
String::from_utf8_lossy(&out.stderr)
);
let text = std::fs::read_to_string(&json_path).expect("read report");
let report: SolveReport = serde_json::from_str(&text).expect("deserialize");
assert!(
(report.solution.x[0] - 2.0).abs() < 1e-5,
"x0 = {}",
report.solution.x[0]
);
assert!(
(report.solution.x[1] - 1.0).abs() < 1e-5,
"x1 = {}",
report.solution.x[1]
);
assert_eq!(report.solution.lambda.len(), 2, "two constraint duals");
let g0 = report.solution.lambda[0].abs(); let g1 = report.solution.lambda[1].abs(); assert!(
(g0 - 2.0).abs() < 1e-3,
"lambda[0] (g0, the x<=2 inequality) = {} expected |·|≈2; \
pre-fix c/d-split order put the equality's ≈58 dual here",
report.solution.lambda[0]
);
assert!(
(g1 - 58.0).abs() < 1e-3,
"lambda[1] (g1, the y==1 equality) = {} expected |·|≈58; \
pre-fix c/d-split order put the inequality's ≈2 dual here",
report.solution.lambda[1]
);
let _ = std::fs::remove_file(&json_path);
}
#[test]
fn lambda_is_unscaled_by_obj_scale_factor_under_gradient_scaling() {
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
}
let json_path = tmp_path("dual_scaled.json");
let _ = std::fs::remove_file(&json_path);
let out = Command::new(pounce_exe())
.arg(fixture_named("dual_scaled.nl"))
.arg("--no-sol")
.arg("--json-output")
.arg(&json_path)
.arg("solver_selection=nlp")
.output()
.expect("spawn pounce");
assert_eq!(
out.status.code(),
Some(0),
"solve should succeed; stderr=\n{}",
String::from_utf8_lossy(&out.stderr)
);
let text = std::fs::read_to_string(&json_path).expect("read report");
let report: SolveReport = serde_json::from_str(&text).expect("deserialize");
assert!(
(report.solution.x[0] - 2.0).abs() < 1e-5,
"x0 = {}",
report.solution.x[0]
);
assert!(
(report.solution.x[1] - 1.0).abs() < 1e-5,
"x1 = {}",
report.solution.x[1]
);
assert_eq!(report.solution.lambda.len(), 2, "two constraint duals");
let g0 = report.solution.lambda[0].abs(); let g1 = report.solution.lambda[1].abs(); assert!(
g1 > 1000.0,
"lambda[1] (g1, y==1 equality) = {} is scaled by obj_scale_factor; \
expected the unscaled |·|≈5998, not the ≈99.97 the pre-F1 hook emitted",
report.solution.lambda[1]
);
assert!(
(g0 - 2.0).abs() < 1e-2,
"lambda[0] (g0, x<=2 inequality) = {} expected unscaled |·|≈2 \
(pre-fix ≈0.033 = 2/60)",
report.solution.lambda[0]
);
assert!(
(g1 - 5998.0).abs() < 1e-1,
"lambda[1] (g1, y==1 equality) = {} expected unscaled |·|≈5998 \
(pre-fix ≈99.97 = 5998/60)",
report.solution.lambda[1]
);
let _ = std::fs::remove_file(&json_path);
}