Skip to main content

pounce_cli/
debug_repl.rs

1//! Interactive solver debugger front end — "pdb for the IPM".
2//!
3//! Implements [`pounce_algorithm::debug::DebugHook`]. The core fires us
4//! at every checkpoint (today: the top of each outer iteration); we
5//! pause, hand the user (or an agent) a command prompt, and apply
6//! inspect / mutate / flow commands against the live [`DebugState`] before
7//! returning [`DebugAction::Resume`] or [`DebugAction::Stop`].
8//!
9//! Two front ends share one command engine ([`SolverDebugger::dispatch`]):
10//!
11//!   * [`DebugMode::Repl`] — a human line REPL. Prompts and command
12//!     output go to **stderr** so they never interleave with the
13//!     solver's iteration table on stdout.
14//!   * [`DebugMode::Json`] — a newline-delimited JSON protocol on
15//!     stdin/stdout for an LLM agent, visual debugger, or any program.
16//!     stdout is a *pure* protocol channel (the CLI routes the banner /
17//!     problem stats / summary to stderr and forces `print_level 0`), so
18//!     a GUI can consume it line-by-line. Session lifecycle:
19//!       1. `{"event":"hello",…}`  — once, up front: protocol version,
20//!          advertised capabilities, command / metric / block vocabulary.
21//!       2. `{"event":"pause",…}`  — at each stop: iter, μ, residuals,
22//!          dims, active breakpoints/conditions, and the firing `reason`.
23//!       3. `{"event":"result",…}` — one per command, echoing the
24//!          client's `request_id` for async correlation.
25//!       4. `{"event":"terminated",…}` — emitted by the CLI after the
26//!          solve, carrying the final status, iteration count, objective,
27//!          and eval counts.
28//!
29//!     Commands may be a bare string or `{"cmd":…,"args":[…],"id":…}`.
30//!
31//! Flow / exit model: the debugger pauses at the *first* checkpoint (so
32//! you get control at iter 0), then only when re-armed — by `step` (pause
33//! next iteration), `break N` (pause at iter N), `break if …` (pause on a
34//! condition), or `run N` (pause at iter ≥ N). Exit paths:
35//!   * `continue` — run to the next breakpoint, else to completion.
36//!   * `detach`   — stop pausing; run to completion.
37//!   * `quit`     — stop now (surfaces as `UserRequestedStop`).
38//!   * stdin EOF  — REPL (Ctrl-D) detaches and finishes; JSON (pipe
39//!     closed → client gone) aborts the solve.
40//!
41//! Every non-kill path ends with a `terminated` event in JSON mode.
42
43use crate::cli::DebugMode;
44use pounce_algorithm::debug::{
45    BLOCK_NAMES, DebugCtx, IterateSnapshot, ResidKind, Residual, is_live_tolerance,
46};
47use pounce_algorithm::debug_rank::{RankReport, RankRow};
48use pounce_common::debug::{Checkpoint, DebugAction, DebugHook, DebugState};
49use pounce_common::reg_options::{DefaultValue, OptionType, RegisteredOptions};
50use pounce_nlp::ipopt_nlp::SplitNames;
51use pounce_presolve::dulmage_mendelsohn::DulmageMendelsohnPartition;
52use pounce_presolve::incidence::EqualityIncidence;
53use pounce_presolve::matching::hopcroft_karp;
54#[cfg(not(target_family = "wasm"))]
55use rustyline::completion::{Completer, Pair};
56#[cfg(not(target_family = "wasm"))]
57use rustyline::error::ReadlineError;
58#[cfg(not(target_family = "wasm"))]
59use rustyline::history::FileHistory;
60#[cfg(not(target_family = "wasm"))]
61use rustyline::{Context, Editor, Helper, Highlighter, Hinter, Validator};
62use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
63use std::io::{IsTerminal, Write};
64use std::path::PathBuf;
65use std::rc::Rc;
66
67/// The line editor behind the human REPL. On wasm targets there is no
68/// terminal (the debugger is driven over its JSON protocol from a browser),
69/// so the editor type is uninhabited and every editor path compiles away.
70#[cfg(not(target_family = "wasm"))]
71type ReplEditor = Editor<DbgHelper, FileHistory>;
72#[cfg(target_family = "wasm")]
73type ReplEditor = std::convert::Infallible;
74
75/// All command verbs, for `help` and `complete`.
76const COMMANDS: &[&str] = &[
77    "help",
78    "info",
79    "print",
80    "step",
81    "stepi",
82    "continue",
83    "run",
84    "break",
85    "tbreak",
86    "watchpoint",
87    "commands",
88    "stop-at",
89    "set",
90    "get",
91    "opt",
92    "complete",
93    "viz",
94    "save",
95    "load",
96    "sweep",
97    "multistart",
98    "goto",
99    "restart",
100    "resolve",
101    "ask",
102    "watch",
103    "diff",
104    "diagnose",
105    "source",
106    "progress",
107    "artifacts",
108    "detach",
109    "quit",
110];
111
112/// Events a user can `break on` (advertised in `hello.events`). Each is
113/// derived from observable state at the relevant checkpoint.
114const EVENTS: &[&str] = &[
115    "resto_entered",
116    "resto_exited",
117    "regularized",
118    "tiny_step",
119    "ls_rejected",
120    "mu_stalled",
121    "nan",
122];
123
124/// μ is "stalled" once it has held (to relative tolerance) for this many
125/// consecutive iterations.
126const MU_STALL_ITERS: u32 = 3;
127
128/// A data watchpoint: pause when a watched value changes by more than
129/// `threshold` between iterations.
130#[derive(Clone)]
131struct WatchPoint {
132    /// Source text, e.g. `x` or `x[3]`, for display.
133    raw: String,
134    block: String,
135    idx: Option<usize>,
136    threshold: f64,
137    /// Last observed value(s); `None` until first seen.
138    last: Option<Vec<f64>>,
139}
140
141/// Checkpoint names a user can `stop-at` (matches `Checkpoint::as_str`).
142const CHECKPOINTS: &[&str] = &[
143    "iter_start",
144    "after_mu",
145    "after_search_dir",
146    "after_step",
147    "step_rejected",
148    "pre_restoration_entry",
149    "post_restoration_exit",
150    "terminated",
151];
152
153/// Request to re-run the solve from a captured point with new options.
154/// Written by the `resolve` command into the shared [`RestartCell`] and
155/// read by the CLI after the solve unwinds.
156pub struct RestartRequest {
157    /// Primal seed (the algorithm-space `x` at the time of `resolve`).
158    /// Also drives `sweep` / `multistart`, where only `x` varies.
159    pub seed_x: Vec<f64>,
160    /// `set opt` edits staged during the session, to apply before re-solve.
161    pub options: Vec<(String, String)>,
162    /// Full primal-dual iterate (all 8 blocks + μ) captured at the pause,
163    /// for a true warm `resolve` that continues from the current interior
164    /// point. `None` for primal-only restarts (sweep / multistart). When
165    /// present, the CLI installs it via `set_warm_start_iterate` and turns
166    /// on `warm_start_init_point` / `warm_start_target_mu`.
167    pub warm: Option<IterateSnapshot>,
168}
169
170/// Shared slot the debugger uses to hand a [`RestartRequest`] back to the
171/// CLI's re-solve loop.
172pub type RestartCell = Rc<std::cell::RefCell<Option<RestartRequest>>>;
173
174/// One completed solve in a `sweep` / `multistart` run.
175#[derive(Clone)]
176struct SweepRecord {
177    /// 0-based index in the sweep.
178    idx: usize,
179    /// The primal seed this solve started from.
180    seed: Vec<f64>,
181    /// Terminal `SolverReturn` (debug string).
182    status: String,
183    /// Final objective.
184    objective: f64,
185    /// Final primal infeasibility.
186    inf_pr: f64,
187    /// Iteration count at termination.
188    iters: i32,
189}
190
191/// In-flight `sweep` state, carried across the CLI's re-solve loop (the
192/// same debugger instance is re-armed each solve, so this persists). Each
193/// queued seed is run as a full solve; the terminal checkpoint records the
194/// outcome and launches the next.
195struct SweepState {
196    /// Starts not yet run.
197    queue: VecDeque<Vec<f64>>,
198    /// The seed of the solve currently running (recorded at its terminal).
199    current: Option<Vec<f64>>,
200    /// Completed solves, in order.
201    records: Vec<SweepRecord>,
202    /// Total starts requested (for progress display).
203    total: usize,
204    /// `pause_iters` to restore when the sweep finishes (a sweep runs each
205    /// solve free, so it disables per-iteration pausing for the duration).
206    saved_pause_iters: bool,
207}
208
209/// Cap on retained per-iteration snapshots (bounds rewind memory; oldest
210/// are evicted first).
211const SNAPSHOT_CAP: usize = 2000;
212
213/// SolverReturn debug strings that count as a successful solve (so
214/// `--debug-on-error` does *not* pause at the terminal checkpoint).
215fn is_success_status(s: &str) -> bool {
216    matches!(s, "Success" | "StopAtAcceptablePoint")
217}
218
219/// Parse a free-form numeric blob — values separated by commas, whitespace,
220/// or newlines — into `f64`s (used by `load` and `sweep` for plain start
221/// files). Errors on the first unparsable token.
222fn parse_floats(s: &str) -> Result<Vec<f64>, String> {
223    s.split(|c: char| c == ',' || c.is_whitespace())
224        .filter(|t| !t.is_empty())
225        .map(|t| t.parse::<f64>().map_err(|_| format!("bad number `{t}`")))
226        .collect()
227}
228
229/// A `splitmix64` step — a tiny deterministic PRNG (no `rand` dependency).
230/// Returns a uniform draw in `[-1, 1]` and advances the state.
231fn splitmix_unit(state: &mut u64) -> f64 {
232    *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
233    let mut z = *state;
234    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
235    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
236    z ^= z >> 31;
237    // Top 53 bits → [0,1), then map to [-1,1).
238    ((z >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0
239}
240
241/// Per-start PRNG seed: deterministic in `k` so a `multistart` reproduces.
242fn seed_for(k: usize) -> u64 {
243    0x9E37_79B9_7F4A_7C15u64
244        ^ (k as u64)
245            .wrapping_mul(0xD1B5_4A32_D192_ED03)
246            .wrapping_add(1)
247}
248
249/// Sample `multistart` start `k`. Start 0 is the unperturbed `base` (so the
250/// run always covers the current point). For `k ≥ 1`, each component is
251/// drawn **uniformly in its box** `[loᵢ, hiᵢ]` when both bounds are finite;
252/// where a bound is missing (`±∞`), it falls back to a relative jitter
253/// `±rel·(|baseᵢ|+1)` around the base. Deterministic in `k`.
254fn sample_start(base: &[f64], bounds: Option<(&[f64], &[f64])>, rel: f64, k: usize) -> Vec<f64> {
255    if k == 0 {
256        return base.to_vec();
257    }
258    let mut state = seed_for(k);
259    base.iter()
260        .enumerate()
261        .map(|(i, &xi)| {
262            let unit = splitmix_unit(&mut state); // [-1, 1)
263            if let Some((lo, hi)) = bounds {
264                let (l, u) = (lo[i], hi[i]);
265                if l.is_finite() && u.is_finite() && u > l {
266                    // [-1,1) → [l, u).
267                    return l + (u - l) * (unit * 0.5 + 0.5);
268                }
269            }
270            xi + rel * (xi.abs() + 1.0) * unit
271        })
272        .collect()
273}
274
275/// `multistart` with no bounds — pure relative jitter around `base`.
276#[cfg(test)]
277fn jitter(base: &[f64], rel: f64, k: usize) -> Vec<f64> {
278    sample_start(base, None, rel, k)
279}
280
281/// SIGINT → "break into the debugger at the next iteration". A first
282/// Ctrl-C sets a pending flag the hook consumes at the next checkpoint;
283/// a second Ctrl-C before that (or any Ctrl-C once detached) hard-exits,
284/// preserving the usual "abort" escape hatch.
285///
286/// At a rustyline prompt the terminal is in raw mode, so Ctrl-C arrives
287/// as input (handled as `Interrupted`) rather than as SIGINT — this handler
288/// only fires while the solve is running. The prompt has its own analogous
289/// double-tap: the first Ctrl-C cancels the line, a second quits the solve
290/// (see [`SolverDebugger::on_prompt_interrupt`]).
291pub mod interrupt {
292    use std::sync::atomic::{AtomicBool, Ordering};
293
294    static PENDING: AtomicBool = AtomicBool::new(false);
295    #[cfg(unix)]
296    static INSTALLED: AtomicBool = AtomicBool::new(false);
297
298    #[cfg(unix)]
299    extern "C" fn handler(_sig: nix::libc::c_int) {
300        // `swap` returns the previous value: if a break was already
301        // pending and unconsumed, the user pressed Ctrl-C twice — abort.
302        if PENDING.swap(true, Ordering::SeqCst) {
303            // _exit is async-signal-safe; 130 = 128 + SIGINT.
304            unsafe { nix::libc::_exit(130) };
305        }
306    }
307
308    /// Install the handler once (idempotent). Call only when a debugger
309    /// is active, so a normal run keeps default Ctrl-C behavior.
310    #[cfg(unix)]
311    pub fn install() {
312        use nix::sys::signal::{self, SigHandler, Signal};
313        if INSTALLED.swap(true, Ordering::SeqCst) {
314            return;
315        }
316        // SAFETY: `handler` only touches an atomic and `_exit`.
317        unsafe {
318            let _ = signal::signal(Signal::SIGINT, SigHandler::Handler(handler));
319        }
320    }
321
322    /// Non-Unix targets have no POSIX `SIGINT` handler to install, so the
323    /// solve-time Ctrl-C-to-break path is unavailable there. `take()` simply
324    /// never sees a pending break; the rustyline prompt's own double-tap
325    /// (see [`SolverDebugger::on_prompt_interrupt`]) remains the escape hatch.
326    #[cfg(not(unix))]
327    pub fn install() {}
328
329    /// Consume a pending break request (clears it).
330    pub fn take() -> bool {
331        PENDING.swap(false, Ordering::SeqCst)
332    }
333
334    /// Test-only: simulate a Ctrl-C without raising a real signal.
335    #[cfg(test)]
336    pub fn set_pending_for_test() {
337        PENDING.store(true, Ordering::SeqCst);
338    }
339}
340
341/// What to do after a command runs.
342#[derive(Clone, Copy)]
343enum Flow {
344    /// Stay paused; keep reading commands.
345    Stay,
346    /// Resume solving.
347    Resume,
348    /// Stop the solve.
349    Stop,
350}
351
352/// Outcome of one command: human lines + optional structured payload.
353struct CmdOut {
354    ok: bool,
355    lines: Vec<String>,
356    data: Option<serde_json::Value>,
357    flow: Flow,
358}
359
360impl CmdOut {
361    fn ok(lines: Vec<String>) -> Self {
362        Self {
363            ok: true,
364            lines,
365            data: None,
366            flow: Flow::Stay,
367        }
368    }
369    fn err(msg: impl Into<String>) -> Self {
370        Self {
371            ok: false,
372            lines: vec![msg.into()],
373            data: None,
374            flow: Flow::Stay,
375        }
376    }
377    fn with_data(mut self, data: serde_json::Value) -> Self {
378        self.data = Some(data);
379        self
380    }
381    fn flow(mut self, flow: Flow) -> Self {
382        self.flow = flow;
383        self
384    }
385}
386
387/// Metric names accepted in `break if …` (and shown by `help`).
388// The streamed scalar field names, in the exact form they appear on `pause` /
389// `progress` / `terminated` events — so a client can read `hello.metrics` and
390// index those keys directly off the event objects. Command input additionally
391// accepts the short aliases `obj`/`err`/`compl` (see `Metric::parse`); these are
392// the canonical advertised names.
393const METRICS: &[&str] = &[
394    "iter",
395    "mu",
396    "objective",
397    "inf_pr",
398    "inf_du",
399    "nlp_error",
400    "complementarity",
401];
402
403/// A scalar the solver exposes for conditional breakpoints.
404#[derive(Clone, Copy, Debug, PartialEq, Eq)]
405enum Metric {
406    Mu,
407    InfPr,
408    InfDu,
409    Obj,
410    NlpError,
411    Compl,
412    Iter,
413}
414
415impl Metric {
416    fn parse(s: &str) -> Option<Metric> {
417        Some(match s {
418            "mu" => Metric::Mu,
419            "inf_pr" => Metric::InfPr,
420            "inf_du" => Metric::InfDu,
421            "obj" | "objective" => Metric::Obj,
422            "err" | "nlp_error" => Metric::NlpError,
423            "compl" | "complementarity" => Metric::Compl,
424            "iter" => Metric::Iter,
425            _ => return None,
426        })
427    }
428    fn eval(self, ctx: &dyn DebugState) -> f64 {
429        match self {
430            Metric::Mu => ctx.mu(),
431            Metric::InfPr => ctx.inf_pr(),
432            Metric::InfDu => ctx.inf_du(),
433            Metric::Obj => ctx.objective(),
434            Metric::NlpError => ctx.nlp_error(),
435            Metric::Compl => ctx.complementarity(),
436            Metric::Iter => ctx.iter() as f64,
437        }
438    }
439}
440
441/// The single source of truth for the streamed scalar-metric block.
442///
443/// Builds the `(name, value)` pairs that go on every `pause` / `progress`
444/// event and the `info` command's `data`, in [`METRICS`] order and driven
445/// *by* [`METRICS`] — so the advertised `hello.metrics` vocabulary and the
446/// fields that actually appear on events can never drift apart. Add a name
447/// to `METRICS` (with a matching [`Metric`] arm) and it shows up everywhere
448/// at once.
449///
450/// Every interior-point backend necessarily answers each entry: the metric
451/// accessors (`mu`/`objective`/`inf_pr`/…) are *required* [`DebugState`]
452/// methods, and the one optional metric (`nlp_error`) defaults to `NaN`,
453/// which `serde_json` renders as `null` — so a backend without that scalar
454/// reports it explicitly rather than dropping the field. `iter` is emitted
455/// as an integer; the rest as JSON numbers.
456fn metric_fields(ctx: &dyn DebugState) -> Vec<(&'static str, serde_json::Value)> {
457    METRICS
458        .iter()
459        .map(|&name| {
460            let value = if name == "iter" {
461                serde_json::json!(ctx.iter())
462            } else {
463                let metric = Metric::parse(name)
464                    .expect("every METRICS entry must have a matching Metric arm");
465                serde_json::json!(metric.eval(ctx))
466            };
467            (name, value)
468        })
469        .collect()
470}
471
472/// Merge the canonical [`metric_fields`] into a JSON object event in place.
473fn insert_metric_fields(ev: &mut serde_json::Value, ctx: &dyn DebugState) {
474    if let serde_json::Value::Object(map) = ev {
475        for (name, value) in metric_fields(ctx) {
476            map.insert(name.to_string(), value);
477        }
478    }
479}
480
481/// Comparison operator for a conditional breakpoint.
482#[derive(Clone, Copy, Debug, PartialEq, Eq)]
483enum CmpOp {
484    Lt,
485    Le,
486    Gt,
487    Ge,
488    Eq,
489}
490
491impl CmpOp {
492    fn eval(self, lhs: f64, rhs: f64) -> bool {
493        match self {
494            CmpOp::Lt => lhs < rhs,
495            CmpOp::Le => lhs <= rhs,
496            CmpOp::Gt => lhs > rhs,
497            CmpOp::Ge => lhs >= rhs,
498            // Tolerant equality so float metrics aren't impossible to hit:
499            // |lhs − rhs| ≤ 1e-12·max(1, |rhs|). Note this is relative for
500            // large rhs but collapses to an absolute 1e-12 when rhs == 0, so
501            // `obj==0` means "|obj| ≤ 1e-12" and `iter==N` is exact for the
502            // integer-valued metrics.
503            CmpOp::Eq => (lhs - rhs).abs() <= 1e-12 * rhs.abs().max(1.0),
504        }
505    }
506}
507
508/// A single comparison `metric op rhs`.
509#[derive(Clone, Debug)]
510struct Atom {
511    metric: Metric,
512    op: CmpOp,
513    rhs: f64,
514}
515
516impl Atom {
517    /// Parse one `metric<op>value` (whitespace already stripped by the
518    /// caller). Operators: `<`, `<=`, `>`, `>=`, `==`.
519    fn parse(expr: &str) -> Result<Atom, String> {
520        let expr = expr.trim();
521        // Scan left-to-right for the *first* comparison operator, preferring
522        // the two-char form at each position so `<=` isn't truncated to `<`
523        // (and so we split on the leftmost op, not whichever the array lists
524        // first).
525        let mut found: Option<(&str, usize, usize)> = None;
526        for (i, _) in expr.char_indices() {
527            let rest = &expr[i..];
528            if rest.starts_with("<=") || rest.starts_with(">=") || rest.starts_with("==") {
529                found = Some((&expr[i..i + 2], i, 2));
530                break;
531            }
532            if rest.starts_with('<') || rest.starts_with('>') {
533                found = Some((&expr[i..i + 1], i, 1));
534                break;
535            }
536        }
537        let (op, pos, oplen) = found
538            .ok_or_else(|| format!("no comparison operator in `{expr}` (use < <= > >= ==)"))?;
539        let metric_s = expr[..pos].trim();
540        let rhs_s = expr[pos + oplen..].trim();
541        let metric = Metric::parse(metric_s)
542            .ok_or_else(|| format!("unknown metric `{metric_s}` (one of {METRICS:?})"))?;
543        let rhs = rhs_s
544            .parse::<f64>()
545            .map_err(|_| format!("bad threshold `{rhs_s}`"))?;
546        let cmp = match op {
547            "<" => CmpOp::Lt,
548            "<=" => CmpOp::Le,
549            ">" => CmpOp::Gt,
550            ">=" => CmpOp::Ge,
551            "==" => CmpOp::Eq,
552            _ => unreachable!(),
553        };
554        Ok(Atom {
555            metric,
556            op: cmp,
557            rhs,
558        })
559    }
560
561    fn holds(&self, ctx: &dyn DebugState) -> bool {
562        self.op.eval(self.metric.eval(ctx), self.rhs)
563    }
564}
565
566/// Boolean join between atoms (#72 §4).
567#[derive(Clone, Copy, Debug, PartialEq, Eq)]
568enum Join {
569    And,
570    Or,
571}
572
573/// A conditional breakpoint: one or more [`Atom`]s joined by `&&`/`||`,
574/// evaluated strictly left-to-right (no operator precedence — matches the
575/// issue's minimal-viable spec; parentheses are stripped). Pause when the
576/// chain evaluates true.
577#[derive(Clone, Debug)]
578struct Condition {
579    first: Atom,
580    rest: Vec<(Join, Atom)>,
581    /// Normalized source text, for display / dedup.
582    raw: String,
583}
584
585impl Condition {
586    fn parse(expr: &str) -> Result<Condition, String> {
587        // Parentheses are advisory only (no precedence), so drop them.
588        let cleaned: String = expr.chars().filter(|c| !matches!(c, '(' | ')')).collect();
589        // Split into atoms, remembering the joiner before each.
590        let mut atoms: Vec<(Option<Join>, &str)> = Vec::new();
591        let bytes = cleaned.as_bytes();
592        let mut start = 0usize;
593        let mut i = 0usize;
594        let mut pending: Option<Join> = None;
595        while i + 1 < bytes.len() {
596            let two = &cleaned[i..i + 2];
597            let join = match two {
598                "&&" => Some(Join::And),
599                "||" => Some(Join::Or),
600                _ => None,
601            };
602            if let Some(j) = join {
603                atoms.push((pending, &cleaned[start..i]));
604                pending = Some(j);
605                i += 2;
606                start = i;
607            } else {
608                i += 1;
609            }
610        }
611        atoms.push((pending, &cleaned[start..]));
612
613        let mut iter = atoms.into_iter();
614        let Some((_, first_s)) = iter.next() else {
615            return Err("empty condition".into());
616        };
617        let first = Atom::parse(first_s)?;
618        let mut rest = Vec::new();
619        for (join, s) in iter {
620            let join = join.ok_or("malformed compound condition (dangling &&/||)")?;
621            rest.push((join, Atom::parse(s)?));
622        }
623        // The cleaned source (whitespace/parens removed) is the display form.
624        Ok(Condition {
625            first,
626            rest,
627            raw: cleaned,
628        })
629    }
630
631    fn holds(&self, ctx: &dyn DebugState) -> bool {
632        let mut acc = self.first.holds(ctx);
633        for (join, atom) in &self.rest {
634            let v = atom.holds(ctx);
635            acc = match join {
636                Join::And => acc && v,
637                Join::Or => acc || v,
638            };
639        }
640        acc
641    }
642}
643
644/// Context-sensitive completion candidates for the REPL line editor (and
645/// the `complete` command). `before` is the line text up to the start of
646/// the word being completed; `word` is that partial word. Pure so it can
647/// be unit-tested without a terminal.
648/// Filesystem completions for a path argument (`save`/`load`/`sweep`/
649/// `source`). `word` is the whole path token typed so far; the returned
650/// candidates carry its directory prefix (so they replace the token whole),
651/// directories get a trailing `/`, and dotfiles are hidden unless the
652/// prefix opens with a dot.
653fn path_candidates(word: &str) -> Vec<String> {
654    // Split into the directory to list and the basename prefix to match.
655    let (dir, prefix) = match word.rfind('/') {
656        Some(i) => (&word[..=i], &word[i + 1..]), // dir keeps its trailing '/'
657        None => ("", word),
658    };
659    let read_from = if dir.is_empty() { "." } else { dir };
660    let Ok(entries) = std::fs::read_dir(read_from) else {
661        return Vec::new();
662    };
663    let mut out: Vec<String> = Vec::new();
664    for e in entries.flatten() {
665        let name = e.file_name().to_string_lossy().into_owned();
666        if !name.starts_with(prefix) {
667            continue;
668        }
669        if name.starts_with('.') && !prefix.starts_with('.') {
670            continue;
671        }
672        let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
673        let mut cand = format!("{dir}{name}");
674        if is_dir {
675            cand.push('/');
676        }
677        out.push(cand);
678    }
679    out.sort();
680    out
681}
682
683fn completion_candidates(reg: Option<&RegisteredOptions>, before: &str, word: &str) -> Vec<String> {
684    let toks: Vec<&str> = before.split_whitespace().collect();
685    let starts = |opts: &[&str]| -> Vec<String> {
686        opts.iter()
687            .filter(|c| c.starts_with(word))
688            .map(|c| c.to_string())
689            .collect()
690    };
691    let opt_names = || -> Vec<String> {
692        reg.map(|r| {
693            r.registered_options_in_order()
694                .iter()
695                .map(|o| o.name.clone())
696                .filter(|n| n.starts_with(word))
697                .collect()
698        })
699        .unwrap_or_default()
700    };
701    match toks.as_slice() {
702        [] => starts(COMMANDS),
703        ["set"] => {
704            let mut v = starts(&["mu", "opt"]);
705            v.extend(starts(&BLOCK_NAMES));
706            v
707        }
708        ["set", "opt"] | ["get", "opt"] | ["get"] | ["opt"] | ["options"] => opt_names(),
709        // After `set opt <name>`, complete the option's valid values.
710        ["set", "opt", name] => reg
711            .and_then(|r| r.get_option(name))
712            .map(|o| {
713                o.valid_strings
714                    .iter()
715                    .map(|e| e.value.clone())
716                    .filter(|v| v.starts_with(word) && v != "*")
717                    .collect()
718            })
719            .unwrap_or_default(),
720        ["artifacts"] => starts(&["inline", "file"]),
721        ["ask"] => starts(&["--prompt-only"]),
722        ["stop-at"] | ["stopat"] => starts(CHECKPOINTS),
723        ["break", "if"] | ["b", "if"] => starts(METRICS),
724        ["break", "on"] | ["b", "on"] => starts(EVENTS),
725        ["break"] | ["b"] => starts(&["if", "on", "clear", "del"]),
726        ["watchpoint"] | ["wp"] => starts(&BLOCK_NAMES),
727        ["print"] | ["p"] | ["watch"] | ["display"] => {
728            let mut v = starts(&BLOCK_NAMES);
729            v.extend(starts(&[
730                "mu",
731                "obj",
732                "inf_pr",
733                "inf_du",
734                "err",
735                "compl",
736                "iter",
737                "kkt",
738                "active",
739                "inactive",
740                "residuals",
741                "equation",
742                "rank",
743            ]));
744            v
745        }
746        ["viz"] | ["plot"] => {
747            let mut v = starts(&BLOCK_NAMES);
748            v.extend(starts(&["kkt", "L"]));
749            v
750        }
751        ["complete"] => starts(COMMANDS),
752        // Path arguments: complete against the filesystem.
753        ["save"] | ["load"] | ["sweep"] | ["source"] => path_candidates(word),
754        // `load <file> [block]` — the optional second arg names a block.
755        ["load", _] => starts(&BLOCK_NAMES),
756        _ => Vec::new(),
757    }
758}
759
760/// rustyline helper: supplies Tab completion against the live command /
761/// option vocabulary. Hinting / highlighting / validation are the
762/// no-op derived defaults.
763#[cfg(not(target_family = "wasm"))]
764#[derive(Helper, Hinter, Highlighter, Validator)]
765struct DbgHelper {
766    reg: Option<Rc<RegisteredOptions>>,
767}
768
769#[cfg(not(target_family = "wasm"))]
770impl Completer for DbgHelper {
771    type Candidate = Pair;
772    fn complete(
773        &self,
774        line: &str,
775        pos: usize,
776        _ctx: &Context<'_>,
777    ) -> rustyline::Result<(usize, Vec<Pair>)> {
778        let before = &line[..pos];
779        let start = before
780            .rfind(char::is_whitespace)
781            .map(|i| i + 1)
782            .unwrap_or(0);
783        let word = &before[start..];
784        let cands = completion_candidates(self.reg.as_deref(), &before[..start], word);
785        let pairs = cands
786            .into_iter()
787            .map(|c| Pair {
788                display: c.clone(),
789                replacement: c,
790            })
791            .collect();
792        Ok((start, pairs))
793    }
794}
795
796/// Rendered constraint equations from the source model, indexed in
797/// original `.nl` row order. Lets the debugger answer
798/// `print equation <name|row>` with the actual algebra — the source
799/// expression for a constraint, resolved by its model name. This closes
800/// the loop on the residual-name labeling (`print residuals`): once a
801/// culprit equation is named, the user can read it. Naming and printing
802/// culprit equations rather than bare indices is the diagnostic
803/// recommendation of Lee et al. (2024,
804/// <https://doi.org/10.69997/sct.147875>).
805pub struct EquationBook {
806    /// Constraint names in original `.nl` row order (empty `String` when a
807    /// row has no name, e.g. no `.row` auxfile was emitted).
808    names: Vec<String>,
809    /// Rendered equation text, parallel to `names`.
810    equations: Vec<String>,
811}
812
813impl EquationBook {
814    /// Build from parallel name / rendered-equation vectors (original
815    /// `.nl` row order). Lengths are zipped to the shorter of the two.
816    pub fn new(names: Vec<String>, equations: Vec<String>) -> Self {
817        Self { names, equations }
818    }
819
820    /// Number of constraints with a rendered equation.
821    pub fn len(&self) -> usize {
822        self.equations.len()
823    }
824
825    /// True when there are no equations.
826    pub fn is_empty(&self) -> bool {
827        self.equations.is_empty()
828    }
829
830    /// Human label for row `i`: its model name if present, else `c[i]`
831    /// (original `.nl` row index).
832    fn label(&self, i: usize) -> String {
833        match self.names.get(i) {
834            Some(n) if !n.is_empty() => n.clone(),
835            _ => format!("c[{i}]"),
836        }
837    }
838
839    /// Resolve a user key to an original row index: an exact name match
840    /// first, else the key parsed as a `usize` row index.
841    fn resolve(&self, key: &str) -> Option<usize> {
842        if let Some(i) = self.names.iter().position(|n| n == key) {
843            return Some(i);
844        }
845        key.parse::<usize>()
846            .ok()
847            .filter(|&i| i < self.equations.len())
848    }
849}
850
851/// Maximum number of named culprits listed inline in a structural
852/// finding before it switches to a "+N more" tail. Keeps a pathological
853/// model (hundreds of redundant rows) from flooding the report while
854/// still reporting the full count — no silent truncation.
855const MAX_STRUCT_NAMES: usize = 10;
856
857/// Maximum singular values echoed inline by `print rank` before the tail
858/// is elided (the full spectrum is always in the JSON payload).
859const MAX_SINGULAR_VALUES_SHOWN: usize = 16;
860
861/// Maximum implicated rows listed inline by `print rank` before a
862/// "+N more" tail. Same no-silent-truncation rule as [`MAX_STRUCT_NAMES`].
863const MAX_RANK_CULPRITS: usize = 12;
864
865/// Structural rank analysis of the *equality* constraint Jacobian,
866/// after the Dulmage–Mendelsohn decomposition used by IDAES's
867/// `DiagnosticsToolbox`. The Hessian-free, iterate-independent sparsity
868/// pattern alone tells us whether a subset of equations is
869/// over-determined — more equations than the variables they jointly
870/// touch — which forces at least one of them to be redundant or
871/// mutually inconsistent (a structurally singular Jacobian, LICQ
872/// failure).
873///
874/// The payoff is *naming* those rows. The solver's δ_c dual
875/// regularization and wrong-inertia flags detect rank deficiency but
876/// report it as a scalar; this book maps the dependent rows back to the
877/// model's equation names so `diagnose` can say `mass_balance` instead
878/// of "equation 13". Tracing a singular system to *named* equations is
879/// exactly the roadblock Lee et al. (2024) identify for
880/// equation-oriented model debugging. See
881/// <https://doi.org/10.69997/sct.147875>.
882pub struct StructureBook {
883    /// Equality-row × variable incidence graph (built from the source
884    /// model's Jacobian sparsity).
885    inc: EqualityIncidence,
886    /// Constraint names in original `.nl` row order (empty `String`
887    /// when a row has no name).
888    con_names: Vec<String>,
889    /// Variable names in original column order (empty `String` when a
890    /// column has no name).
891    var_names: Vec<String>,
892}
893
894impl StructureBook {
895    /// Build from the equality incidence graph plus the model's
896    /// constraint and variable name vectors (original order). The
897    /// incidence rows index into `con_names` via
898    /// `inc.eq_row_inner_idx`; the incidence columns index `var_names`
899    /// directly.
900    pub fn new(inc: EqualityIncidence, con_names: Vec<String>, var_names: Vec<String>) -> Self {
901        Self {
902            inc,
903            con_names,
904            var_names,
905        }
906    }
907
908    /// Label for equality-incidence row `eq_row`: the source model's
909    /// constraint name if present, else `c[<orig row>]`.
910    fn con_label(&self, eq_row: usize) -> String {
911        let orig = self.inc.eq_row_inner_idx[eq_row];
912        match self.con_names.get(orig) {
913            Some(n) if !n.is_empty() => n.clone(),
914            _ => format!("c[{orig}]"),
915        }
916    }
917
918    /// Label for variable column `v`: the source model's variable name
919    /// if present, else `x[v]`.
920    fn var_label(&self, v: usize) -> String {
921        match self.var_names.get(v) {
922            Some(n) if !n.is_empty() => n.clone(),
923            _ => format!("x[{v}]"),
924        }
925    }
926
927    /// Join up to [`MAX_STRUCT_NAMES`] labels, appending an explicit
928    /// "+N more" tail when truncated so nothing is dropped silently.
929    fn join_capped(labels: &[String]) -> String {
930        if labels.len() <= MAX_STRUCT_NAMES {
931            labels.join(", ")
932        } else {
933            let head = labels[..MAX_STRUCT_NAMES].join(", ");
934            let more = labels.len() - MAX_STRUCT_NAMES;
935            format!("{head}, … (+{more} more)")
936        }
937    }
938
939    /// Run the structural pass and return `diagnose` findings.
940    ///
941    /// Only the *over-determined* block is reported: it names the
942    /// candidate dependent (redundant / inconsistent) equations behind
943    /// a singular Jacobian. The under-determined block is deliberately
944    /// suppressed — an NLP with more variables than equality
945    /// constraints is the normal, well-posed case (the remaining
946    /// degrees of freedom are pinned by the objective, bounds, and
947    /// inequalities), so flagging it would fire on nearly every model.
948    fn findings(&self) -> Vec<(&'static str, &'static str, String)> {
949        let mut out = Vec::new();
950        if self.inc.n_eq_rows() == 0 {
951            return out;
952        }
953        let matching = hopcroft_karp(&self.inc);
954        let dm = DulmageMendelsohnPartition::from_matching(&self.inc, &matching);
955        if dm.over_rows.is_empty() {
956            return out;
957        }
958
959        // over_rows.len() == over_cols.len() + (unmatched rows); the
960        // unmatched count is the minimum number of structurally
961        // redundant equations.
962        let excess = dm.over_rows.len().saturating_sub(dm.over_cols.len());
963        let eq_labels: Vec<String> = dm.over_rows.iter().map(|&r| self.con_label(r)).collect();
964        let var_labels: Vec<String> = dm.over_cols.iter().map(|&v| self.var_label(v)).collect();
965        let eqs = Self::join_capped(&eq_labels);
966        let shared = if var_labels.is_empty() {
967            "no variables".to_string()
968        } else {
969            Self::join_capped(&var_labels)
970        };
971        out.push((
972            "warning",
973            "structural_singularity",
974            format!(
975                "Constraint Jacobian is structurally singular (Dulmage–Mendelsohn): {} equation(s) \
976                 over-determine the {} variable(s) they jointly touch ({}), so ≥{} of them must be \
977                 redundant or mutually inconsistent (LICQ fails on this block). Candidate \
978                 dependent equations: {}. Inspect them with `print equation <name>`; this names \
979                 the rows behind any δ_c dual-regularization / wrong-inertia signal.",
980                dm.over_rows.len(),
981                dm.over_cols.len(),
982                shared,
983                excess.max(1),
984                eqs
985            ),
986        ));
987        out
988    }
989}
990
991pub struct SolverDebugger {
992    mode: DebugMode,
993    reg: Option<Rc<RegisteredOptions>>,
994    /// Pause at the next checkpoint (one-shot, re-armed by `step`).
995    step: bool,
996    /// Pause once iteration ≥ this value.
997    run_to: Option<i32>,
998    /// Iterations to break at.
999    breaks: Vec<i32>,
1000    /// One-shot iteration breakpoints (`tbreak`), removed when hit.
1001    temp_breaks: Vec<i32>,
1002    /// Command lists attached to iteration breakpoints (`commands N …`):
1003    /// run automatically when iteration N is paused at.
1004    bp_commands: HashMap<i32, Vec<String>>,
1005    /// Conditional breakpoints (`break if mu<1e-4`): pause when any holds.
1006    conds: Vec<Condition>,
1007    /// Data watchpoints (`watchpoint x[3]`): pause when a value changes.
1008    watchpoints: Vec<WatchPoint>,
1009    /// μ-stall tracking for the `mu_stalled` event.
1010    last_mu: Option<f64>,
1011    mu_stall: u32,
1012    /// True while between `pre_restoration_entry` and
1013    /// `post_restoration_exit` — marks pauses fired by the inner IPM.
1014    in_restoration: bool,
1015    /// Once true, never pause again (`detach`).
1016    detached: bool,
1017    /// Set by `quit` and by a closed stdin, in JSON mode only. Final: every
1018    /// later checkpoint returns `Stop` at once, without pausing or reading
1019    /// stdin. Stopping at one checkpoint does not end the run: the solve's
1020    /// own terminal checkpoint follows, and a `quit` typed inside
1021    /// restoration returns into the outer solve, which reaches its own
1022    /// checkpoints. Without the latch each of those paused again and sat
1023    /// waiting on a stdin the client had stopped writing to, so `quit`
1024    /// never ended the process. (The application's automatic re-solves do
1025    /// not re-enter the hook: it drives one solve, and they stand down
1026    /// while it is attached.)
1027    ///
1028    /// Not set in REPL mode, where a person at the prompt can type `quit`
1029    /// again: there the stop still lands on the post-mortem pause, so the
1030    /// failing iterate stays inspectable, as it always has.
1031    quit: bool,
1032    /// Whether the JSON `hello` handshake has been emitted (once per
1033    /// session, at the first checkpoint).
1034    hello_sent: bool,
1035    /// Pause at iteration checkpoints (false for `--debug-on-error`,
1036    /// which runs freely until the terminal checkpoint).
1037    pause_iters: bool,
1038    /// Pause at the terminal (post-mortem) checkpoint.
1039    pause_terminal: bool,
1040    /// At the terminal checkpoint, pause only when the solve failed.
1041    terminal_only_on_error: bool,
1042    /// Honor a pending SIGINT (Ctrl-C) by pausing at the next iteration.
1043    interruptible: bool,
1044    /// Emit a per-iteration `progress` event (JSON mode) when running
1045    /// between pauses, so a visual debugger can show live progress.
1046    emit_progress: bool,
1047    /// Return `viz` / `save` artifacts inside the command result's `data`
1048    /// instead of writing a file and launching a viewer (`artifacts
1049    /// inline|file`). The default on wasm targets, which have no
1050    /// filesystem to write to and no viewer to launch.
1051    inline_artifacts: bool,
1052    /// One-shot: pause at the very next checkpoint of *any* kind (set by
1053    /// `stepi`, for walking through sub-iteration phases).
1054    sub_step: bool,
1055    /// Checkpoint kinds (by name) to always pause at (`stop-at`).
1056    stop_at: HashSet<&'static str>,
1057    /// Events to break on (`break on <event>`), from [`EVENTS`].
1058    break_events: HashSet<&'static str>,
1059    /// Per-iteration primal-dual snapshots for `goto`/`restart`, keyed by
1060    /// iteration index. Capped at [`SNAPSHOT_CAP`] (oldest evicted).
1061    snapshots: BTreeMap<i32, Box<dyn pounce_common::debug::IterSnapshot>>,
1062    /// Shared slot for `resolve` to request a fresh solve from the
1063    /// current point with staged options. `None` disables `resolve`.
1064    restart: Option<RestartCell>,
1065    /// rustyline editor for the human REPL on a TTY (history + Tab +
1066    /// Ctrl-R). `None` for JSON mode or when stdin isn't a terminal, in
1067    /// which case a plain line reader is used.
1068    #[cfg_attr(target_family = "wasm", allow(dead_code))]
1069    editor: Option<ReplEditor>,
1070    /// Where REPL history is persisted, if a home directory was found.
1071    #[cfg_attr(target_family = "wasm", allow(dead_code))]
1072    hist_path: Option<PathBuf>,
1073    /// Background stdin reader (JSON mode) enabling async `{"cmd":"pause"}`
1074    /// during a run. `None` in REPL mode.
1075    pump: Option<StdinPump>,
1076    /// Expressions to auto-print at every pause (`watch`). Each is a
1077    /// `print` target (block, `dx`, scalar, `kkt`).
1078    watches: Vec<String>,
1079    /// A debugger script (file path) to run once at the first pause
1080    /// (`--debug-script`); consumed on use.
1081    pending_script: Option<String>,
1082    /// A `--debug-script` was given (it may already have been consumed).
1083    /// With a non-TTY stdin, an exhausted script means "let it run" rather
1084    /// than blocking on a stream nobody is typing into (gh#990 item 14).
1085    script_given: bool,
1086    /// Option edits accepted at the prompt. Validated against the
1087    /// registry; surfaced to the caller after the solve. Not applied to
1088    /// already-built strategies mid-solve (see `staged_options`).
1089    staged: Vec<(String, String)>,
1090    /// Active `sweep` / `multistart` run, if any. Driven at the terminal
1091    /// checkpoint across re-solves (see [`SolverDebugger::drive_sweep`]).
1092    sweep: Option<SweepState>,
1093    /// Consecutive Ctrl-C presses at the REPL prompt with no command in
1094    /// between. The first cancels the line (readline convention); a second
1095    /// quits the solve — a discoverable Ctrl-C escape hatch that mirrors the
1096    /// running-mode double-tap. Reset whenever a real line is entered.
1097    #[cfg_attr(target_family = "wasm", allow(dead_code))]
1098    prompt_interrupts: u8,
1099    /// Rendered constraint equations from the source model (`.nl`), for the
1100    /// `print equation <name|row>` command. `None` when no model was wired in
1101    /// (e.g. a non-`.nl` entry point). See Lee et al. (2024,
1102    /// <https://doi.org/10.69997/sct.147875>) on naming culprit equations.
1103    equation_book: Option<EquationBook>,
1104    /// Structural rank analysis of the source model's equality Jacobian,
1105    /// for the `diagnose` command's `structural_singularity` finding.
1106    /// `None` when no `.nl` model was wired in. See Lee et al. (2024,
1107    /// <https://doi.org/10.69997/sct.147875>).
1108    structure_book: Option<StructureBook>,
1109    /// A command queue shared with another REPL (the branch-and-bound tree
1110    /// debugger), used when this debugger drives a *sub-solve* under
1111    /// `--debug-script`. When set, [`next_command_line`](Self::next_command_line)
1112    /// pops from it instead of stdin, so a single script interleaves tree and
1113    /// interior-point commands.
1114    script_queue: Option<SharedScript>,
1115}
1116
1117/// A command queue shared between the tree debugger and an interior-point
1118/// sub-solve debugger so one `--debug-script` drives both (they run
1119/// sequentially, never concurrently).
1120pub type SharedScript = Rc<std::cell::RefCell<VecDeque<String>>>;
1121
1122impl SolverDebugger {
1123    /// Fully interactive: pause at the first iteration and at the
1124    /// terminal checkpoint.
1125    pub fn new(mode: DebugMode, reg: Option<Rc<RegisteredOptions>>) -> Self {
1126        Self {
1127            mode,
1128            reg,
1129            // Pause at the very first checkpoint so the user has control
1130            // before iteration 0's step is computed.
1131            step: true,
1132            run_to: None,
1133            breaks: Vec::new(),
1134            temp_breaks: Vec::new(),
1135            bp_commands: HashMap::new(),
1136            conds: Vec::new(),
1137            watchpoints: Vec::new(),
1138            last_mu: None,
1139            mu_stall: 0,
1140            in_restoration: false,
1141            detached: false,
1142            quit: false,
1143            hello_sent: false,
1144            pause_iters: true,
1145            pause_terminal: true,
1146            terminal_only_on_error: false,
1147            // No signals on wasm targets, so nothing can break in.
1148            interruptible: !cfg!(target_family = "wasm"),
1149            emit_progress: true,
1150            inline_artifacts: cfg!(target_family = "wasm"),
1151            sub_step: false,
1152            stop_at: HashSet::new(),
1153            break_events: HashSet::new(),
1154            snapshots: BTreeMap::new(),
1155            restart: None,
1156            editor: None,
1157            hist_path: None,
1158            pump: None,
1159            watches: Vec::new(),
1160            pending_script: None,
1161            script_given: false,
1162            staged: Vec::new(),
1163            sweep: None,
1164            prompt_interrupts: 0,
1165            equation_book: None,
1166            structure_book: None,
1167            script_queue: None,
1168        }
1169    }
1170
1171    /// A debugger that stays **quiet** (never pauses) until [`arm`]ed. Used as
1172    /// the on-demand sub-solve hook for the branch-and-bound tree debugger:
1173    /// it sees a node's relaxation solve only when the user steps into it.
1174    ///
1175    /// [`arm`]: DebugHook::arm
1176    pub fn quiet(mode: DebugMode, reg: Option<Rc<RegisteredOptions>>) -> Self {
1177        let mut d = Self::new(mode, reg);
1178        d.step = false;
1179        d.pause_iters = false;
1180        d.pause_terminal = false;
1181        d.detached = true;
1182        d
1183    }
1184
1185    /// Queue a debugger script to run once at the first pause.
1186    pub fn with_script(mut self, path: String) -> Self {
1187        self.pending_script = Some(path);
1188        self.script_given = true;
1189        self
1190    }
1191
1192    /// Attach the source model's rendered constraint equations, enabling
1193    /// `print equation <name|row>`. Wired in on the `.nl` entry path
1194    /// (see Lee et al. 2024, <https://doi.org/10.69997/sct.147875>).
1195    pub fn set_equation_book(&mut self, book: EquationBook) {
1196        self.equation_book = Some(book);
1197    }
1198
1199    /// Attach the source model's structural rank analysis, enabling the
1200    /// `diagnose` command's `structural_singularity` finding (named
1201    /// dependent equations). Wired in on the `.nl` entry path alongside
1202    /// the equation book. See Lee et al. (2024,
1203    /// <https://doi.org/10.69997/sct.147875>).
1204    pub fn set_structure_book(&mut self, book: StructureBook) {
1205        self.structure_book = Some(book);
1206    }
1207
1208    /// Read commands from a queue shared with the tree debugger, so one
1209    /// `--debug-script` drives both this sub-solve and the tree (see
1210    /// [`SharedScript`]). Takes precedence over stdin / the editor.
1211    pub fn with_shared_script(mut self, queue: SharedScript) -> Self {
1212        self.script_queue = Some(queue);
1213        self
1214    }
1215
1216    /// Enable the `resolve` command, wiring the shared restart slot the
1217    /// CLI's re-solve loop reads.
1218    pub fn with_restart(mut self, cell: RestartCell) -> Self {
1219        self.restart = Some(cell);
1220        self
1221    }
1222
1223    /// Post-mortem: run freely, then drop in at the terminal checkpoint
1224    /// only if the solve did not succeed (`--debug-on-error`).
1225    pub fn on_error(mode: DebugMode, reg: Option<Rc<RegisteredOptions>>) -> Self {
1226        Self {
1227            step: false,
1228            pause_iters: false,
1229            terminal_only_on_error: true,
1230            ..Self::new(mode, reg)
1231        }
1232    }
1233
1234    /// Attach-on-demand: run normally and only drop in when the user
1235    /// presses Ctrl-C (`--debug-on-interrupt`). No automatic iter or
1236    /// terminal pauses.
1237    pub fn on_interrupt(mode: DebugMode, reg: Option<Rc<RegisteredOptions>>) -> Self {
1238        Self {
1239            step: false,
1240            pause_iters: false,
1241            pause_terminal: false,
1242            ..Self::new(mode, reg)
1243        }
1244    }
1245
1246    /// Option edits accepted at the prompt (validated). The caller may
1247    /// re-run the solve with these applied.
1248    pub fn staged_options(&self) -> &[(String, String)] {
1249        &self.staged
1250    }
1251
1252    fn should_pause(&mut self, iter: i32) -> bool {
1253        if self.detached {
1254            return false;
1255        }
1256        if self.step {
1257            return true;
1258        }
1259        if let Some(t) = self.run_to {
1260            if iter >= t {
1261                self.run_to = None;
1262                return true;
1263            }
1264        }
1265        if self.breaks.contains(&iter) {
1266            return true;
1267        }
1268        // One-shot breakpoints fire once then delete themselves.
1269        if let Some(pos) = self.temp_breaks.iter().position(|&b| b == iter) {
1270            self.temp_breaks.remove(pos);
1271            return true;
1272        }
1273        false
1274    }
1275
1276    /// First conditional breakpoint that holds at the current state, if
1277    /// any. Returns its source text (for the pause banner / event).
1278    fn matched_condition(&self, ctx: &dyn DebugState) -> Option<String> {
1279        if self.detached {
1280            return None;
1281        }
1282        self.conds
1283            .iter()
1284            .find(|c| c.holds(ctx))
1285            .map(|c| c.raw.clone())
1286    }
1287
1288    /// First armed event that fires at the current checkpoint/state, if
1289    /// any. Events are derived from observable state, so they're evaluated
1290    /// at the checkpoint where the relevant quantity is meaningful.
1291    fn matched_event(&self, ctx: &dyn DebugState) -> Option<&'static str> {
1292        if self.detached || self.break_events.is_empty() {
1293            return None;
1294        }
1295        let cp = ctx.checkpoint();
1296        // Tiny-step threshold mirrors the solver's own scale.
1297        let tiny = 1e-10;
1298        EVENTS.iter().copied().find(|&e| {
1299            self.break_events.contains(e)
1300                && match e {
1301                    "resto_entered" => cp == Checkpoint::PreRestoration,
1302                    "resto_exited" => cp == Checkpoint::PostRestoration,
1303                    "regularized" => {
1304                        cp == Checkpoint::AfterSearchDirection && ctx.regularization() > 0.0
1305                    }
1306                    "tiny_step" => {
1307                        cp == Checkpoint::AfterSearchDirection
1308                            && ctx
1309                                .delta_block("x")
1310                                .map(|v| v.iter().fold(0.0_f64, |m, &x| m.max(x.abs())) < tiny)
1311                                .unwrap_or(false)
1312                    }
1313                    "ls_rejected" => cp == Checkpoint::AfterStep && ctx.ls_count() > 1,
1314                    "mu_stalled" => cp == Checkpoint::IterStart && self.mu_stall >= MU_STALL_ITERS,
1315                    "nan" => !ctx.nlp_error().is_finite() || !ctx.objective().is_finite(),
1316                    _ => false,
1317                }
1318        })
1319    }
1320
1321    /// Update μ-stall tracking once per iteration (drives `mu_stalled`).
1322    fn update_mu_stall(&mut self, mu: f64) {
1323        if let Some(last) = self.last_mu {
1324            if (mu - last).abs() <= 1e-12 * last.abs().max(1.0) {
1325                self.mu_stall += 1;
1326            } else {
1327                self.mu_stall = 0;
1328            }
1329        }
1330        self.last_mu = Some(mu);
1331    }
1332
1333    /// First watchpoint whose value changed (beyond its threshold) since
1334    /// the previous iteration. Updates the stored baselines.
1335    fn matched_watchpoint(&mut self, ctx: &dyn DebugState) -> Option<String> {
1336        if self.detached {
1337            return None;
1338        }
1339        let mut hit = None;
1340        for wp in self.watchpoints.iter_mut() {
1341            let Some(full) = ctx.block(&wp.block) else {
1342                continue;
1343            };
1344            let cur: Vec<f64> = match wp.idx {
1345                Some(i) => match full.get(i) {
1346                    Some(&v) => vec![v],
1347                    None => continue,
1348                },
1349                None => full,
1350            };
1351            if let Some(prev) = &wp.last {
1352                if prev.len() == cur.len() {
1353                    let changed = prev
1354                        .iter()
1355                        .zip(&cur)
1356                        .any(|(p, c)| (p - c).abs() > wp.threshold);
1357                    if changed && hit.is_none() {
1358                        hit = Some(wp.raw.clone());
1359                    }
1360                }
1361            }
1362            wp.last = Some(cur);
1363        }
1364        hit
1365    }
1366
1367    // ---- command engine -----------------------------------------------
1368
1369    fn dispatch(&mut self, line: &str, ctx: &mut dyn DebugState) -> CmdOut {
1370        // Quote-aware so a file path with spaces (e.g. `load "my run.json"`)
1371        // survives as a single token; identical to `split_whitespace` for any
1372        // line without quotes. `owned` backs the `&str` slices `toks` holds.
1373        let owned = tokenize_quoted(line);
1374        let toks: Vec<&str> = owned.iter().map(String::as_str).collect();
1375        let Some(&verb) = toks.first() else {
1376            return CmdOut::ok(vec![]); // empty line: reprompt
1377        };
1378        let rest = &toks[1..];
1379        match verb {
1380            "help" | "h" | "?" => self.cmd_help(),
1381            "info" | "i" => self.cmd_info(ctx),
1382            "print" | "p" => self.cmd_print(rest, ctx),
1383            // `step` → next iter_start; `step sub` (or `stepi`/`si`) →
1384            // next checkpoint of any kind (issue #72's step ["sub"]).
1385            "step" | "s" | "n" | "next" if rest.first() == Some(&"sub") => {
1386                self.sub_step = true;
1387                CmdOut::ok(vec![
1388                    "stepping to the next checkpoint (sub-iteration)".into(),
1389                ])
1390                .flow(Flow::Resume)
1391            }
1392            "step" | "s" | "n" | "next" => {
1393                self.step = true;
1394                CmdOut::ok(vec!["stepping one iteration".into()]).flow(Flow::Resume)
1395            }
1396            "stepi" | "si" => {
1397                self.sub_step = true;
1398                CmdOut::ok(vec![
1399                    "stepping to the next checkpoint (sub-iteration)".into(),
1400                ])
1401                .flow(Flow::Resume)
1402            }
1403            "continue" | "c" | "cont" => {
1404                self.step = false;
1405                self.sub_step = false;
1406                self.run_to = None;
1407                CmdOut::ok(vec!["continuing".into()]).flow(Flow::Resume)
1408            }
1409            "run" | "r" => self.cmd_run(rest),
1410            "break" | "b" => self.cmd_break(rest),
1411            "tbreak" | "tb" => match rest.first().and_then(|s| s.parse::<i32>().ok()) {
1412                Some(n) => {
1413                    if !self.temp_breaks.contains(&n) {
1414                        self.temp_breaks.push(n);
1415                    }
1416                    CmdOut::ok(vec![format!("temporary breakpoint at iteration {n}")])
1417                }
1418                None => CmdOut::err("usage: tbreak <iteration>"),
1419            },
1420            "watchpoint" | "wp" => self.cmd_watchpoint(rest, ctx),
1421            "commands" => self.cmd_commands(rest),
1422            "stop-at" | "stopat" => self.cmd_stop_at(rest),
1423            "progress" => match rest.first().copied() {
1424                Some("on") | None => {
1425                    self.emit_progress = true;
1426                    CmdOut::ok(vec!["progress events on".into()])
1427                }
1428                Some("off") => {
1429                    self.emit_progress = false;
1430                    CmdOut::ok(vec!["progress events off".into()])
1431                }
1432                _ => CmdOut::err("usage: progress [on|off]"),
1433            },
1434            "artifacts" => match rest.first().copied() {
1435                Some("inline") => {
1436                    self.inline_artifacts = true;
1437                    CmdOut::ok(vec!["viz / save artifacts returned inline".into()])
1438                }
1439                Some("file") => {
1440                    self.inline_artifacts = false;
1441                    CmdOut::ok(vec!["viz / save artifacts written to files".into()])
1442                }
1443                None => CmdOut::ok(vec![format!(
1444                    "artifacts: {}",
1445                    if self.inline_artifacts {
1446                        "inline"
1447                    } else {
1448                        "file"
1449                    }
1450                )])
1451                .with_data(serde_json::json!({"inline": self.inline_artifacts})),
1452                _ => CmdOut::err("usage: artifacts [inline|file]"),
1453            },
1454            "set" => self.cmd_set(rest, ctx),
1455            "get" => self.cmd_get(rest),
1456            "opt" | "options" => self.cmd_opt(rest),
1457            "complete" => self.cmd_complete(rest),
1458            "viz" | "plot" => self.cmd_viz(rest, ctx),
1459            "save" => self.cmd_save(rest, ctx),
1460            "load" => match as_nlp_mut(ctx) {
1461                Some(c) => self.cmd_load(rest, c),
1462                None => nlp_only("load"),
1463            },
1464            "sweep" => match as_nlp_mut(ctx) {
1465                Some(c) => self.cmd_sweep(rest, c),
1466                None => nlp_only("sweep"),
1467            },
1468            "multistart" => match as_nlp_mut(ctx) {
1469                Some(c) => self.cmd_multistart(rest, c),
1470                None => nlp_only("multistart"),
1471            },
1472            "goto" | "jump" => self.cmd_goto(rest, ctx),
1473            "restart" => match self.snapshots.keys().next().copied() {
1474                Some(k) => self.restore_to(k, ctx),
1475                None => CmdOut::err("no snapshots captured yet"),
1476            },
1477            "resolve" | "re-solve" => match as_nlp(ctx) {
1478                Some(c) => self.cmd_resolve(c),
1479                None => nlp_only("resolve"),
1480            },
1481            "ask" | "explain" | "claude" => self.cmd_ask(rest, ctx),
1482            "watch" | "display" => self.cmd_watch(rest),
1483            "diff" => self.cmd_diff(ctx),
1484            "diagnose" | "diag" => match as_nlp(ctx) {
1485                Some(c) => self.cmd_diagnose(c),
1486                None => nlp_only("diagnose"),
1487            },
1488            "source" => self.cmd_source(rest, ctx),
1489            "detach" => {
1490                self.detached = true;
1491                self.step = false;
1492                self.run_to = None;
1493                CmdOut::ok(vec!["detached — solving to completion".into()]).flow(Flow::Resume)
1494            }
1495            // A `pause` received while already paused is a no-op; the
1496            // meaningful use is async, consumed mid-run by `try_take_pause`.
1497            "pause" => CmdOut::ok(vec!["already paused".into()]),
1498            // Easter egg — not in COMMANDS / help / Tab, so it stays hidden.
1499            "coffee" | "brew" | "espresso" => self.cmd_coffee(),
1500            "quit" | "q" | "exit" => {
1501                self.quit = matches!(self.mode, DebugMode::Json);
1502                CmdOut::ok(vec!["stopping solve".into()]).flow(Flow::Stop)
1503            }
1504            other => CmdOut::err(format!("unknown command `{other}` (try `help`)")),
1505        }
1506    }
1507
1508    /// `coffee` — a hidden treat. Prints a steaming mug in colour (TTY +
1509    /// `NO_COLOR`-respecting, like the banner). Pure output, no solver
1510    /// effect; every IPM deserves a coffee break.
1511    fn cmd_coffee(&self) -> CmdOut {
1512        let color = matches!(self.mode, DebugMode::Repl)
1513            && std::io::stderr().is_terminal()
1514            && std::env::var_os("NO_COLOR").is_none();
1515        let paint = |r: u8, g: u8, b: u8, s: &str| -> String {
1516            if color {
1517                format!("\x1b[38;2;{r};{g};{b}m{s}\x1b[0m")
1518            } else {
1519                s.to_string()
1520            }
1521        };
1522        // Palette: ceramic white, dark-roast & medium brown, gray steam.
1523        let cup = |s: &str| paint(0xEC, 0xEC, 0xEF, s);
1524        let dark = |s: &str| paint(0x5A, 0x32, 0x1E, s);
1525        let brew = |s: &str| paint(0x96, 0x5F, 0x37, s);
1526        let steam = |s: &str| paint(0xB4, 0xB9, 0xC3, s);
1527        let lines = vec![
1528            String::new(),
1529            format!("     {}", steam(") )  )")),
1530            format!("    {}", steam("( (  (")),
1531            format!("   {}", cup("._________.")),
1532            format!("   {}{}{}", cup("|"), dark("~~~~~~~~"), cup("|_")),
1533            format!("   {}{}{}", cup("|  "), brew("COFFEE"), cup("| |")),
1534            format!("   {}{}{}", cup("|  "), dark("~~~~~~"), cup("| |")),
1535            format!("   {}", cup("|________|_|")),
1536            format!("    {}", cup("\\________/")),
1537            format!("      {}", brew("a fresh cup for a stuck solve")),
1538            String::new(),
1539        ];
1540        CmdOut::ok(lines).with_data(serde_json::json!({"easter_egg": "coffee"}))
1541    }
1542
1543    fn cmd_help(&self) -> CmdOut {
1544        let lines = vec![
1545            "commands:".into(),
1546            "  info | i                 summary of the current iterate".into(),
1547            "  print | p <what>         x|s|y_c|y_d|z_l|z_u|v_l|v_u | dx (step) |".into(),
1548            "                           mu|obj|inf_pr|inf_du|err|compl|iter | kkt | active | inactive".into(),
1549            "  print residuals [pr|du] [k]  top-k largest-magnitude residuals (default k=10)".into(),
1550            "  print equation [name|row]    source algebra of a constraint, by model name or row".into(),
1551            "  print rank                   SVD rank of the equality Jacobian; names dependent equations".into(),
1552            "  step | s | n             run one iteration, pause again".into(),
1553            "  stepi | si | step sub    run to the next checkpoint (into sub-iteration phases)".into(),
1554            "  progress [on|off]        toggle per-iteration progress events (JSON mode)".into(),
1555            "  stop-at <cp>             always pause at a checkpoint: after_mu|after_search_dir|after_step".into(),
1556            "  continue | c             run to the next breakpoint".into(),
1557            "  run | r <N>              run until iteration N".into(),
1558            "  break | b [N|clear|del N] set/list/clear breakpoints".into(),
1559            "  break if <m><op><v>      conditional bp; m in mu|inf_pr|inf_du|obj|err|iter,".into(),
1560            "                           op in < <= > >= ==  (e.g. break if inf_pr<1e-6)".into(),
1561            "  break on <event>         event bp: resto_entered|resto_exited|regularized|".into(),
1562            "                           tiny_step|ls_rejected|mu_stalled|nan".into(),
1563            "  tbreak <N>               one-shot breakpoint (deletes after firing)".into(),
1564            "  watchpoint <blk>[<i>] [τ] pause when a value changes by > τ (alias wp)".into(),
1565            "  commands <N> <c>;<c>…    auto-run commands when iter N's breakpoint hits".into(),
1566            "  set mu <v>               overwrite the barrier parameter".into(),
1567            "  set <blk>[<i>] <v>       overwrite one component (e.g. set x[2] 1.5)".into(),
1568            "  set <blk> <v0,v1,...>    overwrite a whole block".into(),
1569            "  set opt <name> <value>   stage a solver option (validated)".into(),
1570            "  get opt <name>           show an option's effective value (staged or default)".into(),
1571            "  opt [filter]             list solver options (name/type/default)".into(),
1572            "  complete <prefix>        completion candidates (commands + options)".into(),
1573            "  viz <x|s|dx|...|kkt|L>   open the artifact in an external viewer".into(),
1574            "  save [path]              write the current iterate + residuals to JSON".into(),
1575            "  artifacts [inline|file]  return viz/save payloads in the result instead of files".into(),
1576            "  load <file> [block]      read a block (default x) from a save artifact / numeric file".into(),
1577            "  sweep <file>             one solve per start in <file>; tabulate outcomes".into(),
1578            "  multistart <N> [rel]     N restarts (uniform in each finite box; jitter else)".into(),
1579            "  goto <k> | restart       rewind to a captured iteration (primal-dual only)".into(),
1580            "  resolve                  re-solve from the current x with staged `set opt`s".into(),
1581            "  ask [question]           ask an LLM about the state (default Claude Code; set".into(),
1582            "                           POUNCE_DBG_LLM=claude|codex|gemini|llm or a command template)".into(),
1583            "  ask --prompt-only [q]    return the prompt `ask` would send, without running an LLM".into(),
1584            "  watch [target|clear|del] auto-print a `print` target at every pause".into(),
1585            "  diff                     what changed in the iterate since the last iteration".into(),
1586            "  diagnose | diag          live health report: named culprit residuals, KKT inertia, stalls".into(),
1587            "  source <file>            run debugger commands from a file".into(),
1588            "  detach                   stop pausing; solve to completion".into(),
1589            "  quit | q                 stop the solve now".into(),
1590        ];
1591        CmdOut::ok(lines)
1592    }
1593
1594    fn cmd_info(&self, ctx: &dyn DebugState) -> CmdOut {
1595        let dims: Vec<_> = ctx.block_dims();
1596        let dims_json: serde_json::Map<String, serde_json::Value> = dims
1597            .iter()
1598            .map(|(n, d)| ((*n).to_string(), serde_json::json!(d)))
1599            .collect();
1600        let lines = vec![
1601            format!("iter      = {}", ctx.iter()),
1602            format!("mu        = {:.6e}", ctx.mu()),
1603            format!("objective = {:.8e}", ctx.objective()),
1604            format!("inf_pr    = {:.6e}", ctx.inf_pr()),
1605            format!("inf_du    = {:.6e}", ctx.inf_du()),
1606            format!("nlp_error = {:.6e}", ctx.nlp_error()),
1607            format!(
1608                "dims      = {}",
1609                dims.iter()
1610                    .map(|(n, d)| format!("{n}:{d}"))
1611                    .collect::<Vec<_>>()
1612                    .join(" ")
1613            ),
1614        ];
1615        let mut data = serde_json::json!({ "dims": dims_json });
1616        // The same canonical metric block as the streamed events, so `info`'s
1617        // `data` matches `hello.metrics` (this adds `complementarity`, which
1618        // the human-readable lines above omit for brevity).
1619        insert_metric_fields(&mut data, ctx);
1620        CmdOut::ok(lines).with_data(data)
1621    }
1622
1623    fn cmd_print(&self, rest: &[&str], ctx: &dyn DebugState) -> CmdOut {
1624        let Some(&what) = rest.first() else {
1625            return self.cmd_info(ctx);
1626        };
1627        // The backend-conditional sub-commands are rejected on a capability
1628        // basis *before* any timing check: on a backend that can never
1629        // answer them (the convex/conic IPM has no augmented system, no
1630        // per-component residual pool, no bound slacks), a "not yet — stop
1631        // later" message would send the user round a loop that never ends
1632        // in an answer. #462.
1633        if what == "kkt" {
1634            return match as_nlp(ctx) {
1635                Some(_) => self.cmd_print_kkt(ctx),
1636                None => nlp_only("print kkt"),
1637            };
1638        }
1639        if what == "active" || what == "inactive" {
1640            return match as_nlp(ctx) {
1641                Some(_) => self.cmd_print_bounds(ctx, what == "active"),
1642                None => nlp_only(&format!("print {what}")),
1643            };
1644        }
1645        if what == "residuals" || what == "resid" {
1646            return match as_nlp(ctx) {
1647                Some(_) => self.cmd_print_residuals(&rest[1..], ctx),
1648                None => nlp_only("print residuals"),
1649            };
1650        }
1651        if what == "equation" || what == "eqn" || what == "eq" {
1652            return self.cmd_print_equation(&rest[1..]);
1653        }
1654        if what == "rank" {
1655            return match as_nlp(ctx) {
1656                Some(c) => self.cmd_print_rank(c),
1657                None => nlp_only("print rank"),
1658            };
1659        }
1660        // step / delta blocks: `dx`, `ds`, ... or `delta_x`.
1661        let delta = what.strip_prefix("d").filter(|b| is_block(ctx, b));
1662        if is_block(ctx, what) {
1663            match ctx.block(what) {
1664                Some(v) => CmdOut::ok(vec![fmt_vec(what, &v)])
1665                    .with_data(serde_json::json!({"name": what, "values": v})),
1666                None => CmdOut::err(format!("no iterate yet for block `{what}`")),
1667            }
1668        } else if let Some(blk) = delta {
1669            match ctx.delta_block(blk) {
1670                Some(v) => CmdOut::ok(vec![fmt_vec(&format!("d{blk}"), &v)])
1671                    .with_data(serde_json::json!({"name": format!("d{blk}"), "values": v})),
1672                None => CmdOut::err(format!("no search direction available for `d{blk}` yet")),
1673            }
1674        } else {
1675            let val = match what {
1676                "mu" => ctx.mu(),
1677                "obj" | "objective" => ctx.objective(),
1678                "inf_pr" => ctx.inf_pr(),
1679                "inf_du" => ctx.inf_du(),
1680                "err" | "nlp_error" => ctx.nlp_error(),
1681                "compl" | "complementarity" => ctx.complementarity(),
1682                "iter" => ctx.iter() as f64,
1683                _ => {
1684                    return CmdOut::err(format!(
1685                        "don't know how to print `{what}` (try a block name or mu|obj|inf_pr|inf_du|err|compl|iter)"
1686                    ));
1687                }
1688            };
1689            CmdOut::ok(vec![format!("{what} = {val:.10e}")])
1690                .with_data(serde_json::json!({"name": what, "value": val}))
1691        }
1692    }
1693
1694    /// `print active` / `print inactive` — bound-slack classification per
1695    /// category. `active` counts bounds the iterate is pressing on (slack
1696    /// below `tol`) and reports the min slack; `inactive` is the mirror —
1697    /// it counts the bounds with room to spare (slack ≥ `tol`) and reports
1698    /// the max slack, the variables furthest from their bound.
1699    fn cmd_print_bounds(&self, ctx: &dyn DebugState, active: bool) -> CmdOut {
1700        let tol = 1e-6;
1701        let mut lines = Vec::new();
1702        let mut cats = serde_json::Map::new();
1703        for cat in ["x_l", "x_u", "s_l", "s_u"] {
1704            let Some(sl) = ctx.bound_slack(cat) else {
1705                continue;
1706            };
1707            if sl.is_empty() {
1708                continue;
1709            }
1710            let n = sl.len();
1711            if active {
1712                let min = sl.iter().copied().fold(f64::INFINITY, f64::min);
1713                let near = sl.iter().filter(|&&s| s.abs() < tol).count();
1714                lines.push(format!(
1715                    "{cat}: {n} bound(s), {near} near-active (slack<{tol:.0e}), min slack {min:.3e}"
1716                ));
1717                cats.insert(
1718                    cat.to_string(),
1719                    serde_json::json!({"n": n, "near_active": near, "min_slack": min}),
1720                );
1721            } else {
1722                let max = sl.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1723                let far = sl.iter().filter(|&&s| s.abs() >= tol).count();
1724                lines.push(format!(
1725                    "{cat}: {n} bound(s), {far} inactive (slack≥{tol:.0e}), max slack {max:.3e}"
1726                ));
1727                cats.insert(
1728                    cat.to_string(),
1729                    serde_json::json!({"n": n, "inactive": far, "max_slack": max}),
1730                );
1731            }
1732        }
1733        if lines.is_empty() {
1734            lines.push("no bounded variables or inequality slacks".into());
1735        }
1736        CmdOut::ok(lines).with_data(serde_json::json!({"tol": tol, "categories": cats}))
1737    }
1738
1739    /// `print residuals [primal|dual] [k]` — the `k` largest-magnitude
1740    /// residuals at this step, ranked. With no filter, primal
1741    /// (constraint) and dual (∇L) residuals are pooled and ranked
1742    /// together; `primal`/`dual` restrict to one space. Default `k=10`.
1743    /// The top primal entry equals `inf_pr`; the top dual equals
1744    /// `inf_du`. Args may appear in either order.
1745    fn cmd_print_residuals(&self, rest: &[&str], ctx: &dyn DebugState) -> CmdOut {
1746        let mut k: Option<usize> = None;
1747        let mut filter: Option<bool> = None; // Some(true)=primal, Some(false)=dual
1748        for &arg in rest {
1749            if let Ok(n) = arg.parse::<usize>() {
1750                k = Some(n);
1751            } else {
1752                match arg {
1753                    "primal" | "pr" => filter = Some(true),
1754                    "dual" | "du" => filter = Some(false),
1755                    other => {
1756                        return CmdOut::err(format!(
1757                            "usage: print residuals [primal|dual] [k] (got `{other}`)"
1758                        ));
1759                    }
1760                }
1761            }
1762        }
1763        let k = k.unwrap_or(10);
1764
1765        let mut all = Vec::new();
1766        if filter != Some(false) {
1767            let Some(primal) = ctx.constraint_residuals() else {
1768                return CmdOut::err("no iterate yet — residuals unavailable");
1769            };
1770            all.extend(primal);
1771        }
1772        if filter != Some(true) {
1773            let Some(dual) = ctx.dual_residuals() else {
1774                return CmdOut::err("no iterate yet — residuals unavailable");
1775            };
1776            all.extend(dual);
1777        }
1778
1779        let total = all.len();
1780        let top = rank_residuals(all, k);
1781        if top.is_empty() {
1782            return CmdOut::ok(vec!["no residuals at this iterate".into()])
1783                .with_data(serde_json::json!({"k": k, "total": total, "top": []}));
1784        }
1785
1786        // Model names projected into the solver's split space, when the
1787        // problem carries them (`.col`/`.row`, no presolve). Lets a residual
1788        // print as `mass_balance` rather than `c[3]` — the model-vs-index
1789        // gap Lee et al. (2024, <https://doi.org/10.69997/sct.147875>) flag
1790        // for equation-oriented debugging. `None` ⇒ index labels throughout.
1791        // Model names are NLP-specific (.col/.row); only the NLP debugger
1792        // exposes them — other solvers fall back to index labels.
1793        let names = ctx
1794            .as_any()
1795            .and_then(|a| a.downcast_ref::<DebugCtx>())
1796            .and_then(|c| c.split_names());
1797        let name_of = |r: &Residual| resid_name(r, &names);
1798
1799        let lines = top
1800            .iter()
1801            .map(|r| {
1802                let label = match name_of(r) {
1803                    Some(name) => format!("{}[{}]", r.kind.tag(), name),
1804                    None => format!("{}[{}]", r.kind.tag(), r.index),
1805                };
1806                format!("{:>8} = {:+.6e}   |{:.3e}|", label, r.value, r.value.abs())
1807            })
1808            .collect();
1809        let data: Vec<_> = top
1810            .iter()
1811            .map(|r| {
1812                serde_json::json!({
1813                    "space": r.kind.tag(),
1814                    "primal": r.kind.is_primal(),
1815                    "index": r.index,
1816                    "name": name_of(r),
1817                    "value": r.value,
1818                })
1819            })
1820            .collect();
1821        CmdOut::ok(lines).with_data(serde_json::json!({"k": k, "total": total, "top": data}))
1822    }
1823
1824    /// `print equation [name|row]` — the source algebra of a constraint,
1825    /// resolved by its model name (preferred) or original `.nl` row index.
1826    /// With no argument, reports how many equations are available and how
1827    /// to address one. This is the read-side companion to the named
1828    /// residual labels (`print residuals`): once a culprit constraint is
1829    /// named, this prints what it actually says. Naming and surfacing
1830    /// culprit equations rather than bare indices is the diagnostic path
1831    /// urged by Lee et al. (2024, <https://doi.org/10.69997/sct.147875>).
1832    fn cmd_print_equation(&self, rest: &[&str]) -> CmdOut {
1833        let Some(book) = self.equation_book.as_ref() else {
1834            return CmdOut::err(
1835                "no equation source — `print equation` needs an .nl model (none was loaded)",
1836            );
1837        };
1838        if book.is_empty() {
1839            return CmdOut::err("the model has no constraint equations to print");
1840        }
1841        let Some(&key) = rest.first() else {
1842            return CmdOut::ok(vec![format!(
1843                "{} constraint equation(s) — `print equation <name|row>` to show one",
1844                book.len()
1845            )])
1846            .with_data(serde_json::json!({"count": book.len()}));
1847        };
1848        let Some(i) = book.resolve(key) else {
1849            return CmdOut::err(format!(
1850                "no constraint named or indexed `{key}` (have {} equation(s); try a name or 0..{})",
1851                book.len(),
1852                book.len().saturating_sub(1)
1853            ));
1854        };
1855        let label = book.label(i);
1856        // `i` may come from a name lookup that indexes `names`; guard against a
1857        // names/equations length skew rather than risk an out-of-bounds panic.
1858        let Some(eq) = book.equations.get(i) else {
1859            return CmdOut::err(format!(
1860                "constraint `{key}` has no source algebra (index {i} out of range)"
1861            ));
1862        };
1863        CmdOut::ok(vec![format!("{label}:  {eq}")]).with_data(serde_json::json!({
1864            "index": i,
1865            "name": book.names.get(i).filter(|n| !n.is_empty()),
1866            "equation": eq,
1867        }))
1868    }
1869
1870    /// `diagnose` (`diag`) — a point-in-time health report for the
1871    /// *current* iterate.
1872    ///
1873    /// Where the studio `diagnose` tool runs temporal heuristics over a
1874    /// finished solve report, this runs **live**: it reads the current KKT
1875    /// inertia / regularization, the named primal & dual residuals, the
1876    /// iterate geometry, and the debugger's own restoration / μ-stall
1877    /// tracking — and names the culprit equation or variable wherever it
1878    /// can. Tracing a numerical symptom back to the *named* equation behind
1879    /// it, rather than a bare row index, is the actionable-diagnostics path
1880    /// of Lee et al. (2024, <https://doi.org/10.69997/sct.147875>).
1881    ///
1882    /// Each finding is `{severity, code, message}` — the same shape the
1883    /// report-based `diagnose` emits — so a client can treat both uniformly.
1884    fn cmd_diagnose(&self, ctx: &DebugCtx) -> CmdOut {
1885        const TOL: f64 = 1e-6;
1886        let names = ctx.split_names();
1887        // (severity, code, message). Severity ranks error > warning > info.
1888        let mut f: Vec<(&'static str, &'static str, String)> = Vec::new();
1889
1890        // --- Primal feasibility: the worst *named* constraint residual. ---
1891        let inf_pr = ctx.inf_pr();
1892        if inf_pr > TOL {
1893            if let Some(resids) = ctx.constraint_residuals() {
1894                if let Some((label, val)) = worst_named(resids, &names) {
1895                    let sev = if inf_pr > 1e-2 { "error" } else { "warning" };
1896                    f.push((
1897                        sev,
1898                        "primal_infeasible",
1899                        format!(
1900                            "Primal infeasibility {inf_pr:.2e}; worst constraint residual is \
1901                         {label} = {val:+.3e}. Inspect this equation's feasibility and scaling \
1902                         at the current point (`print equation {label}`)."
1903                        ),
1904                    ));
1905                }
1906            }
1907        }
1908
1909        // --- Dual stationarity: the worst *named* ∇L component. ---
1910        let inf_du = ctx.inf_du();
1911        if inf_du > TOL {
1912            if let Some(resids) = ctx.dual_residuals() {
1913                if let Some((label, val)) = worst_named(resids, &names) {
1914                    f.push((
1915                        "warning",
1916                        "dual_infeasible",
1917                        format!(
1918                            "Dual infeasibility {inf_du:.2e}; largest stationarity residual is \
1919                         {label} = {val:+.3e}."
1920                        ),
1921                    ));
1922                }
1923            }
1924        }
1925
1926        // --- KKT structural health (only once a search dir is computed). ---
1927        if let Some(k) = ctx.kkt() {
1928            if k.provides_inertia && !k.inertia_correct {
1929                f.push((
1930                    "warning",
1931                    "inertia_wrong",
1932                    format!(
1933                        "KKT inertia is wrong (n-={} vs expected {}): the system was \
1934                     indefinite/singular and the step had to be stabilized. A persistent \
1935                     mismatch points at a rank-deficient Jacobian or an indefinite Hessian.",
1936                        k.n_neg, k.expected_neg
1937                    ),
1938                ));
1939            }
1940            if k.delta_w > 1e-4 {
1941                f.push((
1942                    "info",
1943                    "heavy_regularization",
1944                    format!(
1945                        "Primal regularization δ_w={:.2e} applied — the Hessian was indefinite at \
1946                     this step. Normal near saddle points; persistent large δ_w suggests a \
1947                     problematic Hessian.",
1948                        k.delta_w
1949                    ),
1950                ));
1951            }
1952            if k.delta_c > 0.0 {
1953                f.push((
1954                    "warning",
1955                    "dual_regularization",
1956                    format!(
1957                    "Dual regularization δ_c={:.2e} applied — the constraint Jacobian is (near) \
1958                     rank-deficient (linearly dependent or redundant equalities). Inspect the \
1959                     equality residuals by name (`print residuals primal`).",
1960                    k.delta_c
1961                ),
1962                ));
1963            }
1964        }
1965
1966        // --- Structural rank: name the dependent equations (DM). ---
1967        // Iterate-independent; localizes the δ_c / wrong-inertia signal
1968        // above to the specific over-determined rows by model name.
1969        if let Some(book) = self.structure_book.as_ref() {
1970            f.extend(book.findings());
1971        }
1972
1973        // --- Numerical rank: SVD of the equality Jacobian at this point. ---
1974        // The numerical complement to the structural pass above: catches
1975        // *value* dependencies a full sparsity pattern hides, and localizes
1976        // the δ_c signal to specific equations even when the structure is
1977        // nominally full rank. Iterate-dependent (it factors J_c at x).
1978        if let Some(rep) = ctx.rank_report() {
1979            if rep.is_rank_deficient() {
1980                let culprits: Vec<String> = rep
1981                    .culprits
1982                    .iter()
1983                    .take(MAX_RANK_CULPRITS)
1984                    .map(|c| rank_row_label(&rep.rows[c.row], &names))
1985                    .collect();
1986                let named = if culprits.is_empty() {
1987                    String::new()
1988                } else {
1989                    format!(" Implicated equations: {}.", culprits.join(", "))
1990                };
1991                f.push((
1992                    "warning",
1993                    "rank_deficient_jacobian",
1994                    format!(
1995                        "Equality Jacobian J_c is numerically rank-deficient at this iterate: \
1996                         rank {}/{} (deficiency {}), σ_min={:.2e}, cond={}. Linearly dependent \
1997                         or redundant equality constraints — the root cause behind δ_c \
1998                         regularization / wrong inertia.{named}",
1999                        rep.rank,
2000                        rep.n_rows(),
2001                        rep.deficiency(),
2002                        rep.sigma_min(),
2003                        fmt_cond(rep.cond),
2004                    ),
2005                ));
2006            }
2007        }
2008
2009        // --- Multiplier magnitude: constraint-qualification / scaling. ---
2010        let mut max_mult = 0.0_f64;
2011        for blk in ["y_c", "y_d", "z_l", "z_u", "v_l", "v_u"] {
2012            if let Some(v) = ctx.block(blk) {
2013                max_mult = v.iter().fold(max_mult, |m, &x| m.max(x.abs()));
2014            }
2015        }
2016        if max_mult > 1e8 {
2017            f.push((
2018                "warning",
2019                "large_multipliers",
2020                format!(
2021                "Largest multiplier magnitude is {max_mult:.2e}. Very large multipliers signal a \
2022                 constraint-qualification failure or poor scaling — consider rescaling the \
2023                 offending rows."
2024            ),
2025            ));
2026        }
2027
2028        // --- Iterate geometry: variable bounds pressed at this point. ---
2029        let mut pinned = 0usize;
2030        for cat in ["x_l", "x_u"] {
2031            if let Some(sl) = ctx.bound_slack(cat) {
2032                pinned += sl.iter().filter(|&&s| s.abs() < TOL).count();
2033            }
2034        }
2035        if pinned > 0 {
2036            f.push((
2037                "info",
2038                "bounds_pinned",
2039                format!(
2040                    "{pinned} variable bound(s) are active (slack < {TOL:.0e}). Active bounds are \
2041                 expected at a solution, but a large count early can throttle the line search."
2042                ),
2043            ));
2044        }
2045
2046        // --- Line search / step length at this iteration. ---
2047        let (alpha_pr, _) = ctx.alpha();
2048        if ctx.iter() > 0 && alpha_pr > 0.0 && alpha_pr < 1e-6 {
2049            f.push((
2050                "warning",
2051                "tiny_step",
2052                format!(
2053                    "Accepted primal step α_pr={alpha_pr:.2e} is tiny — the line search is barely \
2054                 moving. Often a poor search direction or an ill-conditioned KKT system."
2055                ),
2056            ));
2057        }
2058        let ls = ctx.ls_count();
2059        if ls >= 10 {
2060            f.push((
2061                "warning",
2062                "heavy_line_search",
2063                format!(
2064                    "Line search needed {ls} trial points for the accepted step — search-direction \
2065                 quality may be poor (check Hessian accuracy)."
2066                ),
2067            ));
2068        }
2069
2070        // --- Temporal flags the debugger already tracks across iters. ---
2071        if self.in_restoration {
2072            f.push((
2073                "warning",
2074                "in_restoration",
2075                "Currently inside feasibility restoration: the line search could not make \
2076                 progress on the original problem at the working point."
2077                    .to_string(),
2078            ));
2079        }
2080        if self.mu_stall >= MU_STALL_ITERS {
2081            f.push((
2082                "warning",
2083                "mu_stalled",
2084                format!(
2085                    "μ has not decreased for {} consecutive iterations — the barrier is stuck. \
2086                 Try mu_strategy=adaptive or a smaller mu_init.",
2087                    self.mu_stall
2088                ),
2089            ));
2090        }
2091
2092        // --- Healthy fallback. ---
2093        if f.is_empty() {
2094            f.push((
2095                "info",
2096                "healthy",
2097                format!(
2098                    "No issues detected at iter {}: inf_pr={:.2e}, inf_du={:.2e}, μ={:.2e}.",
2099                    ctx.iter(),
2100                    inf_pr,
2101                    inf_du,
2102                    ctx.mu()
2103                ),
2104            ));
2105        }
2106
2107        // Surface errors first, then warnings, then info.
2108        let rank = |s: &str| match s {
2109            "error" => 0,
2110            "warning" => 1,
2111            _ => 2,
2112        };
2113        f.sort_by_key(|(sev, _, _)| rank(sev));
2114
2115        let lines: Vec<String> = f
2116            .iter()
2117            .map(|(sev, code, msg)| format!("[{sev:>7}] {code}: {msg}"))
2118            .collect();
2119        let data: Vec<_> = f
2120            .iter()
2121            .map(|(sev, code, msg)| serde_json::json!({"severity": sev, "code": code, "message": msg}))
2122            .collect();
2123        let n = data.len();
2124        CmdOut::ok(lines)
2125            .with_data(serde_json::json!({"iter": ctx.iter(), "findings": data, "n_findings": n}))
2126    }
2127
2128    /// `print kkt` — inertia + regularization of the factored augmented
2129    /// system. Only meaningful at/after `after_search_dir`.
2130    fn cmd_print_kkt(&self, ctx: &dyn DebugState) -> CmdOut {
2131        let Some(k) = ctx.kkt() else {
2132            return CmdOut::err(
2133                "no KKT factorization yet — stop at `after_search_dir` (e.g. `stop-at kkt`)",
2134            );
2135        };
2136        let inertia = if k.provides_inertia {
2137            format!(
2138                "n+={} n-={} (expected n-={}) → {}",
2139                k.n_pos,
2140                k.n_neg,
2141                k.expected_neg,
2142                if k.inertia_correct {
2143                    "correct"
2144                } else {
2145                    "WRONG (step stabilized)"
2146                }
2147            )
2148        } else {
2149            "n/a (backend reports no inertia)".to_string()
2150        };
2151        let lines = vec![
2152            format!("dim       = {}", k.dim),
2153            format!("inertia   = {inertia}"),
2154            format!("delta_w   = {:.6e}   (primal regularization)", k.delta_w),
2155            format!("delta_c   = {:.6e}   (dual regularization)", k.delta_c),
2156            format!("status    = {}", k.status),
2157        ];
2158        CmdOut::ok(lines).with_data(serde_json::json!({
2159            "dim": k.dim,
2160            "n_pos": k.n_pos,
2161            "n_neg": k.n_neg,
2162            "expected_neg": k.expected_neg,
2163            "provides_inertia": k.provides_inertia,
2164            "inertia_correct": k.inertia_correct,
2165            "delta_w": k.delta_w,
2166            "delta_c": k.delta_c,
2167            "status": k.status,
2168        }))
2169    }
2170
2171    /// `print rank` — numerical rank diagnosis of the equality-constraint
2172    /// Jacobian `J_c` at the current iterate. Runs a rank-revealing SVD,
2173    /// reports the numerical rank / condition number, and — when the block
2174    /// is rank-deficient — names the equations participating in the
2175    /// near-null space (the dependency the `δ_c` regularization is papering
2176    /// over). The numerical complement to the structural `diagnose` /
2177    /// Dulmage–Mendelsohn pass: it also catches *value* dependencies a
2178    /// full sparsity pattern hides.
2179    fn cmd_print_rank(&self, ctx: &DebugCtx) -> CmdOut {
2180        let Some(rep) = ctx.rank_report() else {
2181            return CmdOut::err(
2182                "no equality-constraint Jacobian to analyze (the problem has no equality \
2183                 constraints, or there is no iterate yet)",
2184            );
2185        };
2186        let names = ctx.split_names();
2187        let (lines, data) =
2188            render_rank_report(&rep, &names, self.equation_book.as_ref(), ctx.iter());
2189        CmdOut::ok(lines).with_data(data)
2190    }
2191
2192    fn cmd_run(&mut self, rest: &[&str]) -> CmdOut {
2193        match rest.first().and_then(|s| s.parse::<i32>().ok()) {
2194            Some(n) => {
2195                self.run_to = Some(n);
2196                self.step = false;
2197                CmdOut::ok(vec![format!("running until iteration {n}")]).flow(Flow::Resume)
2198            }
2199            None => CmdOut::err("usage: run <iteration>"),
2200        }
2201    }
2202
2203    fn cmd_break(&mut self, rest: &[&str]) -> CmdOut {
2204        // Conditional breakpoint: `break if <metric><op><value>`. Tokens
2205        // after `if` are concatenated so `inf_pr < 1e-6` and `inf_pr<1e-6`
2206        // parse the same.
2207        if rest.first().copied() == Some("if") {
2208            let expr: String = rest[1..].concat();
2209            if expr.is_empty() {
2210                return CmdOut::err(
2211                    "usage: break if <metric><op><value>  (e.g. break if inf_pr<1e-6)",
2212                );
2213            }
2214            return match Condition::parse(&expr) {
2215                Ok(c) => {
2216                    let raw = c.raw.clone();
2217                    if !self.conds.iter().any(|e| e.raw == raw) {
2218                        self.conds.push(c);
2219                    }
2220                    CmdOut::ok(vec![format!("conditional breakpoint: {raw}")])
2221                        .with_data(serde_json::json!({"condition": raw}))
2222                }
2223                Err(e) => CmdOut::err(e),
2224            };
2225        }
2226        // Event breakpoint: `break on <event>` (#72 §3).
2227        if rest.first().copied() == Some("on") {
2228            let Some(&name) = rest.get(1) else {
2229                return CmdOut::err(format!("usage: break on <event>  (one of {EVENTS:?})"));
2230            };
2231            let Some(&canon) = EVENTS.iter().find(|&&e| e == name) else {
2232                return CmdOut::err(format!("unknown event `{name}` (one of {EVENTS:?})"));
2233            };
2234            self.break_events.insert(canon);
2235            return CmdOut::ok(vec![format!("break on event `{canon}`")])
2236                .with_data(serde_json::json!({"event": canon}));
2237        }
2238        match rest {
2239            [] => {
2240                let mut bs = self.breaks.clone();
2241                bs.sort_unstable();
2242                let conds: Vec<String> = self.conds.iter().map(|c| c.raw.clone()).collect();
2243                let mut events: Vec<&str> = self.break_events.iter().copied().collect();
2244                events.sort_unstable();
2245                let mut lines = vec![format!("breakpoints: {bs:?}")];
2246                if !conds.is_empty() {
2247                    lines.push(format!("conditions: {}", conds.join(", ")));
2248                }
2249                if !events.is_empty() {
2250                    lines.push(format!("events: {}", events.join(", ")));
2251                }
2252                CmdOut::ok(lines).with_data(
2253                    serde_json::json!({"breakpoints": bs, "conditions": conds, "events": events}),
2254                )
2255            }
2256            ["clear", "cond"] | ["clear", "conditions"] => {
2257                self.conds.clear();
2258                CmdOut::ok(vec!["cleared conditional breakpoints".into()])
2259            }
2260            ["clear", "events"] => {
2261                self.break_events.clear();
2262                CmdOut::ok(vec!["cleared event breakpoints".into()])
2263            }
2264            ["clear"] => {
2265                self.breaks.clear();
2266                self.conds.clear();
2267                self.break_events.clear();
2268                CmdOut::ok(vec!["cleared all breakpoints".into()])
2269            }
2270            ["del", n] | ["delete", n] => match n.parse::<i32>() {
2271                Ok(n) => {
2272                    self.breaks.retain(|&b| b != n);
2273                    CmdOut::ok(vec![format!("removed breakpoint {n}")])
2274                }
2275                Err(_) => CmdOut::err("usage: break del <iteration>"),
2276            },
2277            [n] => match n.parse::<i32>() {
2278                Ok(n) => {
2279                    if !self.breaks.contains(&n) {
2280                        self.breaks.push(n);
2281                    }
2282                    CmdOut::ok(vec![format!("breakpoint at iteration {n}")])
2283                }
2284                Err(_) => CmdOut::err("usage: break <iteration>"),
2285            },
2286            _ => CmdOut::err("usage: break [N | if <m><op><v> | clear | clear cond | del N]"),
2287        }
2288    }
2289
2290    /// `stop-at [name|clear]` — pause at a sub-iteration checkpoint every
2291    /// time it fires. Names: after_mu, after_search_dir, after_step
2292    /// (also iter_start / terminated). Aliases: mu, kkt/search_dir, step.
2293    fn cmd_stop_at(&mut self, rest: &[&str]) -> CmdOut {
2294        let canon = |s: &str| -> Option<&'static str> {
2295            match s {
2296                "mu" | "after_mu" => Some("after_mu"),
2297                "kkt" | "search_dir" | "after_search_dir" => Some("after_search_dir"),
2298                "step" | "after_step" => Some("after_step"),
2299                "rejected" | "ls_rejected" | "step_rejected" => Some("step_rejected"),
2300                "resto" | "restoration" | "pre_restoration_entry" => Some("pre_restoration_entry"),
2301                "resto_exit" | "post_restoration_exit" => Some("post_restoration_exit"),
2302                "iter" | "iter_start" => Some("iter_start"),
2303                "terminated" => Some("terminated"),
2304                _ => None,
2305            }
2306        };
2307        match rest {
2308            [] => {
2309                let mut v: Vec<&str> = self.stop_at.iter().copied().collect();
2310                v.sort_unstable();
2311                CmdOut::ok(vec![format!(
2312                    "stop-at: {v:?}  (available: {CHECKPOINTS:?})"
2313                )])
2314                .with_data(serde_json::json!({"stop_at": v, "available": CHECKPOINTS}))
2315            }
2316            ["clear"] => {
2317                self.stop_at.clear();
2318                CmdOut::ok(vec!["cleared stop-at checkpoints".into()])
2319            }
2320            [name] => match canon(name) {
2321                Some(c) => {
2322                    self.stop_at.insert(c);
2323                    CmdOut::ok(vec![format!("will stop at checkpoint `{c}`")])
2324                        .with_data(serde_json::json!({"stop_at_added": c}))
2325                }
2326                None => CmdOut::err(format!(
2327                    "unknown checkpoint `{name}` (one of {CHECKPOINTS:?})"
2328                )),
2329            },
2330            _ => CmdOut::err("usage: stop-at [<checkpoint> | clear]"),
2331        }
2332    }
2333
2334    fn cmd_set(&mut self, rest: &[&str], ctx: &mut dyn DebugState) -> CmdOut {
2335        match rest {
2336            ["mu", v] => match v.parse::<f64>() {
2337                Ok(mu) => match ctx.set_mu(mu) {
2338                    Ok(()) => CmdOut::ok(vec![format!("mu := {mu:.6e}")]),
2339                    Err(e) => CmdOut::err(e),
2340                },
2341                Err(_) => CmdOut::err("usage: set mu <value>"),
2342            },
2343            ["opt", name, value] => match as_nlp_mut(ctx) {
2344                Some(c) => self.cmd_set_opt(name, value, c),
2345                None => nlp_only("set opt"),
2346            },
2347            [target, value] => self.cmd_set_block(target, value, ctx),
2348            _ => CmdOut::err(
2349                "usage: set mu <v> | set <blk>[<i>] <v> | set <blk> <v0,v1,..> | set opt <name> <v>",
2350            ),
2351        }
2352    }
2353
2354    /// `set x[2] 1.5` (component) or `set x 1,2,3` (whole block).
2355    fn cmd_set_block(&mut self, target: &str, value: &str, ctx: &mut dyn DebugState) -> CmdOut {
2356        // Component form: name[idx]
2357        if let Some(open) = target.find('[') {
2358            if !target.ends_with(']') {
2359                return CmdOut::err("malformed component target (expected name[idx])");
2360            }
2361            let name = &target[..open];
2362            let idx_str = &target[open + 1..target.len() - 1];
2363            let Ok(idx) = idx_str.parse::<usize>() else {
2364                return CmdOut::err(format!("bad index `{idx_str}`"));
2365            };
2366            let Ok(val) = value.parse::<f64>() else {
2367                return CmdOut::err(format!("bad value `{value}`"));
2368            };
2369            return match ctx.set_component(name, idx, val) {
2370                Ok(()) => CmdOut::ok(vec![format!("{name}[{idx}] := {val:.6e}")]),
2371                Err(e) => CmdOut::err(e),
2372            };
2373        }
2374        // Whole-block form: comma-separated values.
2375        let parsed: Result<Vec<f64>, _> =
2376            value.split(',').map(|s| s.trim().parse::<f64>()).collect();
2377        match parsed {
2378            Ok(vals) => match ctx.set_block(target, &vals) {
2379                Ok(()) => CmdOut::ok(vec![format!("{target} := {} value(s)", vals.len())]),
2380                Err(e) => CmdOut::err(e),
2381            },
2382            Err(_) => CmdOut::err("could not parse comma-separated values"),
2383        }
2384    }
2385
2386    fn cmd_set_opt(&mut self, name: &str, value: &str, ctx: &mut DebugCtx) -> CmdOut {
2387        let Some(reg) = self.reg.as_ref() else {
2388            return CmdOut::err("no options registry available");
2389        };
2390        let Some(opt) = reg.get_option(name) else {
2391            return CmdOut::err(format!("unknown option `{name}` (try `opt {name}`)"));
2392        };
2393        // Validate against the registered type/bounds.
2394        let valid = match opt.option_type {
2395            OptionType::OT_Number => value
2396                .parse::<f64>()
2397                .map(|v| opt.is_valid_number(v))
2398                .unwrap_or(false),
2399            OptionType::OT_Integer => value
2400                .parse::<i32>()
2401                .map(|v| opt.is_valid_integer(v))
2402                .unwrap_or(false),
2403            OptionType::OT_String => opt.is_valid_string(value),
2404            OptionType::OT_Unknown => true,
2405        };
2406        if !valid {
2407            return CmdOut::err(format!("`{value}` is not a valid value for `{name}`"));
2408        }
2409        // Record it on the staged list either way, so `get opt` reflects
2410        // it and a later `resolve` re-applies it from scratch.
2411        self.staged.retain(|(k, _)| k != name);
2412        self.staged.push((name.to_string(), value.to_string()));
2413        // Convergence tolerances are re-read by the conv-check policy each
2414        // iteration, so we can hot-swap them in place: hand the value to
2415        // the live `DebugCtx`, which the main loop drains after this hook
2416        // returns. The next `step` honors it — no `resolve` required.
2417        if is_live_tolerance(name) {
2418            if let Ok(v) = value.parse::<f64>() {
2419                ctx.set_live_tolerance(name, v);
2420                return CmdOut::ok(vec![format!(
2421                    "{name} = {value}  (applied live — the next `step` uses it)"
2422                )])
2423                .with_data(serde_json::json!({
2424                    "option": name, "value": value, "live": true
2425                }));
2426            }
2427        }
2428        CmdOut::ok(vec![format!(
2429            "staged {name} = {value}  (validated; takes effect on `resolve` — built strategies don't re-read mid-solve)"
2430        )])
2431        .with_data(serde_json::json!({"option": name, "value": value, "staged": true}))
2432    }
2433
2434    /// `get opt <name>` (or the shorthand `get <name>`) — show the value
2435    /// an option would take on the next solve: the value you staged this
2436    /// session with `set opt`, if any, else the registered default. The
2437    /// debugger holds the staged overrides and the option registry, not
2438    /// the running solver's live `OptionsList`, so this is the *configured*
2439    /// value, not a mid-solve internal.
2440    fn cmd_get(&self, rest: &[&str]) -> CmdOut {
2441        // Accept both `get opt <name>` and the shorthand `get <name>`.
2442        let name = match rest {
2443            ["opt", n] => *n,
2444            [n] => *n,
2445            _ => return CmdOut::err("usage: get opt <name>"),
2446        };
2447        let Some(reg) = self.reg.as_ref() else {
2448            return CmdOut::err("no options registry available");
2449        };
2450        let Some(o) = reg.get_option(name) else {
2451            return CmdOut::err(format!("unknown option `{name}` (try `opt {name}`)"));
2452        };
2453        let def = default_str(&o.default);
2454        let staged = self
2455            .staged
2456            .iter()
2457            .find(|(k, _)| k == name)
2458            .map(|(_, v)| v.clone());
2459        let (value, source) = match &staged {
2460            Some(v) => (v.clone(), "staged"),
2461            None => (def.clone(), "default"),
2462        };
2463        CmdOut::ok(vec![format!("{name} = {value}  ({source}; default={def})")]).with_data(
2464            serde_json::json!({
2465                "option": name, "value": value, "source": source,
2466                "default": def, "staged": staged,
2467            }),
2468        )
2469    }
2470
2471    fn cmd_opt(&self, rest: &[&str]) -> CmdOut {
2472        let Some(reg) = self.reg.as_ref() else {
2473            return CmdOut::err("no options registry available");
2474        };
2475        let filter = rest.first().copied().unwrap_or("");
2476        let mut lines = Vec::new();
2477        let mut data = Vec::new();
2478        for o in reg.registered_options_in_order() {
2479            if !filter.is_empty()
2480                && !o.name.contains(filter)
2481                && !o
2482                    .category
2483                    .to_ascii_lowercase()
2484                    .contains(&filter.to_ascii_lowercase())
2485            {
2486                continue;
2487            }
2488            let ty = type_str(o.option_type);
2489            let def = default_str(&o.default);
2490            lines.push(format!(
2491                "  {:<28} {:<7} default={:<12} {}",
2492                o.name, ty, def, o.short_description
2493            ));
2494            data.push(serde_json::json!({
2495                "name": o.name,
2496                "type": ty,
2497                "default": def,
2498                "category": o.category,
2499                "short": o.short_description,
2500                "valid": o.valid_strings.iter().map(|e| e.value.clone()).collect::<Vec<_>>(),
2501            }));
2502        }
2503        if lines.is_empty() {
2504            return CmdOut::ok(vec![format!("no options match `{filter}`")]);
2505        }
2506        // For a single exact match, also show the long description.
2507        if data.len() == 1 {
2508            if let Some(o) = reg.get_option(filter) {
2509                if !o.long_description.is_empty() {
2510                    lines.push(String::new());
2511                    lines.push(o.long_description.clone());
2512                }
2513            }
2514        }
2515        CmdOut::ok(lines).with_data(serde_json::json!({"options": data}))
2516    }
2517
2518    /// `complete <line…>` — context-sensitive completion candidates for
2519    /// the last token, using the same engine as TTY Tab. The preceding
2520    /// tokens form the context (so `complete set opt mu` completes option
2521    /// names, `complete set opt mu_strategy a` completes valid values).
2522    fn cmd_complete(&self, rest: &[&str]) -> CmdOut {
2523        let (before, word) = match rest.split_last() {
2524            Some((w, pre)) => (pre.join(" "), *w),
2525            None => (String::new(), ""),
2526        };
2527        let mut cands = completion_candidates(self.reg.as_deref(), &before, word);
2528        cands.sort();
2529        cands.dedup();
2530        CmdOut::ok(vec![cands.join(" ")]).with_data(serde_json::json!({"candidates": cands}))
2531    }
2532
2533    /// `save [path]` — dump the full current iterate (all blocks +
2534    /// search-direction blocks) and residual scalars to a JSON file for
2535    /// external analysis. Defaults to a temp path keyed by iteration.
2536    fn cmd_save(&self, rest: &[&str], ctx: &dyn DebugState) -> CmdOut {
2537        let iter = ctx.iter();
2538        let collect = |delta: bool| -> serde_json::Map<String, serde_json::Value> {
2539            let mut m = serde_json::Map::new();
2540            for b in block_names(ctx) {
2541                let v = if delta {
2542                    ctx.delta_block(b)
2543                } else {
2544                    ctx.block(b)
2545                };
2546                if let Some(v) = v {
2547                    if !v.is_empty() {
2548                        let key = if delta {
2549                            format!("d{b}")
2550                        } else {
2551                            b.to_string()
2552                        };
2553                        m.insert(key, serde_json::json!(v));
2554                    }
2555                }
2556            }
2557            m
2558        };
2559        let payload = serde_json::json!({
2560            "iter": iter,
2561            "mu": ctx.mu(),
2562            "objective": ctx.objective(),
2563            "inf_pr": ctx.inf_pr(),
2564            "inf_du": ctx.inf_du(),
2565            "nlp_error": ctx.nlp_error(),
2566            "iterate": collect(false),
2567            "delta": collect(true),
2568        });
2569        // Inline unless a path was named: an explicit path is a request for
2570        // a file, whatever the session setting.
2571        if self.inline_artifacts && rest.is_empty() {
2572            return CmdOut::ok(vec![format!("iterate at iter {iter} (inline)")]).with_data(payload);
2573        }
2574        let path = rest
2575            .first()
2576            .map(PathBuf::from)
2577            .unwrap_or_else(|| std::env::temp_dir().join(format!("pounce-dbg-iter{iter}.json")));
2578        match std::fs::write(&path, format!("{payload}\n")) {
2579            Ok(()) => {
2580                let p = path.to_string_lossy().to_string();
2581                CmdOut::ok(vec![format!("saved iterate to {p}")])
2582                    .with_data(serde_json::json!({"path": p}))
2583            }
2584            Err(e) => CmdOut::err(format!("save failed: {e}")),
2585        }
2586    }
2587
2588    /// `load <file> [block]` — the inverse of `save`. Read a block (by
2589    /// default `x`) into the live iterate from either a `save` artifact
2590    /// (JSON: top-level or under `iterate`, every block found is loaded) or
2591    /// a plain numeric file (comma/whitespace/newline-separated values →
2592    /// the named block, default `x`). The point that a many-variable start
2593    /// is awkward to type by hand — generate it once, `load` it here.
2594    fn cmd_load(&mut self, rest: &[&str], ctx: &mut DebugCtx) -> CmdOut {
2595        let Some(&path) = rest.first() else {
2596            return CmdOut::err("usage: load <file> [block]   (inverse of `save`)");
2597        };
2598        let content = match std::fs::read_to_string(path) {
2599            Ok(c) => c,
2600            Err(e) => return CmdOut::err(format!("cannot read `{path}`: {e}")),
2601        };
2602        // JSON path: a `save` artifact (blocks at top level or under
2603        // `iterate`). Load every block present; report dims and any
2604        // dimension mismatches per block.
2605        if let Ok(v) = serde_json::from_str::<serde_json::Value>(content.trim()) {
2606            let obj = v
2607                .get("iterate")
2608                .and_then(|o| o.as_object())
2609                .or_else(|| v.as_object());
2610            if let Some(obj) = obj {
2611                let mut loaded: Vec<(String, usize)> = Vec::new();
2612                let mut errs: Vec<String> = Vec::new();
2613                for &b in BLOCK_NAMES.iter() {
2614                    let Some(arr) = obj.get(b).and_then(|a| a.as_array()) else {
2615                        continue;
2616                    };
2617                    let vals: Option<Vec<f64>> = arr.iter().map(|x| x.as_f64()).collect();
2618                    let Some(vals) = vals else {
2619                        errs.push(format!("{b}: non-numeric entries"));
2620                        continue;
2621                    };
2622                    match ctx.set_block(b, &vals) {
2623                        Ok(()) => loaded.push((b.to_string(), vals.len())),
2624                        Err(e) => errs.push(format!("{b}: {e}")),
2625                    }
2626                }
2627                if loaded.is_empty() && errs.is_empty() {
2628                    return CmdOut::err(
2629                        "no recognizable blocks in JSON (expected `x`, `s`, … at top level or under `iterate`)",
2630                    );
2631                }
2632                let mut lines: Vec<String> = loaded
2633                    .iter()
2634                    .map(|(b, n)| format!("loaded {b} ({n} values)"))
2635                    .collect();
2636                lines.extend(errs.iter().map(|e| format!("skipped {e}")));
2637                return CmdOut::ok(lines).with_data(serde_json::json!({
2638                    "loaded": loaded.iter().map(|(b, n)| serde_json::json!({"block": b, "n": n})).collect::<Vec<_>>(),
2639                    "skipped": errs,
2640                }));
2641            }
2642        }
2643        // Raw numeric path: parse floats and set the named block (default x).
2644        let block = rest.get(1).copied().unwrap_or("x");
2645        let vals = match parse_floats(&content) {
2646            Ok(v) if !v.is_empty() => v,
2647            Ok(_) => return CmdOut::err("file held no numbers"),
2648            Err(e) => return CmdOut::err(e),
2649        };
2650        match ctx.set_block(block, &vals) {
2651            Ok(()) => CmdOut::ok(vec![format!("loaded {block} ({} values)", vals.len())])
2652                .with_data(serde_json::json!({"block": block, "n": vals.len()})),
2653            Err(e) => CmdOut::err(e),
2654        }
2655    }
2656
2657    /// `sweep <file>` — run one full solve per start point in `file` (one
2658    /// start per line, comma/whitespace-separated; `#` comments skipped),
2659    /// then tabulate the terminal status / objective of each. An
2660    /// initialization-sensitivity probe: which starts converge, and to
2661    /// which minima. Needs the re-solve machinery (a restart cell).
2662    fn cmd_sweep(&mut self, rest: &[&str], ctx: &mut DebugCtx) -> CmdOut {
2663        if self.restart.is_none() {
2664            return CmdOut::err("sweep needs re-solve, which is not available in this context");
2665        }
2666        let Some(&path) = rest.first() else {
2667            return CmdOut::err("usage: sweep <file>   (one start per line, comma-separated)");
2668        };
2669        let content = match std::fs::read_to_string(path) {
2670            Ok(c) => c,
2671            Err(e) => return CmdOut::err(format!("cannot read `{path}`: {e}")),
2672        };
2673        let dim = ctx.block("x").map(|x| x.len()).unwrap_or(0);
2674        let mut seeds: Vec<Vec<f64>> = Vec::new();
2675        for (lineno, raw) in content.lines().enumerate() {
2676            let line = raw.trim();
2677            if line.is_empty() || line.starts_with('#') || line.starts_with("//") {
2678                continue;
2679            }
2680            match parse_floats(line) {
2681                Ok(v) if v.len() == dim => seeds.push(v),
2682                Ok(v) => {
2683                    return CmdOut::err(format!(
2684                        "line {}: got {} values, expected {dim} (= dim x)",
2685                        lineno + 1,
2686                        v.len()
2687                    ));
2688                }
2689                Err(e) => return CmdOut::err(format!("line {}: {e}", lineno + 1)),
2690            }
2691        }
2692        self.start_sweep(seeds, &format!("sweep `{path}`"))
2693    }
2694
2695    /// `multistart <N> [rel]` — run `N` full solves from sampled starts,
2696    /// then tabulate the outcomes. Each variable with a finite box
2697    /// `[x_Lᵢ, x_Uᵢ]` is sampled **uniformly in that box**; variables that
2698    /// are unbounded on either side fall back to a relative jitter
2699    /// `±rel·(|xᵢ|+1)` around the current point (`rel` default 0.1). Start 0
2700    /// is always the current `x`. Deterministic (a fixed-seed PRNG), so runs
2701    /// reproduce.
2702    fn cmd_multistart(&mut self, rest: &[&str], ctx: &mut DebugCtx) -> CmdOut {
2703        if self.restart.is_none() {
2704            return CmdOut::err(
2705                "multistart needs re-solve, which is not available in this context",
2706            );
2707        }
2708        let Some(n) = rest.first().and_then(|s| s.parse::<usize>().ok()) else {
2709            return CmdOut::err("usage: multistart <N> [rel]   (N sampled restarts)");
2710        };
2711        if n == 0 {
2712            return CmdOut::err("N must be ≥ 1");
2713        }
2714        let rel = rest
2715            .get(1)
2716            .and_then(|s| s.parse::<f64>().ok())
2717            .unwrap_or(0.1);
2718        let Some(base) = ctx.block("x") else {
2719            return CmdOut::err("no current iterate to sample from");
2720        };
2721        // Full-length algorithm-space bounds, if available and aligned.
2722        let bounds = ctx
2723            .var_bounds()
2724            .filter(|(lo, hi)| lo.len() == base.len() && hi.len() == base.len());
2725        let n_box = bounds
2726            .as_ref()
2727            .map(|(lo, hi)| {
2728                lo.iter()
2729                    .zip(hi)
2730                    .filter(|(l, u)| l.is_finite() && u.is_finite() && u > l)
2731                    .count()
2732            })
2733            .unwrap_or(0);
2734        let seeds: Vec<Vec<f64>> = (0..n)
2735            .map(|k| {
2736                let b = bounds
2737                    .as_ref()
2738                    .map(|(lo, hi)| (lo.as_slice(), hi.as_slice()));
2739                sample_start(&base, b, rel, k)
2740            })
2741            .collect();
2742        let n_var = base.len();
2743        let label = if n_box == n_var {
2744            format!("multistart {n} (box-sampled, {n_box}/{n_var} vars bounded)")
2745        } else if n_box > 0 {
2746            format!(
2747                "multistart {n} (box {n_box}/{n_var} vars; {} unbounded → jitter rel={rel})",
2748                n_var - n_box
2749            )
2750        } else {
2751            format!("multistart {n} (no finite boxes → jitter rel={rel})")
2752        };
2753        self.start_sweep(seeds, &label)
2754    }
2755
2756    /// Launch a sweep: stop the current solve and re-solve from the first
2757    /// seed; the rest are driven from the terminal checkpoint
2758    /// ([`Self::drive_sweep`]). Each solve runs free (`pause_iters` off,
2759    /// restored when the sweep ends).
2760    fn start_sweep(&mut self, seeds: Vec<Vec<f64>>, label: &str) -> CmdOut {
2761        if seeds.is_empty() {
2762            return CmdOut::err("no start points");
2763        }
2764        let Some(cell) = self.restart.as_ref() else {
2765            return CmdOut::err("sweep needs re-solve, which is not available in this context");
2766        };
2767        let total = seeds.len();
2768        let mut queue: VecDeque<Vec<f64>> = seeds.into();
2769        let first = queue.pop_front().expect("non-empty");
2770        *cell.borrow_mut() = Some(RestartRequest {
2771            seed_x: first.clone(),
2772            options: self.staged.clone(),
2773            warm: None,
2774        });
2775        // Run each sweep solve free; we intercept only at the terminal
2776        // checkpoint. Clear any one-shot arming so the re-solve doesn't pause.
2777        let saved_pause_iters = self.pause_iters;
2778        self.pause_iters = false;
2779        self.step = false;
2780        self.sub_step = false;
2781        self.run_to = None;
2782        self.sweep = Some(SweepState {
2783            queue,
2784            current: Some(first),
2785            records: Vec::new(),
2786            total,
2787            saved_pause_iters,
2788        });
2789        CmdOut::ok(vec![format!("{label}: running {total} start(s)…")])
2790            .with_data(serde_json::json!({"sweep": label, "starts": total}))
2791            .flow(Flow::Stop)
2792    }
2793
2794    /// Drive an in-flight sweep at the terminal checkpoint: record the
2795    /// solve that just finished, then either launch the next seed (returns
2796    /// `Some(Resume)` — the CLI re-solve loop picks up the queued
2797    /// [`RestartRequest`]) or, when the queue drains, print the summary,
2798    /// restore state, and return `None` so the caller falls through to the
2799    /// normal terminal handling.
2800    fn drive_sweep(&mut self, ctx: &DebugCtx) -> Option<DebugAction> {
2801        let mut sweep = self.sweep.take()?;
2802        let rec = SweepRecord {
2803            idx: sweep.records.len(),
2804            seed: sweep.current.clone().unwrap_or_default(),
2805            status: ctx.status().unwrap_or("?").to_string(),
2806            objective: ctx.objective(),
2807            inf_pr: ctx.inf_pr(),
2808            iters: ctx.iter(),
2809        };
2810        self.emit_sweep_progress(&rec, sweep.total);
2811        sweep.records.push(rec);
2812        if let Some(next) = sweep.queue.pop_front() {
2813            sweep.current = Some(next.clone());
2814            if let Some(cell) = self.restart.as_ref() {
2815                *cell.borrow_mut() = Some(RestartRequest {
2816                    seed_x: next,
2817                    options: self.staged.clone(),
2818                    warm: None,
2819                });
2820            }
2821            self.sweep = Some(sweep);
2822            return Some(DebugAction::Resume);
2823        }
2824        // Sweep complete: restore per-iteration pausing and report.
2825        self.pause_iters = sweep.saved_pause_iters;
2826        self.emit_sweep_summary(&sweep);
2827        None
2828    }
2829
2830    /// One-line-per-solve progress as a sweep runs (REPL → stderr; JSON →
2831    /// a `sweep_result` event).
2832    fn emit_sweep_progress(&self, rec: &SweepRecord, total: usize) {
2833        match self.mode {
2834            DebugMode::Repl => eprintln!(
2835                "   sweep {}/{}: {:<22} iters={:<4} obj={:.6e} inf_pr={:.2e}",
2836                rec.idx + 1,
2837                total,
2838                rec.status,
2839                rec.iters,
2840                rec.objective,
2841                rec.inf_pr,
2842            ),
2843            DebugMode::Json => emit_json(&serde_json::json!({
2844                "event": "sweep_result",
2845                "index": rec.idx,
2846                "total": total,
2847                "status": rec.status,
2848                "iters": rec.iters,
2849                "objective": rec.objective,
2850                "inf_pr": rec.inf_pr,
2851                "seed": rec.seed,
2852            })),
2853        }
2854    }
2855
2856    /// Final sweep summary: a table of every solve plus a distinct-minima
2857    /// count and the best (lowest-objective) successful solve.
2858    fn emit_sweep_summary(&self, sweep: &SweepState) {
2859        let succeeded: Vec<&SweepRecord> = sweep
2860            .records
2861            .iter()
2862            .filter(|r| is_success_status(&r.status))
2863            .collect();
2864        // Distinct minima: successful objectives clustered to a relative 1e-6.
2865        let mut distinct: Vec<f64> = Vec::new();
2866        for r in &succeeded {
2867            if !distinct
2868                .iter()
2869                .any(|&o| (o - r.objective).abs() <= 1e-6 * o.abs().max(1.0))
2870            {
2871                distinct.push(r.objective);
2872            }
2873        }
2874        let best = succeeded.iter().min_by(|a, b| {
2875            a.objective
2876                .partial_cmp(&b.objective)
2877                .unwrap_or(std::cmp::Ordering::Equal)
2878        });
2879        match self.mode {
2880            DebugMode::Repl => {
2881                eprintln!(
2882                    "\n── sweep complete ── {} solves, {} succeeded, {} distinct end points (by objective; not curvature-classified)",
2883                    sweep.records.len(),
2884                    succeeded.len(),
2885                    distinct.len()
2886                );
2887                eprintln!(
2888                    "   {:>3}  {:<22} {:>5}  {:>14}  {:>9}",
2889                    "#", "status", "iters", "objective", "inf_pr"
2890                );
2891                for r in &sweep.records {
2892                    eprintln!(
2893                        "   {:>3}  {:<22} {:>5}  {:>14.6e}  {:>9.2e}",
2894                        r.idx, r.status, r.iters, r.objective, r.inf_pr
2895                    );
2896                }
2897                if let Some(b) = best {
2898                    eprintln!("   best: solve #{}  obj={:.8e}", b.idx, b.objective);
2899                }
2900            }
2901            DebugMode::Json => emit_json(&serde_json::json!({
2902                "event": "sweep_summary",
2903                "solves": sweep.records.len(),
2904                "succeeded": succeeded.len(),
2905                // gh#987: clusters of converged *objective values*; a saddle
2906                // that the solver accepts counts as one. `distinct_minima` is
2907                // kept as a deprecated alias of the same number.
2908                "distinct_points": distinct.len(),
2909                "distinct_minima": distinct.len(),
2910                "best_index": best.map(|b| b.idx),
2911                "best_objective": best.map(|b| b.objective),
2912                "records": sweep.records.iter().map(|r| serde_json::json!({
2913                    "index": r.idx, "status": r.status, "iters": r.iters,
2914                    "objective": r.objective, "inf_pr": r.inf_pr,
2915                })).collect::<Vec<_>>(),
2916            })),
2917        }
2918    }
2919
2920    /// `goto <k>` — rewind to a captured iteration.
2921    fn cmd_goto(&mut self, rest: &[&str], ctx: &mut dyn DebugState) -> CmdOut {
2922        match rest.first().and_then(|s| s.parse::<i32>().ok()) {
2923            Some(k) => self.restore_to(k, ctx),
2924            None => CmdOut::err("usage: goto <iteration>"),
2925        }
2926    }
2927
2928    /// Restore the snapshot for iteration `k` (primal-dual state only;
2929    /// strategy history is not rewound). Stays paused so the user can
2930    /// inspect / re-tune before `continue`/`step`.
2931    fn restore_to(&mut self, k: i32, ctx: &mut dyn DebugState) -> CmdOut {
2932        match self.snapshots.get(&k) {
2933            Some(snap) => {
2934                if !ctx.restore(snap.as_ref()) {
2935                    return CmdOut::err(format!(
2936                        "this solver does not support rewinding to iter {k}"
2937                    ));
2938                }
2939                CmdOut::ok(vec![format!(
2940                    "rewound to iter {k} (primal-dual only; strategy history not restored). \
2941                     `continue`/`step` to resume."
2942                )])
2943                .with_data(serde_json::json!({"restored_iter": k}))
2944            }
2945            None => {
2946                let have: Vec<i32> = self.snapshots.keys().copied().collect();
2947                CmdOut::err(format!("no snapshot for iter {k} (captured: {have:?})"))
2948            }
2949        }
2950    }
2951
2952    /// `resolve` — capture the full primal-dual iterate (all 8 blocks +
2953    /// μ) and the staged option edits, then stop this solve so the CLI
2954    /// re-runs continuing from that interior point with the new options
2955    /// applied (a true warm start: duals carry over, the barrier resumes
2956    /// at the current μ rather than restarting at `mu_init`). Falls back
2957    /// to a primal-only seed if the iterate can't be snapshotted. Needs a
2958    /// restart cell (wired by the CLI); a no-op error otherwise.
2959    fn cmd_resolve(&mut self, ctx: &DebugCtx) -> CmdOut {
2960        let Some(cell) = self.restart.as_ref() else {
2961            return CmdOut::err("re-solve is not available in this context");
2962        };
2963        let Some(seed_x) = ctx.block("x") else {
2964            return CmdOut::err("no current iterate to seed from");
2965        };
2966        let warm = ctx.snapshot();
2967        let mu = warm.as_ref().map(|s| s.mu());
2968        let options = self.staged.clone();
2969        let n_opt = options.len();
2970        let warm_msg = match mu {
2971            Some(mu) => format!(
2972                "re-solving warm from the current primal-dual iterate (μ={mu:.3e}) \
2973                 with {n_opt} staged option override(s)…"
2974            ),
2975            None => format!(
2976                "re-solving from current x (primal-only) with {n_opt} staged option override(s)…"
2977            ),
2978        };
2979        *cell.borrow_mut() = Some(RestartRequest {
2980            seed_x,
2981            options,
2982            warm,
2983        });
2984        CmdOut::ok(vec![warm_msg])
2985            .with_data(serde_json::json!({
2986                "resolve": true,
2987                "options": n_opt,
2988                "warm": mu.is_some(),
2989                "mu": mu,
2990            }))
2991            .flow(Flow::Stop)
2992    }
2993
2994    /// `ask [question]` — hand the current solver state to an LLM CLI and
2995    /// print its reply. Defaults to headless Claude Code; `$POUNCE_DBG_LLM`
2996    /// selects another provider (`codex`, `gemini`, `llm`) or a full command
2997    /// template. Degrades gracefully when the CLI isn't installed.
2998    /// "Ask why this step looks wrong without leaving the debugger."
2999    fn cmd_ask(&self, rest: &[&str], ctx: &dyn DebugState) -> CmdOut {
3000        // `--prompt-only`: hand the prompt back instead of running a CLI, so
3001        // a front end can send it to a model of its own (the browser studio's
3002        // in-page LLM) with the same grounding `ask` gives.
3003        let (prompt_only, rest) = match rest.split_first() {
3004            Some((&"--prompt-only", tail)) => (true, tail),
3005            _ => (false, rest),
3006        };
3007        let question = if rest.is_empty() {
3008            "Explain the current state of this interior-point solve and suggest what to try next."
3009                .to_string()
3010        } else {
3011            rest.join(" ")
3012        };
3013        let prompt = build_ask_prompt(ctx, &question);
3014        if prompt_only {
3015            let lines = prompt.lines().map(str::to_string).collect();
3016            return CmdOut::ok(lines).with_data(serde_json::json!({
3017                "question": question,
3018                "prompt": prompt,
3019            }));
3020        }
3021        match run_llm(&prompt) {
3022            Ok(reply) => {
3023                let lines: Vec<String> = reply.lines().map(|l| l.to_string()).collect();
3024                CmdOut::ok(lines).with_data(serde_json::json!({
3025                    "question": question,
3026                    "reply": reply,
3027                }))
3028            }
3029            Err(e) => CmdOut::err(e),
3030        }
3031    }
3032
3033    /// `watch [target|clear|del <target>]` — auto-print a `print` target
3034    /// (block, `dx`, scalar, `kkt`) at every pause.
3035    fn cmd_watch(&mut self, rest: &[&str]) -> CmdOut {
3036        match rest {
3037            [] => CmdOut::ok(vec![format!("watches: {:?}", self.watches)])
3038                .with_data(serde_json::json!({"watches": self.watches})),
3039            ["clear"] => {
3040                self.watches.clear();
3041                CmdOut::ok(vec!["cleared watches".into()])
3042            }
3043            ["del", w] | ["delete", w] => {
3044                self.watches.retain(|x| x != w);
3045                CmdOut::ok(vec![format!("unwatched {w}")])
3046            }
3047            [w] => {
3048                let w = w.to_string();
3049                if !self.watches.contains(&w) {
3050                    self.watches.push(w.clone());
3051                }
3052                CmdOut::ok(vec![format!("watching {w}")])
3053            }
3054            _ => CmdOut::err("usage: watch [<target> | clear | del <target>]"),
3055        }
3056    }
3057
3058    /// `watchpoint <blk>[<i>] [threshold] | clear | del <spec>` — pause
3059    /// when a watched value changes by more than `threshold` (default 0,
3060    /// any change) between iterations.
3061    fn cmd_watchpoint(&mut self, rest: &[&str], ctx: &dyn DebugState) -> CmdOut {
3062        match rest {
3063            [] => {
3064                let v: Vec<&str> = self.watchpoints.iter().map(|w| w.raw.as_str()).collect();
3065                CmdOut::ok(vec![format!("watchpoints: {v:?}")])
3066                    .with_data(serde_json::json!({"watchpoints": v}))
3067            }
3068            ["clear"] => {
3069                self.watchpoints.clear();
3070                CmdOut::ok(vec!["cleared watchpoints".into()])
3071            }
3072            ["del", spec] | ["delete", spec] => {
3073                self.watchpoints.retain(|w| w.raw != *spec);
3074                CmdOut::ok(vec![format!("removed watchpoint {spec}")])
3075            }
3076            [spec, rest @ ..] => {
3077                let threshold = rest
3078                    .first()
3079                    .and_then(|s| s.parse::<f64>().ok())
3080                    .unwrap_or(0.0);
3081                // Parse `block` or `block[idx]`.
3082                let (block, idx) = match spec.find('[') {
3083                    Some(open) if spec.ends_with(']') => {
3084                        let b = &spec[..open];
3085                        match spec[open + 1..spec.len() - 1].parse::<usize>() {
3086                            Ok(i) => (b.to_string(), Some(i)),
3087                            Err(_) => return CmdOut::err(format!("bad index in `{spec}`")),
3088                        }
3089                    }
3090                    _ => (spec.to_string(), None),
3091                };
3092                if !is_block(ctx, block.as_str()) {
3093                    return CmdOut::err(format!("unknown block `{block}`"));
3094                }
3095                let raw = spec.to_string();
3096                if !self.watchpoints.iter().any(|w| w.raw == raw) {
3097                    self.watchpoints.push(WatchPoint {
3098                        raw: raw.clone(),
3099                        block,
3100                        idx,
3101                        threshold,
3102                        last: None,
3103                    });
3104                }
3105                CmdOut::ok(vec![format!("watchpoint on {raw} (Δ>{threshold:.3e})")])
3106            }
3107        }
3108    }
3109
3110    /// `commands <iter> <cmd> ; <cmd> …` — attach an auto-run command
3111    /// list to the breakpoint at iteration `iter` (e.g.
3112    /// `commands 5 set mu 0.1 ; continue`). `commands <iter> clear`
3113    /// removes it; `commands` lists all.
3114    fn cmd_commands(&mut self, rest: &[&str]) -> CmdOut {
3115        let Some(iter) = rest.first().and_then(|s| s.parse::<i32>().ok()) else {
3116            if rest.is_empty() {
3117                let mut items: Vec<(i32, Vec<String>)> = self
3118                    .bp_commands
3119                    .iter()
3120                    .map(|(k, v)| (*k, v.clone()))
3121                    .collect();
3122                items.sort_by_key(|(k, _)| *k);
3123                let lines = if items.is_empty() {
3124                    vec!["no breakpoint command lists".into()]
3125                } else {
3126                    items
3127                        .iter()
3128                        .map(|(k, v)| format!("iter {k}: {}", v.join(" ; ")))
3129                        .collect()
3130                };
3131                return CmdOut::ok(lines);
3132            }
3133            return CmdOut::err(
3134                "usage: commands <iter> <cmd> ; <cmd> …  (or: commands <iter> clear)",
3135            );
3136        };
3137        let tail = rest[1..].join(" ");
3138        let tail = tail.trim();
3139        if tail.is_empty() || tail == "clear" {
3140            self.bp_commands.remove(&iter);
3141            return CmdOut::ok(vec![format!("cleared commands for iteration {iter}")]);
3142        }
3143        let cmds: Vec<String> = tail
3144            .split(';')
3145            .map(|s| s.trim().to_string())
3146            .filter(|s| !s.is_empty())
3147            .collect();
3148        self.bp_commands.insert(iter, cmds.clone());
3149        CmdOut::ok(vec![format!(
3150            "commands for iter {iter}: {}",
3151            cmds.join(" ; ")
3152        )])
3153        .with_data(serde_json::json!({"iter": iter, "commands": cmds}))
3154    }
3155
3156    /// `diff` — what changed in the iterate since the previous captured
3157    /// iteration: per-block max |Δ| (and where), plus Δμ.
3158    fn cmd_diff(&self, ctx: &dyn DebugState) -> CmdOut {
3159        let iter = ctx.iter();
3160        let Some((&piter, prev)) = self.snapshots.range(..iter).next_back() else {
3161            return CmdOut::err("no previous iterate to diff against");
3162        };
3163        let mut lines = vec![format!("Δ since iter {piter}:")];
3164        let dmu = ctx.mu() - prev.mu();
3165        lines.push(format!("  mu  = {:.6e}  (Δ {:+.3e})", ctx.mu(), dmu));
3166        let mut blocks = serde_json::Map::new();
3167        for b in block_names(ctx) {
3168            let (Some(cur), Some(old)) = (ctx.block(b), prev.block(b)) else {
3169                continue;
3170            };
3171            if cur.is_empty() || cur.len() != old.len() {
3172                continue;
3173            }
3174            let mut amax = 0.0_f64;
3175            let mut imax = 0usize;
3176            for (i, (c, o)) in cur.iter().zip(&old).enumerate() {
3177                let d = (c - o).abs();
3178                if d > amax {
3179                    amax = d;
3180                    imax = i;
3181                }
3182            }
3183            if amax > 0.0 {
3184                lines.push(format!(
3185                    "  {b}: max|Δ|={amax:.3e} at [{imax}]  ({:.4e} → {:.4e})",
3186                    old[imax], cur[imax]
3187                ));
3188                blocks.insert(
3189                    b.to_string(),
3190                    serde_json::json!({"max_abs_change": amax, "argmax": imax}),
3191                );
3192            }
3193        }
3194        if lines.len() == 2 {
3195            lines.push("  (no change)".into());
3196        }
3197        CmdOut::ok(lines).with_data(
3198            serde_json::json!({"from_iter": piter, "to_iter": iter, "dmu": dmu, "blocks": blocks}),
3199        )
3200    }
3201
3202    /// `source <file>` — run debugger commands from a file (one per line;
3203    /// `#` comments and blank lines skipped). Stops early if a command
3204    /// resumes or stops the solve, propagating that control flow.
3205    fn cmd_source(&mut self, rest: &[&str], ctx: &mut dyn DebugState) -> CmdOut {
3206        let Some(&path) = rest.first() else {
3207            return CmdOut::err("usage: source <file>");
3208        };
3209        let content = match std::fs::read_to_string(path) {
3210            Ok(c) => c,
3211            Err(e) => return CmdOut::err(format!("cannot read `{path}`: {e}")),
3212        };
3213        let mut lines = Vec::new();
3214        let mut flow = Flow::Stay;
3215        for raw in content.lines() {
3216            let cmd = raw.trim();
3217            if cmd.is_empty() || cmd.starts_with('#') || cmd.starts_with("//") {
3218                continue;
3219            }
3220            lines.push(format!("[source] {cmd}"));
3221            let out = self.dispatch(cmd, ctx);
3222            lines.extend(out.lines);
3223            if !matches!(out.flow, Flow::Stay) {
3224                flow = out.flow;
3225                break;
3226            }
3227        }
3228        CmdOut {
3229            ok: true,
3230            lines,
3231            data: None,
3232            flow,
3233        }
3234    }
3235
3236    fn cmd_viz(&self, rest: &[&str], ctx: &mut dyn DebugState) -> CmdOut {
3237        let Some(&target) = rest.first() else {
3238            return CmdOut::err("usage: viz <x|s|y_c|...|dx|kkt|L>");
3239        };
3240        // `viz kkt` writes the assembled augmented-system matrix (triplets
3241        // → heatmap) plus the inertia/regularization summary.
3242        if target == "kkt" {
3243            // Capability first, timing second (see `print kkt`, #462).
3244            if as_nlp(ctx).is_none() {
3245                return nlp_only("viz kkt");
3246            }
3247            let Some(k) = ctx.kkt() else {
3248                return CmdOut::err(
3249                    "no KKT factorization captured yet — nothing has been factored (iter 0), \
3250                     or the debugger is detached. `step` once to capture.",
3251                );
3252            };
3253            // The matrix triplets are captured into `kkt_debug` whenever the
3254            // debugger is stepping, so once anything has been factored they're
3255            // here — this is the previous iteration's system at `iter_start`,
3256            // the current one at `after_search_dir`.
3257            let Some((dim, irn, jcn, vals)) = ctx.kkt_matrix() else {
3258                return CmdOut::err(
3259                    "KKT matrix not captured here — the debugger is detached \
3260                     (running free). `step` once to capture and re-run `viz kkt`.",
3261                );
3262            };
3263            // Label with the iteration the factorization came from — at an
3264            // `iter_start` pause that's the previous iteration, not `ctx.iter()`.
3265            let kiter = k.iter;
3266            let matrix = serde_json::json!({"dim": dim, "irn": irn, "jcn": jcn, "vals": vals,
3267                                            "format": "triplet_1based_lower"});
3268            let payload = serde_json::json!({
3269                "label": "kkt", "iter": kiter,
3270                "dim": k.dim, "n_pos": k.n_pos, "n_neg": k.n_neg,
3271                "expected_neg": k.expected_neg, "inertia_correct": k.inertia_correct,
3272                "delta_w": k.delta_w, "delta_c": k.delta_c, "status": k.status,
3273                "matrix": matrix,
3274            });
3275            if self.inline_artifacts {
3276                return CmdOut::ok(vec![format!(
3277                    "KKT system, iter {kiter} (dim {dim}, inline)"
3278                )])
3279                .with_data(payload);
3280            }
3281            return match write_json_and_open("kkt", kiter, &payload) {
3282                Ok((path, viewer)) => CmdOut::ok(vec![format!(
3283                    "wrote {path} (KKT system, iter {kiter}); opened with `{viewer}`"
3284                )])
3285                .with_data(serde_json::json!({"path": path, "viewer": viewer})),
3286                Err(e) => CmdOut::err(e),
3287            };
3288        }
3289        // `viz L` writes the LDLᵀ factor triplets, read out of the factor
3290        // the solver actually computed. Captured into `kkt_debug` whenever
3291        // the debugger is stepping (same as the matrix), so it shows the
3292        // previous iteration's factorization at `iter_start`.
3293        if target == "L" {
3294            if as_nlp(ctx).is_none() {
3295                return nlp_only("viz L");
3296            }
3297            match ctx.kkt_l_factor() {
3298                Some((n, perm, l_irn, l_jcn, l_vals)) => {
3299                    // Iteration the factor came from (previous iter at `iter_start`).
3300                    let kiter = ctx.kkt_captured_iter().unwrap_or_else(|| ctx.iter());
3301                    let payload = serde_json::json!({
3302                        "label": "L", "iter": kiter, "n": n, "perm": perm,
3303                        "l_irn": l_irn, "l_jcn": l_jcn, "l_vals": l_vals,
3304                        "format": "strict_lower_1based_permuted",
3305                    });
3306                    if self.inline_artifacts {
3307                        return CmdOut::ok(vec![format!("L factor, iter {kiter} (n {n}, inline)")])
3308                            .with_data(payload);
3309                    }
3310                    return match write_json_and_open("L", kiter, &payload) {
3311                        Ok((path, viewer)) => CmdOut::ok(vec![format!(
3312                            "wrote {path} (L factor, iter {kiter}); opened with `{viewer}`"
3313                        )])
3314                        .with_data(serde_json::json!({"path": path, "viewer": viewer})),
3315                        Err(e) => CmdOut::err(e),
3316                    };
3317                }
3318                None => {
3319                    return CmdOut::err(
3320                        "L factor not captured here — nothing factored yet (iter 0), \
3321                         or the debugger is detached. `step` once to capture.",
3322                    );
3323                }
3324            }
3325        }
3326        // Resolve the vector to visualize.
3327        let (label, vals) = if is_block(ctx, target) {
3328            match ctx.block(target) {
3329                Some(v) => (target.to_string(), v),
3330                None => return CmdOut::err(format!("no data for block `{target}`")),
3331            }
3332        } else if let Some(blk) = target.strip_prefix("d").filter(|b| is_block(ctx, b)) {
3333            match ctx.delta_block(blk) {
3334                Some(v) => (format!("d{blk}"), v),
3335                None => return CmdOut::err(format!("no search direction for `d{blk}`")),
3336            }
3337        } else {
3338            return CmdOut::err(format!("don't know how to visualize `{target}`"));
3339        };
3340        if self.inline_artifacts {
3341            return CmdOut::ok(vec![format!(
3342                "{label}, iter {} ({} values, inline)",
3343                ctx.iter(),
3344                vals.len()
3345            )])
3346            .with_data(serde_json::json!({"label": label, "iter": ctx.iter(), "values": vals}));
3347        }
3348        match write_and_open(&label, ctx.iter(), &vals) {
3349            Ok((path, viewer)) => CmdOut::ok(vec![format!(
3350                "wrote {} ({} values); opened with `{}`",
3351                path,
3352                vals.len(),
3353                viewer
3354            )])
3355            .with_data(serde_json::json!({"path": path, "viewer": viewer, "n": vals.len()})),
3356            Err(e) => CmdOut::err(e),
3357        }
3358    }
3359
3360    // ---- front ends ----------------------------------------------------
3361
3362    /// Emit the pause banner / state for the current front end.
3363    fn emit_pause(&self, ctx: &dyn DebugState, reason: Option<&str>) {
3364        let terminal = matches!(ctx.checkpoint(), Checkpoint::Terminated);
3365        match self.mode {
3366            DebugMode::Repl => {
3367                if terminal {
3368                    // gh#987: restoration's inner solve fires `terminated`
3369                    // too; say so rather than let it read as the real end.
3370                    let which = if self.in_restoration {
3371                        "restoration inner solve"
3372                    } else {
3373                        "solve"
3374                    };
3375                    eprintln!(
3376                        "\n── pounce-dbg ── TERMINATED {which} ({})  iter {}  obj={:.6e}  inf_pr={:.2e}  inf_du={:.2e}",
3377                        ctx.status().unwrap_or("?"),
3378                        ctx.iter(),
3379                        ctx.objective(),
3380                        ctx.inf_pr(),
3381                        ctx.inf_du(),
3382                    );
3383                } else {
3384                    let resto = if self.in_restoration {
3385                        " [restoration]"
3386                    } else {
3387                        ""
3388                    };
3389                    eprintln!(
3390                        "\n── pounce-dbg ── iter {} @{}{}  mu={:.3e}  obj={:.6e}  inf_pr={:.2e}  inf_du={:.2e}",
3391                        ctx.iter(),
3392                        ctx.checkpoint().as_str(),
3393                        resto,
3394                        ctx.mu(),
3395                        ctx.objective(),
3396                        ctx.inf_pr(),
3397                        ctx.inf_du(),
3398                    );
3399                }
3400                if let Some(r) = reason {
3401                    eprintln!("   ↳ {r}");
3402                }
3403                for w in &self.watches {
3404                    let out = self.cmd_print(&[w.as_str()], ctx);
3405                    if out.ok {
3406                        for l in &out.lines {
3407                            eprintln!("   watch {l}");
3408                        }
3409                    } else {
3410                        // Don't spam the full error every pause for a target
3411                        // that isn't available yet (e.g. `kkt` before a
3412                        // factorization) — a compact note instead.
3413                        eprintln!("   watch {w}: (n/a)");
3414                    }
3415                }
3416            }
3417            DebugMode::Json => {
3418                let watches: Vec<serde_json::Value> = self
3419                    .watches
3420                    .iter()
3421                    .map(|w| {
3422                        let out = self.cmd_print(&[w.as_str()], ctx);
3423                        serde_json::json!({"expr": w, "ok": out.ok, "output": out.lines, "data": out.data})
3424                    })
3425                    .collect();
3426                let dims: serde_json::Map<String, serde_json::Value> = ctx
3427                    .block_dims()
3428                    .into_iter()
3429                    .map(|(n, d)| (n.to_string(), serde_json::json!(d)))
3430                    .collect();
3431                let conds: Vec<String> = self.conds.iter().map(|c| c.raw.clone()).collect();
3432                let mut ev = serde_json::json!({
3433                    "event": "pause",
3434                    "checkpoint": ctx.checkpoint().as_str(),
3435                    "status": ctx.status(),
3436                    "in_restoration": self.in_restoration,
3437                    // gh#987: `terminated` also fires when restoration's
3438                    // inner solve returns. `final` is true only for the end
3439                    // of the whole solve; a client waiting for "the" end must
3440                    // test it (or `in_restoration`), not the checkpoint name.
3441                    "phase": if self.in_restoration { "restoration" } else { "main" },
3442                    "final": terminal && !self.in_restoration,
3443                    "dims": dims,
3444                    "breakpoints": self.breaks,
3445                    "conditions": conds,
3446                    "reason": reason,
3447                    "watches": watches,
3448                });
3449                // iter / mu / objective / inf_pr / inf_du / nlp_error /
3450                // complementarity — from the single METRICS source of truth.
3451                insert_metric_fields(&mut ev, ctx);
3452                emit_json(&ev);
3453            }
3454        }
3455    }
3456
3457    /// Emit a per-iteration `progress` event (JSON mode only). Carries the
3458    /// same scalar fields, under the same names, as `pause` (minus the
3459    /// per-pause `dims` / `breakpoints` / `watches`); fired while running
3460    /// between pauses.
3461    fn emit_progress_event(&self, ctx: &dyn DebugState) {
3462        let mut ev = serde_json::json!({ "event": "progress" });
3463        // Same scalar metric block as `pause`, from the single METRICS source.
3464        insert_metric_fields(&mut ev, ctx);
3465        emit_json(&ev);
3466    }
3467
3468    /// Emit a command result for the current front end. `req_id` is the
3469    /// client's request id (JSON mode), echoed for response correlation.
3470    fn emit_result(&self, command: &str, out: &CmdOut, req_id: Option<&serde_json::Value>) {
3471        match self.mode {
3472            DebugMode::Repl => {
3473                let stderr = std::io::stderr();
3474                let mut h = stderr.lock();
3475                for l in &out.lines {
3476                    let _ = writeln!(h, "{l}");
3477                }
3478                if !out.ok {
3479                    let _ = writeln!(h, "(error)");
3480                }
3481            }
3482            DebugMode::Json => {
3483                let ev = serde_json::json!({
3484                    "event": "result",
3485                    "request_id": req_id,
3486                    "command": command,
3487                    "ok": out.ok,
3488                    "output": out.lines,
3489                    "data": out.data,
3490                });
3491                emit_json(&ev);
3492            }
3493        }
3494    }
3495
3496    /// Emit the one-time JSON handshake: protocol version, the solver
3497    /// version, advertised capabilities, and the command / metric
3498    /// vocabulary — everything a visual debugger needs to configure its
3499    /// UI before the first `pause`.
3500    ///
3501    /// The backend-conditional entries are answered for the backend that is
3502    /// actually running: a client feature-detecting off `capabilities` must
3503    /// not be told the convex/conic IPM can do `print kkt` / `diagnose`
3504    /// when the REPL rejects those with "not available for this solver"
3505    /// (#462). Likewise `blocks` names *this* solver's iterate blocks.
3506    fn emit_hello(&self, ctx: &dyn DebugState) {
3507        let nlp = as_nlp(ctx).is_some();
3508        // `viz kkt` / `viz L` need an augmented system to show.
3509        let viz: &[&str] = if nlp {
3510            &["block", "delta", "kkt", "L"]
3511        } else {
3512            &["block", "delta"]
3513        };
3514        let ev = serde_json::json!({
3515            "event": "hello",
3516            "protocol": "pounce-dbg/1",
3517            "pounce_version": env!("CARGO_PKG_VERSION"),
3518            "capabilities": {
3519                "inspect": true,
3520                "mutate_iterate": true,
3521                // The convex μ is derived from ⟨s, z⟩; `set mu` is rejected
3522                // there (edit the `s`/`z` blocks instead).
3523                "mutate_mu": nlp,
3524                "conditional_breakpoints": "compound",
3525                "request_ids": true,
3526                "viz": viz,
3527                "save": true,
3528                "load": nlp,
3529                "sweep": nlp && self.restart.is_some(),
3530                "kkt_inspect": nlp,
3531                // `print equation <name|row>` is available when a source
3532                // model (`.nl`) supplied constraint algebra to render.
3533                "equations": self.equation_book.is_some(),
3534                // Live `diagnose` — point-in-time named health findings.
3535                "diagnose": nlp,
3536                // `diagnose`'s structural rank pass (Dulmage–Mendelsohn)
3537                // names dependent equations; available with a `.nl` model.
3538                "structural_diagnose": nlp && self.structure_book.is_some(),
3539                "llm_assist": true,
3540                "rewind": "primal_dual",
3541                "resolve": nlp && self.restart.is_some(),
3542                "terminal_checkpoint": true,
3543                "interruptible": self.interruptible,
3544                // #72 §1 / §5.
3545                "progress_events": self.emit_progress,
3546                "inline_artifacts": self.inline_artifacts,
3547                "ask_prompt_only": true,
3548                "async_pause": "checkpoint",
3549                // Both transports for async pause: SIGINT and the in-band
3550                // `{"cmd":"pause"}` (JSON mode).
3551                // Not on wasm: there is no reader thread to see a command
3552                // that arrives while the solve runs.
3553                "pause_command": !cfg!(target_family = "wasm"),
3554            },
3555            "checkpoints": CHECKPOINTS,
3556            "events": EVENTS,
3557            "commands": COMMANDS,
3558            "blocks": block_names(ctx),
3559            "metrics": METRICS,
3560        });
3561        emit_json(&ev);
3562    }
3563
3564    /// No terminal on wasm targets, so no editor to build.
3565    #[cfg(target_family = "wasm")]
3566    fn ensure_editor(&mut self) {}
3567
3568    /// Lazily build the rustyline editor for an interactive REPL on a
3569    /// TTY. No-op for JSON mode, non-terminal stdin, or if construction
3570    /// fails — those paths fall back to a plain line reader.
3571    #[cfg(not(target_family = "wasm"))]
3572    fn ensure_editor(&mut self) {
3573        if !matches!(self.mode, DebugMode::Repl)
3574            || self.editor.is_some()
3575            || !std::io::stdin().is_terminal()
3576        {
3577            return;
3578        }
3579        let mut ed: Editor<DbgHelper, FileHistory> = match Editor::new() {
3580            Ok(e) => e,
3581            Err(_) => return,
3582        };
3583        ed.set_helper(Some(DbgHelper {
3584            reg: self.reg.clone(),
3585        }));
3586        let path = std::env::var_os("HOME")
3587            .or_else(|| std::env::var_os("USERPROFILE"))
3588            .map(|h| PathBuf::from(h).join(".pounce_dbg_history"));
3589        if let Some(p) = &path {
3590            let _ = ed.load_history(p);
3591        }
3592        self.hist_path = path;
3593        self.editor = Some(ed);
3594    }
3595
3596    /// Handle a Ctrl-C received at the prompt. Returns the command line to
3597    /// feed the loop: the first interrupt in a row cancels the line (empty
3598    /// string → reprompt) with a hint; a second quits the solve. The
3599    /// counter resets when any real line is entered (see `next_command_line`).
3600    #[cfg_attr(target_family = "wasm", allow(dead_code))]
3601    fn on_prompt_interrupt(&mut self) -> String {
3602        self.prompt_interrupts += 1;
3603        if self.prompt_interrupts >= 2 {
3604            self.prompt_interrupts = 0;
3605            eprintln!("(quitting — Ctrl-C)");
3606            "quit".to_string()
3607        } else {
3608            eprintln!("(Ctrl-C — press again, or `quit`/Ctrl-D, to stop the solve)");
3609            String::new()
3610        }
3611    }
3612
3613    /// Read one command line. Returns `None` on EOF. Uses rustyline when
3614    /// an editor is active (history / Tab / Ctrl-R); otherwise a plain
3615    /// reader with a stderr prompt (REPL) or no prompt (JSON).
3616    fn next_command_line(&mut self) -> Option<String> {
3617        // A shared script (sub-solve under the tree debugger's --debug-script)
3618        // takes precedence: pop the next command, echoing it. An empty queue
3619        // returns None, which resumes this sub-solve back to the tree.
3620        if let Some(q) = &self.script_queue {
3621            let cmd = q.borrow_mut().pop_front();
3622            if let Some(c) = &cmd {
3623                let _ = writeln!(std::io::stderr(), "pounce-dbg> {c}");
3624            }
3625            return cmd;
3626        }
3627        if let DebugMode::Repl = self.mode {
3628            #[cfg(not(target_family = "wasm"))]
3629            if let Some(ed) = self.editor.as_mut() {
3630                return match ed.readline("pounce-dbg> ") {
3631                    Ok(l) => {
3632                        self.prompt_interrupts = 0;
3633                        let _ = ed.add_history_entry(l.as_str());
3634                        if let Some(p) = &self.hist_path {
3635                            let _ = ed.save_history(p);
3636                        }
3637                        Some(l)
3638                    }
3639                    // Ctrl-C at the prompt: the first cancels the current
3640                    // line (readline convention); a second in a row quits the
3641                    // solve, so Ctrl-C is a working escape hatch here too —
3642                    // matching the running-mode double-tap.
3643                    Err(ReadlineError::Interrupted) => Some(self.on_prompt_interrupt()),
3644                    // Ctrl-D / closed input: EOF.
3645                    Err(ReadlineError::Eof) => None,
3646                    Err(_) => None,
3647                };
3648            }
3649            // Batch mode: a `--debug-script` plus a non-terminal stdin (a
3650            // Jupyter kernel, a CI step, a subprocess pipe left open) has
3651            // nobody to answer a prompt, so the end of the script is EOF,
3652            // which detaches and lets the solve finish.
3653            if self.script_given && !std::io::stdin().is_terminal() {
3654                return None;
3655            }
3656            let _ = write!(std::io::stderr(), "pounce-dbg> ");
3657            let _ = std::io::stderr().flush();
3658            return read_stdin_line();
3659        }
3660        // JSON mode reads through the background pump (so async pause can
3661        // peek the same stream); lazily start it.
3662        self.pump.get_or_insert_with(StdinPump::start).next()
3663    }
3664}
3665
3666/// Plain blocking line read from stdin; `None` on EOF.
3667fn read_stdin_line() -> Option<String> {
3668    let mut line = String::new();
3669    match std::io::stdin().read_line(&mut line) {
3670        Ok(0) => None,
3671        Ok(_) => Some(line),
3672        Err(_) => None,
3673    }
3674}
3675
3676/// Rank residuals by descending magnitude and keep the top `k`.
3677///
3678/// Pure (no solver state) so it can be unit-tested directly. Ties on
3679/// `|value|` keep input order (stable sort), so within equal magnitudes
3680/// equality constraints precede inequalities precede dual components —
3681/// the order [`DebugCtx::constraint_residuals`]/`dual_residuals` emit.
3682/// `k == 0` returns empty.
3683fn rank_residuals(mut entries: Vec<Residual>, k: usize) -> Vec<Residual> {
3684    entries.sort_by(|a, b| {
3685        b.value
3686            .abs()
3687            .partial_cmp(&a.value.abs())
3688            .unwrap_or(std::cmp::Ordering::Equal)
3689    });
3690    entries.truncate(k);
3691    entries
3692}
3693
3694/// Look up the model name for a residual by kind + split index, given
3695/// optional split-space names. Equality residuals index the `eq` pool;
3696/// inequality and `s`-space dual residuals share the `ineq` pool (one
3697/// slack per inequality); `x`-space dual residuals index `x_var`. Returns
3698/// `None` when the problem carries no names or the index is out of range.
3699/// Render a [`RankReport`] into the human-readable REPL lines and the JSON
3700/// payload for the agent interface. Pure (no solver access) so it can be
3701/// unit-tested with a synthetic report and a name pool. Shared by the
3702/// `print rank` command; the `diagnose` finding builds its own one-line
3703/// summary directly from the report.
3704fn render_rank_report(
3705    rep: &RankReport,
3706    names: &Option<SplitNames>,
3707    equations: Option<&EquationBook>,
3708    iter: i32,
3709) -> (Vec<String>, serde_json::Value) {
3710    let m = rep.n_rows();
3711    let n = rep.n_cols;
3712    let mut lines = vec![
3713        format!("equality Jacobian J_c: {m} row(s) × {n} column(s)"),
3714        format!(
3715            "numerical rank = {} / {}  (deficiency {})",
3716            rep.rank,
3717            m,
3718            rep.deficiency()
3719        ),
3720        format!(
3721            "σ_max = {:.3e}   σ_min = {:.3e}   cond = {}   (rank tol τ = {:.3e})",
3722            rep.sigma_max(),
3723            rep.sigma_min(),
3724            fmt_cond(rep.cond),
3725            rep.tol
3726        ),
3727    ];
3728
3729    // Singular-value spectrum, capped so a large block stays readable.
3730    let shown: Vec<String> = rep
3731        .singular_values
3732        .iter()
3733        .take(MAX_SINGULAR_VALUES_SHOWN)
3734        .map(|s| format!("{s:.3e}"))
3735        .collect();
3736    let tail = if rep.singular_values.len() > MAX_SINGULAR_VALUES_SHOWN {
3737        " …"
3738    } else {
3739        ""
3740    };
3741    lines.push(format!("singular values: [{}{tail}]", shown.join(", ")));
3742
3743    if rep.is_rank_deficient() {
3744        lines.push(format!(
3745            "rank-deficient: {} equation(s) lie in the near-null space \
3746             (linearly dependent / redundant) — the source of δ_c regularization:",
3747            rep.deficiency()
3748        ));
3749        let mut shown_any_eq = false;
3750        for c in rep.culprits.iter().take(MAX_RANK_CULPRITS) {
3751            let row = &rep.rows[c.row];
3752            let label = rank_row_label(row, names);
3753            lines.push(format!("  {label}   (participation {:.2})", c.weight));
3754            // Print the offending equation's source algebra directly beneath
3755            // it, so the dependency is readable without a second command.
3756            // Resolves by model name, so it lands only when the row is named.
3757            if let Some(eq) = culprit_equation(row, names, equations) {
3758                lines.push(format!("      {eq}"));
3759                shown_any_eq = true;
3760            }
3761        }
3762        if rep.culprits.len() > MAX_RANK_CULPRITS {
3763            lines.push(format!(
3764                "  … and {} more",
3765                rep.culprits.len() - MAX_RANK_CULPRITS
3766            ));
3767        }
3768        // Only nag about `print equation` when we couldn't show the algebra
3769        // inline (no .nl model loaded, or the rows are unnamed).
3770        if !shown_any_eq {
3771            lines.push("inspect a row with `print equation <name>` to see its terms".to_string());
3772        }
3773    } else {
3774        lines.push("J_c has full row rank at this iterate.".to_string());
3775    }
3776
3777    let culprits_json: Vec<serde_json::Value> = rep
3778        .culprits
3779        .iter()
3780        .map(|c| {
3781            let row = &rep.rows[c.row];
3782            serde_json::json!({
3783                "row": c.row,
3784                "kind": row.kind.tag(),
3785                "index": row.index,
3786                "name": rank_row_name(row, names),
3787                "label": rank_row_label(row, names),
3788                "weight": c.weight,
3789                "equation": culprit_equation(row, names, equations),
3790            })
3791        })
3792        .collect();
3793
3794    let data = serde_json::json!({
3795        "iter": iter,
3796        "n_rows": m,
3797        "n_cols": n,
3798        "rank": rep.rank,
3799        "deficiency": rep.deficiency(),
3800        "rank_deficient": rep.is_rank_deficient(),
3801        "sigma_max": rep.sigma_max(),
3802        "sigma_min": rep.sigma_min(),
3803        "cond": cond_json(rep.cond),
3804        "tol": rep.tol,
3805        "singular_values": rep.singular_values,
3806        "culprits": culprits_json,
3807    });
3808
3809    (lines, data)
3810}
3811
3812/// Rendered source algebra of a rank-report culprit row, resolved through
3813/// the [`EquationBook`] by model name (the same DAG-faithful text `print
3814/// equation` shows). `None` when no equation book is loaded, the row is
3815/// unnamed, or the name doesn't resolve — the split equality index the
3816/// rank report carries is *not* the original `.nl` row index the book keys
3817/// on, so only named rows can be mapped.
3818fn culprit_equation(
3819    row: &RankRow,
3820    names: &Option<SplitNames>,
3821    equations: Option<&EquationBook>,
3822) -> Option<String> {
3823    let book = equations?;
3824    let name = rank_row_name(row, names)?;
3825    let i = book.resolve(&name)?;
3826    Some(book.equations.get(i)?.clone())
3827}
3828
3829/// Model name of a rank-report row, if the problem carries names — the
3830/// bare name (e.g. `mass_balance`), no `kind[..]` wrapper. `None` when
3831/// unnamed. Routes through [`resid_name`] so equality/inequality rows hit
3832/// the same name pools as the rest of the debugger.
3833fn rank_row_name(row: &RankRow, names: &Option<SplitNames>) -> Option<String> {
3834    let r = Residual {
3835        kind: row.kind,
3836        index: row.index,
3837        value: 0.0,
3838    };
3839    resid_name(&r, names).map(|s| s.to_string())
3840}
3841
3842/// Display label for a rank-report row: `c[mass_balance]` when named, else
3843/// `c[3]` by split index — matching [`worst_named`]'s convention.
3844fn rank_row_label(row: &RankRow, names: &Option<SplitNames>) -> String {
3845    match rank_row_name(row, names) {
3846        Some(name) => format!("{}[{}]", row.kind.tag(), name),
3847        None => format!("{}[{}]", row.kind.tag(), row.index),
3848    }
3849}
3850
3851/// Human rendering of a condition number, spelling out a non-finite ratio
3852/// (`σ_min == 0`) as `inf` rather than `NaN`/`inf` float formatting.
3853fn fmt_cond(cond: f64) -> String {
3854    if cond.is_finite() {
3855        format!("{cond:.3e}")
3856    } else {
3857        "inf (σ_min = 0)".to_string()
3858    }
3859}
3860
3861/// JSON rendering of a condition number — `null` for a non-finite ratio,
3862/// since JSON has no infinity.
3863fn cond_json(cond: f64) -> serde_json::Value {
3864    if cond.is_finite() {
3865        serde_json::json!(cond)
3866    } else {
3867        serde_json::Value::Null
3868    }
3869}
3870
3871fn resid_name<'a>(r: &Residual, names: &'a Option<SplitNames>) -> Option<&'a str> {
3872    let n = names.as_ref()?;
3873    let pool = match r.kind {
3874        ResidKind::Eq => &n.eq,
3875        ResidKind::Ineq | ResidKind::DualS => &n.ineq,
3876        ResidKind::DualX => &n.x_var,
3877    };
3878    pool.get(r.index).and_then(|o| o.as_deref())
3879}
3880
3881/// The single largest-magnitude residual, labeled with its model name
3882/// (`c[mass_balance]`) when available, else its split index (`c[3]`),
3883/// paired with its signed value. `None` for an empty input.
3884fn worst_named(resids: Vec<Residual>, names: &Option<SplitNames>) -> Option<(String, f64)> {
3885    let top = rank_residuals(resids, 1);
3886    let r = top.first()?;
3887    let label = match resid_name(r, names) {
3888        Some(name) => format!("{}[{}]", r.kind.tag(), name),
3889        None => format!("{}[{}]", r.kind.tag(), r.index),
3890    };
3891    Some((label, r.value))
3892}
3893
3894/// Print the branded open banner (human REPL only): the project POUNCE
3895/// wordmark (shared with the solve header) over a brief command cheat
3896/// sheet. Colour only on a TTY and unless `NO_COLOR` is set.
3897pub fn print_open_banner(mode: DebugMode) {
3898    if !matches!(mode, DebugMode::Repl) {
3899        return;
3900    }
3901    let color = std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none();
3902    let paint = |r: u8, g: u8, b: u8, bold: bool, s: &str| -> String {
3903        if color {
3904            let w = if bold { "1;" } else { "" };
3905            format!("\x1b[{w}38;2;{r};{g};{b}m{s}\x1b[0m")
3906        } else {
3907            s.to_string()
3908        }
3909    };
3910    // Project palette: tiger-orange accents, gold highlight, dim text.
3911    let orange = |s: &str| paint(0xE8, 0x7A, 0x1E, true, s);
3912    let gold = |s: &str| paint(0xFF, 0xB0, 0x00, true, s);
3913    let dim = |s: &str| paint(0x7A, 0x7E, 0x88, false, s);
3914    // One cheat-sheet item: orange key (with shortcut) + dim gloss.
3915    let item = |key: &str, gloss: &str| format!("{} {}", orange(key), dim(gloss));
3916
3917    let err = std::io::stderr();
3918    let mut h = err.lock();
3919    let _ = writeln!(h);
3920    // The official wordmark (steel sheen + molten claws), shared with the
3921    // solve header, rendered to stderr with a small indent.
3922    for row in crate::print::logo_rows(color) {
3923        let _ = writeln!(h, "  {row}");
3924    }
3925    let _ = writeln!(h);
3926    let _ = writeln!(
3927        h,
3928        "  {}  {}",
3929        gold("interior-point debugger"),
3930        dim(&format!(
3931            "· pounce {} · pdb for the IPM",
3932            env!("CARGO_PKG_VERSION")
3933        ))
3934    );
3935    let _ = writeln!(h);
3936    // Most-common commands with their letter shortcuts.
3937    let _ = writeln!(
3938        h,
3939        "  {}   {}   {}   {}   {}",
3940        item("s", "step"),
3941        item("c", "continue"),
3942        item("b", "N break"),
3943        item("r", "N run"),
3944        item("q", "quit"),
3945    );
3946    let _ = writeln!(
3947        h,
3948        "  {}   {}   {}   {}   {}",
3949        item("p", "x print"),
3950        item("i", "info"),
3951        item("set", "x[i] v"),
3952        item("watch", "x"),
3953        item("viz", "kkt"),
3954    );
3955    let _ = writeln!(
3956        h,
3957        "  {} {} {}",
3958        dim("type"),
3959        gold("help"),
3960        dim("for all commands · `ask` to consult Claude · Ctrl-C breaks in"),
3961    );
3962    let _ = writeln!(h);
3963}
3964
3965/// Whether a command line is an in-band pause request (`pause`, or a JSON
3966/// `{"cmd":"pause"}`), used for the async-pause-while-running path.
3967#[cfg_attr(target_family = "wasm", allow(dead_code))]
3968fn is_pause_command(line: &str) -> bool {
3969    parse_command(line, DebugMode::Json).command.trim() == "pause"
3970}
3971
3972/// Background stdin reader for JSON mode. A thread reads newline-delimited
3973/// commands into a shared queue so the running loop can *peek* for an
3974/// async `{"cmd":"pause"}` between iterations (no signals — the
3975/// Windows-friendly path) while the prompt still pops commands blocking.
3976#[cfg(not(target_family = "wasm"))]
3977struct StdinPump {
3978    inner: std::sync::Arc<(
3979        std::sync::Mutex<VecDeque<Option<String>>>,
3980        std::sync::Condvar,
3981    )>,
3982}
3983
3984#[cfg(not(target_family = "wasm"))]
3985impl StdinPump {
3986    fn start() -> Self {
3987        let inner = std::sync::Arc::new((
3988            std::sync::Mutex::new(VecDeque::new()),
3989            std::sync::Condvar::new(),
3990        ));
3991        let w = std::sync::Arc::clone(&inner);
3992        std::thread::spawn(move || {
3993            use std::io::BufRead;
3994            let stdin = std::io::stdin();
3995            let mut lock = stdin.lock();
3996            let (m, cv) = &*w;
3997            loop {
3998                let mut line = String::new();
3999                let item = match lock.read_line(&mut line) {
4000                    Ok(0) | Err(_) => None, // EOF / error sentinel
4001                    Ok(_) => Some(line),
4002                };
4003                let done = item.is_none();
4004                m.lock()
4005                    .unwrap_or_else(std::sync::PoisonError::into_inner)
4006                    .push_back(item);
4007                cv.notify_one();
4008                if done {
4009                    break;
4010                }
4011            }
4012        });
4013        Self { inner }
4014    }
4015
4016    /// Blocking pop of the next command line; `None` on EOF (sticky).
4017    fn next(&self) -> Option<String> {
4018        let (m, cv) = &*self.inner;
4019        let mut q = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
4020        loop {
4021            match q.front() {
4022                None => {
4023                    q = cv
4024                        .wait(q)
4025                        .unwrap_or_else(std::sync::PoisonError::into_inner)
4026                }
4027                Some(None) => return None, // EOF — leave sentinel in place
4028                Some(Some(_)) => return q.pop_front().flatten(),
4029            }
4030        }
4031    }
4032
4033    /// Non-blocking: if a queued `pause` request is at the front, consume
4034    /// it and return true. Leaves any other queued command in place.
4035    fn try_take_pause(&self) -> bool {
4036        let (m, _) = &*self.inner;
4037        let mut q = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
4038        if let Some(Some(front)) = q.front() {
4039            if is_pause_command(front) {
4040                q.pop_front();
4041                return true;
4042            }
4043        }
4044        false
4045    }
4046}
4047
4048/// JSON-mode stdin on wasm targets, which have no threads to run the
4049/// background reader. Reads block on stdin directly (in the browser build
4050/// that read is an asyncified import that resolves when the front end sends
4051/// a command), so an async `pause` is not seen mid-run: the front end
4052/// pauses with a `break` or `stop-at` set before continuing instead.
4053#[cfg(target_family = "wasm")]
4054struct StdinPump {
4055    eof: std::cell::Cell<bool>,
4056}
4057
4058#[cfg(target_family = "wasm")]
4059impl StdinPump {
4060    fn start() -> Self {
4061        Self {
4062            eof: std::cell::Cell::new(false),
4063        }
4064    }
4065
4066    /// Blocking read of the next command line; `None` on EOF (sticky).
4067    fn next(&self) -> Option<String> {
4068        if self.eof.get() {
4069            return None;
4070        }
4071        let line = read_stdin_line();
4072        if line.is_none() {
4073            self.eof.set(true);
4074        }
4075        line
4076    }
4077
4078    /// Always false: nothing can arrive while the solve is running.
4079    fn try_take_pause(&self) -> bool {
4080        false
4081    }
4082}
4083
4084impl DebugHook for SolverDebugger {
4085    /// Capture the heavy KKT matrix / `LDLᵀ` factor only while attached:
4086    /// once detached the debugger runs free and won't `viz`, so there's
4087    /// no reason to pay the O(nnz) assembly every iteration.
4088    fn wants_kkt_capture(&self) -> bool {
4089        !self.detached
4090    }
4091
4092    /// Re-arm a [`quiet`](SolverDebugger::quiet) debugger to drop in at the
4093    /// next checkpoint of the next sub-solve (the tree debugger's
4094    /// step-into-relaxation).
4095    fn arm(&mut self) {
4096        self.step = true;
4097        self.detached = false;
4098        self.pause_iters = true;
4099        self.pause_terminal = true;
4100    }
4101
4102    fn at_checkpoint(&mut self, ctx: &mut dyn DebugState) -> DebugAction {
4103        // In JSON mode `quit` is final: no further pause or stdin read for
4104        // the rest of the run (see the `quit` field).
4105        if self.quit {
4106            return DebugAction::Stop;
4107        }
4108        // One-time handshake so a JSON client learns the protocol /
4109        // capabilities before the first pause.
4110        if matches!(self.mode, DebugMode::Json) && !self.hello_sent {
4111            self.emit_hello(&*ctx);
4112            self.hello_sent = true;
4113        }
4114        // Terminal post-mortem checkpoint: pause if configured (and, for
4115        // `--debug-on-error`, only when the solve failed). Snapshots /
4116        // rewinding don't apply — the solve is over.
4117        if let Checkpoint::Terminated = ctx.checkpoint() {
4118            // An in-flight `sweep`/`multistart` records this solve and
4119            // launches the next; `Some` means "re-solving from the next
4120            // seed", `None` means the sweep finished (fall through).
4121            if self.sweep.is_some() && !self.in_restoration {
4122                // A sweep can only be started on the NLP solver, so the
4123                // downcast succeeds whenever one is in flight.
4124                if let Some(c) = as_nlp(ctx) {
4125                    if let Some(action) = self.drive_sweep(c) {
4126                        return action;
4127                    }
4128                }
4129            }
4130            let failed = ctx.status().map(|s| !is_success_status(s)).unwrap_or(false);
4131            let should =
4132                self.pause_terminal && !self.detached && (!self.terminal_only_on_error || failed);
4133            if !should {
4134                return DebugAction::Resume;
4135            }
4136            self.ensure_editor();
4137            self.emit_pause(ctx, None);
4138            return self.prompt_loop(ctx);
4139        }
4140
4141        let cp = ctx.checkpoint();
4142        // Track the restoration bracket so inner-IPM pauses are flagged.
4143        match cp {
4144            Checkpoint::PreRestoration => self.in_restoration = true,
4145            Checkpoint::PostRestoration => self.in_restoration = false,
4146            _ => {}
4147        }
4148        let is_iter_start = matches!(cp, Checkpoint::IterStart);
4149
4150        // At each iteration top, snapshot the primal-dual state (cheap —
4151        // Rc clone) so `goto` can reach any seen iteration. Bound memory
4152        // by evicting the oldest beyond the cap.
4153        if is_iter_start {
4154            if let Some(snap) = ctx.snapshot() {
4155                self.snapshots.insert(ctx.iter(), snap);
4156                while self.snapshots.len() > SNAPSHOT_CAP {
4157                    let Some(&oldest) = self.snapshots.keys().next() else {
4158                        break;
4159                    };
4160                    self.snapshots.remove(&oldest);
4161                }
4162            }
4163            // Update μ-stall tracking before events are evaluated.
4164            self.update_mu_stall(ctx.mu());
4165        }
4166
4167        // Decide whether to pause. `stop-at` and a one-shot `stepi` apply
4168        // at every checkpoint; step / run / breakpoints / conditions /
4169        // Ctrl-C only at the iteration top. Once detached, `stop-at` and
4170        // `stepi` no longer pause, like breakpoints, events and watchpoints.
4171        let mut reason: Option<String> = None;
4172        let mut pause = !self.detached && (self.sub_step || self.stop_at.contains(cp.as_str()));
4173
4174        // Event breakpoints fire at whatever checkpoint makes them
4175        // observable (e.g. `regularized` at after_search_dir), so check
4176        // them at every checkpoint, not just iter_start.
4177        if let Some(ev) = self.matched_event(ctx) {
4178            pause = true;
4179            reason = Some(format!("event: {ev}"));
4180        }
4181
4182        if is_iter_start {
4183            if self.interruptible && interrupt::take() {
4184                pause = true;
4185                reason = Some("interrupt (Ctrl-C)".into());
4186            }
4187            // In-band async pause: a `{"cmd":"pause"}` that arrived on
4188            // stdin during the run (JSON mode, #72 §5 option b).
4189            if let Some(p) = self.pump.as_ref() {
4190                if p.try_take_pause() {
4191                    pause = true;
4192                    reason = Some("pause (requested)".into());
4193                }
4194            }
4195            if self.pause_iters {
4196                if self.should_pause(ctx.iter()) {
4197                    pause = true;
4198                }
4199                if let Some(c) = self.matched_condition(ctx) {
4200                    pause = true;
4201                    reason = Some(c);
4202                }
4203            }
4204            // Watchpoints fire regardless of pause_iters (explicit, like
4205            // breakpoints); evaluated every iter to keep baselines fresh.
4206            if let Some(w) = self.matched_watchpoint(ctx) {
4207                pause = true;
4208                reason = Some(format!("watchpoint: {w}"));
4209            }
4210        }
4211
4212        if !pause {
4213            // Not pausing: in JSON mode emit a per-iteration `progress`
4214            // event (once per outer iter) so a visual debugger isn't blind
4215            // during a long `continue`. Issue #72 §1.
4216            if is_iter_start && self.emit_progress && matches!(self.mode, DebugMode::Json) {
4217                self.emit_progress_event(ctx);
4218            }
4219            return DebugAction::Resume;
4220        }
4221        // Consume one-shot arming; commands re-arm as needed.
4222        self.step = false;
4223        self.sub_step = false;
4224        self.emit_pause(ctx, reason.as_deref());
4225
4226        // Auto-run any command list attached to this iteration's
4227        // breakpoint (`commands N …`). If it resumes/stops, honor that
4228        // without dropping to the prompt.
4229        if is_iter_start {
4230            if let Some(cmds) = self.bp_commands.get(&ctx.iter()).cloned() {
4231                for c in cmds {
4232                    let out = self.dispatch(&c, ctx);
4233                    self.emit_result(&c, &out, None);
4234                    match out.flow {
4235                        Flow::Resume => return DebugAction::Resume,
4236                        Flow::Stop => return DebugAction::Stop,
4237                        Flow::Stay => {}
4238                    }
4239                }
4240            }
4241        }
4242
4243        self.ensure_editor();
4244        self.prompt_loop(ctx)
4245    }
4246}
4247
4248impl SolverDebugger {
4249    /// Read and dispatch commands until one resumes or stops the solve.
4250    fn prompt_loop(&mut self, ctx: &mut dyn DebugState) -> DebugAction {
4251        // Run a `--debug-script` once, at the first pause, before reading
4252        // any interactive command. It may itself resume / stop the solve.
4253        if let Some(path) = self.pending_script.take() {
4254            let out = self.cmd_source(&[path.as_str()], ctx);
4255            self.emit_result("source", &out, None);
4256            match out.flow {
4257                Flow::Resume => return DebugAction::Resume,
4258                Flow::Stop => return DebugAction::Stop,
4259                Flow::Stay => {}
4260            }
4261        }
4262        loop {
4263            let line = match self.next_command_line() {
4264                Some(l) => l,
4265                None => {
4266                    // EOF on stdin. REPL (Ctrl-D) means "let it run" —
4267                    // detach and finish, pdb-style. In JSON mode a closed
4268                    // pipe means the controlling client went away, so
4269                    // abort the solve rather than run on headless.
4270                    return match self.mode {
4271                        DebugMode::Repl => {
4272                            self.detached = true;
4273                            DebugAction::Resume
4274                        }
4275                        DebugMode::Json => {
4276                            self.quit = true;
4277                            DebugAction::Stop
4278                        }
4279                    };
4280                }
4281            };
4282            let parsed = parse_command(&line, self.mode);
4283            let cmd = parsed.command.trim().to_string();
4284            if cmd.is_empty() {
4285                continue;
4286            }
4287            let out = self.dispatch(&cmd, ctx);
4288            self.emit_result(&cmd, &out, parsed.id.as_ref());
4289            match out.flow {
4290                Flow::Stay => continue,
4291                Flow::Resume => return DebugAction::Resume,
4292                Flow::Stop => return DebugAction::Stop,
4293            }
4294        }
4295    }
4296}
4297
4298/// A command read from the input stream: the resolved command string
4299/// plus an optional client-supplied request id (echoed back as
4300/// `request_id` so an async client can correlate responses).
4301struct ParsedCmd {
4302    command: String,
4303    id: Option<serde_json::Value>,
4304}
4305
4306/// Split a command line on whitespace, honoring double-quoted spans so a
4307/// file-path argument containing spaces survives as one token. Quotes are
4308/// delimiters and stripped; for any line without quotes this is byte-for-byte
4309/// equivalent to `str::split_whitespace` (collapsing runs of whitespace,
4310/// trimming the ends).
4311fn tokenize_quoted(line: &str) -> Vec<String> {
4312    let mut out = Vec::new();
4313    let mut cur = String::new();
4314    let mut in_quote = false;
4315    let mut has_tok = false;
4316    for c in line.chars() {
4317        match c {
4318            '"' => {
4319                in_quote = !in_quote;
4320                has_tok = true; // an empty "" is still a token
4321            }
4322            c if c.is_whitespace() && !in_quote => {
4323                if has_tok {
4324                    out.push(std::mem::take(&mut cur));
4325                    has_tok = false;
4326                }
4327            }
4328            c => {
4329                cur.push(c);
4330                has_tok = true;
4331            }
4332        }
4333    }
4334    if has_tok {
4335        out.push(cur);
4336    }
4337    out
4338}
4339
4340/// In JSON mode a command line may be a bare string or a JSON object
4341/// `{"cmd": "...", "args": [...], "id": <any>}`. Returns the resolved
4342/// command string and the request id (if the object carried one).
4343fn parse_command(line: &str, mode: DebugMode) -> ParsedCmd {
4344    let trimmed = line.trim();
4345    if let DebugMode::Json = mode {
4346        if trimmed.starts_with('{') {
4347            if let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed) {
4348                let cmd = v.get("cmd").and_then(|c| c.as_str()).unwrap_or("");
4349                let mut s = cmd.to_string();
4350                if let Some(args) = v.get("args").and_then(|a| a.as_array()) {
4351                    for a in args {
4352                        s.push(' ');
4353                        let tok = a
4354                            .as_str()
4355                            .map(str::to_string)
4356                            .unwrap_or_else(|| a.to_string());
4357                        // Quote whitespace-bearing args (e.g. paths) so the
4358                        // quote-aware tokenizer keeps them as one token.
4359                        if tok.contains(char::is_whitespace) {
4360                            s.push('"');
4361                            s.push_str(&tok);
4362                            s.push('"');
4363                        } else {
4364                            s.push_str(&tok);
4365                        }
4366                    }
4367                }
4368                return ParsedCmd {
4369                    command: s,
4370                    id: v.get("id").cloned(),
4371                };
4372            }
4373        }
4374    }
4375    ParsedCmd {
4376        command: trimmed.to_string(),
4377        id: None,
4378    }
4379}
4380
4381fn emit_json(v: &serde_json::Value) {
4382    let stdout = std::io::stdout();
4383    let mut h = stdout.lock();
4384    let _ = writeln!(h, "{v}");
4385    let _ = h.flush();
4386}
4387
4388/// Downcast a generic [`DebugState`] to the NLP solver's concrete
4389/// [`DebugCtx`], for the NLP-only REPL commands (rank diagnosis, model-name
4390/// resolution, warm `resolve`, sweep/multistart). `None` for the
4391/// convex/conic solver, whose REPL reports "not supported".
4392fn as_nlp<'a>(ctx: &'a dyn DebugState) -> Option<&'a DebugCtx> {
4393    ctx.as_any().and_then(|a| a.downcast_ref::<DebugCtx>())
4394}
4395
4396/// Mutable form of [`as_nlp`], for commands that mutate NLP-specific state.
4397fn as_nlp_mut<'a>(ctx: &'a mut dyn DebugState) -> Option<&'a mut DebugCtx> {
4398    ctx.as_any_mut().and_then(|a| a.downcast_mut::<DebugCtx>())
4399}
4400
4401/// Standard "command needs the NLP solver" error for the convex/conic REPL.
4402fn nlp_only(cmd: &str) -> CmdOut {
4403    CmdOut::err(format!(
4404        "`{cmd}` is only available for the NLP solver (not the convex/conic solver)"
4405    ))
4406}
4407
4408/// The iterate-block names the *current* solver exposes (NLP: the eight
4409/// primal-dual blocks; convex IPM: `x`/`s`/`y`/`z`). Block commands use
4410/// this rather than the static NLP [`BLOCK_NAMES`] so they work for any
4411/// solver behind the [`DebugState`] trait.
4412fn block_names(ctx: &dyn DebugState) -> Vec<&'static str> {
4413    ctx.block_dims().into_iter().map(|(n, _)| n).collect()
4414}
4415
4416/// Whether `name` is one of the current solver's iterate blocks.
4417fn is_block(ctx: &dyn DebugState, name: &str) -> bool {
4418    block_names(ctx).iter().any(|n| *n == name)
4419}
4420
4421fn fmt_vec(name: &str, v: &[f64]) -> String {
4422    const MAX: usize = 12;
4423    if v.len() <= MAX {
4424        format!(
4425            "{name} = [{}]",
4426            v.iter()
4427                .map(|x| format!("{x:.6e}"))
4428                .collect::<Vec<_>>()
4429                .join(", ")
4430        )
4431    } else {
4432        let head = v[..MAX]
4433            .iter()
4434            .map(|x| format!("{x:.6e}"))
4435            .collect::<Vec<_>>()
4436            .join(", ");
4437        format!("{name} = [{head}, … ({} total)]", v.len())
4438    }
4439}
4440
4441fn type_str(t: OptionType) -> &'static str {
4442    match t {
4443        OptionType::OT_Number => "Number",
4444        OptionType::OT_Integer => "Integer",
4445        OptionType::OT_String => "String",
4446        OptionType::OT_Unknown => "Unknown",
4447    }
4448}
4449
4450fn default_str(d: &DefaultValue) -> String {
4451    match d {
4452        DefaultValue::None => "-".into(),
4453        DefaultValue::Number(v) => format!("{v}"),
4454        DefaultValue::Integer(v) => format!("{v}"),
4455        DefaultValue::String(s) => s.clone(),
4456    }
4457}
4458
4459/// Write `vals` to a temp JSON file and open it in an external viewer.
4460/// The viewer command comes from `POUNCE_DBG_VIEWER` (a template where
4461/// `{}` is replaced by the path; if absent, the path is appended), else
4462/// the platform default (`xdg-open` on Linux, `open` on macOS).
4463fn write_and_open(label: &str, iter: i32, vals: &[f64]) -> Result<(String, String), String> {
4464    let payload = serde_json::json!({"label": label, "iter": iter, "values": vals});
4465    write_json_and_open(label, iter, &payload)
4466}
4467
4468/// Build the prompt handed to the LLM by `ask`: a compact, self-contained
4469/// description of the paused interior-point state plus the user question.
4470fn build_ask_prompt(ctx: &dyn DebugState, question: &str) -> String {
4471    use std::fmt::Write as _;
4472    let mut p = String::new();
4473    p.push_str(
4474        "You are helping debug a paused run of POUNCE, a pure-Rust interior-point \
4475         optimization solver whose NLP core is ported from Ipopt. The solve is \
4476         stopped at a debugger checkpoint. \
4477         Use the state below to answer concisely and suggest concrete next steps \
4478         (options to try, what to inspect). State:\n\n",
4479    );
4480    let _ = writeln!(p, "checkpoint = {}", ctx.checkpoint().as_str());
4481    if let Some(s) = ctx.status() {
4482        let _ = writeln!(p, "status     = {s}");
4483    }
4484    let _ = writeln!(p, "iter       = {}", ctx.iter());
4485    let _ = writeln!(p, "mu         = {:.6e}", ctx.mu());
4486    let _ = writeln!(p, "objective  = {:.8e}", ctx.objective());
4487    let _ = writeln!(p, "inf_pr     = {:.6e}", ctx.inf_pr());
4488    let _ = writeln!(p, "inf_du     = {:.6e}", ctx.inf_du());
4489    let _ = writeln!(p, "nlp_error  = {:.6e}", ctx.nlp_error());
4490    let (ap, ad) = ctx.alpha();
4491    let _ = writeln!(p, "alpha_pr   = {ap:.4e}, alpha_du = {ad:.4e}");
4492    let _ = writeln!(p, "ls_trials  = {}", ctx.ls_count());
4493    let dims: Vec<String> = ctx
4494        .block_dims()
4495        .into_iter()
4496        .map(|(n, d)| format!("{n}:{d}"))
4497        .collect();
4498    let _ = writeln!(p, "dims       = {}", dims.join(" "));
4499    if let Some(k) = ctx.kkt() {
4500        let _ = writeln!(
4501            p,
4502            "kkt        = dim {} inertia n+={} n-={} (expected n-={}, {}) delta_w={:.3e} delta_c={:.3e} status={}",
4503            k.dim,
4504            k.n_pos,
4505            k.n_neg,
4506            k.expected_neg,
4507            if k.inertia_correct {
4508                "correct"
4509            } else {
4510                "WRONG"
4511            },
4512            k.delta_w,
4513            k.delta_c,
4514            k.status
4515        );
4516    }
4517    let _ = write!(p, "\nQuestion: {question}\n");
4518    p
4519}
4520
4521/// Provider keywords with a built-in non-interactive invocation, so a user
4522/// can select one with just `POUNCE_DBG_LLM=codex` instead of memorizing
4523/// each CLI's flags. Returns the program, its argv (with the prompt already
4524/// placed for arg-style tools), and whether the prompt is *also* written to
4525/// stdin. Keep `LLM_PROVIDERS` in sync for help/error text.
4526const LLM_PROVIDERS: &[&str] = &["claude", "codex", "gemini", "llm"];
4527
4528fn llm_preset(name: &str, prompt: &str) -> Option<(String, Vec<String>, bool)> {
4529    match name {
4530        // Claude Code — headless print mode, prompt on stdin.
4531        "claude" => Some(("claude".to_string(), vec!["-p".to_string()], true)),
4532        // OpenAI Codex CLI — non-interactive `codex exec <prompt>`.
4533        "codex" => Some((
4534            "codex".to_string(),
4535            vec!["exec".to_string(), prompt.to_string()],
4536            false,
4537        )),
4538        // Google Gemini CLI — non-interactive `gemini -p <prompt>`.
4539        "gemini" => Some((
4540            "gemini".to_string(),
4541            vec!["-p".to_string(), prompt.to_string()],
4542            false,
4543        )),
4544        // simonw's `llm` — prompt as a positional argument.
4545        "llm" => Some(("llm".to_string(), vec![prompt.to_string()], false)),
4546        _ => None,
4547    }
4548}
4549
4550/// Resolve the LLM command from `$POUNCE_DBG_LLM`, defaulting to `claude`.
4551/// The value may be either a **bare provider keyword** (`claude`, `codex`,
4552/// `gemini`, `llm` — see `llm_preset`) or a **full command template**
4553/// (whitespace-split; `{}` substitutes the prompt as an argument, else the
4554/// prompt is fed on stdin). The bool is whether the prompt goes on stdin.
4555fn llm_command(prompt: &str) -> (String, Vec<String>, bool) {
4556    let raw = std::env::var("POUNCE_DBG_LLM").unwrap_or_default();
4557    let tmpl = raw.trim();
4558    if tmpl.is_empty() {
4559        // Default provider.
4560        return llm_preset("claude", prompt).expect("claude is a known provider");
4561    }
4562    // A bare keyword (no whitespace) matching a known provider wins; this is
4563    // the ergonomic `POUNCE_DBG_LLM=codex` path.
4564    if !tmpl.contains(char::is_whitespace) {
4565        if let Some(preset) = llm_preset(tmpl, prompt) {
4566            return preset;
4567        }
4568    }
4569    // Otherwise: a full command template.
4570    let mut parts = tmpl
4571        .split_whitespace()
4572        .map(str::to_string)
4573        .collect::<Vec<_>>();
4574    let prog = parts.remove(0);
4575    let mut substituted = false;
4576    for a in parts.iter_mut() {
4577        if a.contains("{}") {
4578            *a = a.replace("{}", prompt);
4579            substituted = true;
4580        }
4581    }
4582    (prog, parts, !substituted)
4583}
4584
4585/// Run the configured LLM command, feeding `prompt` on stdin (unless it
4586/// was substituted into an argument), and return its stdout.
4587fn run_llm(prompt: &str) -> Result<String, String> {
4588    use std::io::Write as _;
4589    use std::process::{Command, Stdio};
4590    let (prog, args, on_stdin) = llm_command(prompt);
4591    let mut cmd = Command::new(&prog);
4592    cmd.args(&args)
4593        .stdout(Stdio::piped())
4594        .stderr(Stdio::piped());
4595    cmd.stdin(if on_stdin {
4596        Stdio::piped()
4597    } else {
4598        Stdio::null()
4599    });
4600    let mut child = cmd.spawn().map_err(|e| {
4601        if e.kind() == std::io::ErrorKind::NotFound {
4602            // The configured LLM CLI isn't installed / not on PATH. Fail with
4603            // an actionable message instead of a raw OS error — the rest of
4604            // the debugger keeps working regardless.
4605            format!(
4606                "LLM CLI `{prog}` is not installed or not on PATH. Install it, \
4607                 or set POUNCE_DBG_LLM to another provider \
4608                 ({}) or a full command template (e.g. `my-llm --ask {{}}`).",
4609                LLM_PROVIDERS.join(" | ")
4610            )
4611        } else {
4612            format!("could not launch `{prog}`: {e}")
4613        }
4614    })?;
4615    if on_stdin {
4616        // Write the prompt and close stdin so the child sees EOF.
4617        if let Some(mut si) = child.stdin.take() {
4618            let _ = si.write_all(prompt.as_bytes());
4619        }
4620    }
4621    let out = child
4622        .wait_with_output()
4623        .map_err(|e| format!("`{prog}` failed: {e}"))?;
4624    if !out.status.success() {
4625        let err = String::from_utf8_lossy(&out.stderr);
4626        return Err(format!(
4627            "`{prog}` exited with {}: {}",
4628            out.status,
4629            err.trim()
4630        ));
4631    }
4632    let reply = String::from_utf8_lossy(&out.stdout).trim().to_string();
4633    if reply.is_empty() {
4634        Err(format!("`{prog}` returned no output"))
4635    } else {
4636        Ok(reply)
4637    }
4638}
4639
4640/// Write a JSON artifact to a temp file and open it in an external viewer
4641/// (`POUNCE_DBG_VIEWER`, else `xdg-open`/`open`). Shared by `viz`.
4642fn write_json_and_open(
4643    label: &str,
4644    iter: i32,
4645    payload: &serde_json::Value,
4646) -> Result<(String, String), String> {
4647    let dir = std::env::temp_dir();
4648    let path = dir.join(format!("pounce-dbg-{label}-iter{iter}.json"));
4649    std::fs::write(&path, payload.to_string()).map_err(|e| format!("write failed: {e}"))?;
4650    let path_s = path.to_string_lossy().to_string();
4651
4652    // Candidate viewers, tried in order until one launches. Each carries
4653    // the artifact path we report on success (JSON for the data consumers,
4654    // the rendered HTML for the OS opener):
4655    //   1. $POUNCE_DBG_VIEWER (a command template; `{}` ← the JSON path),
4656    //   2. `pounce-dbg-viz` — the bundled interactive Plotly viewer
4657    //      (`pip install 'pounce-solver[viz]'`), when on PATH,
4658    //   3. the OS opener (xdg-open / open) on a self-contained HTML
4659    //      visualization — NOT the raw JSON, which a text editor (VS Code)
4660    //      would just display instead of plotting.
4661    let mut candidates: Vec<(String, Vec<String>, String)> = Vec::new();
4662    match std::env::var("POUNCE_DBG_VIEWER") {
4663        Ok(tmpl) if !tmpl.trim().is_empty() => {
4664            let mut parts = tmpl
4665                .split_whitespace()
4666                .map(String::from)
4667                .collect::<Vec<_>>();
4668            let prog = parts.remove(0);
4669            let mut replaced = false;
4670            for a in parts.iter_mut() {
4671                if a.contains("{}") {
4672                    *a = a.replace("{}", &path_s);
4673                    replaced = true;
4674                }
4675            }
4676            if !replaced {
4677                parts.push(path_s.clone());
4678            }
4679            candidates.push((prog, parts, path_s.clone()));
4680        }
4681        _ => {
4682            candidates.push((
4683                "pounce-dbg-viz".to_string(),
4684                vec![path_s.clone()],
4685                path_s.clone(),
4686            ));
4687            let opener = if cfg!(target_os = "macos") {
4688                "open"
4689            } else {
4690                "xdg-open"
4691            };
4692            // Render the HTML spy/bar plot; if that write fails for any
4693            // reason, fall back to opening the raw JSON.
4694            let artifact = write_html_viz(label, iter, payload).unwrap_or_else(|_| path_s.clone());
4695            candidates.push((opener.to_string(), vec![artifact.clone()], artifact));
4696        }
4697    }
4698
4699    let mut last_err = String::new();
4700    for (program, args, artifact) in &candidates {
4701        match std::process::Command::new(program).args(args).spawn() {
4702            Ok(_) => return Ok((artifact.clone(), format!("{program} {}", args.join(" ")))),
4703            Err(e) => last_err = format!("`{program}`: {e}"),
4704        }
4705    }
4706    Err(format!(
4707        "wrote {path_s} but could not launch a viewer ({last_err}). \
4708         Install the interactive viewer (`pip install 'pounce-solver[viz]'`) \
4709         or set POUNCE_DBG_VIEWER, e.g. `python my_plot.py {{}}`."
4710    ))
4711}
4712
4713/// Render a self-contained HTML visualization (no external assets, no pip
4714/// install) for a `viz` payload and write it next to the JSON. A KKT/L
4715/// matrix becomes a sign-colored sparsity (spy) plot; a plain vector
4716/// becomes a zero-centered bar chart. Opening this in the OS default
4717/// handler pops a browser window that actually draws the artifact —
4718/// unlike the raw JSON, which a text editor (VS Code) would just display.
4719fn write_html_viz(label: &str, iter: i32, payload: &serde_json::Value) -> Result<String, String> {
4720    let dir = std::env::temp_dir();
4721    let path = dir.join(format!("pounce-dbg-{label}-iter{iter}.html"));
4722    let html = VIZ_HTML_TEMPLATE.replace("__PAYLOAD__", &payload.to_string());
4723    std::fs::write(&path, html).map_err(|e| format!("write failed: {e}"))?;
4724    Ok(path.to_string_lossy().to_string())
4725}
4726
4727/// Self-contained HTML viewer for `viz` artifacts. `__PAYLOAD__` is
4728/// replaced with the JSON payload; an inline canvas renderer picks the
4729/// plot type from the payload shape (`matrix` → KKT spy, `l_irn` → L-factor
4730/// spy, `values` → vector bar chart).
4731const VIZ_HTML_TEMPLATE: &str = r##"<!doctype html>
4732<html lang="en"><head><meta charset="utf-8">
4733<title>pounce-dbg viz</title>
4734<style>
4735 html,body{margin:0;background:#0e1116;color:#d6dae0;
4736   font:13px/1.5 -apple-system,BlinkMacSystemFont,"SF Mono",Menlo,monospace}
4737 .wrap{padding:18px 20px;max-width:880px;margin:0 auto}
4738 h1{font-size:15px;margin:0 0 4px;font-weight:600}
4739 .sub{color:#7d8694;margin:0 0 12px}
4740 .stats{color:#9aa4b2;white-space:pre-wrap;margin:0 0 14px;
4741   background:#161b22;border:1px solid #21262d;border-radius:6px;padding:10px 12px}
4742 canvas{background:#161b22;border:1px solid #30363d;border-radius:6px;
4743   max-width:100%;height:auto;image-rendering:pixelated}
4744 .legend{margin-top:10px;color:#9aa4b2}
4745 .pos{color:#4ea1ff}.neg{color:#ff6b6b}.bad{color:#ff6b6b;font-weight:600}
4746 .ok{color:#56d364;font-weight:600}
4747</style></head><body><div class="wrap">
4748<h1 id="title">pounce-dbg</h1>
4749<div class="sub" id="sub"></div>
4750<div class="stats" id="stats"></div>
4751<canvas id="c" width="820" height="820"></canvas>
4752<div class="legend" id="legend"></div>
4753</div>
4754<script>
4755const D = __PAYLOAD__;
4756const cv = document.getElementById('c');
4757const ctx = cv.getContext('2d');
4758const $ = id => document.getElementById(id);
4759const fmt = x => (x===null||x===undefined) ? '—'
4760  : (Math.abs(x) >= 1e4 || (x!==0 && Math.abs(x) < 1e-3) ? x.toExponential(3) : (+x).toPrecision(6));
4761
4762function clearCanvas(){ ctx.fillStyle='#161b22'; ctx.fillRect(0,0,cv.width,cv.height); }
4763
4764function spy(irn, jcn, vals, dim, symmetric, title){
4765  $('sub').textContent = title;
4766  clearCanvas();
4767  const W=cv.width, H=cv.height, pad=42;
4768  const span=Math.max(1, dim);
4769  const cell=(Math.min(W,H)-2*pad)/span;
4770  const px=Math.max(0.7, cell);
4771  // frame + light grid ticks
4772  ctx.strokeStyle='#30363d'; ctx.lineWidth=1;
4773  ctx.strokeRect(pad-0.5, pad-0.5, span*cell+1, span*cell+1);
4774  ctx.fillStyle='#6e7681'; ctx.font='11px monospace';
4775  ctx.fillText('0', pad-12, pad+9);
4776  ctx.fillText(String(dim), pad+span*cell-8, pad-8);
4777  ctx.fillText('row', pad-34, pad+span*cell/2);
4778  ctx.fillText('col', pad+span*cell/2-8, pad-22);
4779  let nnz=0;
4780  for(let k=0;k<irn.length;k++){
4781    const i=irn[k]-1, j=jcn[k]-1, v=vals?vals[k]:1;
4782    ctx.fillStyle = v>=0 ? 'rgba(78,161,255,0.92)' : 'rgba(255,107,107,0.92)';
4783    ctx.fillRect(pad+j*cell, pad+i*cell, px, px); nnz++;
4784    if(symmetric && i!==j){ ctx.fillRect(pad+i*cell, pad+j*cell, px, px); nnz++; }
4785  }
4786  $('legend').innerHTML =
4787    `<span class="pos">■</span> positive&nbsp;&nbsp;<span class="neg">■</span> negative`
4788    + `&nbsp;&nbsp;·&nbsp;&nbsp;${dim}×${dim}, ${nnz} plotted nonzeros`
4789    + (symmetric ? ' (lower triangle mirrored)' : '');
4790}
4791
4792function bars(values, title){
4793  $('sub').textContent = title;
4794  clearCanvas();
4795  const W=cv.width, H=cv.height, pad=42;
4796  const n=values.length;
4797  const maxAbs=Math.max(1e-300, ...values.map(v=>Math.abs(v)));
4798  const x0=pad, y0=H-pad, plotW=W-2*pad, plotH=H-2*pad, mid=pad+plotH/2;
4799  const bw=Math.max(0.7, plotW/Math.max(1,n));
4800  // zero axis
4801  ctx.strokeStyle='#30363d'; ctx.beginPath();
4802  ctx.moveTo(pad, mid); ctx.lineTo(W-pad, mid); ctx.stroke();
4803  ctx.fillStyle='#6e7681'; ctx.font='11px monospace';
4804  ctx.fillText('+'+fmt(maxAbs), 4, pad+10);
4805  ctx.fillText('-'+fmt(maxAbs), 4, H-pad-2);
4806  ctx.fillText('0', 4, mid+4);
4807  for(let k=0;k<n;k++){
4808    const v=values[k], h=(Math.abs(v)/maxAbs)*(plotH/2);
4809    ctx.fillStyle = v>=0 ? 'rgba(78,161,255,0.92)' : 'rgba(255,107,107,0.92)';
4810    if(v>=0) ctx.fillRect(pad+k*bw, mid-h, bw, h);
4811    else     ctx.fillRect(pad+k*bw, mid, bw, h);
4812  }
4813  $('legend').innerHTML = `${n} components · max |val| = ${fmt(maxAbs)}`;
4814}
4815
4816const lbl = D.label || 'viz';
4817const iter = (D.iter!==undefined) ? D.iter : '?';
4818$('title').textContent = `pounce-dbg · viz ${lbl} · iter ${iter}`;
4819
4820if(D.matrix && D.matrix.irn){
4821  const m=D.matrix;
4822  const inertia = (D.inertia_correct===false)
4823    ? `<span class="bad">WRONG</span>` : `<span class="ok">correct</span>`;
4824  $('stats').innerHTML =
4825    `KKT augmented system   dim=${D.dim}\n`+
4826    `inertia  n+=${D.n_pos}  n-=${D.n_neg}  (expected n-=${D.expected_neg}, ${inertia})\n`+
4827    `regularization  delta_w=${fmt(D.delta_w)}  delta_c=${fmt(D.delta_c)}\n`+
4828    `factorization status: ${D.status}`;
4829  spy(m.irn, m.jcn, m.vals, m.dim, true, 'sparsity pattern (sign-colored)');
4830} else if(D.l_irn){
4831  $('stats').textContent =
4832    `LDLᵀ factor   n=${D.n}   nnz(L)=${D.l_irn.length}   format=${D.format||''}`;
4833  spy(D.l_irn, D.l_jcn, D.l_vals, D.n, false, 'L factor sparsity (permuted, strict lower)');
4834} else if(D.values){
4835  $('stats').textContent = `vector ${lbl}   length=${D.values.length}`;
4836  bars(D.values, 'component magnitudes (zero-centered)');
4837} else {
4838  $('stats').textContent = 'unrecognized payload — raw JSON:\n'+JSON.stringify(D,null,2);
4839}
4840</script></body></html>
4841"##;
4842
4843#[cfg(test)]
4844mod tests {
4845    use super::*;
4846
4847    fn dbg(mode: DebugMode) -> SolverDebugger {
4848        SolverDebugger::new(mode, None)
4849    }
4850
4851    #[test]
4852    fn json_command_object_is_flattened() {
4853        assert_eq!(
4854            parse_command("{\"cmd\":\"print x\"}", DebugMode::Json).command,
4855            "print x"
4856        );
4857        let p = parse_command(
4858            "{\"cmd\":\"set\",\"args\":[\"x[0]\",\"1.5\"],\"id\":7}",
4859            DebugMode::Json,
4860        );
4861        assert_eq!(p.command, "set x[0] 1.5");
4862        // Request id is captured for response correlation.
4863        assert_eq!(p.id, Some(serde_json::json!(7)));
4864        // Bare strings pass through in either mode, with no id.
4865        let s = parse_command("step\n", DebugMode::Json);
4866        assert_eq!(s.command, "step");
4867        assert!(s.id.is_none());
4868        assert_eq!(
4869            parse_command("  print x \n", DebugMode::Repl).command,
4870            "print x"
4871        );
4872    }
4873
4874    #[test]
4875    fn pauses_at_first_checkpoint_then_only_when_rearmed() {
4876        let mut d = dbg(DebugMode::Repl);
4877        // Fresh debugger is armed (step=true) so it pauses at iter 0.
4878        assert!(d.should_pause(0));
4879        // After consuming the arming (as at_checkpoint does), no pause.
4880        d.step = false;
4881        assert!(!d.should_pause(1));
4882        assert!(!d.should_pause(2));
4883    }
4884
4885    #[test]
4886    fn breakpoints_and_run_to_arm_pauses() {
4887        let mut d = dbg(DebugMode::Repl);
4888        d.step = false;
4889        d.breaks = vec![3, 7];
4890        assert!(!d.should_pause(2));
4891        assert!(d.should_pause(3));
4892        assert!(d.should_pause(7));
4893        // run_to fires once at/after target, then disarms.
4894        d.run_to = Some(5);
4895        assert!(!d.should_pause(4));
4896        assert!(d.should_pause(5));
4897        assert_eq!(d.run_to, None);
4898        assert!(!d.should_pause(6));
4899    }
4900
4901    #[test]
4902    fn atom_parses_metric_op_threshold() {
4903        let a = Atom::parse("mu<1e-4").unwrap();
4904        assert_eq!(a.metric, Metric::Mu);
4905        assert_eq!(a.op, CmpOp::Lt);
4906        assert_eq!(a.rhs, 1e-4);
4907
4908        // `<=` must not be truncated to `<`.
4909        let a = Atom::parse("inf_pr<=1e-6").unwrap();
4910        assert_eq!(a.metric, Metric::InfPr);
4911        assert_eq!(a.op, CmpOp::Le);
4912
4913        let a = Atom::parse("iter==10").unwrap();
4914        assert_eq!(a.metric, Metric::Iter);
4915        assert_eq!(a.op, CmpOp::Eq);
4916        assert_eq!(a.rhs, 10.0);
4917    }
4918
4919    /// A bare-minimum [`DebugState`] that implements only the required
4920    /// methods and leaves every optional one (including `nlp_error`) at its
4921    /// trait default. Stands in for "a new backend with no solver-specific
4922    /// extras", so the metric-vocabulary test below exercises the
4923    /// default-`NaN` (unsupported-metric) path.
4924    struct MinimalState;
4925    impl DebugState for MinimalState {
4926        fn checkpoint(&self) -> Checkpoint {
4927            Checkpoint::IterStart
4928        }
4929        fn iter(&self) -> i32 {
4930            7
4931        }
4932        fn mu(&self) -> f64 {
4933            1e-3
4934        }
4935        fn objective(&self) -> f64 {
4936            42.0
4937        }
4938        fn inf_pr(&self) -> f64 {
4939            1e-4
4940        }
4941        fn inf_du(&self) -> f64 {
4942            2e-4
4943        }
4944        fn complementarity(&self) -> f64 {
4945            5e-4
4946        }
4947        fn alpha(&self) -> (f64, f64) {
4948            (1.0, 1.0)
4949        }
4950        fn block_dims(&self) -> Vec<(&'static str, usize)> {
4951            vec![]
4952        }
4953        fn block(&self, _name: &str) -> Option<Vec<f64>> {
4954            None
4955        }
4956        fn delta_block(&self, _name: &str) -> Option<Vec<f64>> {
4957            None
4958        }
4959    }
4960
4961    /// The streamed scalar block is driven by the single `METRICS` source of
4962    /// truth: `metric_fields` emits *exactly* the advertised `hello.metrics`
4963    /// names, every backend answers each (required accessors), and an
4964    /// unsupported metric surfaces explicitly as JSON `null` (default `NaN`)
4965    /// rather than a dropped field — so the protocol can't silently drift.
4966    #[test]
4967    fn metric_fields_match_advertised_vocabulary() {
4968        let fields = metric_fields(&MinimalState);
4969
4970        // Same names, same order as the advertised `hello.metrics`.
4971        let names: Vec<&str> = fields.iter().map(|(n, _)| *n).collect();
4972        assert_eq!(names, METRICS);
4973
4974        // Every METRICS name parses to a Metric arm (except `iter`, the
4975        // integer counter) — the invariant `metric_fields` relies on.
4976        for &name in METRICS {
4977            assert!(
4978                name == "iter" || Metric::parse(name).is_some(),
4979                "METRICS entry `{name}` has no matching Metric arm"
4980            );
4981        }
4982
4983        let map: std::collections::HashMap<_, _> = fields.into_iter().collect();
4984        // `iter` is an integer, not a float.
4985        assert_eq!(map["iter"], serde_json::json!(7));
4986        assert_eq!(map["objective"], serde_json::json!(42.0));
4987        // The one optional metric, left at its `NaN` default, is reported
4988        // explicitly as `null` — present, not silently omitted.
4989        assert_eq!(map["nlp_error"], serde_json::Value::Null);
4990    }
4991
4992    #[test]
4993    fn atom_parse_rejects_garbage() {
4994        assert!(Atom::parse("inf_pr 1e-6").is_err()); // no operator
4995        assert!(Atom::parse("bogus<1").is_err()); // unknown metric
4996        assert!(Atom::parse("mu<abc").is_err()); // bad threshold
4997    }
4998
4999    #[test]
5000    fn compound_condition_parses_and_evaluates_left_to_right() {
5001        // Chain length + joins.
5002        let c = Condition::parse("mu<1e-4&&inf_pr>1e-3").unwrap();
5003        assert_eq!(c.rest.len(), 1);
5004        assert_eq!(c.rest[0].0, Join::And);
5005
5006        // Parens are stripped; `||` recognized.
5007        let c = Condition::parse("iter>10&&(inf_du>1e-2||obj<0)").unwrap();
5008        assert_eq!(c.rest.len(), 2);
5009        assert_eq!(c.rest[0].0, Join::And);
5010        assert_eq!(c.rest[1].0, Join::Or);
5011        assert_eq!(c.raw, "iter>10&&inf_du>1e-2||obj<0");
5012
5013        // A bad atom anywhere fails the whole parse.
5014        assert!(Condition::parse("mu<1e-4&&bogus>0").is_err());
5015    }
5016
5017    #[test]
5018    fn completion_is_context_sensitive() {
5019        // First token completes command verbs.
5020        let c = completion_candidates(None, "", "co");
5021        assert!(c.contains(&"continue".to_string()));
5022        assert!(c.contains(&"complete".to_string()));
5023        assert!(!c.contains(&"step".to_string()));
5024
5025        // After `set`, both mu/opt and block names are offered.
5026        let c = completion_candidates(None, "set ", "");
5027        assert!(c.contains(&"mu".to_string()));
5028        assert!(c.contains(&"opt".to_string()));
5029        assert!(c.contains(&"x".to_string()));
5030
5031        // After `break if`, metric names.
5032        let c = completion_candidates(None, "break if ", "inf");
5033        assert!(c.contains(&"inf_pr".to_string()));
5034        assert!(c.contains(&"inf_du".to_string()));
5035        assert!(!c.contains(&"mu".to_string()));
5036
5037        // `print` completes blocks + scalar keywords.
5038        let c = completion_candidates(None, "print ", "");
5039        assert!(c.contains(&"x".to_string()));
5040        assert!(c.contains(&"obj".to_string()));
5041    }
5042
5043    #[test]
5044    fn cmp_op_truth_table() {
5045        assert!(CmpOp::Lt.eval(1.0, 2.0));
5046        assert!(!CmpOp::Lt.eval(2.0, 2.0));
5047        assert!(CmpOp::Le.eval(2.0, 2.0));
5048        assert!(CmpOp::Gt.eval(3.0, 2.0));
5049        assert!(CmpOp::Ge.eval(2.0, 2.0));
5050        assert!(CmpOp::Eq.eval(2.0, 2.0));
5051        assert!(!CmpOp::Eq.eval(2.0, 2.5));
5052    }
5053
5054    #[test]
5055    fn interrupt_is_consumed_once() {
5056        interrupt::set_pending_for_test();
5057        assert!(interrupt::take(), "first take sees the pending Ctrl-C");
5058        assert!(!interrupt::take(), "second take is clear (consumed once)");
5059    }
5060
5061    #[test]
5062    fn on_interrupt_constructor_runs_free_but_interruptible() {
5063        let d = SolverDebugger::on_interrupt(DebugMode::Repl, None);
5064        assert!(!d.pause_iters, "on-interrupt does not pause each iter");
5065        assert!(!d.pause_terminal, "on-interrupt does not pause at terminal");
5066        assert!(d.interruptible, "on-interrupt honors Ctrl-C");
5067        assert!(!d.step, "on-interrupt starts un-armed");
5068    }
5069
5070    #[test]
5071    fn coffee_easter_egg_prints_art_but_stays_hidden() {
5072        let d = SolverDebugger::new(DebugMode::Repl, None);
5073        let out = d.cmd_coffee();
5074        assert!(out.ok);
5075        assert!(out.lines.len() > 5, "multi-line art");
5076        assert!(
5077            out.lines.iter().any(|l| l.contains("COFFEE")),
5078            "the mug says COFFEE"
5079        );
5080        // Easter egg: not advertised anywhere discoverable.
5081        assert!(
5082            !COMMANDS.contains(&"coffee"),
5083            "hidden from help/complete/Tab"
5084        );
5085        // Output is plain in the (non-TTY) test context — no escape codes.
5086        assert!(
5087            out.lines.iter().all(|l| !l.contains('\x1b')),
5088            "no color when stderr isn't a TTY"
5089        );
5090    }
5091
5092    /// `quit` latches only in JSON mode. At the REPL the stop still lands on
5093    /// the post-mortem pause (a person can type `quit` again there), so the
5094    /// failing iterate stays inspectable; a JSON client cannot, and the
5095    /// latch is what lets its process exit.
5096    #[test]
5097    fn quit_latches_in_json_mode_only() {
5098        let mut repl = SolverDebugger::new(DebugMode::Repl, None);
5099        let _ = repl.dispatch("quit", &mut MinimalState);
5100        assert!(!repl.quit, "REPL quit must keep the post-mortem pause");
5101        let mut json = SolverDebugger::new(DebugMode::Json, None);
5102        let _ = json.dispatch("quit", &mut MinimalState);
5103        assert!(json.quit, "JSON quit must latch");
5104    }
5105
5106    #[test]
5107    fn double_ctrl_c_at_prompt_quits_single_cancels_line() {
5108        let mut d = SolverDebugger::new(DebugMode::Repl, None);
5109        // First Ctrl-C in a row cancels the line (empty → reprompt).
5110        assert_eq!(d.on_prompt_interrupt(), "");
5111        // Second in a row quits the solve.
5112        assert_eq!(d.on_prompt_interrupt(), "quit");
5113        // Counter reset after quitting, so the next single press cancels again.
5114        assert_eq!(d.on_prompt_interrupt(), "");
5115        // A real command in between resets the streak (simulating the
5116        // `Ok(l)` branch of `next_command_line`).
5117        d.prompt_interrupts = 0;
5118        assert_eq!(d.on_prompt_interrupt(), "", "fresh streak after a command");
5119    }
5120
5121    #[test]
5122    fn stop_at_accepts_names_and_aliases() {
5123        let mut d = SolverDebugger::new(DebugMode::Repl, None);
5124        assert!(d.cmd_stop_at(&["after_search_dir"]).ok);
5125        assert!(d.stop_at.contains("after_search_dir"));
5126        // Aliases canonicalize.
5127        assert!(d.cmd_stop_at(&["mu"]).ok);
5128        assert!(d.stop_at.contains("after_mu"));
5129        assert!(d.cmd_stop_at(&["kkt"]).ok);
5130        assert!(d.stop_at.contains("after_search_dir"));
5131        // Unknown name is rejected.
5132        assert!(!d.cmd_stop_at(&["bogus"]).ok);
5133        // Clear empties the set.
5134        assert!(d.cmd_stop_at(&["clear"]).ok);
5135        assert!(d.stop_at.is_empty());
5136    }
5137
5138    #[test]
5139    fn llm_command_defaults_and_overrides() {
5140        // Default is `claude -p`, prompt on stdin.
5141        // FIXME: Audit that the environment access only happens in single-threaded code.
5142        unsafe { std::env::remove_var("POUNCE_DBG_LLM") };
5143        let (prog, args, on_stdin) = llm_command("hi");
5144        assert_eq!(prog, "claude");
5145        assert_eq!(args, vec!["-p".to_string()]);
5146        assert!(on_stdin);
5147
5148        // `{}` substitution puts the prompt in an arg (no stdin).
5149        // FIXME: Audit that the environment access only happens in single-threaded code.
5150        unsafe { std::env::set_var("POUNCE_DBG_LLM", "mytool --ask {}") };
5151        let (prog, args, on_stdin) = llm_command("why");
5152        assert_eq!(prog, "mytool");
5153        assert_eq!(args, vec!["--ask".to_string(), "why".to_string()]);
5154        assert!(!on_stdin);
5155
5156        // No `{}` ⇒ prompt on stdin.
5157        // FIXME: Audit that the environment access only happens in single-threaded code.
5158        unsafe { std::env::set_var("POUNCE_DBG_LLM", "llm -m gpt") };
5159        let (_, _, on_stdin) = llm_command("q");
5160        assert!(on_stdin);
5161
5162        // Bare provider keywords resolve to the right non-interactive call.
5163        // (All env-var assertions live in this one test so they can't race
5164        // a sibling that mutates the same process-global var.)
5165        // FIXME: Audit that the environment access only happens in single-threaded code.
5166        unsafe { std::env::set_var("POUNCE_DBG_LLM", "codex") };
5167        let (prog, args, on_stdin) = llm_command("why is mu stuck");
5168        assert_eq!(prog, "codex");
5169        assert_eq!(
5170            args,
5171            vec!["exec".to_string(), "why is mu stuck".to_string()]
5172        );
5173        assert!(!on_stdin); // prompt is in the argv, not stdin
5174
5175        // FIXME: Audit that the environment access only happens in single-threaded code.
5176        unsafe { std::env::set_var("POUNCE_DBG_LLM", "gemini") };
5177        let (prog, args, _) = llm_command("q");
5178        assert_eq!(prog, "gemini");
5179        assert_eq!(args, vec!["-p".to_string(), "q".to_string()]);
5180
5181        // FIXME: Audit that the environment access only happens in single-threaded code.
5182        unsafe { std::env::set_var("POUNCE_DBG_LLM", "llm") };
5183        let (prog, args, _) = llm_command("q");
5184        assert_eq!(prog, "llm");
5185        assert_eq!(args, vec!["q".to_string()]);
5186
5187        // Bare `claude` keyword goes through the preset (gains `-p`), not the
5188        // bare-program fallback that would hang in interactive mode.
5189        // FIXME: Audit that the environment access only happens in single-threaded code.
5190        unsafe { std::env::set_var("POUNCE_DBG_LLM", "claude") };
5191        let (prog, args, on_stdin) = llm_command("q");
5192        assert_eq!(prog, "claude");
5193        assert_eq!(args, vec!["-p".to_string()]);
5194        assert!(on_stdin);
5195
5196        // An unknown bare word is NOT a preset: bare program, prompt on stdin
5197        // (backward-compatible).
5198        // FIXME: Audit that the environment access only happens in single-threaded code.
5199        unsafe { std::env::set_var("POUNCE_DBG_LLM", "mytool") };
5200        let (prog, args, on_stdin) = llm_command("q");
5201        assert_eq!(prog, "mytool");
5202        assert!(args.is_empty());
5203        assert!(on_stdin);
5204
5205        // A missing CLI fails gracefully: an error (never a panic) with an
5206        // actionable, provider-listing message.
5207        // FIXME: Audit that the environment access only happens in single-threaded code.
5208        unsafe { std::env::set_var("POUNCE_DBG_LLM", "pounce-no-such-llm-xyz") };
5209        let err = run_llm("hello").unwrap_err();
5210        assert!(err.contains("not installed or not on PATH"), "{err}");
5211        assert!(err.contains("codex"), "{err}");
5212
5213        // FIXME: Audit that the environment access only happens in single-threaded code.
5214        unsafe { std::env::remove_var("POUNCE_DBG_LLM") };
5215    }
5216
5217    #[test]
5218    fn detach_disables_all_pausing() {
5219        let mut d = dbg(DebugMode::Repl);
5220        d.detached = true;
5221        d.step = true;
5222        d.breaks = vec![1];
5223        assert!(!d.should_pause(0));
5224        assert!(!d.should_pause(1));
5225    }
5226
5227    #[test]
5228    fn kkt_capture_tracks_attached_state() {
5229        // Heavy KKT/L capture is on while stepping (attached), off once
5230        // detached so a free run doesn't pay the per-iteration assembly.
5231        let mut d = dbg(DebugMode::Repl);
5232        assert!(d.wants_kkt_capture());
5233        d.detached = true;
5234        assert!(!d.wants_kkt_capture());
5235    }
5236
5237    fn resid(kind: ResidKind, index: usize, value: f64) -> Residual {
5238        Residual { kind, index, value }
5239    }
5240
5241    #[test]
5242    fn rank_residuals_sorts_by_magnitude_and_truncates() {
5243        use ResidKind::*;
5244        let entries = vec![
5245            resid(Eq, 0, -0.5),
5246            resid(Ineq, 1, 3.0),
5247            resid(DualX, 2, -7.0),
5248            resid(DualS, 3, 1.0),
5249        ];
5250        let top = rank_residuals(entries, 2);
5251        assert_eq!(top.len(), 2);
5252        // Largest |value| first: |-7|, then |3|.
5253        assert_eq!(top[0].value, -7.0);
5254        assert_eq!(top[0].kind, DualX);
5255        assert_eq!(top[1].value, 3.0);
5256        assert_eq!(top[1].kind, Ineq);
5257    }
5258
5259    #[test]
5260    fn rank_residuals_k_zero_and_k_over_len() {
5261        use ResidKind::*;
5262        let entries = vec![resid(Eq, 0, 1.0), resid(Ineq, 1, 2.0)];
5263        assert!(rank_residuals(entries.clone(), 0).is_empty());
5264        // k larger than the input just returns everything, ranked.
5265        let all = rank_residuals(entries, 99);
5266        assert_eq!(all.len(), 2);
5267        assert_eq!(all[0].value, 2.0);
5268    }
5269
5270    #[test]
5271    fn rank_residuals_is_stable_on_magnitude_ties() {
5272        use ResidKind::*;
5273        // Equal |value|: input order preserved (Eq before Ineq before dual).
5274        let entries = vec![
5275            resid(Ineq, 5, -2.0),
5276            resid(Eq, 1, 2.0),
5277            resid(DualX, 9, -2.0),
5278        ];
5279        let top = rank_residuals(entries, 3);
5280        assert_eq!(
5281            top.iter().map(|r| r.kind).collect::<Vec<_>>(),
5282            vec![Ineq, Eq, DualX]
5283        );
5284    }
5285
5286    fn split_names_fixture() -> SplitNames {
5287        SplitNames {
5288            x_var: vec![Some("T_reactor".into()), None],
5289            eq: vec![Some("mass_balance".into()), Some("energy_balance".into())],
5290            ineq: vec![Some("pressure_cap".into())],
5291        }
5292    }
5293
5294    #[test]
5295    fn resid_name_maps_each_kind_to_its_pool() {
5296        use ResidKind::*;
5297        let names = Some(split_names_fixture());
5298        // Equality → eq pool; inequality and s-space dual → ineq pool;
5299        // x-space dual → x_var pool.
5300        assert_eq!(
5301            resid_name(&resid(Eq, 1, 0.0), &names),
5302            Some("energy_balance")
5303        );
5304        assert_eq!(
5305            resid_name(&resid(Ineq, 0, 0.0), &names),
5306            Some("pressure_cap")
5307        );
5308        assert_eq!(
5309            resid_name(&resid(DualS, 0, 0.0), &names),
5310            Some("pressure_cap")
5311        );
5312        assert_eq!(resid_name(&resid(DualX, 0, 0.0), &names), Some("T_reactor"));
5313        // Unnamed slot (None) and out-of-range fall back to no name.
5314        assert_eq!(resid_name(&resid(DualX, 1, 0.0), &names), None);
5315        assert_eq!(resid_name(&resid(Eq, 9, 0.0), &names), None);
5316        // No names at all ⇒ None.
5317        assert_eq!(resid_name(&resid(Eq, 0, 0.0), &None), None);
5318    }
5319
5320    #[test]
5321    fn worst_named_picks_largest_and_labels_it() {
5322        use ResidKind::*;
5323        let names = Some(split_names_fixture());
5324        // |−3.2| is the largest; it sits in the eq pool at index 1.
5325        let resids = vec![resid(Eq, 0, 0.5), resid(Eq, 1, -3.2), resid(Ineq, 0, 1.1)];
5326        assert_eq!(
5327            worst_named(resids, &names),
5328            Some(("c[energy_balance]".to_string(), -3.2))
5329        );
5330        // Without names, the label falls back to the split index.
5331        let resids = vec![resid(DualX, 7, 9.0)];
5332        assert_eq!(
5333            worst_named(resids, &None),
5334            Some(("grad_x_L[7]".to_string(), 9.0))
5335        );
5336        // Empty input ⇒ None.
5337        assert_eq!(worst_named(vec![], &names), None);
5338    }
5339
5340    use pounce_algorithm::debug_rank::RankCulprit;
5341
5342    fn rank_report_fixture() -> RankReport {
5343        // 2×3 equality block, row 1 redundant: rank 1, deficiency 1, with
5344        // both equality rows sharing the single null direction.
5345        RankReport {
5346            rows: vec![
5347                RankRow {
5348                    kind: ResidKind::Eq,
5349                    index: 0,
5350                },
5351                RankRow {
5352                    kind: ResidKind::Eq,
5353                    index: 1,
5354                },
5355            ],
5356            n_cols: 3,
5357            singular_values: vec![2.0, 0.0],
5358            tol: 1e-15,
5359            rank: 1,
5360            cond: f64::INFINITY,
5361            culprits: vec![
5362                RankCulprit {
5363                    row: 0,
5364                    weight: 0.5,
5365                },
5366                RankCulprit {
5367                    row: 1,
5368                    weight: 0.5,
5369                },
5370            ],
5371        }
5372    }
5373
5374    #[test]
5375    fn render_rank_report_names_culprits_and_builds_json() {
5376        let names = Some(split_names_fixture());
5377        let rep = rank_report_fixture();
5378        // No equation book ⇒ names only, plus the `print equation` hint.
5379        let (lines, data) = render_rank_report(&rep, &names, None, 7);
5380
5381        let text = lines.join("\n");
5382        assert!(text.contains("2 row(s) × 3 column(s)"), "{text}");
5383        assert!(text.contains("numerical rank = 1 / 2"), "{text}");
5384        // cond is non-finite (σ_min = 0) ⇒ spelled out, not "inf"/"NaN".
5385        assert!(text.contains("inf (σ_min = 0)"), "{text}");
5386        // Culprits resolved to model names from the eq pool.
5387        assert!(text.contains("c[mass_balance]"), "{text}");
5388        assert!(text.contains("c[energy_balance]"), "{text}");
5389        assert!(text.contains("participation 0.50"), "{text}");
5390        // No book ⇒ fall back to the inspect hint, no inline algebra.
5391        assert!(text.contains("print equation"), "{text}");
5392
5393        // JSON payload: cond is null (non-finite), culprits carry names but
5394        // no resolved equation (no book).
5395        assert_eq!(data["iter"], 7);
5396        assert_eq!(data["rank"], 1);
5397        assert_eq!(data["deficiency"], 1);
5398        assert_eq!(data["rank_deficient"], true);
5399        assert!(data["cond"].is_null(), "non-finite cond ⇒ null: {data}");
5400        assert_eq!(data["culprits"][0]["name"], "mass_balance");
5401        assert_eq!(data["culprits"][0]["label"], "c[mass_balance]");
5402        assert!(data["culprits"][0]["equation"].is_null());
5403        assert_eq!(data["culprits"][1]["name"], "energy_balance");
5404    }
5405
5406    #[test]
5407    fn render_rank_report_prints_culprit_equations_inline() {
5408        let names = Some(split_names_fixture());
5409        let rep = rank_report_fixture();
5410        // The equation book keys on original .nl row order; both eq names
5411        // present so the rank culprits resolve by name.
5412        let book = EquationBook::new(
5413            vec!["mass_balance".into(), "energy_balance".into()],
5414            vec![
5415                "x[0] + x[1] - 10 = 0".into(),
5416                "T_reactor*flow - Q = 0".into(),
5417            ],
5418        );
5419        let (lines, data) = render_rank_report(&rep, &names, Some(&book), 7);
5420
5421        let text = lines.join("\n");
5422        // The offending equations' algebra is printed inline, beneath each
5423        // named culprit — no second command needed.
5424        assert!(text.contains("x[0] + x[1] - 10 = 0"), "{text}");
5425        assert!(text.contains("T_reactor*flow - Q = 0"), "{text}");
5426        // With the algebra shown inline, the `print equation` nag is dropped.
5427        assert!(!text.contains("inspect a row with"), "{text}");
5428
5429        // JSON carries the resolved equation per culprit.
5430        assert_eq!(data["culprits"][0]["equation"], "x[0] + x[1] - 10 = 0");
5431        assert_eq!(data["culprits"][1]["equation"], "T_reactor*flow - Q = 0");
5432    }
5433
5434    #[test]
5435    fn render_rank_report_full_rank_reports_positive_signal() {
5436        let rep = RankReport {
5437            rows: vec![
5438                RankRow {
5439                    kind: ResidKind::Eq,
5440                    index: 0,
5441                },
5442                RankRow {
5443                    kind: ResidKind::Eq,
5444                    index: 1,
5445                },
5446            ],
5447            n_cols: 3,
5448            singular_values: vec![2.0, 1.0],
5449            tol: 1e-15,
5450            rank: 2,
5451            cond: 2.0,
5452            culprits: vec![],
5453        };
5454        let (lines, data) = render_rank_report(&rep, &None, None, 3);
5455        let text = lines.join("\n");
5456        assert!(text.contains("full row rank"), "{text}");
5457        assert!(!text.contains("rank-deficient"), "{text}");
5458        assert_eq!(data["rank_deficient"], false);
5459        assert_eq!(data["cond"], 2.0);
5460        assert_eq!(data["culprits"].as_array().map(|a| a.len()), Some(0));
5461    }
5462
5463    #[test]
5464    fn print_equation_resolves_by_name_index_and_errors() {
5465        let mut d = dbg(DebugMode::Repl);
5466        // No book wired in yet ⇒ a helpful error, not a panic.
5467        let out = d.cmd_print_equation(&[]);
5468        assert!(!out.ok);
5469        assert!(out.lines[0].contains("needs an .nl model"));
5470
5471        d.set_equation_book(EquationBook::new(
5472            vec!["mass_balance".into(), String::new()],
5473            vec!["x[0] + x[1] = 10".into(), "x[0] - x[1] <= 2".into()],
5474        ));
5475
5476        // No arg ⇒ count + usage hint.
5477        let out = d.cmd_print_equation(&[]);
5478        assert!(out.ok);
5479        assert!(out.lines[0].contains("2 constraint equation"));
5480
5481        // By model name.
5482        let out = d.cmd_print_equation(&["mass_balance"]);
5483        assert!(out.ok);
5484        assert_eq!(out.lines[0], "mass_balance:  x[0] + x[1] = 10");
5485
5486        // By original row index; the unnamed row falls back to `c[1]`.
5487        let out = d.cmd_print_equation(&["1"]);
5488        assert!(out.ok);
5489        assert_eq!(out.lines[0], "c[1]:  x[0] - x[1] <= 2");
5490
5491        // Unknown key ⇒ error.
5492        let out = d.cmd_print_equation(&["nope"]);
5493        assert!(!out.ok);
5494        assert!(out.lines[0].contains("no constraint named or indexed"));
5495    }
5496
5497    /// Build an `EqualityIncidence` from an explicit row→vars adjacency,
5498    /// carrying the original-row indices so `con_label`'s `c[orig]`
5499    /// fallback can be exercised.
5500    fn eq_inc(n_vars: usize, eq_row_inner_idx: Vec<usize>, rows: &[&[usize]]) -> EqualityIncidence {
5501        let mut adj_ptr = vec![0usize];
5502        let mut vars = Vec::new();
5503        for r in rows {
5504            let mut v = r.to_vec();
5505            v.sort_unstable();
5506            v.dedup();
5507            vars.extend_from_slice(&v);
5508            adj_ptr.push(vars.len());
5509        }
5510        EqualityIncidence {
5511            n_vars,
5512            eq_row_inner_idx,
5513            adj_ptr,
5514            vars,
5515        }
5516    }
5517
5518    #[test]
5519    fn structural_singularity_names_overdetermined_equations() {
5520        // 3 equality rows over 2 vars, each touching both → a maximum
5521        // matching saturates the 2 columns, leaving 1 row unmatched;
5522        // the alternating walk pulls all 3 rows into the over-determined
5523        // block. The finding must name every candidate equation, the
5524        // shared variables, and the ≥1 redundancy excess.
5525        let inc = eq_inc(2, vec![0, 1, 2], &[&[0, 1], &[0, 1], &[0, 1]]);
5526        let book = StructureBook::new(
5527            inc,
5528            vec!["balance_a".into(), "balance_b".into(), "balance_c".into()],
5529            vec!["flow".into(), "temp".into()],
5530        );
5531        let f = book.findings();
5532        assert_eq!(f.len(), 1);
5533        let (sev, code, msg) = &f[0];
5534        assert_eq!(*sev, "warning");
5535        assert_eq!(*code, "structural_singularity");
5536        assert!(msg.contains("balance_a"), "msg: {msg}");
5537        assert!(msg.contains("balance_b"), "msg: {msg}");
5538        assert!(msg.contains("balance_c"), "msg: {msg}");
5539        assert!(msg.contains("flow") && msg.contains("temp"), "msg: {msg}");
5540        assert!(msg.contains("≥1"), "msg: {msg}");
5541    }
5542
5543    #[test]
5544    fn structural_findings_silent_when_well_posed_and_fall_back_to_indices() {
5545        // Square 2×2 with a perfect matching → structurally sound, no
5546        // finding (and the normal "more vars than eqs" case is never
5547        // flagged either, since we only report the over-determined side).
5548        let inc = eq_inc(2, vec![0, 1], &[&[0], &[1]]);
5549        let book = StructureBook::new(inc, vec![], vec![]);
5550        assert!(book.findings().is_empty());
5551
5552        // Over-determined but unnamed: 3 rows over 1 var, with the
5553        // original row indices skipping 2 (e.g. an interleaved
5554        // inequality) → labels fall back to `c[<orig>]`.
5555        let inc = eq_inc(1, vec![0, 1, 3], &[&[0], &[0], &[0]]);
5556        let book = StructureBook::new(inc, vec![], vec![]);
5557        let f = book.findings();
5558        assert_eq!(f.len(), 1);
5559        let msg = &f[0].2;
5560        assert!(
5561            msg.contains("c[0]") && msg.contains("c[1]") && msg.contains("c[3]"),
5562            "msg: {msg}"
5563        );
5564    }
5565
5566    #[test]
5567    fn structural_singularity_handles_empty_row_with_no_variables() {
5568        // An empty equality row (no variable support) is unmatched and
5569        // touches no columns → over-determined with no shared variables.
5570        let inc = eq_inc(1, vec![0, 1], &[&[0], &[]]);
5571        let book = StructureBook::new(inc, vec!["real".into(), "ghost".into()], vec!["x".into()]);
5572        let f = book.findings();
5573        assert_eq!(f.len(), 1);
5574        let msg = &f[0].2;
5575        assert!(msg.contains("ghost"), "msg: {msg}");
5576        assert!(msg.contains("no variables"), "msg: {msg}");
5577    }
5578
5579    #[test]
5580    fn parse_floats_accepts_commas_whitespace_and_newlines() {
5581        assert_eq!(parse_floats("1, 2 ,3").unwrap(), vec![1.0, 2.0, 3.0]);
5582        assert_eq!(parse_floats("1\n2\n-3.5").unwrap(), vec![1.0, 2.0, -3.5]);
5583        assert_eq!(parse_floats("  1.0   2e-1 ").unwrap(), vec![1.0, 0.2]);
5584        assert!(parse_floats("1, nope, 3").is_err());
5585        assert_eq!(parse_floats("").unwrap(), Vec::<f64>::new());
5586    }
5587
5588    #[test]
5589    fn jitter_start_zero_is_the_unperturbed_base_and_is_deterministic() {
5590        let base = vec![1.0, -2.0, 0.0];
5591        // k=0 reproduces the base exactly, so a multistart always covers x0.
5592        assert_eq!(jitter(&base, 0.1, 0), base);
5593        // k>0 perturbs, bounded by rel·(|xᵢ|+1), and reproduces run-to-run.
5594        let a = jitter(&base, 0.1, 1);
5595        let b = jitter(&base, 0.1, 1);
5596        assert_eq!(a, b);
5597        assert_ne!(a, base);
5598        for (j, (&p, &x)) in a.iter().zip(&base).enumerate() {
5599            let bound = 0.1 * (x.abs() + 1.0);
5600            assert!(
5601                (p - x).abs() <= bound + 1e-12,
5602                "component {j} moved {} > bound {bound}",
5603                (p - x).abs()
5604            );
5605        }
5606        // Different start index → different point.
5607        assert_ne!(jitter(&base, 0.1, 1), jitter(&base, 0.1, 2));
5608    }
5609
5610    #[test]
5611    fn sample_start_draws_inside_finite_boxes_and_jitters_unbounded() {
5612        let base = vec![1.0, 1.0, 0.5];
5613        // var 0: box [0,2]; var 1: lower-only (upper = +inf); var 2: box [-1,1].
5614        let lo = vec![0.0, 0.0, -1.0];
5615        let hi = vec![2.0, f64::INFINITY, 1.0];
5616        let b = Some((lo.as_slice(), hi.as_slice()));
5617        // Start 0 is always the base, regardless of bounds.
5618        assert_eq!(sample_start(&base, b, 0.1, 0), base);
5619        for k in 1..50 {
5620            let s = sample_start(&base, b, 0.1, k);
5621            // Boxed components land strictly inside their box.
5622            assert!((0.0..=2.0).contains(&s[0]), "var0 {} out of [0,2]", s[0]);
5623            assert!((-1.0..=1.0).contains(&s[2]), "var2 {} out of [-1,1]", s[2]);
5624            // The half-bounded component falls back to jitter around base.
5625            let bound = 0.1 * (base[1].abs() + 1.0);
5626            assert!(
5627                (s[1] - base[1]).abs() <= bound + 1e-12,
5628                "var1 jitter exceeded"
5629            );
5630        }
5631        // Deterministic in k.
5632        assert_eq!(
5633            sample_start(&base, b, 0.1, 7),
5634            sample_start(&base, b, 0.1, 7)
5635        );
5636    }
5637
5638    #[test]
5639    fn path_completion_lists_matching_files_with_dir_prefix() {
5640        let dir = std::env::temp_dir().join("pounce_dbg_complete_test");
5641        let _ = std::fs::remove_dir_all(&dir);
5642        std::fs::create_dir_all(&dir).unwrap();
5643        std::fs::write(dir.join("starts.txt"), "0,0\n").unwrap();
5644        std::fs::write(dir.join("start2.txt"), "1,1\n").unwrap();
5645        std::fs::write(dir.join("other.json"), "{}").unwrap();
5646        std::fs::create_dir_all(dir.join("subdir")).unwrap();
5647
5648        let p = dir.to_string_lossy().to_string();
5649        // Prefix filters; the dir prefix is preserved so the token replaces whole.
5650        let mut got = path_candidates(&format!("{p}/start"));
5651        got.sort();
5652        assert_eq!(
5653            got,
5654            vec![format!("{p}/start2.txt"), format!("{p}/starts.txt")]
5655        );
5656        // Directories get a trailing slash.
5657        let got = path_candidates(&format!("{p}/sub"));
5658        assert_eq!(got, vec![format!("{p}/subdir/")]);
5659        // Listing a directory with an empty basename returns all entries.
5660        assert_eq!(path_candidates(&format!("{p}/")).len(), 4);
5661        // Verb-context routing: `load <file>` arg yields path candidates.
5662        assert!(
5663            completion_candidates(None, "load", &format!("{p}/star"))
5664                .iter()
5665                .all(|c| c.contains("start"))
5666        );
5667
5668        let _ = std::fs::remove_dir_all(&dir);
5669    }
5670}