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