1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
//! gh #313 — `qp-active-set` on a rank-deficient-but-consistent equality QP.
//!
//! Fixture `rankdef_eq_qp.nl` is the strictly convex QP
//! min 0.5 (x0²+x1²+x2²) − x0 − 2 x1 − 3 x2
//! s.t. x0 + x1 == 2
//! 2 x0 + 2 x1 == 4 (exactly 2× the first row — redundant)
//! −10 ≤ xi ≤ 10
//! whose equality block is exactly rank-deficient but consistent. Unique
//! optimum x* = (0.5, 1.5, 3), objective −6.75.
//!
//! Redundant/scaled-duplicate equality rows are routine in modelling, so this
//! must be handled without adversarial intent. Two things are pinned:
//!
//! 1. The active-set path SOLVES it — status optimal, objective −6.75 — the
//! same answer `qp-ipm` and `nlp` find. (The issue was originally filed
//! against a stale binary that aborted with "INTERNAL ERROR: Unknown
//! SolverReturn value" / exit 1; a current binary solves it.)
//! 2. The console summary reports a FINITE scaled objective, not `nan`. The
//! SQP result path left `final_scaled_objective` at its NaN default, so
//! even a clean optimal active-set solve printed
//! "Objective ...: nan <unscaled>".
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("rankdef_eq_qp.nl");
p
}
/// The rank-deficient equality QP must SOLVE on the active-set path to the
/// correct optimum — not crash, not fail, not return a wrong objective.
#[test]
fn active_set_solves_rank_deficient_equality_qp() {
let json = std::env::temp_dir().join("pounce_issue313.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-active-set")
.output()
.expect("spawn pounce");
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
out.status.code(),
Some(0),
"should solve, exit 0:\n{combined}"
);
assert!(
!combined.contains("INTERNAL ERROR") && !combined.contains("Unknown SolverReturn"),
"must not hit the internal-error path:\n{combined}"
);
let report = std::fs::read_to_string(&json).expect("json report written");
// Correct optimum objective is −6.75.
let obj = extract_objective(&report);
assert!(
(obj + 6.75).abs() < 1e-6,
"active-set objective {obj} != −6.75 (correct optimum):\n{report}"
);
assert!(
report.contains("\"status\": \"SolveSucceeded\"") || report.contains("SolveSucceeded"),
"expected SolveSucceeded status:\n{report}"
);
}
/// The console must not print a `nan` scaled objective (gh #313): the SQP
/// result path now mirrors the unscaled objective into
/// `final_scaled_objective` instead of leaving it at the NaN default.
#[test]
fn active_set_solve_reports_finite_scaled_objective() {
let out = Command::new(pounce_exe())
.arg(fixture())
.arg("--no-sol")
.arg("solver_selection=qp-active-set")
.output()
.expect("spawn pounce");
let stdout = String::from_utf8_lossy(&out.stdout);
// Find the "Objective ...:" summary line and confirm neither column is nan.
let obj_line = stdout
.lines()
.find(|l| l.trim_start().starts_with("Objective"))
.unwrap_or_else(|| panic!("no Objective summary line:\n{stdout}"));
assert!(
!obj_line.to_ascii_lowercase().contains("nan"),
"scaled/unscaled objective must be finite, got:\n{obj_line}"
);
assert!(
obj_line.contains("-6.75"),
"objective summary should show the −6.75 optimum:\n{obj_line}"
);
}
/// Minimal JSON scrape of the `"objective"` number from the solve report.
fn extract_objective(report: &str) -> f64 {
let key = "\"objective\":";
let i = report.find(key).expect("objective key in report");
let tail = &report[i + key.len()..];
let tail = tail.trim_start();
let end = tail
.find(|c: char| c == ',' || c == '}' || c == '\n')
.unwrap_or(tail.len());
tail[..end].trim().parse().expect("parse objective number")
}