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
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
//! Issue #257 regression: POUNCE must issue a termination certificate on the
//! branch-and-bound *node* subproblems that a spatial B&B driver actually
//! generates for MINLPLib `jit1` — not just on the published root relaxation
//! that #248 / #252 pinned.
//!
//! `jit1_node.nl` is the first node at which POUNCE reported failure during a
//! real discopt B&B run: `jit1`'s continuous relaxation on a tightened box,
//! captured verbatim (bounds and starting point both). Its distinguishing
//! feature is a scale mix the earlier fixtures do not have — 12 tightly boxed
//! reciprocal-objective variables around 1e-3 alongside 9 variables with
//! `ub = +inf` started far out at ~50 — which drives the computed objective
//! scaling factor down to `1e-5`.
//!
//! That scale factor was the whole bug, and it is *not* a divergence problem:
//! the μ floor is `min(tol, compl_inf_tol) / (barrier_tol_factor + 1)`, but
//! `compl_inf_tol` is enforced on the **unscaled** complementarity while μ
//! lives in scaled space. At `df = 1e-5` the floor sat 1e5× too high, so μ
//! bottomed out at `9.09e-9` — an unscaled complementarity of `9.09e-4`,
//! permanently over the `1e-4` component tolerance. POUNCE therefore sat *on*
//! the optimum, with a scaled NLP error 10× under `tol`, unable to certify it;
//! μ-at-floor plus the vanishing step exited
//! `Search_Direction_Becomes_Too_Small`, which drivers read as UNBOUNDED.
//!
//! The tell is the inversion these tests pin: the failure appeared at *looser*
//! tolerances (1e-7, 1e-6) and not at the default 1e-8, because a smaller `tol`
//! pushed the floor below what the unscaled tolerance needed by accident.
use std::path::PathBuf;
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
use pounce_cli::solve_report::SolveReport;
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 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_issue257_{}_{}_{suffix}",
std::process::id(),
n
));
p
}
fn solve(fixture_name: &str, extra_opts: &[&str]) -> SolveReport {
let json_path = tmp_path(&format!("{fixture_name}.json"));
let sol_path = tmp_path(&format!("{fixture_name}.sol"));
let mut cmd = Command::new(pounce_exe());
cmd.arg(fixture(fixture_name))
.arg(&sol_path)
.arg("--json-output")
.arg(&json_path);
for opt in extra_opts {
cmd.arg(opt);
}
let _ = cmd.status().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);
serde_json::from_str(&text).expect("deserialize SolveReport")
}
/// The node's optimum, as reported by Ipopt (via cyipopt) on the identical
/// evaluator, starting point, and box: `173345.37683089852`.
const NODE_OPTIMUM: f64 = 173_345.376_830_898_52;
/// `solve_result_num` 0 is the strict termination certificate. A driver that
/// maps anything else onto its own status enum is what turned this into a
/// spurious UNBOUNDED downstream. The wider `0..=99` solved band would also
/// admit `1` (`Solved_To_Acceptable_Level`) since gh #591; this node has a
/// certifiable optimum and must reach it.
fn assert_solved_at_optimum(report: &SolveReport, ctx: &str) {
let code = report.solution.solve_result_num;
assert!(
code == 0,
"{ctx}: did not converge (solve_result_num={code}, status={:?}); \
this node has a finite optimum ~{NODE_OPTIMUM} (issue #257)",
report.solution.status,
);
let obj = report.solution.objective;
assert!(
(obj - NODE_OPTIMUM).abs() / NODE_OPTIMUM < 1e-6,
"{ctx}: objective {obj} is not the known node optimum {NODE_OPTIMUM}",
);
}
/// The tolerance a B&B driver actually requests for a node solve, and the one
/// that failed: `tol = 1e-7`.
#[test]
fn jit1_node_certifies_at_driver_tolerance() {
let report = solve("jit1_node.nl", &["tol=1e-7"]);
assert_solved_at_optimum(&report, "jit1 node (tol=1e-7)");
}
/// The whole loose-tolerance band must certify. Before the fix every one of
/// these failed while the default `1e-8` passed — a tolerance a user *loosens*
/// must never cost them the certificate.
#[test]
fn jit1_node_certifies_across_loosened_tolerances() {
for tol in ["1e-8", "1e-7", "1e-6", "1e-5"] {
let report = solve("jit1_node.nl", &[&format!("tol={tol}")]);
assert_solved_at_optimum(&report, &format!("jit1 node (tol={tol})"));
}
}
/// The unscaled complementarity — the quantity `compl_inf_tol` is actually
/// enforced on — must land under `compl_inf_tol` (1e-4). This pins the
/// mechanism rather than the symptom: pre-fix it was `9.09e-4`, held there by
/// a μ floor that could not go any lower, so no amount of further iteration
/// could have produced a certificate.
///
/// The report carries the *scaled* residual, so recover the objective scaling
/// factor from the two objective columns and undo it, exactly as
/// `curr_unscaled_complementarity_max` does.
#[test]
fn jit1_node_unscaled_complementarity_clears_compl_inf_tol() {
let report = solve("jit1_node.nl", &["tol=1e-7"]);
let stats = &report.statistics;
let obj_scale = stats.final_scaled_objective / stats.final_objective;
assert!(
(obj_scale - 1e-5).abs() / 1e-5 < 1e-6,
"expected this node to be scaled by df≈1e-5 (the condition that \
triggers #257); got {obj_scale} — the fixture no longer exercises \
the bug",
);
let unscaled_compl = stats.final_compl / obj_scale;
assert!(
unscaled_compl <= 1e-4,
"unscaled complementarity {unscaled_compl} exceeds compl_inf_tol=1e-4, \
so no strict certificate is reachable no matter how long the solve \
runs (issue #257)",
);
}