Skip to main content

pounce_cli/
debug_repl.rs

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