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
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
//! #462 — on the convex/conic IPM the debugger must answer the commands it
//! *cannot* implement with a capability error, not a timing hint.
//!
//! `print kkt` used to reply "no KKT factorization yet — stop at
//! `after_search_dir`" on the convex backend, and emitted the same message
//! when already stopped at `after_search_dir`: a self-referential loop, since
//! that backend has no augmented-system inertia at any checkpoint. The
//! documented contract (`docs/src/debugger.md`, capability matrix) is that a
//! command unavailable on the current backend "returns an explicit 'not
//! available for this solver' error (it never silently no-ops)" — which the
//! sibling commands (`print rank`, `diagnose`, `resolve`) already did.
//!
//! Covered here, driven over the JSON protocol against the real convex IPM:
//! - `print kkt` / `print residuals` / `print active` / `print inactive` /
//! `viz kkt` / `viz L` are rejected as unavailable on the convex backend,
//! at the very checkpoint the old hint named,
//! - `print kkt` still works on the NLP filter-IPM at that checkpoint (the
//! fix is a capability gate, not a blanket disable),
//! - `hello.capabilities` — what a JSON client is told to feature-detect
//! off — agrees with the REPL on both backends.
use std::io::{BufRead, BufReader, Write};
use std::path::PathBuf;
use std::process::{Command, Stdio};
fn pounce_exe() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_pounce"))
}
/// `min x0² + x1² s.t. x0 + x1 = 2` — routed to the convex IPM under
/// `solver_selection=qp-ipm`, solvable by the NLP path under `nlp`.
fn fixture() -> PathBuf {
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push("tests");
p.push("fixtures");
p.push("convex_qp.nl");
p
}
/// Drive `--debug-json` with `cmds`, stopping at `after_search_dir` first
/// (the checkpoint the old hint pointed at). Returns the `hello` event and
/// the `result` events keyed by the command that produced them.
fn drive(solver: &str, cmds: &[&str]) -> (serde_json::Value, Vec<serde_json::Value>) {
let mut child = Command::new(pounce_exe())
.arg(fixture())
.arg("--no-sol")
.arg("--debug-json")
.arg(format!("solver_selection={solver}"))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn pounce --debug-json");
{
// Taken (not borrowed) so the pipe is *closed* at the end of this
// block: after `quit` the debugger parks at the terminal checkpoint
// and reads again, so an open stdin would hang the child forever.
let mut stdin = child.stdin.take().expect("stdin");
for (i, c) in cmds.iter().enumerate() {
writeln!(
stdin,
"{{\"cmd\":{},\"id\":{}}}",
serde_json::json!(c),
i + 1
)
.expect("write command");
}
writeln!(stdin, "{{\"cmd\":\"quit\"}}").expect("write quit");
stdin.flush().expect("flush");
}
let stdout = child.stdout.take().expect("stdout");
let mut hello = serde_json::Value::Null;
let mut results = Vec::new();
for line in BufReader::new(stdout).lines() {
let line = line.expect("read line");
let Ok(ev) = serde_json::from_str::<serde_json::Value>(&line) else {
continue; // solver banner / report lines are not protocol events
};
match ev.get("event").and_then(|e| e.as_str()) {
Some("hello") => hello = ev,
Some("result") => results.push(ev),
_ => {}
}
}
let _ = child.wait();
(hello, results)
}
fn result_for<'a>(results: &'a [serde_json::Value], cmd: &str) -> &'a serde_json::Value {
results
.iter()
.find(|r| r.get("command").and_then(|c| c.as_str()) == Some(cmd))
.unwrap_or_else(|| panic!("no `result` event for `{cmd}`; got {results:#?}"))
}
/// The commands the capability matrix marks ➖ for the convex/conic IPM must
/// fail with the backend message — not with a "stop later" timing hint,
/// which is the loop #462 reported.
#[test]
fn convex_backend_rejects_kkt_commands_as_unavailable() {
let probes = [
"print kkt",
"print residuals",
"print active",
"print inactive",
"viz kkt",
"viz L",
];
let mut cmds = vec!["stop-at kkt", "step"];
cmds.extend_from_slice(&probes);
let (_, results) = drive("qp-ipm", &cmds);
for probe in probes {
let r = result_for(&results, probe);
assert_eq!(
r.get("ok").and_then(|o| o.as_bool()),
Some(false),
"`{probe}` must be an error on the convex backend: {r}"
);
let out = r.get("output").and_then(|o| o.as_array()).expect("output");
let msg = out
.iter()
.filter_map(|l| l.as_str())
.collect::<Vec<_>>()
.join(" ");
assert!(
msg.contains("only available for the NLP solver"),
"`{probe}` must report a capability error, got: {msg}"
);
// The specific regression: no timing hint that names a checkpoint
// we are already stopped at.
assert!(
!msg.contains("after_search_dir") && !msg.contains("yet"),
"`{probe}` must not answer with a timing hint, got: {msg}"
);
}
}
/// The same command at the same checkpoint still works on the NLP
/// filter-IPM: #462 is a capability gate on the convex backend, not a
/// blanket removal.
#[test]
fn nlp_backend_still_prints_kkt_at_after_search_dir() {
let (_, results) = drive("nlp", &["stop-at kkt", "step", "print kkt"]);
let r = result_for(&results, "print kkt");
assert_eq!(
r.get("ok").and_then(|o| o.as_bool()),
Some(true),
"`print kkt` must still work on the NLP backend: {r}"
);
assert!(
r.pointer("/data/dim").and_then(|d| d.as_i64()).is_some(),
"`print kkt` must report the augmented-system dimension: {r}"
);
}
/// `hello.capabilities` is what the protocol tells clients to feature-detect
/// off, so it must agree with what the REPL actually does on each backend.
#[test]
fn hello_capabilities_match_the_running_backend() {
let (convex, _) = drive("qp-ipm", &[]);
let (nlp, _) = drive("nlp", &[]);
for (name, expected) in [
("kkt_inspect", false),
("diagnose", false),
("mutate_mu", false),
("resolve", false),
] {
assert_eq!(
convex.pointer(&format!("/capabilities/{name}")),
Some(&serde_json::json!(expected)),
"convex `capabilities.{name}` disagrees with the REPL: {convex}"
);
}
assert_eq!(
convex.pointer("/capabilities/viz"),
Some(&serde_json::json!(["block", "delta"])),
"convex must not advertise `viz kkt` / `viz L`: {convex}"
);
// The advertised iterate blocks are the ones this backend really has.
assert_eq!(
convex.pointer("/blocks/0").and_then(|b| b.as_str()),
Some("x")
);
assert!(
convex
.pointer("/blocks")
.and_then(|b| b.as_array())
.expect("blocks")
.iter()
.any(|b| b.as_str() == Some("z")),
"convex blocks must include `z`: {convex}"
);
// The NLP backend keeps every capability it had.
for name in ["kkt_inspect", "diagnose", "mutate_mu", "resolve"] {
assert_eq!(
nlp.pointer(&format!("/capabilities/{name}")),
Some(&serde_json::json!(true)),
"NLP `capabilities.{name}` regressed: {nlp}"
);
}
assert_eq!(
nlp.pointer("/capabilities/viz"),
Some(&serde_json::json!(["block", "delta", "kkt", "L"])),
);
}