1use crate::cli::DebugMode;
44use pounce_algorithm::debug::{
45 BLOCK_NAMES, DebugCtx, IterateSnapshot, ResidKind, Residual, is_live_tolerance,
46};
47use pounce_algorithm::debug_rank::{RankReport, RankRow};
48use pounce_common::debug::{Checkpoint, DebugAction, DebugHook, DebugState};
49use pounce_common::reg_options::{DefaultValue, OptionType, RegisteredOptions};
50use pounce_nlp::ipopt_nlp::SplitNames;
51use pounce_presolve::dulmage_mendelsohn::DulmageMendelsohnPartition;
52use pounce_presolve::incidence::EqualityIncidence;
53use pounce_presolve::matching::hopcroft_karp;
54#[cfg(not(target_family = "wasm"))]
55use rustyline::completion::{Completer, Pair};
56#[cfg(not(target_family = "wasm"))]
57use rustyline::error::ReadlineError;
58#[cfg(not(target_family = "wasm"))]
59use rustyline::history::FileHistory;
60#[cfg(not(target_family = "wasm"))]
61use rustyline::{Context, Editor, Helper, Highlighter, Hinter, Validator};
62use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
63use std::io::{IsTerminal, Write};
64use std::path::PathBuf;
65use std::rc::Rc;
66
67#[cfg(not(target_family = "wasm"))]
71type ReplEditor = Editor<DbgHelper, FileHistory>;
72#[cfg(target_family = "wasm")]
73type ReplEditor = std::convert::Infallible;
74
75const COMMANDS: &[&str] = &[
77 "help",
78 "info",
79 "print",
80 "step",
81 "stepi",
82 "continue",
83 "run",
84 "break",
85 "tbreak",
86 "watchpoint",
87 "commands",
88 "stop-at",
89 "set",
90 "get",
91 "opt",
92 "complete",
93 "viz",
94 "save",
95 "load",
96 "sweep",
97 "multistart",
98 "goto",
99 "restart",
100 "resolve",
101 "ask",
102 "watch",
103 "diff",
104 "diagnose",
105 "source",
106 "progress",
107 "artifacts",
108 "detach",
109 "quit",
110];
111
112const EVENTS: &[&str] = &[
115 "resto_entered",
116 "resto_exited",
117 "regularized",
118 "tiny_step",
119 "ls_rejected",
120 "mu_stalled",
121 "nan",
122];
123
124const MU_STALL_ITERS: u32 = 3;
127
128#[derive(Clone)]
131struct WatchPoint {
132 raw: String,
134 block: String,
135 idx: Option<usize>,
136 threshold: f64,
137 last: Option<Vec<f64>>,
139}
140
141const CHECKPOINTS: &[&str] = &[
143 "iter_start",
144 "after_mu",
145 "after_search_dir",
146 "after_step",
147 "step_rejected",
148 "pre_restoration_entry",
149 "post_restoration_exit",
150 "terminated",
151];
152
153pub struct RestartRequest {
157 pub seed_x: Vec<f64>,
160 pub options: Vec<(String, String)>,
162 pub warm: Option<IterateSnapshot>,
168}
169
170pub type RestartCell = Rc<std::cell::RefCell<Option<RestartRequest>>>;
173
174#[derive(Clone)]
176struct SweepRecord {
177 idx: usize,
179 seed: Vec<f64>,
181 status: String,
183 objective: f64,
185 inf_pr: f64,
187 iters: i32,
189}
190
191struct SweepState {
196 queue: VecDeque<Vec<f64>>,
198 current: Option<Vec<f64>>,
200 records: Vec<SweepRecord>,
202 total: usize,
204 saved_pause_iters: bool,
207}
208
209const SNAPSHOT_CAP: usize = 2000;
212
213fn is_success_status(s: &str) -> bool {
216 matches!(s, "Success" | "StopAtAcceptablePoint")
217}
218
219fn parse_floats(s: &str) -> Result<Vec<f64>, String> {
223 s.split(|c: char| c == ',' || c.is_whitespace())
224 .filter(|t| !t.is_empty())
225 .map(|t| t.parse::<f64>().map_err(|_| format!("bad number `{t}`")))
226 .collect()
227}
228
229fn splitmix_unit(state: &mut u64) -> f64 {
232 *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
233 let mut z = *state;
234 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
235 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
236 z ^= z >> 31;
237 ((z >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0
239}
240
241fn seed_for(k: usize) -> u64 {
243 0x9E37_79B9_7F4A_7C15u64
244 ^ (k as u64)
245 .wrapping_mul(0xD1B5_4A32_D192_ED03)
246 .wrapping_add(1)
247}
248
249fn sample_start(base: &[f64], bounds: Option<(&[f64], &[f64])>, rel: f64, k: usize) -> Vec<f64> {
255 if k == 0 {
256 return base.to_vec();
257 }
258 let mut state = seed_for(k);
259 base.iter()
260 .enumerate()
261 .map(|(i, &xi)| {
262 let unit = splitmix_unit(&mut state); if let Some((lo, hi)) = bounds {
264 let (l, u) = (lo[i], hi[i]);
265 if l.is_finite() && u.is_finite() && u > l {
266 return l + (u - l) * (unit * 0.5 + 0.5);
268 }
269 }
270 xi + rel * (xi.abs() + 1.0) * unit
271 })
272 .collect()
273}
274
275#[cfg(test)]
277fn jitter(base: &[f64], rel: f64, k: usize) -> Vec<f64> {
278 sample_start(base, None, rel, k)
279}
280
281pub mod interrupt {
292 use std::sync::atomic::{AtomicBool, Ordering};
293
294 static PENDING: AtomicBool = AtomicBool::new(false);
295 #[cfg(unix)]
296 static INSTALLED: AtomicBool = AtomicBool::new(false);
297
298 #[cfg(unix)]
299 extern "C" fn handler(_sig: nix::libc::c_int) {
300 if PENDING.swap(true, Ordering::SeqCst) {
303 unsafe { nix::libc::_exit(130) };
305 }
306 }
307
308 #[cfg(unix)]
311 pub fn install() {
312 use nix::sys::signal::{self, SigHandler, Signal};
313 if INSTALLED.swap(true, Ordering::SeqCst) {
314 return;
315 }
316 unsafe {
318 let _ = signal::signal(Signal::SIGINT, SigHandler::Handler(handler));
319 }
320 }
321
322 #[cfg(not(unix))]
327 pub fn install() {}
328
329 pub fn take() -> bool {
331 PENDING.swap(false, Ordering::SeqCst)
332 }
333
334 #[cfg(test)]
336 pub fn set_pending_for_test() {
337 PENDING.store(true, Ordering::SeqCst);
338 }
339}
340
341#[derive(Clone, Copy)]
343enum Flow {
344 Stay,
346 Resume,
348 Stop,
350}
351
352struct CmdOut {
354 ok: bool,
355 lines: Vec<String>,
356 data: Option<serde_json::Value>,
357 flow: Flow,
358}
359
360impl CmdOut {
361 fn ok(lines: Vec<String>) -> Self {
362 Self {
363 ok: true,
364 lines,
365 data: None,
366 flow: Flow::Stay,
367 }
368 }
369 fn err(msg: impl Into<String>) -> Self {
370 Self {
371 ok: false,
372 lines: vec![msg.into()],
373 data: None,
374 flow: Flow::Stay,
375 }
376 }
377 fn with_data(mut self, data: serde_json::Value) -> Self {
378 self.data = Some(data);
379 self
380 }
381 fn flow(mut self, flow: Flow) -> Self {
382 self.flow = flow;
383 self
384 }
385}
386
387const METRICS: &[&str] = &[
394 "iter",
395 "mu",
396 "objective",
397 "inf_pr",
398 "inf_du",
399 "nlp_error",
400 "complementarity",
401];
402
403#[derive(Clone, Copy, Debug, PartialEq, Eq)]
405enum Metric {
406 Mu,
407 InfPr,
408 InfDu,
409 Obj,
410 NlpError,
411 Compl,
412 Iter,
413}
414
415impl Metric {
416 fn parse(s: &str) -> Option<Metric> {
417 Some(match s {
418 "mu" => Metric::Mu,
419 "inf_pr" => Metric::InfPr,
420 "inf_du" => Metric::InfDu,
421 "obj" | "objective" => Metric::Obj,
422 "err" | "nlp_error" => Metric::NlpError,
423 "compl" | "complementarity" => Metric::Compl,
424 "iter" => Metric::Iter,
425 _ => return None,
426 })
427 }
428 fn eval(self, ctx: &dyn DebugState) -> f64 {
429 match self {
430 Metric::Mu => ctx.mu(),
431 Metric::InfPr => ctx.inf_pr(),
432 Metric::InfDu => ctx.inf_du(),
433 Metric::Obj => ctx.objective(),
434 Metric::NlpError => ctx.nlp_error(),
435 Metric::Compl => ctx.complementarity(),
436 Metric::Iter => ctx.iter() as f64,
437 }
438 }
439}
440
441fn metric_fields(ctx: &dyn DebugState) -> Vec<(&'static str, serde_json::Value)> {
457 METRICS
458 .iter()
459 .map(|&name| {
460 let value = if name == "iter" {
461 serde_json::json!(ctx.iter())
462 } else {
463 let metric = Metric::parse(name)
464 .expect("every METRICS entry must have a matching Metric arm");
465 serde_json::json!(metric.eval(ctx))
466 };
467 (name, value)
468 })
469 .collect()
470}
471
472fn insert_metric_fields(ev: &mut serde_json::Value, ctx: &dyn DebugState) {
474 if let serde_json::Value::Object(map) = ev {
475 for (name, value) in metric_fields(ctx) {
476 map.insert(name.to_string(), value);
477 }
478 }
479}
480
481#[derive(Clone, Copy, Debug, PartialEq, Eq)]
483enum CmpOp {
484 Lt,
485 Le,
486 Gt,
487 Ge,
488 Eq,
489}
490
491impl CmpOp {
492 fn eval(self, lhs: f64, rhs: f64) -> bool {
493 match self {
494 CmpOp::Lt => lhs < rhs,
495 CmpOp::Le => lhs <= rhs,
496 CmpOp::Gt => lhs > rhs,
497 CmpOp::Ge => lhs >= rhs,
498 CmpOp::Eq => (lhs - rhs).abs() <= 1e-12 * rhs.abs().max(1.0),
504 }
505 }
506}
507
508#[derive(Clone, Debug)]
510struct Atom {
511 metric: Metric,
512 op: CmpOp,
513 rhs: f64,
514}
515
516impl Atom {
517 fn parse(expr: &str) -> Result<Atom, String> {
520 let expr = expr.trim();
521 let mut found: Option<(&str, usize, usize)> = None;
526 for (i, _) in expr.char_indices() {
527 let rest = &expr[i..];
528 if rest.starts_with("<=") || rest.starts_with(">=") || rest.starts_with("==") {
529 found = Some((&expr[i..i + 2], i, 2));
530 break;
531 }
532 if rest.starts_with('<') || rest.starts_with('>') {
533 found = Some((&expr[i..i + 1], i, 1));
534 break;
535 }
536 }
537 let (op, pos, oplen) = found
538 .ok_or_else(|| format!("no comparison operator in `{expr}` (use < <= > >= ==)"))?;
539 let metric_s = expr[..pos].trim();
540 let rhs_s = expr[pos + oplen..].trim();
541 let metric = Metric::parse(metric_s)
542 .ok_or_else(|| format!("unknown metric `{metric_s}` (one of {METRICS:?})"))?;
543 let rhs = rhs_s
544 .parse::<f64>()
545 .map_err(|_| format!("bad threshold `{rhs_s}`"))?;
546 let cmp = match op {
547 "<" => CmpOp::Lt,
548 "<=" => CmpOp::Le,
549 ">" => CmpOp::Gt,
550 ">=" => CmpOp::Ge,
551 "==" => CmpOp::Eq,
552 _ => unreachable!(),
553 };
554 Ok(Atom {
555 metric,
556 op: cmp,
557 rhs,
558 })
559 }
560
561 fn holds(&self, ctx: &dyn DebugState) -> bool {
562 self.op.eval(self.metric.eval(ctx), self.rhs)
563 }
564}
565
566#[derive(Clone, Copy, Debug, PartialEq, Eq)]
568enum Join {
569 And,
570 Or,
571}
572
573#[derive(Clone, Debug)]
578struct Condition {
579 first: Atom,
580 rest: Vec<(Join, Atom)>,
581 raw: String,
583}
584
585impl Condition {
586 fn parse(expr: &str) -> Result<Condition, String> {
587 let cleaned: String = expr.chars().filter(|c| !matches!(c, '(' | ')')).collect();
589 let mut atoms: Vec<(Option<Join>, &str)> = Vec::new();
591 let bytes = cleaned.as_bytes();
592 let mut start = 0usize;
593 let mut i = 0usize;
594 let mut pending: Option<Join> = None;
595 while i + 1 < bytes.len() {
596 let two = &cleaned[i..i + 2];
597 let join = match two {
598 "&&" => Some(Join::And),
599 "||" => Some(Join::Or),
600 _ => None,
601 };
602 if let Some(j) = join {
603 atoms.push((pending, &cleaned[start..i]));
604 pending = Some(j);
605 i += 2;
606 start = i;
607 } else {
608 i += 1;
609 }
610 }
611 atoms.push((pending, &cleaned[start..]));
612
613 let mut iter = atoms.into_iter();
614 let Some((_, first_s)) = iter.next() else {
615 return Err("empty condition".into());
616 };
617 let first = Atom::parse(first_s)?;
618 let mut rest = Vec::new();
619 for (join, s) in iter {
620 let join = join.ok_or("malformed compound condition (dangling &&/||)")?;
621 rest.push((join, Atom::parse(s)?));
622 }
623 Ok(Condition {
625 first,
626 rest,
627 raw: cleaned,
628 })
629 }
630
631 fn holds(&self, ctx: &dyn DebugState) -> bool {
632 let mut acc = self.first.holds(ctx);
633 for (join, atom) in &self.rest {
634 let v = atom.holds(ctx);
635 acc = match join {
636 Join::And => acc && v,
637 Join::Or => acc || v,
638 };
639 }
640 acc
641 }
642}
643
644fn path_candidates(word: &str) -> Vec<String> {
654 let (dir, prefix) = match word.rfind('/') {
656 Some(i) => (&word[..=i], &word[i + 1..]), None => ("", word),
658 };
659 let read_from = if dir.is_empty() { "." } else { dir };
660 let Ok(entries) = std::fs::read_dir(read_from) else {
661 return Vec::new();
662 };
663 let mut out: Vec<String> = Vec::new();
664 for e in entries.flatten() {
665 let name = e.file_name().to_string_lossy().into_owned();
666 if !name.starts_with(prefix) {
667 continue;
668 }
669 if name.starts_with('.') && !prefix.starts_with('.') {
670 continue;
671 }
672 let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
673 let mut cand = format!("{dir}{name}");
674 if is_dir {
675 cand.push('/');
676 }
677 out.push(cand);
678 }
679 out.sort();
680 out
681}
682
683fn completion_candidates(reg: Option<&RegisteredOptions>, before: &str, word: &str) -> Vec<String> {
684 let toks: Vec<&str> = before.split_whitespace().collect();
685 let starts = |opts: &[&str]| -> Vec<String> {
686 opts.iter()
687 .filter(|c| c.starts_with(word))
688 .map(|c| c.to_string())
689 .collect()
690 };
691 let opt_names = || -> Vec<String> {
692 reg.map(|r| {
693 r.registered_options_in_order()
694 .iter()
695 .map(|o| o.name.clone())
696 .filter(|n| n.starts_with(word))
697 .collect()
698 })
699 .unwrap_or_default()
700 };
701 match toks.as_slice() {
702 [] => starts(COMMANDS),
703 ["set"] => {
704 let mut v = starts(&["mu", "opt"]);
705 v.extend(starts(&BLOCK_NAMES));
706 v
707 }
708 ["set", "opt"] | ["get", "opt"] | ["get"] | ["opt"] | ["options"] => opt_names(),
709 ["set", "opt", name] => reg
711 .and_then(|r| r.get_option(name))
712 .map(|o| {
713 o.valid_strings
714 .iter()
715 .map(|e| e.value.clone())
716 .filter(|v| v.starts_with(word) && v != "*")
717 .collect()
718 })
719 .unwrap_or_default(),
720 ["artifacts"] => starts(&["inline", "file"]),
721 ["ask"] => starts(&["--prompt-only"]),
722 ["stop-at"] | ["stopat"] => starts(CHECKPOINTS),
723 ["break", "if"] | ["b", "if"] => starts(METRICS),
724 ["break", "on"] | ["b", "on"] => starts(EVENTS),
725 ["break"] | ["b"] => starts(&["if", "on", "clear", "del"]),
726 ["watchpoint"] | ["wp"] => starts(&BLOCK_NAMES),
727 ["print"] | ["p"] | ["watch"] | ["display"] => {
728 let mut v = starts(&BLOCK_NAMES);
729 v.extend(starts(&[
730 "mu",
731 "obj",
732 "inf_pr",
733 "inf_du",
734 "err",
735 "compl",
736 "iter",
737 "kkt",
738 "active",
739 "inactive",
740 "residuals",
741 "equation",
742 "rank",
743 ]));
744 v
745 }
746 ["viz"] | ["plot"] => {
747 let mut v = starts(&BLOCK_NAMES);
748 v.extend(starts(&["kkt", "L"]));
749 v
750 }
751 ["complete"] => starts(COMMANDS),
752 ["save"] | ["load"] | ["sweep"] | ["source"] => path_candidates(word),
754 ["load", _] => starts(&BLOCK_NAMES),
756 _ => Vec::new(),
757 }
758}
759
760#[cfg(not(target_family = "wasm"))]
764#[derive(Helper, Hinter, Highlighter, Validator)]
765struct DbgHelper {
766 reg: Option<Rc<RegisteredOptions>>,
767}
768
769#[cfg(not(target_family = "wasm"))]
770impl Completer for DbgHelper {
771 type Candidate = Pair;
772 fn complete(
773 &self,
774 line: &str,
775 pos: usize,
776 _ctx: &Context<'_>,
777 ) -> rustyline::Result<(usize, Vec<Pair>)> {
778 let before = &line[..pos];
779 let start = before
780 .rfind(char::is_whitespace)
781 .map(|i| i + 1)
782 .unwrap_or(0);
783 let word = &before[start..];
784 let cands = completion_candidates(self.reg.as_deref(), &before[..start], word);
785 let pairs = cands
786 .into_iter()
787 .map(|c| Pair {
788 display: c.clone(),
789 replacement: c,
790 })
791 .collect();
792 Ok((start, pairs))
793 }
794}
795
796pub struct EquationBook {
806 names: Vec<String>,
809 equations: Vec<String>,
811}
812
813impl EquationBook {
814 pub fn new(names: Vec<String>, equations: Vec<String>) -> Self {
817 Self { names, equations }
818 }
819
820 pub fn len(&self) -> usize {
822 self.equations.len()
823 }
824
825 pub fn is_empty(&self) -> bool {
827 self.equations.is_empty()
828 }
829
830 fn label(&self, i: usize) -> String {
833 match self.names.get(i) {
834 Some(n) if !n.is_empty() => n.clone(),
835 _ => format!("c[{i}]"),
836 }
837 }
838
839 fn resolve(&self, key: &str) -> Option<usize> {
842 if let Some(i) = self.names.iter().position(|n| n == key) {
843 return Some(i);
844 }
845 key.parse::<usize>()
846 .ok()
847 .filter(|&i| i < self.equations.len())
848 }
849}
850
851const MAX_STRUCT_NAMES: usize = 10;
856
857const MAX_SINGULAR_VALUES_SHOWN: usize = 16;
860
861const MAX_RANK_CULPRITS: usize = 12;
864
865pub struct StructureBook {
883 inc: EqualityIncidence,
886 con_names: Vec<String>,
889 var_names: Vec<String>,
892}
893
894impl StructureBook {
895 pub fn new(inc: EqualityIncidence, con_names: Vec<String>, var_names: Vec<String>) -> Self {
901 Self {
902 inc,
903 con_names,
904 var_names,
905 }
906 }
907
908 fn con_label(&self, eq_row: usize) -> String {
911 let orig = self.inc.eq_row_inner_idx[eq_row];
912 match self.con_names.get(orig) {
913 Some(n) if !n.is_empty() => n.clone(),
914 _ => format!("c[{orig}]"),
915 }
916 }
917
918 fn var_label(&self, v: usize) -> String {
921 match self.var_names.get(v) {
922 Some(n) if !n.is_empty() => n.clone(),
923 _ => format!("x[{v}]"),
924 }
925 }
926
927 fn join_capped(labels: &[String]) -> String {
930 if labels.len() <= MAX_STRUCT_NAMES {
931 labels.join(", ")
932 } else {
933 let head = labels[..MAX_STRUCT_NAMES].join(", ");
934 let more = labels.len() - MAX_STRUCT_NAMES;
935 format!("{head}, … (+{more} more)")
936 }
937 }
938
939 fn findings(&self) -> Vec<(&'static str, &'static str, String)> {
949 let mut out = Vec::new();
950 if self.inc.n_eq_rows() == 0 {
951 return out;
952 }
953 let matching = hopcroft_karp(&self.inc);
954 let dm = DulmageMendelsohnPartition::from_matching(&self.inc, &matching);
955 if dm.over_rows.is_empty() {
956 return out;
957 }
958
959 let excess = dm.over_rows.len().saturating_sub(dm.over_cols.len());
963 let eq_labels: Vec<String> = dm.over_rows.iter().map(|&r| self.con_label(r)).collect();
964 let var_labels: Vec<String> = dm.over_cols.iter().map(|&v| self.var_label(v)).collect();
965 let eqs = Self::join_capped(&eq_labels);
966 let shared = if var_labels.is_empty() {
967 "no variables".to_string()
968 } else {
969 Self::join_capped(&var_labels)
970 };
971 out.push((
972 "warning",
973 "structural_singularity",
974 format!(
975 "Constraint Jacobian is structurally singular (Dulmage–Mendelsohn): {} equation(s) \
976 over-determine the {} variable(s) they jointly touch ({}), so ≥{} of them must be \
977 redundant or mutually inconsistent (LICQ fails on this block). Candidate \
978 dependent equations: {}. Inspect them with `print equation <name>`; this names \
979 the rows behind any δ_c dual-regularization / wrong-inertia signal.",
980 dm.over_rows.len(),
981 dm.over_cols.len(),
982 shared,
983 excess.max(1),
984 eqs
985 ),
986 ));
987 out
988 }
989}
990
991pub struct SolverDebugger {
992 mode: DebugMode,
993 reg: Option<Rc<RegisteredOptions>>,
994 step: bool,
996 run_to: Option<i32>,
998 breaks: Vec<i32>,
1000 temp_breaks: Vec<i32>,
1002 bp_commands: HashMap<i32, Vec<String>>,
1005 conds: Vec<Condition>,
1007 watchpoints: Vec<WatchPoint>,
1009 last_mu: Option<f64>,
1011 mu_stall: u32,
1012 in_restoration: bool,
1015 detached: bool,
1017 hello_sent: bool,
1020 pause_iters: bool,
1023 pause_terminal: bool,
1025 terminal_only_on_error: bool,
1027 interruptible: bool,
1029 emit_progress: bool,
1032 inline_artifacts: bool,
1037 sub_step: bool,
1040 stop_at: HashSet<&'static str>,
1042 break_events: HashSet<&'static str>,
1044 snapshots: BTreeMap<i32, Box<dyn pounce_common::debug::IterSnapshot>>,
1047 restart: Option<RestartCell>,
1050 #[cfg_attr(target_family = "wasm", allow(dead_code))]
1054 editor: Option<ReplEditor>,
1055 #[cfg_attr(target_family = "wasm", allow(dead_code))]
1057 hist_path: Option<PathBuf>,
1058 pump: Option<StdinPump>,
1061 watches: Vec<String>,
1064 pending_script: Option<String>,
1067 script_given: bool,
1071 staged: Vec<(String, String)>,
1075 sweep: Option<SweepState>,
1078 #[cfg_attr(target_family = "wasm", allow(dead_code))]
1083 prompt_interrupts: u8,
1084 equation_book: Option<EquationBook>,
1089 structure_book: Option<StructureBook>,
1094 script_queue: Option<SharedScript>,
1100}
1101
1102pub type SharedScript = Rc<std::cell::RefCell<VecDeque<String>>>;
1106
1107impl SolverDebugger {
1108 pub fn new(mode: DebugMode, reg: Option<Rc<RegisteredOptions>>) -> Self {
1111 Self {
1112 mode,
1113 reg,
1114 step: true,
1117 run_to: None,
1118 breaks: Vec::new(),
1119 temp_breaks: Vec::new(),
1120 bp_commands: HashMap::new(),
1121 conds: Vec::new(),
1122 watchpoints: Vec::new(),
1123 last_mu: None,
1124 mu_stall: 0,
1125 in_restoration: false,
1126 detached: false,
1127 hello_sent: false,
1128 pause_iters: true,
1129 pause_terminal: true,
1130 terminal_only_on_error: false,
1131 interruptible: !cfg!(target_family = "wasm"),
1133 emit_progress: true,
1134 inline_artifacts: cfg!(target_family = "wasm"),
1135 sub_step: false,
1136 stop_at: HashSet::new(),
1137 break_events: HashSet::new(),
1138 snapshots: BTreeMap::new(),
1139 restart: None,
1140 editor: None,
1141 hist_path: None,
1142 pump: None,
1143 watches: Vec::new(),
1144 pending_script: None,
1145 script_given: false,
1146 staged: Vec::new(),
1147 sweep: None,
1148 prompt_interrupts: 0,
1149 equation_book: None,
1150 structure_book: None,
1151 script_queue: None,
1152 }
1153 }
1154
1155 pub fn quiet(mode: DebugMode, reg: Option<Rc<RegisteredOptions>>) -> Self {
1161 let mut d = Self::new(mode, reg);
1162 d.step = false;
1163 d.pause_iters = false;
1164 d.pause_terminal = false;
1165 d.detached = true;
1166 d
1167 }
1168
1169 pub fn with_script(mut self, path: String) -> Self {
1171 self.pending_script = Some(path);
1172 self.script_given = true;
1173 self
1174 }
1175
1176 pub fn set_equation_book(&mut self, book: EquationBook) {
1180 self.equation_book = Some(book);
1181 }
1182
1183 pub fn set_structure_book(&mut self, book: StructureBook) {
1189 self.structure_book = Some(book);
1190 }
1191
1192 pub fn with_shared_script(mut self, queue: SharedScript) -> Self {
1196 self.script_queue = Some(queue);
1197 self
1198 }
1199
1200 pub fn with_restart(mut self, cell: RestartCell) -> Self {
1203 self.restart = Some(cell);
1204 self
1205 }
1206
1207 pub fn on_error(mode: DebugMode, reg: Option<Rc<RegisteredOptions>>) -> Self {
1210 Self {
1211 step: false,
1212 pause_iters: false,
1213 terminal_only_on_error: true,
1214 ..Self::new(mode, reg)
1215 }
1216 }
1217
1218 pub fn on_interrupt(mode: DebugMode, reg: Option<Rc<RegisteredOptions>>) -> Self {
1222 Self {
1223 step: false,
1224 pause_iters: false,
1225 pause_terminal: false,
1226 ..Self::new(mode, reg)
1227 }
1228 }
1229
1230 pub fn staged_options(&self) -> &[(String, String)] {
1233 &self.staged
1234 }
1235
1236 fn should_pause(&mut self, iter: i32) -> bool {
1237 if self.detached {
1238 return false;
1239 }
1240 if self.step {
1241 return true;
1242 }
1243 if let Some(t) = self.run_to {
1244 if iter >= t {
1245 self.run_to = None;
1246 return true;
1247 }
1248 }
1249 if self.breaks.contains(&iter) {
1250 return true;
1251 }
1252 if let Some(pos) = self.temp_breaks.iter().position(|&b| b == iter) {
1254 self.temp_breaks.remove(pos);
1255 return true;
1256 }
1257 false
1258 }
1259
1260 fn matched_condition(&self, ctx: &dyn DebugState) -> Option<String> {
1263 if self.detached {
1264 return None;
1265 }
1266 self.conds
1267 .iter()
1268 .find(|c| c.holds(ctx))
1269 .map(|c| c.raw.clone())
1270 }
1271
1272 fn matched_event(&self, ctx: &dyn DebugState) -> Option<&'static str> {
1276 if self.detached || self.break_events.is_empty() {
1277 return None;
1278 }
1279 let cp = ctx.checkpoint();
1280 let tiny = 1e-10;
1282 EVENTS.iter().copied().find(|&e| {
1283 self.break_events.contains(e)
1284 && match e {
1285 "resto_entered" => cp == Checkpoint::PreRestoration,
1286 "resto_exited" => cp == Checkpoint::PostRestoration,
1287 "regularized" => {
1288 cp == Checkpoint::AfterSearchDirection && ctx.regularization() > 0.0
1289 }
1290 "tiny_step" => {
1291 cp == Checkpoint::AfterSearchDirection
1292 && ctx
1293 .delta_block("x")
1294 .map(|v| v.iter().fold(0.0_f64, |m, &x| m.max(x.abs())) < tiny)
1295 .unwrap_or(false)
1296 }
1297 "ls_rejected" => cp == Checkpoint::AfterStep && ctx.ls_count() > 1,
1298 "mu_stalled" => cp == Checkpoint::IterStart && self.mu_stall >= MU_STALL_ITERS,
1299 "nan" => !ctx.nlp_error().is_finite() || !ctx.objective().is_finite(),
1300 _ => false,
1301 }
1302 })
1303 }
1304
1305 fn update_mu_stall(&mut self, mu: f64) {
1307 if let Some(last) = self.last_mu {
1308 if (mu - last).abs() <= 1e-12 * last.abs().max(1.0) {
1309 self.mu_stall += 1;
1310 } else {
1311 self.mu_stall = 0;
1312 }
1313 }
1314 self.last_mu = Some(mu);
1315 }
1316
1317 fn matched_watchpoint(&mut self, ctx: &dyn DebugState) -> Option<String> {
1320 if self.detached {
1321 return None;
1322 }
1323 let mut hit = None;
1324 for wp in self.watchpoints.iter_mut() {
1325 let Some(full) = ctx.block(&wp.block) else {
1326 continue;
1327 };
1328 let cur: Vec<f64> = match wp.idx {
1329 Some(i) => match full.get(i) {
1330 Some(&v) => vec![v],
1331 None => continue,
1332 },
1333 None => full,
1334 };
1335 if let Some(prev) = &wp.last {
1336 if prev.len() == cur.len() {
1337 let changed = prev
1338 .iter()
1339 .zip(&cur)
1340 .any(|(p, c)| (p - c).abs() > wp.threshold);
1341 if changed && hit.is_none() {
1342 hit = Some(wp.raw.clone());
1343 }
1344 }
1345 }
1346 wp.last = Some(cur);
1347 }
1348 hit
1349 }
1350
1351 fn dispatch(&mut self, line: &str, ctx: &mut dyn DebugState) -> CmdOut {
1354 let owned = tokenize_quoted(line);
1358 let toks: Vec<&str> = owned.iter().map(String::as_str).collect();
1359 let Some(&verb) = toks.first() else {
1360 return CmdOut::ok(vec![]); };
1362 let rest = &toks[1..];
1363 match verb {
1364 "help" | "h" | "?" => self.cmd_help(),
1365 "info" | "i" => self.cmd_info(ctx),
1366 "print" | "p" => self.cmd_print(rest, ctx),
1367 "step" | "s" | "n" | "next" if rest.first() == Some(&"sub") => {
1370 self.sub_step = true;
1371 CmdOut::ok(vec![
1372 "stepping to the next checkpoint (sub-iteration)".into(),
1373 ])
1374 .flow(Flow::Resume)
1375 }
1376 "step" | "s" | "n" | "next" => {
1377 self.step = true;
1378 CmdOut::ok(vec!["stepping one iteration".into()]).flow(Flow::Resume)
1379 }
1380 "stepi" | "si" => {
1381 self.sub_step = true;
1382 CmdOut::ok(vec![
1383 "stepping to the next checkpoint (sub-iteration)".into(),
1384 ])
1385 .flow(Flow::Resume)
1386 }
1387 "continue" | "c" | "cont" => {
1388 self.step = false;
1389 self.sub_step = false;
1390 self.run_to = None;
1391 CmdOut::ok(vec!["continuing".into()]).flow(Flow::Resume)
1392 }
1393 "run" | "r" => self.cmd_run(rest),
1394 "break" | "b" => self.cmd_break(rest),
1395 "tbreak" | "tb" => match rest.first().and_then(|s| s.parse::<i32>().ok()) {
1396 Some(n) => {
1397 if !self.temp_breaks.contains(&n) {
1398 self.temp_breaks.push(n);
1399 }
1400 CmdOut::ok(vec![format!("temporary breakpoint at iteration {n}")])
1401 }
1402 None => CmdOut::err("usage: tbreak <iteration>"),
1403 },
1404 "watchpoint" | "wp" => self.cmd_watchpoint(rest, ctx),
1405 "commands" => self.cmd_commands(rest),
1406 "stop-at" | "stopat" => self.cmd_stop_at(rest),
1407 "progress" => match rest.first().copied() {
1408 Some("on") | None => {
1409 self.emit_progress = true;
1410 CmdOut::ok(vec!["progress events on".into()])
1411 }
1412 Some("off") => {
1413 self.emit_progress = false;
1414 CmdOut::ok(vec!["progress events off".into()])
1415 }
1416 _ => CmdOut::err("usage: progress [on|off]"),
1417 },
1418 "artifacts" => match rest.first().copied() {
1419 Some("inline") => {
1420 self.inline_artifacts = true;
1421 CmdOut::ok(vec!["viz / save artifacts returned inline".into()])
1422 }
1423 Some("file") => {
1424 self.inline_artifacts = false;
1425 CmdOut::ok(vec!["viz / save artifacts written to files".into()])
1426 }
1427 None => CmdOut::ok(vec![format!(
1428 "artifacts: {}",
1429 if self.inline_artifacts {
1430 "inline"
1431 } else {
1432 "file"
1433 }
1434 )])
1435 .with_data(serde_json::json!({"inline": self.inline_artifacts})),
1436 _ => CmdOut::err("usage: artifacts [inline|file]"),
1437 },
1438 "set" => self.cmd_set(rest, ctx),
1439 "get" => self.cmd_get(rest),
1440 "opt" | "options" => self.cmd_opt(rest),
1441 "complete" => self.cmd_complete(rest),
1442 "viz" | "plot" => self.cmd_viz(rest, ctx),
1443 "save" => self.cmd_save(rest, ctx),
1444 "load" => match as_nlp_mut(ctx) {
1445 Some(c) => self.cmd_load(rest, c),
1446 None => nlp_only("load"),
1447 },
1448 "sweep" => match as_nlp_mut(ctx) {
1449 Some(c) => self.cmd_sweep(rest, c),
1450 None => nlp_only("sweep"),
1451 },
1452 "multistart" => match as_nlp_mut(ctx) {
1453 Some(c) => self.cmd_multistart(rest, c),
1454 None => nlp_only("multistart"),
1455 },
1456 "goto" | "jump" => self.cmd_goto(rest, ctx),
1457 "restart" => match self.snapshots.keys().next().copied() {
1458 Some(k) => self.restore_to(k, ctx),
1459 None => CmdOut::err("no snapshots captured yet"),
1460 },
1461 "resolve" | "re-solve" => match as_nlp(ctx) {
1462 Some(c) => self.cmd_resolve(c),
1463 None => nlp_only("resolve"),
1464 },
1465 "ask" | "explain" | "claude" => self.cmd_ask(rest, ctx),
1466 "watch" | "display" => self.cmd_watch(rest),
1467 "diff" => self.cmd_diff(ctx),
1468 "diagnose" | "diag" => match as_nlp(ctx) {
1469 Some(c) => self.cmd_diagnose(c),
1470 None => nlp_only("diagnose"),
1471 },
1472 "source" => self.cmd_source(rest, ctx),
1473 "detach" => {
1474 self.detached = true;
1475 self.step = false;
1476 self.run_to = None;
1477 CmdOut::ok(vec!["detached — solving to completion".into()]).flow(Flow::Resume)
1478 }
1479 "pause" => CmdOut::ok(vec!["already paused".into()]),
1482 "coffee" | "brew" | "espresso" => self.cmd_coffee(),
1484 "quit" | "q" | "exit" => CmdOut::ok(vec!["stopping solve".into()]).flow(Flow::Stop),
1485 other => CmdOut::err(format!("unknown command `{other}` (try `help`)")),
1486 }
1487 }
1488
1489 fn cmd_coffee(&self) -> CmdOut {
1493 let color = matches!(self.mode, DebugMode::Repl)
1494 && std::io::stderr().is_terminal()
1495 && std::env::var_os("NO_COLOR").is_none();
1496 let paint = |r: u8, g: u8, b: u8, s: &str| -> String {
1497 if color {
1498 format!("\x1b[38;2;{r};{g};{b}m{s}\x1b[0m")
1499 } else {
1500 s.to_string()
1501 }
1502 };
1503 let cup = |s: &str| paint(0xEC, 0xEC, 0xEF, s);
1505 let dark = |s: &str| paint(0x5A, 0x32, 0x1E, s);
1506 let brew = |s: &str| paint(0x96, 0x5F, 0x37, s);
1507 let steam = |s: &str| paint(0xB4, 0xB9, 0xC3, s);
1508 let lines = vec![
1509 String::new(),
1510 format!(" {}", steam(") ) )")),
1511 format!(" {}", steam("( ( (")),
1512 format!(" {}", cup("._________.")),
1513 format!(" {}{}{}", cup("|"), dark("~~~~~~~~"), cup("|_")),
1514 format!(" {}{}{}", cup("| "), brew("COFFEE"), cup("| |")),
1515 format!(" {}{}{}", cup("| "), dark("~~~~~~"), cup("| |")),
1516 format!(" {}", cup("|________|_|")),
1517 format!(" {}", cup("\\________/")),
1518 format!(" {}", brew("a fresh cup for a stuck solve")),
1519 String::new(),
1520 ];
1521 CmdOut::ok(lines).with_data(serde_json::json!({"easter_egg": "coffee"}))
1522 }
1523
1524 fn cmd_help(&self) -> CmdOut {
1525 let lines = vec![
1526 "commands:".into(),
1527 " info | i summary of the current iterate".into(),
1528 " print | p <what> x|s|y_c|y_d|z_l|z_u|v_l|v_u | dx (step) |".into(),
1529 " mu|obj|inf_pr|inf_du|err|compl|iter | kkt | active | inactive".into(),
1530 " print residuals [pr|du] [k] top-k largest-magnitude residuals (default k=10)".into(),
1531 " print equation [name|row] source algebra of a constraint, by model name or row".into(),
1532 " print rank SVD rank of the equality Jacobian; names dependent equations".into(),
1533 " step | s | n run one iteration, pause again".into(),
1534 " stepi | si | step sub run to the next checkpoint (into sub-iteration phases)".into(),
1535 " progress [on|off] toggle per-iteration progress events (JSON mode)".into(),
1536 " stop-at <cp> always pause at a checkpoint: after_mu|after_search_dir|after_step".into(),
1537 " continue | c run to the next breakpoint".into(),
1538 " run | r <N> run until iteration N".into(),
1539 " break | b [N|clear|del N] set/list/clear breakpoints".into(),
1540 " break if <m><op><v> conditional bp; m in mu|inf_pr|inf_du|obj|err|iter,".into(),
1541 " op in < <= > >= == (e.g. break if inf_pr<1e-6)".into(),
1542 " break on <event> event bp: resto_entered|resto_exited|regularized|".into(),
1543 " tiny_step|ls_rejected|mu_stalled|nan".into(),
1544 " tbreak <N> one-shot breakpoint (deletes after firing)".into(),
1545 " watchpoint <blk>[<i>] [τ] pause when a value changes by > τ (alias wp)".into(),
1546 " commands <N> <c>;<c>… auto-run commands when iter N's breakpoint hits".into(),
1547 " set mu <v> overwrite the barrier parameter".into(),
1548 " set <blk>[<i>] <v> overwrite one component (e.g. set x[2] 1.5)".into(),
1549 " set <blk> <v0,v1,...> overwrite a whole block".into(),
1550 " set opt <name> <value> stage a solver option (validated)".into(),
1551 " get opt <name> show an option's effective value (staged or default)".into(),
1552 " opt [filter] list solver options (name/type/default)".into(),
1553 " complete <prefix> completion candidates (commands + options)".into(),
1554 " viz <x|s|dx|...|kkt|L> open the artifact in an external viewer".into(),
1555 " save [path] write the current iterate + residuals to JSON".into(),
1556 " artifacts [inline|file] return viz/save payloads in the result instead of files".into(),
1557 " load <file> [block] read a block (default x) from a save artifact / numeric file".into(),
1558 " sweep <file> one solve per start in <file>; tabulate outcomes".into(),
1559 " multistart <N> [rel] N restarts (uniform in each finite box; jitter else)".into(),
1560 " goto <k> | restart rewind to a captured iteration (primal-dual only)".into(),
1561 " resolve re-solve from the current x with staged `set opt`s".into(),
1562 " ask [question] ask an LLM about the state (default Claude Code; set".into(),
1563 " POUNCE_DBG_LLM=claude|codex|gemini|llm or a command template)".into(),
1564 " ask --prompt-only [q] return the prompt `ask` would send, without running an LLM".into(),
1565 " watch [target|clear|del] auto-print a `print` target at every pause".into(),
1566 " diff what changed in the iterate since the last iteration".into(),
1567 " diagnose | diag live health report: named culprit residuals, KKT inertia, stalls".into(),
1568 " source <file> run debugger commands from a file".into(),
1569 " detach stop pausing; solve to completion".into(),
1570 " quit | q stop the solve now".into(),
1571 ];
1572 CmdOut::ok(lines)
1573 }
1574
1575 fn cmd_info(&self, ctx: &dyn DebugState) -> CmdOut {
1576 let dims: Vec<_> = ctx.block_dims();
1577 let dims_json: serde_json::Map<String, serde_json::Value> = dims
1578 .iter()
1579 .map(|(n, d)| ((*n).to_string(), serde_json::json!(d)))
1580 .collect();
1581 let lines = vec![
1582 format!("iter = {}", ctx.iter()),
1583 format!("mu = {:.6e}", ctx.mu()),
1584 format!("objective = {:.8e}", ctx.objective()),
1585 format!("inf_pr = {:.6e}", ctx.inf_pr()),
1586 format!("inf_du = {:.6e}", ctx.inf_du()),
1587 format!("nlp_error = {:.6e}", ctx.nlp_error()),
1588 format!(
1589 "dims = {}",
1590 dims.iter()
1591 .map(|(n, d)| format!("{n}:{d}"))
1592 .collect::<Vec<_>>()
1593 .join(" ")
1594 ),
1595 ];
1596 let mut data = serde_json::json!({ "dims": dims_json });
1597 insert_metric_fields(&mut data, ctx);
1601 CmdOut::ok(lines).with_data(data)
1602 }
1603
1604 fn cmd_print(&self, rest: &[&str], ctx: &dyn DebugState) -> CmdOut {
1605 let Some(&what) = rest.first() else {
1606 return self.cmd_info(ctx);
1607 };
1608 if what == "kkt" {
1615 return match as_nlp(ctx) {
1616 Some(_) => self.cmd_print_kkt(ctx),
1617 None => nlp_only("print kkt"),
1618 };
1619 }
1620 if what == "active" || what == "inactive" {
1621 return match as_nlp(ctx) {
1622 Some(_) => self.cmd_print_bounds(ctx, what == "active"),
1623 None => nlp_only(&format!("print {what}")),
1624 };
1625 }
1626 if what == "residuals" || what == "resid" {
1627 return match as_nlp(ctx) {
1628 Some(_) => self.cmd_print_residuals(&rest[1..], ctx),
1629 None => nlp_only("print residuals"),
1630 };
1631 }
1632 if what == "equation" || what == "eqn" || what == "eq" {
1633 return self.cmd_print_equation(&rest[1..]);
1634 }
1635 if what == "rank" {
1636 return match as_nlp(ctx) {
1637 Some(c) => self.cmd_print_rank(c),
1638 None => nlp_only("print rank"),
1639 };
1640 }
1641 let delta = what.strip_prefix("d").filter(|b| is_block(ctx, b));
1643 if is_block(ctx, what) {
1644 match ctx.block(what) {
1645 Some(v) => CmdOut::ok(vec![fmt_vec(what, &v)])
1646 .with_data(serde_json::json!({"name": what, "values": v})),
1647 None => CmdOut::err(format!("no iterate yet for block `{what}`")),
1648 }
1649 } else if let Some(blk) = delta {
1650 match ctx.delta_block(blk) {
1651 Some(v) => CmdOut::ok(vec![fmt_vec(&format!("d{blk}"), &v)])
1652 .with_data(serde_json::json!({"name": format!("d{blk}"), "values": v})),
1653 None => CmdOut::err(format!("no search direction available for `d{blk}` yet")),
1654 }
1655 } else {
1656 let val = match what {
1657 "mu" => ctx.mu(),
1658 "obj" | "objective" => ctx.objective(),
1659 "inf_pr" => ctx.inf_pr(),
1660 "inf_du" => ctx.inf_du(),
1661 "err" | "nlp_error" => ctx.nlp_error(),
1662 "compl" | "complementarity" => ctx.complementarity(),
1663 "iter" => ctx.iter() as f64,
1664 _ => {
1665 return CmdOut::err(format!(
1666 "don't know how to print `{what}` (try a block name or mu|obj|inf_pr|inf_du|err|compl|iter)"
1667 ));
1668 }
1669 };
1670 CmdOut::ok(vec![format!("{what} = {val:.10e}")])
1671 .with_data(serde_json::json!({"name": what, "value": val}))
1672 }
1673 }
1674
1675 fn cmd_print_bounds(&self, ctx: &dyn DebugState, active: bool) -> CmdOut {
1681 let tol = 1e-6;
1682 let mut lines = Vec::new();
1683 let mut cats = serde_json::Map::new();
1684 for cat in ["x_l", "x_u", "s_l", "s_u"] {
1685 let Some(sl) = ctx.bound_slack(cat) else {
1686 continue;
1687 };
1688 if sl.is_empty() {
1689 continue;
1690 }
1691 let n = sl.len();
1692 if active {
1693 let min = sl.iter().copied().fold(f64::INFINITY, f64::min);
1694 let near = sl.iter().filter(|&&s| s.abs() < tol).count();
1695 lines.push(format!(
1696 "{cat}: {n} bound(s), {near} near-active (slack<{tol:.0e}), min slack {min:.3e}"
1697 ));
1698 cats.insert(
1699 cat.to_string(),
1700 serde_json::json!({"n": n, "near_active": near, "min_slack": min}),
1701 );
1702 } else {
1703 let max = sl.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1704 let far = sl.iter().filter(|&&s| s.abs() >= tol).count();
1705 lines.push(format!(
1706 "{cat}: {n} bound(s), {far} inactive (slack≥{tol:.0e}), max slack {max:.3e}"
1707 ));
1708 cats.insert(
1709 cat.to_string(),
1710 serde_json::json!({"n": n, "inactive": far, "max_slack": max}),
1711 );
1712 }
1713 }
1714 if lines.is_empty() {
1715 lines.push("no bounded variables or inequality slacks".into());
1716 }
1717 CmdOut::ok(lines).with_data(serde_json::json!({"tol": tol, "categories": cats}))
1718 }
1719
1720 fn cmd_print_residuals(&self, rest: &[&str], ctx: &dyn DebugState) -> CmdOut {
1727 let mut k: Option<usize> = None;
1728 let mut filter: Option<bool> = None; for &arg in rest {
1730 if let Ok(n) = arg.parse::<usize>() {
1731 k = Some(n);
1732 } else {
1733 match arg {
1734 "primal" | "pr" => filter = Some(true),
1735 "dual" | "du" => filter = Some(false),
1736 other => {
1737 return CmdOut::err(format!(
1738 "usage: print residuals [primal|dual] [k] (got `{other}`)"
1739 ));
1740 }
1741 }
1742 }
1743 }
1744 let k = k.unwrap_or(10);
1745
1746 let mut all = Vec::new();
1747 if filter != Some(false) {
1748 let Some(primal) = ctx.constraint_residuals() else {
1749 return CmdOut::err("no iterate yet — residuals unavailable");
1750 };
1751 all.extend(primal);
1752 }
1753 if filter != Some(true) {
1754 let Some(dual) = ctx.dual_residuals() else {
1755 return CmdOut::err("no iterate yet — residuals unavailable");
1756 };
1757 all.extend(dual);
1758 }
1759
1760 let total = all.len();
1761 let top = rank_residuals(all, k);
1762 if top.is_empty() {
1763 return CmdOut::ok(vec!["no residuals at this iterate".into()])
1764 .with_data(serde_json::json!({"k": k, "total": total, "top": []}));
1765 }
1766
1767 let names = ctx
1775 .as_any()
1776 .and_then(|a| a.downcast_ref::<DebugCtx>())
1777 .and_then(|c| c.split_names());
1778 let name_of = |r: &Residual| resid_name(r, &names);
1779
1780 let lines = top
1781 .iter()
1782 .map(|r| {
1783 let label = match name_of(r) {
1784 Some(name) => format!("{}[{}]", r.kind.tag(), name),
1785 None => format!("{}[{}]", r.kind.tag(), r.index),
1786 };
1787 format!("{:>8} = {:+.6e} |{:.3e}|", label, r.value, r.value.abs())
1788 })
1789 .collect();
1790 let data: Vec<_> = top
1791 .iter()
1792 .map(|r| {
1793 serde_json::json!({
1794 "space": r.kind.tag(),
1795 "primal": r.kind.is_primal(),
1796 "index": r.index,
1797 "name": name_of(r),
1798 "value": r.value,
1799 })
1800 })
1801 .collect();
1802 CmdOut::ok(lines).with_data(serde_json::json!({"k": k, "total": total, "top": data}))
1803 }
1804
1805 fn cmd_print_equation(&self, rest: &[&str]) -> CmdOut {
1814 let Some(book) = self.equation_book.as_ref() else {
1815 return CmdOut::err(
1816 "no equation source — `print equation` needs an .nl model (none was loaded)",
1817 );
1818 };
1819 if book.is_empty() {
1820 return CmdOut::err("the model has no constraint equations to print");
1821 }
1822 let Some(&key) = rest.first() else {
1823 return CmdOut::ok(vec![format!(
1824 "{} constraint equation(s) — `print equation <name|row>` to show one",
1825 book.len()
1826 )])
1827 .with_data(serde_json::json!({"count": book.len()}));
1828 };
1829 let Some(i) = book.resolve(key) else {
1830 return CmdOut::err(format!(
1831 "no constraint named or indexed `{key}` (have {} equation(s); try a name or 0..{})",
1832 book.len(),
1833 book.len().saturating_sub(1)
1834 ));
1835 };
1836 let label = book.label(i);
1837 let Some(eq) = book.equations.get(i) else {
1840 return CmdOut::err(format!(
1841 "constraint `{key}` has no source algebra (index {i} out of range)"
1842 ));
1843 };
1844 CmdOut::ok(vec![format!("{label}: {eq}")]).with_data(serde_json::json!({
1845 "index": i,
1846 "name": book.names.get(i).filter(|n| !n.is_empty()),
1847 "equation": eq,
1848 }))
1849 }
1850
1851 fn cmd_diagnose(&self, ctx: &DebugCtx) -> CmdOut {
1866 const TOL: f64 = 1e-6;
1867 let names = ctx.split_names();
1868 let mut f: Vec<(&'static str, &'static str, String)> = Vec::new();
1870
1871 let inf_pr = ctx.inf_pr();
1873 if inf_pr > TOL {
1874 if let Some(resids) = ctx.constraint_residuals() {
1875 if let Some((label, val)) = worst_named(resids, &names) {
1876 let sev = if inf_pr > 1e-2 { "error" } else { "warning" };
1877 f.push((
1878 sev,
1879 "primal_infeasible",
1880 format!(
1881 "Primal infeasibility {inf_pr:.2e}; worst constraint residual is \
1882 {label} = {val:+.3e}. Inspect this equation's feasibility and scaling \
1883 at the current point (`print equation {label}`)."
1884 ),
1885 ));
1886 }
1887 }
1888 }
1889
1890 let inf_du = ctx.inf_du();
1892 if inf_du > TOL {
1893 if let Some(resids) = ctx.dual_residuals() {
1894 if let Some((label, val)) = worst_named(resids, &names) {
1895 f.push((
1896 "warning",
1897 "dual_infeasible",
1898 format!(
1899 "Dual infeasibility {inf_du:.2e}; largest stationarity residual is \
1900 {label} = {val:+.3e}."
1901 ),
1902 ));
1903 }
1904 }
1905 }
1906
1907 if let Some(k) = ctx.kkt() {
1909 if k.provides_inertia && !k.inertia_correct {
1910 f.push((
1911 "warning",
1912 "inertia_wrong",
1913 format!(
1914 "KKT inertia is wrong (n-={} vs expected {}): the system was \
1915 indefinite/singular and the step had to be stabilized. A persistent \
1916 mismatch points at a rank-deficient Jacobian or an indefinite Hessian.",
1917 k.n_neg, k.expected_neg
1918 ),
1919 ));
1920 }
1921 if k.delta_w > 1e-4 {
1922 f.push((
1923 "info",
1924 "heavy_regularization",
1925 format!(
1926 "Primal regularization δ_w={:.2e} applied — the Hessian was indefinite at \
1927 this step. Normal near saddle points; persistent large δ_w suggests a \
1928 problematic Hessian.",
1929 k.delta_w
1930 ),
1931 ));
1932 }
1933 if k.delta_c > 0.0 {
1934 f.push((
1935 "warning",
1936 "dual_regularization",
1937 format!(
1938 "Dual regularization δ_c={:.2e} applied — the constraint Jacobian is (near) \
1939 rank-deficient (linearly dependent or redundant equalities). Inspect the \
1940 equality residuals by name (`print residuals primal`).",
1941 k.delta_c
1942 ),
1943 ));
1944 }
1945 }
1946
1947 if let Some(book) = self.structure_book.as_ref() {
1951 f.extend(book.findings());
1952 }
1953
1954 if let Some(rep) = ctx.rank_report() {
1960 if rep.is_rank_deficient() {
1961 let culprits: Vec<String> = rep
1962 .culprits
1963 .iter()
1964 .take(MAX_RANK_CULPRITS)
1965 .map(|c| rank_row_label(&rep.rows[c.row], &names))
1966 .collect();
1967 let named = if culprits.is_empty() {
1968 String::new()
1969 } else {
1970 format!(" Implicated equations: {}.", culprits.join(", "))
1971 };
1972 f.push((
1973 "warning",
1974 "rank_deficient_jacobian",
1975 format!(
1976 "Equality Jacobian J_c is numerically rank-deficient at this iterate: \
1977 rank {}/{} (deficiency {}), σ_min={:.2e}, cond={}. Linearly dependent \
1978 or redundant equality constraints — the root cause behind δ_c \
1979 regularization / wrong inertia.{named}",
1980 rep.rank,
1981 rep.n_rows(),
1982 rep.deficiency(),
1983 rep.sigma_min(),
1984 fmt_cond(rep.cond),
1985 ),
1986 ));
1987 }
1988 }
1989
1990 let mut max_mult = 0.0_f64;
1992 for blk in ["y_c", "y_d", "z_l", "z_u", "v_l", "v_u"] {
1993 if let Some(v) = ctx.block(blk) {
1994 max_mult = v.iter().fold(max_mult, |m, &x| m.max(x.abs()));
1995 }
1996 }
1997 if max_mult > 1e8 {
1998 f.push((
1999 "warning",
2000 "large_multipliers",
2001 format!(
2002 "Largest multiplier magnitude is {max_mult:.2e}. Very large multipliers signal a \
2003 constraint-qualification failure or poor scaling — consider rescaling the \
2004 offending rows."
2005 ),
2006 ));
2007 }
2008
2009 let mut pinned = 0usize;
2011 for cat in ["x_l", "x_u"] {
2012 if let Some(sl) = ctx.bound_slack(cat) {
2013 pinned += sl.iter().filter(|&&s| s.abs() < TOL).count();
2014 }
2015 }
2016 if pinned > 0 {
2017 f.push((
2018 "info",
2019 "bounds_pinned",
2020 format!(
2021 "{pinned} variable bound(s) are active (slack < {TOL:.0e}). Active bounds are \
2022 expected at a solution, but a large count early can throttle the line search."
2023 ),
2024 ));
2025 }
2026
2027 let (alpha_pr, _) = ctx.alpha();
2029 if ctx.iter() > 0 && alpha_pr > 0.0 && alpha_pr < 1e-6 {
2030 f.push((
2031 "warning",
2032 "tiny_step",
2033 format!(
2034 "Accepted primal step α_pr={alpha_pr:.2e} is tiny — the line search is barely \
2035 moving. Often a poor search direction or an ill-conditioned KKT system."
2036 ),
2037 ));
2038 }
2039 let ls = ctx.ls_count();
2040 if ls >= 10 {
2041 f.push((
2042 "warning",
2043 "heavy_line_search",
2044 format!(
2045 "Line search needed {ls} trial points for the accepted step — search-direction \
2046 quality may be poor (check Hessian accuracy)."
2047 ),
2048 ));
2049 }
2050
2051 if self.in_restoration {
2053 f.push((
2054 "warning",
2055 "in_restoration",
2056 "Currently inside feasibility restoration: the line search could not make \
2057 progress on the original problem at the working point."
2058 .to_string(),
2059 ));
2060 }
2061 if self.mu_stall >= MU_STALL_ITERS {
2062 f.push((
2063 "warning",
2064 "mu_stalled",
2065 format!(
2066 "μ has not decreased for {} consecutive iterations — the barrier is stuck. \
2067 Try mu_strategy=adaptive or a smaller mu_init.",
2068 self.mu_stall
2069 ),
2070 ));
2071 }
2072
2073 if f.is_empty() {
2075 f.push((
2076 "info",
2077 "healthy",
2078 format!(
2079 "No issues detected at iter {}: inf_pr={:.2e}, inf_du={:.2e}, μ={:.2e}.",
2080 ctx.iter(),
2081 inf_pr,
2082 inf_du,
2083 ctx.mu()
2084 ),
2085 ));
2086 }
2087
2088 let rank = |s: &str| match s {
2090 "error" => 0,
2091 "warning" => 1,
2092 _ => 2,
2093 };
2094 f.sort_by_key(|(sev, _, _)| rank(sev));
2095
2096 let lines: Vec<String> = f
2097 .iter()
2098 .map(|(sev, code, msg)| format!("[{sev:>7}] {code}: {msg}"))
2099 .collect();
2100 let data: Vec<_> = f
2101 .iter()
2102 .map(|(sev, code, msg)| serde_json::json!({"severity": sev, "code": code, "message": msg}))
2103 .collect();
2104 let n = data.len();
2105 CmdOut::ok(lines)
2106 .with_data(serde_json::json!({"iter": ctx.iter(), "findings": data, "n_findings": n}))
2107 }
2108
2109 fn cmd_print_kkt(&self, ctx: &dyn DebugState) -> CmdOut {
2112 let Some(k) = ctx.kkt() else {
2113 return CmdOut::err(
2114 "no KKT factorization yet — stop at `after_search_dir` (e.g. `stop-at kkt`)",
2115 );
2116 };
2117 let inertia = if k.provides_inertia {
2118 format!(
2119 "n+={} n-={} (expected n-={}) → {}",
2120 k.n_pos,
2121 k.n_neg,
2122 k.expected_neg,
2123 if k.inertia_correct {
2124 "correct"
2125 } else {
2126 "WRONG (step stabilized)"
2127 }
2128 )
2129 } else {
2130 "n/a (backend reports no inertia)".to_string()
2131 };
2132 let lines = vec![
2133 format!("dim = {}", k.dim),
2134 format!("inertia = {inertia}"),
2135 format!("delta_w = {:.6e} (primal regularization)", k.delta_w),
2136 format!("delta_c = {:.6e} (dual regularization)", k.delta_c),
2137 format!("status = {}", k.status),
2138 ];
2139 CmdOut::ok(lines).with_data(serde_json::json!({
2140 "dim": k.dim,
2141 "n_pos": k.n_pos,
2142 "n_neg": k.n_neg,
2143 "expected_neg": k.expected_neg,
2144 "provides_inertia": k.provides_inertia,
2145 "inertia_correct": k.inertia_correct,
2146 "delta_w": k.delta_w,
2147 "delta_c": k.delta_c,
2148 "status": k.status,
2149 }))
2150 }
2151
2152 fn cmd_print_rank(&self, ctx: &DebugCtx) -> CmdOut {
2161 let Some(rep) = ctx.rank_report() else {
2162 return CmdOut::err(
2163 "no equality-constraint Jacobian to analyze (the problem has no equality \
2164 constraints, or there is no iterate yet)",
2165 );
2166 };
2167 let names = ctx.split_names();
2168 let (lines, data) =
2169 render_rank_report(&rep, &names, self.equation_book.as_ref(), ctx.iter());
2170 CmdOut::ok(lines).with_data(data)
2171 }
2172
2173 fn cmd_run(&mut self, rest: &[&str]) -> CmdOut {
2174 match rest.first().and_then(|s| s.parse::<i32>().ok()) {
2175 Some(n) => {
2176 self.run_to = Some(n);
2177 self.step = false;
2178 CmdOut::ok(vec![format!("running until iteration {n}")]).flow(Flow::Resume)
2179 }
2180 None => CmdOut::err("usage: run <iteration>"),
2181 }
2182 }
2183
2184 fn cmd_break(&mut self, rest: &[&str]) -> CmdOut {
2185 if rest.first().copied() == Some("if") {
2189 let expr: String = rest[1..].concat();
2190 if expr.is_empty() {
2191 return CmdOut::err(
2192 "usage: break if <metric><op><value> (e.g. break if inf_pr<1e-6)",
2193 );
2194 }
2195 return match Condition::parse(&expr) {
2196 Ok(c) => {
2197 let raw = c.raw.clone();
2198 if !self.conds.iter().any(|e| e.raw == raw) {
2199 self.conds.push(c);
2200 }
2201 CmdOut::ok(vec![format!("conditional breakpoint: {raw}")])
2202 .with_data(serde_json::json!({"condition": raw}))
2203 }
2204 Err(e) => CmdOut::err(e),
2205 };
2206 }
2207 if rest.first().copied() == Some("on") {
2209 let Some(&name) = rest.get(1) else {
2210 return CmdOut::err(format!("usage: break on <event> (one of {EVENTS:?})"));
2211 };
2212 let Some(&canon) = EVENTS.iter().find(|&&e| e == name) else {
2213 return CmdOut::err(format!("unknown event `{name}` (one of {EVENTS:?})"));
2214 };
2215 self.break_events.insert(canon);
2216 return CmdOut::ok(vec![format!("break on event `{canon}`")])
2217 .with_data(serde_json::json!({"event": canon}));
2218 }
2219 match rest {
2220 [] => {
2221 let mut bs = self.breaks.clone();
2222 bs.sort_unstable();
2223 let conds: Vec<String> = self.conds.iter().map(|c| c.raw.clone()).collect();
2224 let mut events: Vec<&str> = self.break_events.iter().copied().collect();
2225 events.sort_unstable();
2226 let mut lines = vec![format!("breakpoints: {bs:?}")];
2227 if !conds.is_empty() {
2228 lines.push(format!("conditions: {}", conds.join(", ")));
2229 }
2230 if !events.is_empty() {
2231 lines.push(format!("events: {}", events.join(", ")));
2232 }
2233 CmdOut::ok(lines).with_data(
2234 serde_json::json!({"breakpoints": bs, "conditions": conds, "events": events}),
2235 )
2236 }
2237 ["clear", "cond"] | ["clear", "conditions"] => {
2238 self.conds.clear();
2239 CmdOut::ok(vec!["cleared conditional breakpoints".into()])
2240 }
2241 ["clear", "events"] => {
2242 self.break_events.clear();
2243 CmdOut::ok(vec!["cleared event breakpoints".into()])
2244 }
2245 ["clear"] => {
2246 self.breaks.clear();
2247 self.conds.clear();
2248 self.break_events.clear();
2249 CmdOut::ok(vec!["cleared all breakpoints".into()])
2250 }
2251 ["del", n] | ["delete", n] => match n.parse::<i32>() {
2252 Ok(n) => {
2253 self.breaks.retain(|&b| b != n);
2254 CmdOut::ok(vec![format!("removed breakpoint {n}")])
2255 }
2256 Err(_) => CmdOut::err("usage: break del <iteration>"),
2257 },
2258 [n] => match n.parse::<i32>() {
2259 Ok(n) => {
2260 if !self.breaks.contains(&n) {
2261 self.breaks.push(n);
2262 }
2263 CmdOut::ok(vec![format!("breakpoint at iteration {n}")])
2264 }
2265 Err(_) => CmdOut::err("usage: break <iteration>"),
2266 },
2267 _ => CmdOut::err("usage: break [N | if <m><op><v> | clear | clear cond | del N]"),
2268 }
2269 }
2270
2271 fn cmd_stop_at(&mut self, rest: &[&str]) -> CmdOut {
2275 let canon = |s: &str| -> Option<&'static str> {
2276 match s {
2277 "mu" | "after_mu" => Some("after_mu"),
2278 "kkt" | "search_dir" | "after_search_dir" => Some("after_search_dir"),
2279 "step" | "after_step" => Some("after_step"),
2280 "rejected" | "ls_rejected" | "step_rejected" => Some("step_rejected"),
2281 "resto" | "restoration" | "pre_restoration_entry" => Some("pre_restoration_entry"),
2282 "resto_exit" | "post_restoration_exit" => Some("post_restoration_exit"),
2283 "iter" | "iter_start" => Some("iter_start"),
2284 "terminated" => Some("terminated"),
2285 _ => None,
2286 }
2287 };
2288 match rest {
2289 [] => {
2290 let mut v: Vec<&str> = self.stop_at.iter().copied().collect();
2291 v.sort_unstable();
2292 CmdOut::ok(vec![format!(
2293 "stop-at: {v:?} (available: {CHECKPOINTS:?})"
2294 )])
2295 .with_data(serde_json::json!({"stop_at": v, "available": CHECKPOINTS}))
2296 }
2297 ["clear"] => {
2298 self.stop_at.clear();
2299 CmdOut::ok(vec!["cleared stop-at checkpoints".into()])
2300 }
2301 [name] => match canon(name) {
2302 Some(c) => {
2303 self.stop_at.insert(c);
2304 CmdOut::ok(vec![format!("will stop at checkpoint `{c}`")])
2305 .with_data(serde_json::json!({"stop_at_added": c}))
2306 }
2307 None => CmdOut::err(format!(
2308 "unknown checkpoint `{name}` (one of {CHECKPOINTS:?})"
2309 )),
2310 },
2311 _ => CmdOut::err("usage: stop-at [<checkpoint> | clear]"),
2312 }
2313 }
2314
2315 fn cmd_set(&mut self, rest: &[&str], ctx: &mut dyn DebugState) -> CmdOut {
2316 match rest {
2317 ["mu", v] => match v.parse::<f64>() {
2318 Ok(mu) => match ctx.set_mu(mu) {
2319 Ok(()) => CmdOut::ok(vec![format!("mu := {mu:.6e}")]),
2320 Err(e) => CmdOut::err(e),
2321 },
2322 Err(_) => CmdOut::err("usage: set mu <value>"),
2323 },
2324 ["opt", name, value] => match as_nlp_mut(ctx) {
2325 Some(c) => self.cmd_set_opt(name, value, c),
2326 None => nlp_only("set opt"),
2327 },
2328 [target, value] => self.cmd_set_block(target, value, ctx),
2329 _ => CmdOut::err(
2330 "usage: set mu <v> | set <blk>[<i>] <v> | set <blk> <v0,v1,..> | set opt <name> <v>",
2331 ),
2332 }
2333 }
2334
2335 fn cmd_set_block(&mut self, target: &str, value: &str, ctx: &mut dyn DebugState) -> CmdOut {
2337 if let Some(open) = target.find('[') {
2339 if !target.ends_with(']') {
2340 return CmdOut::err("malformed component target (expected name[idx])");
2341 }
2342 let name = &target[..open];
2343 let idx_str = &target[open + 1..target.len() - 1];
2344 let Ok(idx) = idx_str.parse::<usize>() else {
2345 return CmdOut::err(format!("bad index `{idx_str}`"));
2346 };
2347 let Ok(val) = value.parse::<f64>() else {
2348 return CmdOut::err(format!("bad value `{value}`"));
2349 };
2350 return match ctx.set_component(name, idx, val) {
2351 Ok(()) => CmdOut::ok(vec![format!("{name}[{idx}] := {val:.6e}")]),
2352 Err(e) => CmdOut::err(e),
2353 };
2354 }
2355 let parsed: Result<Vec<f64>, _> =
2357 value.split(',').map(|s| s.trim().parse::<f64>()).collect();
2358 match parsed {
2359 Ok(vals) => match ctx.set_block(target, &vals) {
2360 Ok(()) => CmdOut::ok(vec![format!("{target} := {} value(s)", vals.len())]),
2361 Err(e) => CmdOut::err(e),
2362 },
2363 Err(_) => CmdOut::err("could not parse comma-separated values"),
2364 }
2365 }
2366
2367 fn cmd_set_opt(&mut self, name: &str, value: &str, ctx: &mut DebugCtx) -> CmdOut {
2368 let Some(reg) = self.reg.as_ref() else {
2369 return CmdOut::err("no options registry available");
2370 };
2371 let Some(opt) = reg.get_option(name) else {
2372 return CmdOut::err(format!("unknown option `{name}` (try `opt {name}`)"));
2373 };
2374 let valid = match opt.option_type {
2376 OptionType::OT_Number => value
2377 .parse::<f64>()
2378 .map(|v| opt.is_valid_number(v))
2379 .unwrap_or(false),
2380 OptionType::OT_Integer => value
2381 .parse::<i32>()
2382 .map(|v| opt.is_valid_integer(v))
2383 .unwrap_or(false),
2384 OptionType::OT_String => opt.is_valid_string(value),
2385 OptionType::OT_Unknown => true,
2386 };
2387 if !valid {
2388 return CmdOut::err(format!("`{value}` is not a valid value for `{name}`"));
2389 }
2390 self.staged.retain(|(k, _)| k != name);
2393 self.staged.push((name.to_string(), value.to_string()));
2394 if is_live_tolerance(name) {
2399 if let Ok(v) = value.parse::<f64>() {
2400 ctx.set_live_tolerance(name, v);
2401 return CmdOut::ok(vec![format!(
2402 "{name} = {value} (applied live — the next `step` uses it)"
2403 )])
2404 .with_data(serde_json::json!({
2405 "option": name, "value": value, "live": true
2406 }));
2407 }
2408 }
2409 CmdOut::ok(vec![format!(
2410 "staged {name} = {value} (validated; takes effect on `resolve` — built strategies don't re-read mid-solve)"
2411 )])
2412 .with_data(serde_json::json!({"option": name, "value": value, "staged": true}))
2413 }
2414
2415 fn cmd_get(&self, rest: &[&str]) -> CmdOut {
2422 let name = match rest {
2424 ["opt", n] => *n,
2425 [n] => *n,
2426 _ => return CmdOut::err("usage: get opt <name>"),
2427 };
2428 let Some(reg) = self.reg.as_ref() else {
2429 return CmdOut::err("no options registry available");
2430 };
2431 let Some(o) = reg.get_option(name) else {
2432 return CmdOut::err(format!("unknown option `{name}` (try `opt {name}`)"));
2433 };
2434 let def = default_str(&o.default);
2435 let staged = self
2436 .staged
2437 .iter()
2438 .find(|(k, _)| k == name)
2439 .map(|(_, v)| v.clone());
2440 let (value, source) = match &staged {
2441 Some(v) => (v.clone(), "staged"),
2442 None => (def.clone(), "default"),
2443 };
2444 CmdOut::ok(vec![format!("{name} = {value} ({source}; default={def})")]).with_data(
2445 serde_json::json!({
2446 "option": name, "value": value, "source": source,
2447 "default": def, "staged": staged,
2448 }),
2449 )
2450 }
2451
2452 fn cmd_opt(&self, rest: &[&str]) -> CmdOut {
2453 let Some(reg) = self.reg.as_ref() else {
2454 return CmdOut::err("no options registry available");
2455 };
2456 let filter = rest.first().copied().unwrap_or("");
2457 let mut lines = Vec::new();
2458 let mut data = Vec::new();
2459 for o in reg.registered_options_in_order() {
2460 if !filter.is_empty()
2461 && !o.name.contains(filter)
2462 && !o
2463 .category
2464 .to_ascii_lowercase()
2465 .contains(&filter.to_ascii_lowercase())
2466 {
2467 continue;
2468 }
2469 let ty = type_str(o.option_type);
2470 let def = default_str(&o.default);
2471 lines.push(format!(
2472 " {:<28} {:<7} default={:<12} {}",
2473 o.name, ty, def, o.short_description
2474 ));
2475 data.push(serde_json::json!({
2476 "name": o.name,
2477 "type": ty,
2478 "default": def,
2479 "category": o.category,
2480 "short": o.short_description,
2481 "valid": o.valid_strings.iter().map(|e| e.value.clone()).collect::<Vec<_>>(),
2482 }));
2483 }
2484 if lines.is_empty() {
2485 return CmdOut::ok(vec![format!("no options match `{filter}`")]);
2486 }
2487 if data.len() == 1 {
2489 if let Some(o) = reg.get_option(filter) {
2490 if !o.long_description.is_empty() {
2491 lines.push(String::new());
2492 lines.push(o.long_description.clone());
2493 }
2494 }
2495 }
2496 CmdOut::ok(lines).with_data(serde_json::json!({"options": data}))
2497 }
2498
2499 fn cmd_complete(&self, rest: &[&str]) -> CmdOut {
2504 let (before, word) = match rest.split_last() {
2505 Some((w, pre)) => (pre.join(" "), *w),
2506 None => (String::new(), ""),
2507 };
2508 let mut cands = completion_candidates(self.reg.as_deref(), &before, word);
2509 cands.sort();
2510 cands.dedup();
2511 CmdOut::ok(vec![cands.join(" ")]).with_data(serde_json::json!({"candidates": cands}))
2512 }
2513
2514 fn cmd_save(&self, rest: &[&str], ctx: &dyn DebugState) -> CmdOut {
2518 let iter = ctx.iter();
2519 let collect = |delta: bool| -> serde_json::Map<String, serde_json::Value> {
2520 let mut m = serde_json::Map::new();
2521 for b in block_names(ctx) {
2522 let v = if delta {
2523 ctx.delta_block(b)
2524 } else {
2525 ctx.block(b)
2526 };
2527 if let Some(v) = v {
2528 if !v.is_empty() {
2529 let key = if delta {
2530 format!("d{b}")
2531 } else {
2532 b.to_string()
2533 };
2534 m.insert(key, serde_json::json!(v));
2535 }
2536 }
2537 }
2538 m
2539 };
2540 let payload = serde_json::json!({
2541 "iter": iter,
2542 "mu": ctx.mu(),
2543 "objective": ctx.objective(),
2544 "inf_pr": ctx.inf_pr(),
2545 "inf_du": ctx.inf_du(),
2546 "nlp_error": ctx.nlp_error(),
2547 "iterate": collect(false),
2548 "delta": collect(true),
2549 });
2550 if self.inline_artifacts && rest.is_empty() {
2553 return CmdOut::ok(vec![format!("iterate at iter {iter} (inline)")]).with_data(payload);
2554 }
2555 let path = rest
2556 .first()
2557 .map(PathBuf::from)
2558 .unwrap_or_else(|| std::env::temp_dir().join(format!("pounce-dbg-iter{iter}.json")));
2559 match std::fs::write(&path, format!("{payload}\n")) {
2560 Ok(()) => {
2561 let p = path.to_string_lossy().to_string();
2562 CmdOut::ok(vec![format!("saved iterate to {p}")])
2563 .with_data(serde_json::json!({"path": p}))
2564 }
2565 Err(e) => CmdOut::err(format!("save failed: {e}")),
2566 }
2567 }
2568
2569 fn cmd_load(&mut self, rest: &[&str], ctx: &mut DebugCtx) -> CmdOut {
2576 let Some(&path) = rest.first() else {
2577 return CmdOut::err("usage: load <file> [block] (inverse of `save`)");
2578 };
2579 let content = match std::fs::read_to_string(path) {
2580 Ok(c) => c,
2581 Err(e) => return CmdOut::err(format!("cannot read `{path}`: {e}")),
2582 };
2583 if let Ok(v) = serde_json::from_str::<serde_json::Value>(content.trim()) {
2587 let obj = v
2588 .get("iterate")
2589 .and_then(|o| o.as_object())
2590 .or_else(|| v.as_object());
2591 if let Some(obj) = obj {
2592 let mut loaded: Vec<(String, usize)> = Vec::new();
2593 let mut errs: Vec<String> = Vec::new();
2594 for &b in BLOCK_NAMES.iter() {
2595 let Some(arr) = obj.get(b).and_then(|a| a.as_array()) else {
2596 continue;
2597 };
2598 let vals: Option<Vec<f64>> = arr.iter().map(|x| x.as_f64()).collect();
2599 let Some(vals) = vals else {
2600 errs.push(format!("{b}: non-numeric entries"));
2601 continue;
2602 };
2603 match ctx.set_block(b, &vals) {
2604 Ok(()) => loaded.push((b.to_string(), vals.len())),
2605 Err(e) => errs.push(format!("{b}: {e}")),
2606 }
2607 }
2608 if loaded.is_empty() && errs.is_empty() {
2609 return CmdOut::err(
2610 "no recognizable blocks in JSON (expected `x`, `s`, … at top level or under `iterate`)",
2611 );
2612 }
2613 let mut lines: Vec<String> = loaded
2614 .iter()
2615 .map(|(b, n)| format!("loaded {b} ({n} values)"))
2616 .collect();
2617 lines.extend(errs.iter().map(|e| format!("skipped {e}")));
2618 return CmdOut::ok(lines).with_data(serde_json::json!({
2619 "loaded": loaded.iter().map(|(b, n)| serde_json::json!({"block": b, "n": n})).collect::<Vec<_>>(),
2620 "skipped": errs,
2621 }));
2622 }
2623 }
2624 let block = rest.get(1).copied().unwrap_or("x");
2626 let vals = match parse_floats(&content) {
2627 Ok(v) if !v.is_empty() => v,
2628 Ok(_) => return CmdOut::err("file held no numbers"),
2629 Err(e) => return CmdOut::err(e),
2630 };
2631 match ctx.set_block(block, &vals) {
2632 Ok(()) => CmdOut::ok(vec![format!("loaded {block} ({} values)", vals.len())])
2633 .with_data(serde_json::json!({"block": block, "n": vals.len()})),
2634 Err(e) => CmdOut::err(e),
2635 }
2636 }
2637
2638 fn cmd_sweep(&mut self, rest: &[&str], ctx: &mut DebugCtx) -> CmdOut {
2644 if self.restart.is_none() {
2645 return CmdOut::err("sweep needs re-solve, which is not available in this context");
2646 }
2647 let Some(&path) = rest.first() else {
2648 return CmdOut::err("usage: sweep <file> (one start per line, comma-separated)");
2649 };
2650 let content = match std::fs::read_to_string(path) {
2651 Ok(c) => c,
2652 Err(e) => return CmdOut::err(format!("cannot read `{path}`: {e}")),
2653 };
2654 let dim = ctx.block("x").map(|x| x.len()).unwrap_or(0);
2655 let mut seeds: Vec<Vec<f64>> = Vec::new();
2656 for (lineno, raw) in content.lines().enumerate() {
2657 let line = raw.trim();
2658 if line.is_empty() || line.starts_with('#') || line.starts_with("//") {
2659 continue;
2660 }
2661 match parse_floats(line) {
2662 Ok(v) if v.len() == dim => seeds.push(v),
2663 Ok(v) => {
2664 return CmdOut::err(format!(
2665 "line {}: got {} values, expected {dim} (= dim x)",
2666 lineno + 1,
2667 v.len()
2668 ));
2669 }
2670 Err(e) => return CmdOut::err(format!("line {}: {e}", lineno + 1)),
2671 }
2672 }
2673 self.start_sweep(seeds, &format!("sweep `{path}`"))
2674 }
2675
2676 fn cmd_multistart(&mut self, rest: &[&str], ctx: &mut DebugCtx) -> CmdOut {
2684 if self.restart.is_none() {
2685 return CmdOut::err(
2686 "multistart needs re-solve, which is not available in this context",
2687 );
2688 }
2689 let Some(n) = rest.first().and_then(|s| s.parse::<usize>().ok()) else {
2690 return CmdOut::err("usage: multistart <N> [rel] (N sampled restarts)");
2691 };
2692 if n == 0 {
2693 return CmdOut::err("N must be ≥ 1");
2694 }
2695 let rel = rest
2696 .get(1)
2697 .and_then(|s| s.parse::<f64>().ok())
2698 .unwrap_or(0.1);
2699 let Some(base) = ctx.block("x") else {
2700 return CmdOut::err("no current iterate to sample from");
2701 };
2702 let bounds = ctx
2704 .var_bounds()
2705 .filter(|(lo, hi)| lo.len() == base.len() && hi.len() == base.len());
2706 let n_box = bounds
2707 .as_ref()
2708 .map(|(lo, hi)| {
2709 lo.iter()
2710 .zip(hi)
2711 .filter(|(l, u)| l.is_finite() && u.is_finite() && u > l)
2712 .count()
2713 })
2714 .unwrap_or(0);
2715 let seeds: Vec<Vec<f64>> = (0..n)
2716 .map(|k| {
2717 let b = bounds
2718 .as_ref()
2719 .map(|(lo, hi)| (lo.as_slice(), hi.as_slice()));
2720 sample_start(&base, b, rel, k)
2721 })
2722 .collect();
2723 let n_var = base.len();
2724 let label = if n_box == n_var {
2725 format!("multistart {n} (box-sampled, {n_box}/{n_var} vars bounded)")
2726 } else if n_box > 0 {
2727 format!(
2728 "multistart {n} (box {n_box}/{n_var} vars; {} unbounded → jitter rel={rel})",
2729 n_var - n_box
2730 )
2731 } else {
2732 format!("multistart {n} (no finite boxes → jitter rel={rel})")
2733 };
2734 self.start_sweep(seeds, &label)
2735 }
2736
2737 fn start_sweep(&mut self, seeds: Vec<Vec<f64>>, label: &str) -> CmdOut {
2742 if seeds.is_empty() {
2743 return CmdOut::err("no start points");
2744 }
2745 let Some(cell) = self.restart.as_ref() else {
2746 return CmdOut::err("sweep needs re-solve, which is not available in this context");
2747 };
2748 let total = seeds.len();
2749 let mut queue: VecDeque<Vec<f64>> = seeds.into();
2750 let first = queue.pop_front().expect("non-empty");
2751 *cell.borrow_mut() = Some(RestartRequest {
2752 seed_x: first.clone(),
2753 options: self.staged.clone(),
2754 warm: None,
2755 });
2756 let saved_pause_iters = self.pause_iters;
2759 self.pause_iters = false;
2760 self.step = false;
2761 self.sub_step = false;
2762 self.run_to = None;
2763 self.sweep = Some(SweepState {
2764 queue,
2765 current: Some(first),
2766 records: Vec::new(),
2767 total,
2768 saved_pause_iters,
2769 });
2770 CmdOut::ok(vec![format!("{label}: running {total} start(s)…")])
2771 .with_data(serde_json::json!({"sweep": label, "starts": total}))
2772 .flow(Flow::Stop)
2773 }
2774
2775 fn drive_sweep(&mut self, ctx: &DebugCtx) -> Option<DebugAction> {
2782 let mut sweep = self.sweep.take()?;
2783 let rec = SweepRecord {
2784 idx: sweep.records.len(),
2785 seed: sweep.current.clone().unwrap_or_default(),
2786 status: ctx.status().unwrap_or("?").to_string(),
2787 objective: ctx.objective(),
2788 inf_pr: ctx.inf_pr(),
2789 iters: ctx.iter(),
2790 };
2791 self.emit_sweep_progress(&rec, sweep.total);
2792 sweep.records.push(rec);
2793 if let Some(next) = sweep.queue.pop_front() {
2794 sweep.current = Some(next.clone());
2795 if let Some(cell) = self.restart.as_ref() {
2796 *cell.borrow_mut() = Some(RestartRequest {
2797 seed_x: next,
2798 options: self.staged.clone(),
2799 warm: None,
2800 });
2801 }
2802 self.sweep = Some(sweep);
2803 return Some(DebugAction::Resume);
2804 }
2805 self.pause_iters = sweep.saved_pause_iters;
2807 self.emit_sweep_summary(&sweep);
2808 None
2809 }
2810
2811 fn emit_sweep_progress(&self, rec: &SweepRecord, total: usize) {
2814 match self.mode {
2815 DebugMode::Repl => eprintln!(
2816 " sweep {}/{}: {:<22} iters={:<4} obj={:.6e} inf_pr={:.2e}",
2817 rec.idx + 1,
2818 total,
2819 rec.status,
2820 rec.iters,
2821 rec.objective,
2822 rec.inf_pr,
2823 ),
2824 DebugMode::Json => emit_json(&serde_json::json!({
2825 "event": "sweep_result",
2826 "index": rec.idx,
2827 "total": total,
2828 "status": rec.status,
2829 "iters": rec.iters,
2830 "objective": rec.objective,
2831 "inf_pr": rec.inf_pr,
2832 "seed": rec.seed,
2833 })),
2834 }
2835 }
2836
2837 fn emit_sweep_summary(&self, sweep: &SweepState) {
2840 let succeeded: Vec<&SweepRecord> = sweep
2841 .records
2842 .iter()
2843 .filter(|r| is_success_status(&r.status))
2844 .collect();
2845 let mut distinct: Vec<f64> = Vec::new();
2847 for r in &succeeded {
2848 if !distinct
2849 .iter()
2850 .any(|&o| (o - r.objective).abs() <= 1e-6 * o.abs().max(1.0))
2851 {
2852 distinct.push(r.objective);
2853 }
2854 }
2855 let best = succeeded.iter().min_by(|a, b| {
2856 a.objective
2857 .partial_cmp(&b.objective)
2858 .unwrap_or(std::cmp::Ordering::Equal)
2859 });
2860 match self.mode {
2861 DebugMode::Repl => {
2862 eprintln!(
2863 "\n── sweep complete ── {} solves, {} succeeded, {} distinct end points (by objective; not curvature-classified)",
2864 sweep.records.len(),
2865 succeeded.len(),
2866 distinct.len()
2867 );
2868 eprintln!(
2869 " {:>3} {:<22} {:>5} {:>14} {:>9}",
2870 "#", "status", "iters", "objective", "inf_pr"
2871 );
2872 for r in &sweep.records {
2873 eprintln!(
2874 " {:>3} {:<22} {:>5} {:>14.6e} {:>9.2e}",
2875 r.idx, r.status, r.iters, r.objective, r.inf_pr
2876 );
2877 }
2878 if let Some(b) = best {
2879 eprintln!(" best: solve #{} obj={:.8e}", b.idx, b.objective);
2880 }
2881 }
2882 DebugMode::Json => emit_json(&serde_json::json!({
2883 "event": "sweep_summary",
2884 "solves": sweep.records.len(),
2885 "succeeded": succeeded.len(),
2886 "distinct_points": distinct.len(),
2890 "distinct_minima": distinct.len(),
2891 "best_index": best.map(|b| b.idx),
2892 "best_objective": best.map(|b| b.objective),
2893 "records": sweep.records.iter().map(|r| serde_json::json!({
2894 "index": r.idx, "status": r.status, "iters": r.iters,
2895 "objective": r.objective, "inf_pr": r.inf_pr,
2896 })).collect::<Vec<_>>(),
2897 })),
2898 }
2899 }
2900
2901 fn cmd_goto(&mut self, rest: &[&str], ctx: &mut dyn DebugState) -> CmdOut {
2903 match rest.first().and_then(|s| s.parse::<i32>().ok()) {
2904 Some(k) => self.restore_to(k, ctx),
2905 None => CmdOut::err("usage: goto <iteration>"),
2906 }
2907 }
2908
2909 fn restore_to(&mut self, k: i32, ctx: &mut dyn DebugState) -> CmdOut {
2913 match self.snapshots.get(&k) {
2914 Some(snap) => {
2915 if !ctx.restore(snap.as_ref()) {
2916 return CmdOut::err(format!(
2917 "this solver does not support rewinding to iter {k}"
2918 ));
2919 }
2920 CmdOut::ok(vec![format!(
2921 "rewound to iter {k} (primal-dual only; strategy history not restored). \
2922 `continue`/`step` to resume."
2923 )])
2924 .with_data(serde_json::json!({"restored_iter": k}))
2925 }
2926 None => {
2927 let have: Vec<i32> = self.snapshots.keys().copied().collect();
2928 CmdOut::err(format!("no snapshot for iter {k} (captured: {have:?})"))
2929 }
2930 }
2931 }
2932
2933 fn cmd_resolve(&mut self, ctx: &DebugCtx) -> CmdOut {
2941 let Some(cell) = self.restart.as_ref() else {
2942 return CmdOut::err("re-solve is not available in this context");
2943 };
2944 let Some(seed_x) = ctx.block("x") else {
2945 return CmdOut::err("no current iterate to seed from");
2946 };
2947 let warm = ctx.snapshot();
2948 let mu = warm.as_ref().map(|s| s.mu());
2949 let options = self.staged.clone();
2950 let n_opt = options.len();
2951 let warm_msg = match mu {
2952 Some(mu) => format!(
2953 "re-solving warm from the current primal-dual iterate (μ={mu:.3e}) \
2954 with {n_opt} staged option override(s)…"
2955 ),
2956 None => format!(
2957 "re-solving from current x (primal-only) with {n_opt} staged option override(s)…"
2958 ),
2959 };
2960 *cell.borrow_mut() = Some(RestartRequest {
2961 seed_x,
2962 options,
2963 warm,
2964 });
2965 CmdOut::ok(vec![warm_msg])
2966 .with_data(serde_json::json!({
2967 "resolve": true,
2968 "options": n_opt,
2969 "warm": mu.is_some(),
2970 "mu": mu,
2971 }))
2972 .flow(Flow::Stop)
2973 }
2974
2975 fn cmd_ask(&self, rest: &[&str], ctx: &dyn DebugState) -> CmdOut {
2981 let (prompt_only, rest) = match rest.split_first() {
2985 Some((&"--prompt-only", tail)) => (true, tail),
2986 _ => (false, rest),
2987 };
2988 let question = if rest.is_empty() {
2989 "Explain the current state of this interior-point solve and suggest what to try next."
2990 .to_string()
2991 } else {
2992 rest.join(" ")
2993 };
2994 let prompt = build_ask_prompt(ctx, &question);
2995 if prompt_only {
2996 let lines = prompt.lines().map(str::to_string).collect();
2997 return CmdOut::ok(lines).with_data(serde_json::json!({
2998 "question": question,
2999 "prompt": prompt,
3000 }));
3001 }
3002 match run_llm(&prompt) {
3003 Ok(reply) => {
3004 let lines: Vec<String> = reply.lines().map(|l| l.to_string()).collect();
3005 CmdOut::ok(lines).with_data(serde_json::json!({
3006 "question": question,
3007 "reply": reply,
3008 }))
3009 }
3010 Err(e) => CmdOut::err(e),
3011 }
3012 }
3013
3014 fn cmd_watch(&mut self, rest: &[&str]) -> CmdOut {
3017 match rest {
3018 [] => CmdOut::ok(vec![format!("watches: {:?}", self.watches)])
3019 .with_data(serde_json::json!({"watches": self.watches})),
3020 ["clear"] => {
3021 self.watches.clear();
3022 CmdOut::ok(vec!["cleared watches".into()])
3023 }
3024 ["del", w] | ["delete", w] => {
3025 self.watches.retain(|x| x != w);
3026 CmdOut::ok(vec![format!("unwatched {w}")])
3027 }
3028 [w] => {
3029 let w = w.to_string();
3030 if !self.watches.contains(&w) {
3031 self.watches.push(w.clone());
3032 }
3033 CmdOut::ok(vec![format!("watching {w}")])
3034 }
3035 _ => CmdOut::err("usage: watch [<target> | clear | del <target>]"),
3036 }
3037 }
3038
3039 fn cmd_watchpoint(&mut self, rest: &[&str], ctx: &dyn DebugState) -> CmdOut {
3043 match rest {
3044 [] => {
3045 let v: Vec<&str> = self.watchpoints.iter().map(|w| w.raw.as_str()).collect();
3046 CmdOut::ok(vec![format!("watchpoints: {v:?}")])
3047 .with_data(serde_json::json!({"watchpoints": v}))
3048 }
3049 ["clear"] => {
3050 self.watchpoints.clear();
3051 CmdOut::ok(vec!["cleared watchpoints".into()])
3052 }
3053 ["del", spec] | ["delete", spec] => {
3054 self.watchpoints.retain(|w| w.raw != *spec);
3055 CmdOut::ok(vec![format!("removed watchpoint {spec}")])
3056 }
3057 [spec, rest @ ..] => {
3058 let threshold = rest
3059 .first()
3060 .and_then(|s| s.parse::<f64>().ok())
3061 .unwrap_or(0.0);
3062 let (block, idx) = match spec.find('[') {
3064 Some(open) if spec.ends_with(']') => {
3065 let b = &spec[..open];
3066 match spec[open + 1..spec.len() - 1].parse::<usize>() {
3067 Ok(i) => (b.to_string(), Some(i)),
3068 Err(_) => return CmdOut::err(format!("bad index in `{spec}`")),
3069 }
3070 }
3071 _ => (spec.to_string(), None),
3072 };
3073 if !is_block(ctx, block.as_str()) {
3074 return CmdOut::err(format!("unknown block `{block}`"));
3075 }
3076 let raw = spec.to_string();
3077 if !self.watchpoints.iter().any(|w| w.raw == raw) {
3078 self.watchpoints.push(WatchPoint {
3079 raw: raw.clone(),
3080 block,
3081 idx,
3082 threshold,
3083 last: None,
3084 });
3085 }
3086 CmdOut::ok(vec![format!("watchpoint on {raw} (Δ>{threshold:.3e})")])
3087 }
3088 }
3089 }
3090
3091 fn cmd_commands(&mut self, rest: &[&str]) -> CmdOut {
3096 let Some(iter) = rest.first().and_then(|s| s.parse::<i32>().ok()) else {
3097 if rest.is_empty() {
3098 let mut items: Vec<(i32, Vec<String>)> = self
3099 .bp_commands
3100 .iter()
3101 .map(|(k, v)| (*k, v.clone()))
3102 .collect();
3103 items.sort_by_key(|(k, _)| *k);
3104 let lines = if items.is_empty() {
3105 vec!["no breakpoint command lists".into()]
3106 } else {
3107 items
3108 .iter()
3109 .map(|(k, v)| format!("iter {k}: {}", v.join(" ; ")))
3110 .collect()
3111 };
3112 return CmdOut::ok(lines);
3113 }
3114 return CmdOut::err(
3115 "usage: commands <iter> <cmd> ; <cmd> … (or: commands <iter> clear)",
3116 );
3117 };
3118 let tail = rest[1..].join(" ");
3119 let tail = tail.trim();
3120 if tail.is_empty() || tail == "clear" {
3121 self.bp_commands.remove(&iter);
3122 return CmdOut::ok(vec![format!("cleared commands for iteration {iter}")]);
3123 }
3124 let cmds: Vec<String> = tail
3125 .split(';')
3126 .map(|s| s.trim().to_string())
3127 .filter(|s| !s.is_empty())
3128 .collect();
3129 self.bp_commands.insert(iter, cmds.clone());
3130 CmdOut::ok(vec![format!(
3131 "commands for iter {iter}: {}",
3132 cmds.join(" ; ")
3133 )])
3134 .with_data(serde_json::json!({"iter": iter, "commands": cmds}))
3135 }
3136
3137 fn cmd_diff(&self, ctx: &dyn DebugState) -> CmdOut {
3140 let iter = ctx.iter();
3141 let Some((&piter, prev)) = self.snapshots.range(..iter).next_back() else {
3142 return CmdOut::err("no previous iterate to diff against");
3143 };
3144 let mut lines = vec![format!("Δ since iter {piter}:")];
3145 let dmu = ctx.mu() - prev.mu();
3146 lines.push(format!(" mu = {:.6e} (Δ {:+.3e})", ctx.mu(), dmu));
3147 let mut blocks = serde_json::Map::new();
3148 for b in block_names(ctx) {
3149 let (Some(cur), Some(old)) = (ctx.block(b), prev.block(b)) else {
3150 continue;
3151 };
3152 if cur.is_empty() || cur.len() != old.len() {
3153 continue;
3154 }
3155 let mut amax = 0.0_f64;
3156 let mut imax = 0usize;
3157 for (i, (c, o)) in cur.iter().zip(&old).enumerate() {
3158 let d = (c - o).abs();
3159 if d > amax {
3160 amax = d;
3161 imax = i;
3162 }
3163 }
3164 if amax > 0.0 {
3165 lines.push(format!(
3166 " {b}: max|Δ|={amax:.3e} at [{imax}] ({:.4e} → {:.4e})",
3167 old[imax], cur[imax]
3168 ));
3169 blocks.insert(
3170 b.to_string(),
3171 serde_json::json!({"max_abs_change": amax, "argmax": imax}),
3172 );
3173 }
3174 }
3175 if lines.len() == 2 {
3176 lines.push(" (no change)".into());
3177 }
3178 CmdOut::ok(lines).with_data(
3179 serde_json::json!({"from_iter": piter, "to_iter": iter, "dmu": dmu, "blocks": blocks}),
3180 )
3181 }
3182
3183 fn cmd_source(&mut self, rest: &[&str], ctx: &mut dyn DebugState) -> CmdOut {
3187 let Some(&path) = rest.first() else {
3188 return CmdOut::err("usage: source <file>");
3189 };
3190 let content = match std::fs::read_to_string(path) {
3191 Ok(c) => c,
3192 Err(e) => return CmdOut::err(format!("cannot read `{path}`: {e}")),
3193 };
3194 let mut lines = Vec::new();
3195 let mut flow = Flow::Stay;
3196 for raw in content.lines() {
3197 let cmd = raw.trim();
3198 if cmd.is_empty() || cmd.starts_with('#') || cmd.starts_with("//") {
3199 continue;
3200 }
3201 lines.push(format!("[source] {cmd}"));
3202 let out = self.dispatch(cmd, ctx);
3203 lines.extend(out.lines);
3204 if !matches!(out.flow, Flow::Stay) {
3205 flow = out.flow;
3206 break;
3207 }
3208 }
3209 CmdOut {
3210 ok: true,
3211 lines,
3212 data: None,
3213 flow,
3214 }
3215 }
3216
3217 fn cmd_viz(&self, rest: &[&str], ctx: &mut dyn DebugState) -> CmdOut {
3218 let Some(&target) = rest.first() else {
3219 return CmdOut::err("usage: viz <x|s|y_c|...|dx|kkt|L>");
3220 };
3221 if target == "kkt" {
3224 if as_nlp(ctx).is_none() {
3226 return nlp_only("viz kkt");
3227 }
3228 let Some(k) = ctx.kkt() else {
3229 return CmdOut::err(
3230 "no KKT factorization captured yet — nothing has been factored (iter 0), \
3231 or the debugger is detached. `step` once to capture.",
3232 );
3233 };
3234 let Some((dim, irn, jcn, vals)) = ctx.kkt_matrix() else {
3239 return CmdOut::err(
3240 "KKT matrix not captured here — the debugger is detached \
3241 (running free). `step` once to capture and re-run `viz kkt`.",
3242 );
3243 };
3244 let kiter = k.iter;
3247 let matrix = serde_json::json!({"dim": dim, "irn": irn, "jcn": jcn, "vals": vals,
3248 "format": "triplet_1based_lower"});
3249 let payload = serde_json::json!({
3250 "label": "kkt", "iter": kiter,
3251 "dim": k.dim, "n_pos": k.n_pos, "n_neg": k.n_neg,
3252 "expected_neg": k.expected_neg, "inertia_correct": k.inertia_correct,
3253 "delta_w": k.delta_w, "delta_c": k.delta_c, "status": k.status,
3254 "matrix": matrix,
3255 });
3256 if self.inline_artifacts {
3257 return CmdOut::ok(vec![format!(
3258 "KKT system, iter {kiter} (dim {dim}, inline)"
3259 )])
3260 .with_data(payload);
3261 }
3262 return match write_json_and_open("kkt", kiter, &payload) {
3263 Ok((path, viewer)) => CmdOut::ok(vec![format!(
3264 "wrote {path} (KKT system, iter {kiter}); opened with `{viewer}`"
3265 )])
3266 .with_data(serde_json::json!({"path": path, "viewer": viewer})),
3267 Err(e) => CmdOut::err(e),
3268 };
3269 }
3270 if target == "L" {
3275 if as_nlp(ctx).is_none() {
3276 return nlp_only("viz L");
3277 }
3278 match ctx.kkt_l_factor() {
3279 Some((n, perm, l_irn, l_jcn, l_vals)) => {
3280 let kiter = ctx.kkt_captured_iter().unwrap_or_else(|| ctx.iter());
3282 let payload = serde_json::json!({
3283 "label": "L", "iter": kiter, "n": n, "perm": perm,
3284 "l_irn": l_irn, "l_jcn": l_jcn, "l_vals": l_vals,
3285 "format": "strict_lower_1based_permuted",
3286 });
3287 if self.inline_artifacts {
3288 return CmdOut::ok(vec![format!("L factor, iter {kiter} (n {n}, inline)")])
3289 .with_data(payload);
3290 }
3291 return match write_json_and_open("L", kiter, &payload) {
3292 Ok((path, viewer)) => CmdOut::ok(vec![format!(
3293 "wrote {path} (L factor, iter {kiter}); opened with `{viewer}`"
3294 )])
3295 .with_data(serde_json::json!({"path": path, "viewer": viewer})),
3296 Err(e) => CmdOut::err(e),
3297 };
3298 }
3299 None => {
3300 return CmdOut::err(
3301 "L factor not captured here — nothing factored yet (iter 0), \
3302 or the debugger is detached. `step` once to capture.",
3303 );
3304 }
3305 }
3306 }
3307 let (label, vals) = if is_block(ctx, target) {
3309 match ctx.block(target) {
3310 Some(v) => (target.to_string(), v),
3311 None => return CmdOut::err(format!("no data for block `{target}`")),
3312 }
3313 } else if let Some(blk) = target.strip_prefix("d").filter(|b| is_block(ctx, b)) {
3314 match ctx.delta_block(blk) {
3315 Some(v) => (format!("d{blk}"), v),
3316 None => return CmdOut::err(format!("no search direction for `d{blk}`")),
3317 }
3318 } else {
3319 return CmdOut::err(format!("don't know how to visualize `{target}`"));
3320 };
3321 if self.inline_artifacts {
3322 return CmdOut::ok(vec![format!(
3323 "{label}, iter {} ({} values, inline)",
3324 ctx.iter(),
3325 vals.len()
3326 )])
3327 .with_data(serde_json::json!({"label": label, "iter": ctx.iter(), "values": vals}));
3328 }
3329 match write_and_open(&label, ctx.iter(), &vals) {
3330 Ok((path, viewer)) => CmdOut::ok(vec![format!(
3331 "wrote {} ({} values); opened with `{}`",
3332 path,
3333 vals.len(),
3334 viewer
3335 )])
3336 .with_data(serde_json::json!({"path": path, "viewer": viewer, "n": vals.len()})),
3337 Err(e) => CmdOut::err(e),
3338 }
3339 }
3340
3341 fn emit_pause(&self, ctx: &dyn DebugState, reason: Option<&str>) {
3345 let terminal = matches!(ctx.checkpoint(), Checkpoint::Terminated);
3346 match self.mode {
3347 DebugMode::Repl => {
3348 if terminal {
3349 let which = if self.in_restoration {
3352 "restoration inner solve"
3353 } else {
3354 "solve"
3355 };
3356 eprintln!(
3357 "\n── pounce-dbg ── TERMINATED {which} ({}) iter {} obj={:.6e} inf_pr={:.2e} inf_du={:.2e}",
3358 ctx.status().unwrap_or("?"),
3359 ctx.iter(),
3360 ctx.objective(),
3361 ctx.inf_pr(),
3362 ctx.inf_du(),
3363 );
3364 } else {
3365 let resto = if self.in_restoration {
3366 " [restoration]"
3367 } else {
3368 ""
3369 };
3370 eprintln!(
3371 "\n── pounce-dbg ── iter {} @{}{} mu={:.3e} obj={:.6e} inf_pr={:.2e} inf_du={:.2e}",
3372 ctx.iter(),
3373 ctx.checkpoint().as_str(),
3374 resto,
3375 ctx.mu(),
3376 ctx.objective(),
3377 ctx.inf_pr(),
3378 ctx.inf_du(),
3379 );
3380 }
3381 if let Some(r) = reason {
3382 eprintln!(" ↳ {r}");
3383 }
3384 for w in &self.watches {
3385 let out = self.cmd_print(&[w.as_str()], ctx);
3386 if out.ok {
3387 for l in &out.lines {
3388 eprintln!(" watch {l}");
3389 }
3390 } else {
3391 eprintln!(" watch {w}: (n/a)");
3395 }
3396 }
3397 }
3398 DebugMode::Json => {
3399 let watches: Vec<serde_json::Value> = self
3400 .watches
3401 .iter()
3402 .map(|w| {
3403 let out = self.cmd_print(&[w.as_str()], ctx);
3404 serde_json::json!({"expr": w, "ok": out.ok, "output": out.lines, "data": out.data})
3405 })
3406 .collect();
3407 let dims: serde_json::Map<String, serde_json::Value> = ctx
3408 .block_dims()
3409 .into_iter()
3410 .map(|(n, d)| (n.to_string(), serde_json::json!(d)))
3411 .collect();
3412 let conds: Vec<String> = self.conds.iter().map(|c| c.raw.clone()).collect();
3413 let mut ev = serde_json::json!({
3414 "event": "pause",
3415 "checkpoint": ctx.checkpoint().as_str(),
3416 "status": ctx.status(),
3417 "in_restoration": self.in_restoration,
3418 "phase": if self.in_restoration { "restoration" } else { "main" },
3423 "final": terminal && !self.in_restoration,
3424 "dims": dims,
3425 "breakpoints": self.breaks,
3426 "conditions": conds,
3427 "reason": reason,
3428 "watches": watches,
3429 });
3430 insert_metric_fields(&mut ev, ctx);
3433 emit_json(&ev);
3434 }
3435 }
3436 }
3437
3438 fn emit_progress_event(&self, ctx: &dyn DebugState) {
3443 let mut ev = serde_json::json!({ "event": "progress" });
3444 insert_metric_fields(&mut ev, ctx);
3446 emit_json(&ev);
3447 }
3448
3449 fn emit_result(&self, command: &str, out: &CmdOut, req_id: Option<&serde_json::Value>) {
3452 match self.mode {
3453 DebugMode::Repl => {
3454 let stderr = std::io::stderr();
3455 let mut h = stderr.lock();
3456 for l in &out.lines {
3457 let _ = writeln!(h, "{l}");
3458 }
3459 if !out.ok {
3460 let _ = writeln!(h, "(error)");
3461 }
3462 }
3463 DebugMode::Json => {
3464 let ev = serde_json::json!({
3465 "event": "result",
3466 "request_id": req_id,
3467 "command": command,
3468 "ok": out.ok,
3469 "output": out.lines,
3470 "data": out.data,
3471 });
3472 emit_json(&ev);
3473 }
3474 }
3475 }
3476
3477 fn emit_hello(&self, ctx: &dyn DebugState) {
3488 let nlp = as_nlp(ctx).is_some();
3489 let viz: &[&str] = if nlp {
3491 &["block", "delta", "kkt", "L"]
3492 } else {
3493 &["block", "delta"]
3494 };
3495 let ev = serde_json::json!({
3496 "event": "hello",
3497 "protocol": "pounce-dbg/1",
3498 "pounce_version": env!("CARGO_PKG_VERSION"),
3499 "capabilities": {
3500 "inspect": true,
3501 "mutate_iterate": true,
3502 "mutate_mu": nlp,
3505 "conditional_breakpoints": "compound",
3506 "request_ids": true,
3507 "viz": viz,
3508 "save": true,
3509 "load": nlp,
3510 "sweep": nlp && self.restart.is_some(),
3511 "kkt_inspect": nlp,
3512 "equations": self.equation_book.is_some(),
3515 "diagnose": nlp,
3517 "structural_diagnose": nlp && self.structure_book.is_some(),
3520 "llm_assist": true,
3521 "rewind": "primal_dual",
3522 "resolve": nlp && self.restart.is_some(),
3523 "terminal_checkpoint": true,
3524 "interruptible": self.interruptible,
3525 "progress_events": self.emit_progress,
3527 "inline_artifacts": self.inline_artifacts,
3528 "ask_prompt_only": true,
3529 "async_pause": "checkpoint",
3530 "pause_command": !cfg!(target_family = "wasm"),
3535 },
3536 "checkpoints": CHECKPOINTS,
3537 "events": EVENTS,
3538 "commands": COMMANDS,
3539 "blocks": block_names(ctx),
3540 "metrics": METRICS,
3541 });
3542 emit_json(&ev);
3543 }
3544
3545 #[cfg(target_family = "wasm")]
3547 fn ensure_editor(&mut self) {}
3548
3549 #[cfg(not(target_family = "wasm"))]
3553 fn ensure_editor(&mut self) {
3554 if !matches!(self.mode, DebugMode::Repl)
3555 || self.editor.is_some()
3556 || !std::io::stdin().is_terminal()
3557 {
3558 return;
3559 }
3560 let mut ed: Editor<DbgHelper, FileHistory> = match Editor::new() {
3561 Ok(e) => e,
3562 Err(_) => return,
3563 };
3564 ed.set_helper(Some(DbgHelper {
3565 reg: self.reg.clone(),
3566 }));
3567 let path = std::env::var_os("HOME")
3568 .or_else(|| std::env::var_os("USERPROFILE"))
3569 .map(|h| PathBuf::from(h).join(".pounce_dbg_history"));
3570 if let Some(p) = &path {
3571 let _ = ed.load_history(p);
3572 }
3573 self.hist_path = path;
3574 self.editor = Some(ed);
3575 }
3576
3577 #[cfg_attr(target_family = "wasm", allow(dead_code))]
3582 fn on_prompt_interrupt(&mut self) -> String {
3583 self.prompt_interrupts += 1;
3584 if self.prompt_interrupts >= 2 {
3585 self.prompt_interrupts = 0;
3586 eprintln!("(quitting — Ctrl-C)");
3587 "quit".to_string()
3588 } else {
3589 eprintln!("(Ctrl-C — press again, or `quit`/Ctrl-D, to stop the solve)");
3590 String::new()
3591 }
3592 }
3593
3594 fn next_command_line(&mut self) -> Option<String> {
3598 if let Some(q) = &self.script_queue {
3602 let cmd = q.borrow_mut().pop_front();
3603 if let Some(c) = &cmd {
3604 let _ = writeln!(std::io::stderr(), "pounce-dbg> {c}");
3605 }
3606 return cmd;
3607 }
3608 if let DebugMode::Repl = self.mode {
3609 #[cfg(not(target_family = "wasm"))]
3610 if let Some(ed) = self.editor.as_mut() {
3611 return match ed.readline("pounce-dbg> ") {
3612 Ok(l) => {
3613 self.prompt_interrupts = 0;
3614 let _ = ed.add_history_entry(l.as_str());
3615 if let Some(p) = &self.hist_path {
3616 let _ = ed.save_history(p);
3617 }
3618 Some(l)
3619 }
3620 Err(ReadlineError::Interrupted) => Some(self.on_prompt_interrupt()),
3625 Err(ReadlineError::Eof) => None,
3627 Err(_) => None,
3628 };
3629 }
3630 if self.script_given && !std::io::stdin().is_terminal() {
3635 return None;
3636 }
3637 let _ = write!(std::io::stderr(), "pounce-dbg> ");
3638 let _ = std::io::stderr().flush();
3639 return read_stdin_line();
3640 }
3641 self.pump.get_or_insert_with(StdinPump::start).next()
3644 }
3645}
3646
3647fn read_stdin_line() -> Option<String> {
3649 let mut line = String::new();
3650 match std::io::stdin().read_line(&mut line) {
3651 Ok(0) => None,
3652 Ok(_) => Some(line),
3653 Err(_) => None,
3654 }
3655}
3656
3657fn rank_residuals(mut entries: Vec<Residual>, k: usize) -> Vec<Residual> {
3665 entries.sort_by(|a, b| {
3666 b.value
3667 .abs()
3668 .partial_cmp(&a.value.abs())
3669 .unwrap_or(std::cmp::Ordering::Equal)
3670 });
3671 entries.truncate(k);
3672 entries
3673}
3674
3675fn render_rank_report(
3686 rep: &RankReport,
3687 names: &Option<SplitNames>,
3688 equations: Option<&EquationBook>,
3689 iter: i32,
3690) -> (Vec<String>, serde_json::Value) {
3691 let m = rep.n_rows();
3692 let n = rep.n_cols;
3693 let mut lines = vec![
3694 format!("equality Jacobian J_c: {m} row(s) × {n} column(s)"),
3695 format!(
3696 "numerical rank = {} / {} (deficiency {})",
3697 rep.rank,
3698 m,
3699 rep.deficiency()
3700 ),
3701 format!(
3702 "σ_max = {:.3e} σ_min = {:.3e} cond = {} (rank tol τ = {:.3e})",
3703 rep.sigma_max(),
3704 rep.sigma_min(),
3705 fmt_cond(rep.cond),
3706 rep.tol
3707 ),
3708 ];
3709
3710 let shown: Vec<String> = rep
3712 .singular_values
3713 .iter()
3714 .take(MAX_SINGULAR_VALUES_SHOWN)
3715 .map(|s| format!("{s:.3e}"))
3716 .collect();
3717 let tail = if rep.singular_values.len() > MAX_SINGULAR_VALUES_SHOWN {
3718 " …"
3719 } else {
3720 ""
3721 };
3722 lines.push(format!("singular values: [{}{tail}]", shown.join(", ")));
3723
3724 if rep.is_rank_deficient() {
3725 lines.push(format!(
3726 "rank-deficient: {} equation(s) lie in the near-null space \
3727 (linearly dependent / redundant) — the source of δ_c regularization:",
3728 rep.deficiency()
3729 ));
3730 let mut shown_any_eq = false;
3731 for c in rep.culprits.iter().take(MAX_RANK_CULPRITS) {
3732 let row = &rep.rows[c.row];
3733 let label = rank_row_label(row, names);
3734 lines.push(format!(" {label} (participation {:.2})", c.weight));
3735 if let Some(eq) = culprit_equation(row, names, equations) {
3739 lines.push(format!(" {eq}"));
3740 shown_any_eq = true;
3741 }
3742 }
3743 if rep.culprits.len() > MAX_RANK_CULPRITS {
3744 lines.push(format!(
3745 " … and {} more",
3746 rep.culprits.len() - MAX_RANK_CULPRITS
3747 ));
3748 }
3749 if !shown_any_eq {
3752 lines.push("inspect a row with `print equation <name>` to see its terms".to_string());
3753 }
3754 } else {
3755 lines.push("J_c has full row rank at this iterate.".to_string());
3756 }
3757
3758 let culprits_json: Vec<serde_json::Value> = rep
3759 .culprits
3760 .iter()
3761 .map(|c| {
3762 let row = &rep.rows[c.row];
3763 serde_json::json!({
3764 "row": c.row,
3765 "kind": row.kind.tag(),
3766 "index": row.index,
3767 "name": rank_row_name(row, names),
3768 "label": rank_row_label(row, names),
3769 "weight": c.weight,
3770 "equation": culprit_equation(row, names, equations),
3771 })
3772 })
3773 .collect();
3774
3775 let data = serde_json::json!({
3776 "iter": iter,
3777 "n_rows": m,
3778 "n_cols": n,
3779 "rank": rep.rank,
3780 "deficiency": rep.deficiency(),
3781 "rank_deficient": rep.is_rank_deficient(),
3782 "sigma_max": rep.sigma_max(),
3783 "sigma_min": rep.sigma_min(),
3784 "cond": cond_json(rep.cond),
3785 "tol": rep.tol,
3786 "singular_values": rep.singular_values,
3787 "culprits": culprits_json,
3788 });
3789
3790 (lines, data)
3791}
3792
3793fn culprit_equation(
3800 row: &RankRow,
3801 names: &Option<SplitNames>,
3802 equations: Option<&EquationBook>,
3803) -> Option<String> {
3804 let book = equations?;
3805 let name = rank_row_name(row, names)?;
3806 let i = book.resolve(&name)?;
3807 Some(book.equations.get(i)?.clone())
3808}
3809
3810fn rank_row_name(row: &RankRow, names: &Option<SplitNames>) -> Option<String> {
3815 let r = Residual {
3816 kind: row.kind,
3817 index: row.index,
3818 value: 0.0,
3819 };
3820 resid_name(&r, names).map(|s| s.to_string())
3821}
3822
3823fn rank_row_label(row: &RankRow, names: &Option<SplitNames>) -> String {
3826 match rank_row_name(row, names) {
3827 Some(name) => format!("{}[{}]", row.kind.tag(), name),
3828 None => format!("{}[{}]", row.kind.tag(), row.index),
3829 }
3830}
3831
3832fn fmt_cond(cond: f64) -> String {
3835 if cond.is_finite() {
3836 format!("{cond:.3e}")
3837 } else {
3838 "inf (σ_min = 0)".to_string()
3839 }
3840}
3841
3842fn cond_json(cond: f64) -> serde_json::Value {
3845 if cond.is_finite() {
3846 serde_json::json!(cond)
3847 } else {
3848 serde_json::Value::Null
3849 }
3850}
3851
3852fn resid_name<'a>(r: &Residual, names: &'a Option<SplitNames>) -> Option<&'a str> {
3853 let n = names.as_ref()?;
3854 let pool = match r.kind {
3855 ResidKind::Eq => &n.eq,
3856 ResidKind::Ineq | ResidKind::DualS => &n.ineq,
3857 ResidKind::DualX => &n.x_var,
3858 };
3859 pool.get(r.index).and_then(|o| o.as_deref())
3860}
3861
3862fn worst_named(resids: Vec<Residual>, names: &Option<SplitNames>) -> Option<(String, f64)> {
3866 let top = rank_residuals(resids, 1);
3867 let r = top.first()?;
3868 let label = match resid_name(r, names) {
3869 Some(name) => format!("{}[{}]", r.kind.tag(), name),
3870 None => format!("{}[{}]", r.kind.tag(), r.index),
3871 };
3872 Some((label, r.value))
3873}
3874
3875pub fn print_open_banner(mode: DebugMode) {
3879 if !matches!(mode, DebugMode::Repl) {
3880 return;
3881 }
3882 let color = std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none();
3883 let paint = |r: u8, g: u8, b: u8, bold: bool, s: &str| -> String {
3884 if color {
3885 let w = if bold { "1;" } else { "" };
3886 format!("\x1b[{w}38;2;{r};{g};{b}m{s}\x1b[0m")
3887 } else {
3888 s.to_string()
3889 }
3890 };
3891 let orange = |s: &str| paint(0xE8, 0x7A, 0x1E, true, s);
3893 let gold = |s: &str| paint(0xFF, 0xB0, 0x00, true, s);
3894 let dim = |s: &str| paint(0x7A, 0x7E, 0x88, false, s);
3895 let item = |key: &str, gloss: &str| format!("{} {}", orange(key), dim(gloss));
3897
3898 let err = std::io::stderr();
3899 let mut h = err.lock();
3900 let _ = writeln!(h);
3901 for row in crate::print::logo_rows(color) {
3904 let _ = writeln!(h, " {row}");
3905 }
3906 let _ = writeln!(h);
3907 let _ = writeln!(
3908 h,
3909 " {} {}",
3910 gold("interior-point debugger"),
3911 dim(&format!(
3912 "· pounce {} · pdb for the IPM",
3913 env!("CARGO_PKG_VERSION")
3914 ))
3915 );
3916 let _ = writeln!(h);
3917 let _ = writeln!(
3919 h,
3920 " {} {} {} {} {}",
3921 item("s", "step"),
3922 item("c", "continue"),
3923 item("b", "N break"),
3924 item("r", "N run"),
3925 item("q", "quit"),
3926 );
3927 let _ = writeln!(
3928 h,
3929 " {} {} {} {} {}",
3930 item("p", "x print"),
3931 item("i", "info"),
3932 item("set", "x[i] v"),
3933 item("watch", "x"),
3934 item("viz", "kkt"),
3935 );
3936 let _ = writeln!(
3937 h,
3938 " {} {} {}",
3939 dim("type"),
3940 gold("help"),
3941 dim("for all commands · `ask` to consult Claude · Ctrl-C breaks in"),
3942 );
3943 let _ = writeln!(h);
3944}
3945
3946#[cfg_attr(target_family = "wasm", allow(dead_code))]
3949fn is_pause_command(line: &str) -> bool {
3950 parse_command(line, DebugMode::Json).command.trim() == "pause"
3951}
3952
3953#[cfg(not(target_family = "wasm"))]
3958struct StdinPump {
3959 inner: std::sync::Arc<(
3960 std::sync::Mutex<VecDeque<Option<String>>>,
3961 std::sync::Condvar,
3962 )>,
3963}
3964
3965#[cfg(not(target_family = "wasm"))]
3966impl StdinPump {
3967 fn start() -> Self {
3968 let inner = std::sync::Arc::new((
3969 std::sync::Mutex::new(VecDeque::new()),
3970 std::sync::Condvar::new(),
3971 ));
3972 let w = std::sync::Arc::clone(&inner);
3973 std::thread::spawn(move || {
3974 use std::io::BufRead;
3975 let stdin = std::io::stdin();
3976 let mut lock = stdin.lock();
3977 let (m, cv) = &*w;
3978 loop {
3979 let mut line = String::new();
3980 let item = match lock.read_line(&mut line) {
3981 Ok(0) | Err(_) => None, Ok(_) => Some(line),
3983 };
3984 let done = item.is_none();
3985 m.lock()
3986 .unwrap_or_else(std::sync::PoisonError::into_inner)
3987 .push_back(item);
3988 cv.notify_one();
3989 if done {
3990 break;
3991 }
3992 }
3993 });
3994 Self { inner }
3995 }
3996
3997 fn next(&self) -> Option<String> {
3999 let (m, cv) = &*self.inner;
4000 let mut q = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
4001 loop {
4002 match q.front() {
4003 None => {
4004 q = cv
4005 .wait(q)
4006 .unwrap_or_else(std::sync::PoisonError::into_inner)
4007 }
4008 Some(None) => return None, Some(Some(_)) => return q.pop_front().flatten(),
4010 }
4011 }
4012 }
4013
4014 fn try_take_pause(&self) -> bool {
4017 let (m, _) = &*self.inner;
4018 let mut q = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
4019 if let Some(Some(front)) = q.front() {
4020 if is_pause_command(front) {
4021 q.pop_front();
4022 return true;
4023 }
4024 }
4025 false
4026 }
4027}
4028
4029#[cfg(target_family = "wasm")]
4035struct StdinPump {
4036 eof: std::cell::Cell<bool>,
4037}
4038
4039#[cfg(target_family = "wasm")]
4040impl StdinPump {
4041 fn start() -> Self {
4042 Self {
4043 eof: std::cell::Cell::new(false),
4044 }
4045 }
4046
4047 fn next(&self) -> Option<String> {
4049 if self.eof.get() {
4050 return None;
4051 }
4052 let line = read_stdin_line();
4053 if line.is_none() {
4054 self.eof.set(true);
4055 }
4056 line
4057 }
4058
4059 fn try_take_pause(&self) -> bool {
4061 false
4062 }
4063}
4064
4065impl DebugHook for SolverDebugger {
4066 fn wants_kkt_capture(&self) -> bool {
4070 !self.detached
4071 }
4072
4073 fn arm(&mut self) {
4077 self.step = true;
4078 self.detached = false;
4079 self.pause_iters = true;
4080 self.pause_terminal = true;
4081 }
4082
4083 fn at_checkpoint(&mut self, ctx: &mut dyn DebugState) -> DebugAction {
4084 if matches!(self.mode, DebugMode::Json) && !self.hello_sent {
4087 self.emit_hello(&*ctx);
4088 self.hello_sent = true;
4089 }
4090 if let Checkpoint::Terminated = ctx.checkpoint() {
4094 if self.sweep.is_some() && !self.in_restoration {
4098 if let Some(c) = as_nlp(ctx) {
4101 if let Some(action) = self.drive_sweep(c) {
4102 return action;
4103 }
4104 }
4105 }
4106 let failed = ctx.status().map(|s| !is_success_status(s)).unwrap_or(false);
4107 let should =
4108 self.pause_terminal && !self.detached && (!self.terminal_only_on_error || failed);
4109 if !should {
4110 return DebugAction::Resume;
4111 }
4112 self.ensure_editor();
4113 self.emit_pause(ctx, None);
4114 return self.prompt_loop(ctx);
4115 }
4116
4117 let cp = ctx.checkpoint();
4118 match cp {
4120 Checkpoint::PreRestoration => self.in_restoration = true,
4121 Checkpoint::PostRestoration => self.in_restoration = false,
4122 _ => {}
4123 }
4124 let is_iter_start = matches!(cp, Checkpoint::IterStart);
4125
4126 if is_iter_start {
4130 if let Some(snap) = ctx.snapshot() {
4131 self.snapshots.insert(ctx.iter(), snap);
4132 while self.snapshots.len() > SNAPSHOT_CAP {
4133 let Some(&oldest) = self.snapshots.keys().next() else {
4134 break;
4135 };
4136 self.snapshots.remove(&oldest);
4137 }
4138 }
4139 self.update_mu_stall(ctx.mu());
4141 }
4142
4143 let mut reason: Option<String> = None;
4148 let mut pause = !self.detached && (self.sub_step || self.stop_at.contains(cp.as_str()));
4149
4150 if let Some(ev) = self.matched_event(ctx) {
4154 pause = true;
4155 reason = Some(format!("event: {ev}"));
4156 }
4157
4158 if is_iter_start {
4159 if self.interruptible && interrupt::take() {
4160 pause = true;
4161 reason = Some("interrupt (Ctrl-C)".into());
4162 }
4163 if let Some(p) = self.pump.as_ref() {
4166 if p.try_take_pause() {
4167 pause = true;
4168 reason = Some("pause (requested)".into());
4169 }
4170 }
4171 if self.pause_iters {
4172 if self.should_pause(ctx.iter()) {
4173 pause = true;
4174 }
4175 if let Some(c) = self.matched_condition(ctx) {
4176 pause = true;
4177 reason = Some(c);
4178 }
4179 }
4180 if let Some(w) = self.matched_watchpoint(ctx) {
4183 pause = true;
4184 reason = Some(format!("watchpoint: {w}"));
4185 }
4186 }
4187
4188 if !pause {
4189 if is_iter_start && self.emit_progress && matches!(self.mode, DebugMode::Json) {
4193 self.emit_progress_event(ctx);
4194 }
4195 return DebugAction::Resume;
4196 }
4197 self.step = false;
4199 self.sub_step = false;
4200 self.emit_pause(ctx, reason.as_deref());
4201
4202 if is_iter_start {
4206 if let Some(cmds) = self.bp_commands.get(&ctx.iter()).cloned() {
4207 for c in cmds {
4208 let out = self.dispatch(&c, ctx);
4209 self.emit_result(&c, &out, None);
4210 match out.flow {
4211 Flow::Resume => return DebugAction::Resume,
4212 Flow::Stop => return DebugAction::Stop,
4213 Flow::Stay => {}
4214 }
4215 }
4216 }
4217 }
4218
4219 self.ensure_editor();
4220 self.prompt_loop(ctx)
4221 }
4222}
4223
4224impl SolverDebugger {
4225 fn prompt_loop(&mut self, ctx: &mut dyn DebugState) -> DebugAction {
4227 if let Some(path) = self.pending_script.take() {
4230 let out = self.cmd_source(&[path.as_str()], ctx);
4231 self.emit_result("source", &out, None);
4232 match out.flow {
4233 Flow::Resume => return DebugAction::Resume,
4234 Flow::Stop => return DebugAction::Stop,
4235 Flow::Stay => {}
4236 }
4237 }
4238 loop {
4239 let line = match self.next_command_line() {
4240 Some(l) => l,
4241 None => {
4242 return match self.mode {
4247 DebugMode::Repl => {
4248 self.detached = true;
4249 DebugAction::Resume
4250 }
4251 DebugMode::Json => DebugAction::Stop,
4252 };
4253 }
4254 };
4255 let parsed = parse_command(&line, self.mode);
4256 let cmd = parsed.command.trim().to_string();
4257 if cmd.is_empty() {
4258 continue;
4259 }
4260 let out = self.dispatch(&cmd, ctx);
4261 self.emit_result(&cmd, &out, parsed.id.as_ref());
4262 match out.flow {
4263 Flow::Stay => continue,
4264 Flow::Resume => return DebugAction::Resume,
4265 Flow::Stop => return DebugAction::Stop,
4266 }
4267 }
4268 }
4269}
4270
4271struct ParsedCmd {
4275 command: String,
4276 id: Option<serde_json::Value>,
4277}
4278
4279fn tokenize_quoted(line: &str) -> Vec<String> {
4285 let mut out = Vec::new();
4286 let mut cur = String::new();
4287 let mut in_quote = false;
4288 let mut has_tok = false;
4289 for c in line.chars() {
4290 match c {
4291 '"' => {
4292 in_quote = !in_quote;
4293 has_tok = true; }
4295 c if c.is_whitespace() && !in_quote => {
4296 if has_tok {
4297 out.push(std::mem::take(&mut cur));
4298 has_tok = false;
4299 }
4300 }
4301 c => {
4302 cur.push(c);
4303 has_tok = true;
4304 }
4305 }
4306 }
4307 if has_tok {
4308 out.push(cur);
4309 }
4310 out
4311}
4312
4313fn parse_command(line: &str, mode: DebugMode) -> ParsedCmd {
4317 let trimmed = line.trim();
4318 if let DebugMode::Json = mode {
4319 if trimmed.starts_with('{') {
4320 if let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed) {
4321 let cmd = v.get("cmd").and_then(|c| c.as_str()).unwrap_or("");
4322 let mut s = cmd.to_string();
4323 if let Some(args) = v.get("args").and_then(|a| a.as_array()) {
4324 for a in args {
4325 s.push(' ');
4326 let tok = a
4327 .as_str()
4328 .map(str::to_string)
4329 .unwrap_or_else(|| a.to_string());
4330 if tok.contains(char::is_whitespace) {
4333 s.push('"');
4334 s.push_str(&tok);
4335 s.push('"');
4336 } else {
4337 s.push_str(&tok);
4338 }
4339 }
4340 }
4341 return ParsedCmd {
4342 command: s,
4343 id: v.get("id").cloned(),
4344 };
4345 }
4346 }
4347 }
4348 ParsedCmd {
4349 command: trimmed.to_string(),
4350 id: None,
4351 }
4352}
4353
4354fn emit_json(v: &serde_json::Value) {
4355 let stdout = std::io::stdout();
4356 let mut h = stdout.lock();
4357 let _ = writeln!(h, "{v}");
4358 let _ = h.flush();
4359}
4360
4361fn as_nlp<'a>(ctx: &'a dyn DebugState) -> Option<&'a DebugCtx> {
4366 ctx.as_any().and_then(|a| a.downcast_ref::<DebugCtx>())
4367}
4368
4369fn as_nlp_mut<'a>(ctx: &'a mut dyn DebugState) -> Option<&'a mut DebugCtx> {
4371 ctx.as_any_mut().and_then(|a| a.downcast_mut::<DebugCtx>())
4372}
4373
4374fn nlp_only(cmd: &str) -> CmdOut {
4376 CmdOut::err(format!(
4377 "`{cmd}` is only available for the NLP solver (not the convex/conic solver)"
4378 ))
4379}
4380
4381fn block_names(ctx: &dyn DebugState) -> Vec<&'static str> {
4386 ctx.block_dims().into_iter().map(|(n, _)| n).collect()
4387}
4388
4389fn is_block(ctx: &dyn DebugState, name: &str) -> bool {
4391 block_names(ctx).iter().any(|n| *n == name)
4392}
4393
4394fn fmt_vec(name: &str, v: &[f64]) -> String {
4395 const MAX: usize = 12;
4396 if v.len() <= MAX {
4397 format!(
4398 "{name} = [{}]",
4399 v.iter()
4400 .map(|x| format!("{x:.6e}"))
4401 .collect::<Vec<_>>()
4402 .join(", ")
4403 )
4404 } else {
4405 let head = v[..MAX]
4406 .iter()
4407 .map(|x| format!("{x:.6e}"))
4408 .collect::<Vec<_>>()
4409 .join(", ");
4410 format!("{name} = [{head}, … ({} total)]", v.len())
4411 }
4412}
4413
4414fn type_str(t: OptionType) -> &'static str {
4415 match t {
4416 OptionType::OT_Number => "Number",
4417 OptionType::OT_Integer => "Integer",
4418 OptionType::OT_String => "String",
4419 OptionType::OT_Unknown => "Unknown",
4420 }
4421}
4422
4423fn default_str(d: &DefaultValue) -> String {
4424 match d {
4425 DefaultValue::None => "-".into(),
4426 DefaultValue::Number(v) => format!("{v}"),
4427 DefaultValue::Integer(v) => format!("{v}"),
4428 DefaultValue::String(s) => s.clone(),
4429 }
4430}
4431
4432fn write_and_open(label: &str, iter: i32, vals: &[f64]) -> Result<(String, String), String> {
4437 let payload = serde_json::json!({"label": label, "iter": iter, "values": vals});
4438 write_json_and_open(label, iter, &payload)
4439}
4440
4441fn build_ask_prompt(ctx: &dyn DebugState, question: &str) -> String {
4444 use std::fmt::Write as _;
4445 let mut p = String::new();
4446 p.push_str(
4447 "You are helping debug a paused run of POUNCE, a pure-Rust interior-point \
4448 optimization solver whose NLP core is ported from Ipopt. The solve is \
4449 stopped at a debugger checkpoint. \
4450 Use the state below to answer concisely and suggest concrete next steps \
4451 (options to try, what to inspect). State:\n\n",
4452 );
4453 let _ = writeln!(p, "checkpoint = {}", ctx.checkpoint().as_str());
4454 if let Some(s) = ctx.status() {
4455 let _ = writeln!(p, "status = {s}");
4456 }
4457 let _ = writeln!(p, "iter = {}", ctx.iter());
4458 let _ = writeln!(p, "mu = {:.6e}", ctx.mu());
4459 let _ = writeln!(p, "objective = {:.8e}", ctx.objective());
4460 let _ = writeln!(p, "inf_pr = {:.6e}", ctx.inf_pr());
4461 let _ = writeln!(p, "inf_du = {:.6e}", ctx.inf_du());
4462 let _ = writeln!(p, "nlp_error = {:.6e}", ctx.nlp_error());
4463 let (ap, ad) = ctx.alpha();
4464 let _ = writeln!(p, "alpha_pr = {ap:.4e}, alpha_du = {ad:.4e}");
4465 let _ = writeln!(p, "ls_trials = {}", ctx.ls_count());
4466 let dims: Vec<String> = ctx
4467 .block_dims()
4468 .into_iter()
4469 .map(|(n, d)| format!("{n}:{d}"))
4470 .collect();
4471 let _ = writeln!(p, "dims = {}", dims.join(" "));
4472 if let Some(k) = ctx.kkt() {
4473 let _ = writeln!(
4474 p,
4475 "kkt = dim {} inertia n+={} n-={} (expected n-={}, {}) delta_w={:.3e} delta_c={:.3e} status={}",
4476 k.dim,
4477 k.n_pos,
4478 k.n_neg,
4479 k.expected_neg,
4480 if k.inertia_correct {
4481 "correct"
4482 } else {
4483 "WRONG"
4484 },
4485 k.delta_w,
4486 k.delta_c,
4487 k.status
4488 );
4489 }
4490 let _ = write!(p, "\nQuestion: {question}\n");
4491 p
4492}
4493
4494const LLM_PROVIDERS: &[&str] = &["claude", "codex", "gemini", "llm"];
4500
4501fn llm_preset(name: &str, prompt: &str) -> Option<(String, Vec<String>, bool)> {
4502 match name {
4503 "claude" => Some(("claude".to_string(), vec!["-p".to_string()], true)),
4505 "codex" => Some((
4507 "codex".to_string(),
4508 vec!["exec".to_string(), prompt.to_string()],
4509 false,
4510 )),
4511 "gemini" => Some((
4513 "gemini".to_string(),
4514 vec!["-p".to_string(), prompt.to_string()],
4515 false,
4516 )),
4517 "llm" => Some(("llm".to_string(), vec![prompt.to_string()], false)),
4519 _ => None,
4520 }
4521}
4522
4523fn llm_command(prompt: &str) -> (String, Vec<String>, bool) {
4529 let raw = std::env::var("POUNCE_DBG_LLM").unwrap_or_default();
4530 let tmpl = raw.trim();
4531 if tmpl.is_empty() {
4532 return llm_preset("claude", prompt).expect("claude is a known provider");
4534 }
4535 if !tmpl.contains(char::is_whitespace) {
4538 if let Some(preset) = llm_preset(tmpl, prompt) {
4539 return preset;
4540 }
4541 }
4542 let mut parts = tmpl
4544 .split_whitespace()
4545 .map(str::to_string)
4546 .collect::<Vec<_>>();
4547 let prog = parts.remove(0);
4548 let mut substituted = false;
4549 for a in parts.iter_mut() {
4550 if a.contains("{}") {
4551 *a = a.replace("{}", prompt);
4552 substituted = true;
4553 }
4554 }
4555 (prog, parts, !substituted)
4556}
4557
4558fn run_llm(prompt: &str) -> Result<String, String> {
4561 use std::io::Write as _;
4562 use std::process::{Command, Stdio};
4563 let (prog, args, on_stdin) = llm_command(prompt);
4564 let mut cmd = Command::new(&prog);
4565 cmd.args(&args)
4566 .stdout(Stdio::piped())
4567 .stderr(Stdio::piped());
4568 cmd.stdin(if on_stdin {
4569 Stdio::piped()
4570 } else {
4571 Stdio::null()
4572 });
4573 let mut child = cmd.spawn().map_err(|e| {
4574 if e.kind() == std::io::ErrorKind::NotFound {
4575 format!(
4579 "LLM CLI `{prog}` is not installed or not on PATH. Install it, \
4580 or set POUNCE_DBG_LLM to another provider \
4581 ({}) or a full command template (e.g. `my-llm --ask {{}}`).",
4582 LLM_PROVIDERS.join(" | ")
4583 )
4584 } else {
4585 format!("could not launch `{prog}`: {e}")
4586 }
4587 })?;
4588 if on_stdin {
4589 if let Some(mut si) = child.stdin.take() {
4591 let _ = si.write_all(prompt.as_bytes());
4592 }
4593 }
4594 let out = child
4595 .wait_with_output()
4596 .map_err(|e| format!("`{prog}` failed: {e}"))?;
4597 if !out.status.success() {
4598 let err = String::from_utf8_lossy(&out.stderr);
4599 return Err(format!(
4600 "`{prog}` exited with {}: {}",
4601 out.status,
4602 err.trim()
4603 ));
4604 }
4605 let reply = String::from_utf8_lossy(&out.stdout).trim().to_string();
4606 if reply.is_empty() {
4607 Err(format!("`{prog}` returned no output"))
4608 } else {
4609 Ok(reply)
4610 }
4611}
4612
4613fn write_json_and_open(
4616 label: &str,
4617 iter: i32,
4618 payload: &serde_json::Value,
4619) -> Result<(String, String), String> {
4620 let dir = std::env::temp_dir();
4621 let path = dir.join(format!("pounce-dbg-{label}-iter{iter}.json"));
4622 std::fs::write(&path, payload.to_string()).map_err(|e| format!("write failed: {e}"))?;
4623 let path_s = path.to_string_lossy().to_string();
4624
4625 let mut candidates: Vec<(String, Vec<String>, String)> = Vec::new();
4635 match std::env::var("POUNCE_DBG_VIEWER") {
4636 Ok(tmpl) if !tmpl.trim().is_empty() => {
4637 let mut parts = tmpl
4638 .split_whitespace()
4639 .map(String::from)
4640 .collect::<Vec<_>>();
4641 let prog = parts.remove(0);
4642 let mut replaced = false;
4643 for a in parts.iter_mut() {
4644 if a.contains("{}") {
4645 *a = a.replace("{}", &path_s);
4646 replaced = true;
4647 }
4648 }
4649 if !replaced {
4650 parts.push(path_s.clone());
4651 }
4652 candidates.push((prog, parts, path_s.clone()));
4653 }
4654 _ => {
4655 candidates.push((
4656 "pounce-dbg-viz".to_string(),
4657 vec![path_s.clone()],
4658 path_s.clone(),
4659 ));
4660 let opener = if cfg!(target_os = "macos") {
4661 "open"
4662 } else {
4663 "xdg-open"
4664 };
4665 let artifact = write_html_viz(label, iter, payload).unwrap_or_else(|_| path_s.clone());
4668 candidates.push((opener.to_string(), vec![artifact.clone()], artifact));
4669 }
4670 }
4671
4672 let mut last_err = String::new();
4673 for (program, args, artifact) in &candidates {
4674 match std::process::Command::new(program).args(args).spawn() {
4675 Ok(_) => return Ok((artifact.clone(), format!("{program} {}", args.join(" ")))),
4676 Err(e) => last_err = format!("`{program}`: {e}"),
4677 }
4678 }
4679 Err(format!(
4680 "wrote {path_s} but could not launch a viewer ({last_err}). \
4681 Install the interactive viewer (`pip install 'pounce-solver[viz]'`) \
4682 or set POUNCE_DBG_VIEWER, e.g. `python my_plot.py {{}}`."
4683 ))
4684}
4685
4686fn write_html_viz(label: &str, iter: i32, payload: &serde_json::Value) -> Result<String, String> {
4693 let dir = std::env::temp_dir();
4694 let path = dir.join(format!("pounce-dbg-{label}-iter{iter}.html"));
4695 let html = VIZ_HTML_TEMPLATE.replace("__PAYLOAD__", &payload.to_string());
4696 std::fs::write(&path, html).map_err(|e| format!("write failed: {e}"))?;
4697 Ok(path.to_string_lossy().to_string())
4698}
4699
4700const VIZ_HTML_TEMPLATE: &str = r##"<!doctype html>
4705<html lang="en"><head><meta charset="utf-8">
4706<title>pounce-dbg viz</title>
4707<style>
4708 html,body{margin:0;background:#0e1116;color:#d6dae0;
4709 font:13px/1.5 -apple-system,BlinkMacSystemFont,"SF Mono",Menlo,monospace}
4710 .wrap{padding:18px 20px;max-width:880px;margin:0 auto}
4711 h1{font-size:15px;margin:0 0 4px;font-weight:600}
4712 .sub{color:#7d8694;margin:0 0 12px}
4713 .stats{color:#9aa4b2;white-space:pre-wrap;margin:0 0 14px;
4714 background:#161b22;border:1px solid #21262d;border-radius:6px;padding:10px 12px}
4715 canvas{background:#161b22;border:1px solid #30363d;border-radius:6px;
4716 max-width:100%;height:auto;image-rendering:pixelated}
4717 .legend{margin-top:10px;color:#9aa4b2}
4718 .pos{color:#4ea1ff}.neg{color:#ff6b6b}.bad{color:#ff6b6b;font-weight:600}
4719 .ok{color:#56d364;font-weight:600}
4720</style></head><body><div class="wrap">
4721<h1 id="title">pounce-dbg</h1>
4722<div class="sub" id="sub"></div>
4723<div class="stats" id="stats"></div>
4724<canvas id="c" width="820" height="820"></canvas>
4725<div class="legend" id="legend"></div>
4726</div>
4727<script>
4728const D = __PAYLOAD__;
4729const cv = document.getElementById('c');
4730const ctx = cv.getContext('2d');
4731const $ = id => document.getElementById(id);
4732const fmt = x => (x===null||x===undefined) ? '—'
4733 : (Math.abs(x) >= 1e4 || (x!==0 && Math.abs(x) < 1e-3) ? x.toExponential(3) : (+x).toPrecision(6));
4734
4735function clearCanvas(){ ctx.fillStyle='#161b22'; ctx.fillRect(0,0,cv.width,cv.height); }
4736
4737function spy(irn, jcn, vals, dim, symmetric, title){
4738 $('sub').textContent = title;
4739 clearCanvas();
4740 const W=cv.width, H=cv.height, pad=42;
4741 const span=Math.max(1, dim);
4742 const cell=(Math.min(W,H)-2*pad)/span;
4743 const px=Math.max(0.7, cell);
4744 // frame + light grid ticks
4745 ctx.strokeStyle='#30363d'; ctx.lineWidth=1;
4746 ctx.strokeRect(pad-0.5, pad-0.5, span*cell+1, span*cell+1);
4747 ctx.fillStyle='#6e7681'; ctx.font='11px monospace';
4748 ctx.fillText('0', pad-12, pad+9);
4749 ctx.fillText(String(dim), pad+span*cell-8, pad-8);
4750 ctx.fillText('row', pad-34, pad+span*cell/2);
4751 ctx.fillText('col', pad+span*cell/2-8, pad-22);
4752 let nnz=0;
4753 for(let k=0;k<irn.length;k++){
4754 const i=irn[k]-1, j=jcn[k]-1, v=vals?vals[k]:1;
4755 ctx.fillStyle = v>=0 ? 'rgba(78,161,255,0.92)' : 'rgba(255,107,107,0.92)';
4756 ctx.fillRect(pad+j*cell, pad+i*cell, px, px); nnz++;
4757 if(symmetric && i!==j){ ctx.fillRect(pad+i*cell, pad+j*cell, px, px); nnz++; }
4758 }
4759 $('legend').innerHTML =
4760 `<span class="pos">■</span> positive <span class="neg">■</span> negative`
4761 + ` · ${dim}×${dim}, ${nnz} plotted nonzeros`
4762 + (symmetric ? ' (lower triangle mirrored)' : '');
4763}
4764
4765function bars(values, title){
4766 $('sub').textContent = title;
4767 clearCanvas();
4768 const W=cv.width, H=cv.height, pad=42;
4769 const n=values.length;
4770 const maxAbs=Math.max(1e-300, ...values.map(v=>Math.abs(v)));
4771 const x0=pad, y0=H-pad, plotW=W-2*pad, plotH=H-2*pad, mid=pad+plotH/2;
4772 const bw=Math.max(0.7, plotW/Math.max(1,n));
4773 // zero axis
4774 ctx.strokeStyle='#30363d'; ctx.beginPath();
4775 ctx.moveTo(pad, mid); ctx.lineTo(W-pad, mid); ctx.stroke();
4776 ctx.fillStyle='#6e7681'; ctx.font='11px monospace';
4777 ctx.fillText('+'+fmt(maxAbs), 4, pad+10);
4778 ctx.fillText('-'+fmt(maxAbs), 4, H-pad-2);
4779 ctx.fillText('0', 4, mid+4);
4780 for(let k=0;k<n;k++){
4781 const v=values[k], h=(Math.abs(v)/maxAbs)*(plotH/2);
4782 ctx.fillStyle = v>=0 ? 'rgba(78,161,255,0.92)' : 'rgba(255,107,107,0.92)';
4783 if(v>=0) ctx.fillRect(pad+k*bw, mid-h, bw, h);
4784 else ctx.fillRect(pad+k*bw, mid, bw, h);
4785 }
4786 $('legend').innerHTML = `${n} components · max |val| = ${fmt(maxAbs)}`;
4787}
4788
4789const lbl = D.label || 'viz';
4790const iter = (D.iter!==undefined) ? D.iter : '?';
4791$('title').textContent = `pounce-dbg · viz ${lbl} · iter ${iter}`;
4792
4793if(D.matrix && D.matrix.irn){
4794 const m=D.matrix;
4795 const inertia = (D.inertia_correct===false)
4796 ? `<span class="bad">WRONG</span>` : `<span class="ok">correct</span>`;
4797 $('stats').innerHTML =
4798 `KKT augmented system dim=${D.dim}\n`+
4799 `inertia n+=${D.n_pos} n-=${D.n_neg} (expected n-=${D.expected_neg}, ${inertia})\n`+
4800 `regularization delta_w=${fmt(D.delta_w)} delta_c=${fmt(D.delta_c)}\n`+
4801 `factorization status: ${D.status}`;
4802 spy(m.irn, m.jcn, m.vals, m.dim, true, 'sparsity pattern (sign-colored)');
4803} else if(D.l_irn){
4804 $('stats').textContent =
4805 `LDLᵀ factor n=${D.n} nnz(L)=${D.l_irn.length} format=${D.format||''}`;
4806 spy(D.l_irn, D.l_jcn, D.l_vals, D.n, false, 'L factor sparsity (permuted, strict lower)');
4807} else if(D.values){
4808 $('stats').textContent = `vector ${lbl} length=${D.values.length}`;
4809 bars(D.values, 'component magnitudes (zero-centered)');
4810} else {
4811 $('stats').textContent = 'unrecognized payload — raw JSON:\n'+JSON.stringify(D,null,2);
4812}
4813</script></body></html>
4814"##;
4815
4816#[cfg(test)]
4817mod tests {
4818 use super::*;
4819
4820 fn dbg(mode: DebugMode) -> SolverDebugger {
4821 SolverDebugger::new(mode, None)
4822 }
4823
4824 #[test]
4825 fn json_command_object_is_flattened() {
4826 assert_eq!(
4827 parse_command("{\"cmd\":\"print x\"}", DebugMode::Json).command,
4828 "print x"
4829 );
4830 let p = parse_command(
4831 "{\"cmd\":\"set\",\"args\":[\"x[0]\",\"1.5\"],\"id\":7}",
4832 DebugMode::Json,
4833 );
4834 assert_eq!(p.command, "set x[0] 1.5");
4835 assert_eq!(p.id, Some(serde_json::json!(7)));
4837 let s = parse_command("step\n", DebugMode::Json);
4839 assert_eq!(s.command, "step");
4840 assert!(s.id.is_none());
4841 assert_eq!(
4842 parse_command(" print x \n", DebugMode::Repl).command,
4843 "print x"
4844 );
4845 }
4846
4847 #[test]
4848 fn pauses_at_first_checkpoint_then_only_when_rearmed() {
4849 let mut d = dbg(DebugMode::Repl);
4850 assert!(d.should_pause(0));
4852 d.step = false;
4854 assert!(!d.should_pause(1));
4855 assert!(!d.should_pause(2));
4856 }
4857
4858 #[test]
4859 fn breakpoints_and_run_to_arm_pauses() {
4860 let mut d = dbg(DebugMode::Repl);
4861 d.step = false;
4862 d.breaks = vec![3, 7];
4863 assert!(!d.should_pause(2));
4864 assert!(d.should_pause(3));
4865 assert!(d.should_pause(7));
4866 d.run_to = Some(5);
4868 assert!(!d.should_pause(4));
4869 assert!(d.should_pause(5));
4870 assert_eq!(d.run_to, None);
4871 assert!(!d.should_pause(6));
4872 }
4873
4874 #[test]
4875 fn atom_parses_metric_op_threshold() {
4876 let a = Atom::parse("mu<1e-4").unwrap();
4877 assert_eq!(a.metric, Metric::Mu);
4878 assert_eq!(a.op, CmpOp::Lt);
4879 assert_eq!(a.rhs, 1e-4);
4880
4881 let a = Atom::parse("inf_pr<=1e-6").unwrap();
4883 assert_eq!(a.metric, Metric::InfPr);
4884 assert_eq!(a.op, CmpOp::Le);
4885
4886 let a = Atom::parse("iter==10").unwrap();
4887 assert_eq!(a.metric, Metric::Iter);
4888 assert_eq!(a.op, CmpOp::Eq);
4889 assert_eq!(a.rhs, 10.0);
4890 }
4891
4892 struct MinimalState;
4898 impl DebugState for MinimalState {
4899 fn checkpoint(&self) -> Checkpoint {
4900 Checkpoint::IterStart
4901 }
4902 fn iter(&self) -> i32 {
4903 7
4904 }
4905 fn mu(&self) -> f64 {
4906 1e-3
4907 }
4908 fn objective(&self) -> f64 {
4909 42.0
4910 }
4911 fn inf_pr(&self) -> f64 {
4912 1e-4
4913 }
4914 fn inf_du(&self) -> f64 {
4915 2e-4
4916 }
4917 fn complementarity(&self) -> f64 {
4918 5e-4
4919 }
4920 fn alpha(&self) -> (f64, f64) {
4921 (1.0, 1.0)
4922 }
4923 fn block_dims(&self) -> Vec<(&'static str, usize)> {
4924 vec![]
4925 }
4926 fn block(&self, _name: &str) -> Option<Vec<f64>> {
4927 None
4928 }
4929 fn delta_block(&self, _name: &str) -> Option<Vec<f64>> {
4930 None
4931 }
4932 }
4933
4934 #[test]
4940 fn metric_fields_match_advertised_vocabulary() {
4941 let fields = metric_fields(&MinimalState);
4942
4943 let names: Vec<&str> = fields.iter().map(|(n, _)| *n).collect();
4945 assert_eq!(names, METRICS);
4946
4947 for &name in METRICS {
4950 assert!(
4951 name == "iter" || Metric::parse(name).is_some(),
4952 "METRICS entry `{name}` has no matching Metric arm"
4953 );
4954 }
4955
4956 let map: std::collections::HashMap<_, _> = fields.into_iter().collect();
4957 assert_eq!(map["iter"], serde_json::json!(7));
4959 assert_eq!(map["objective"], serde_json::json!(42.0));
4960 assert_eq!(map["nlp_error"], serde_json::Value::Null);
4963 }
4964
4965 #[test]
4966 fn atom_parse_rejects_garbage() {
4967 assert!(Atom::parse("inf_pr 1e-6").is_err()); assert!(Atom::parse("bogus<1").is_err()); assert!(Atom::parse("mu<abc").is_err()); }
4971
4972 #[test]
4973 fn compound_condition_parses_and_evaluates_left_to_right() {
4974 let c = Condition::parse("mu<1e-4&&inf_pr>1e-3").unwrap();
4976 assert_eq!(c.rest.len(), 1);
4977 assert_eq!(c.rest[0].0, Join::And);
4978
4979 let c = Condition::parse("iter>10&&(inf_du>1e-2||obj<0)").unwrap();
4981 assert_eq!(c.rest.len(), 2);
4982 assert_eq!(c.rest[0].0, Join::And);
4983 assert_eq!(c.rest[1].0, Join::Or);
4984 assert_eq!(c.raw, "iter>10&&inf_du>1e-2||obj<0");
4985
4986 assert!(Condition::parse("mu<1e-4&&bogus>0").is_err());
4988 }
4989
4990 #[test]
4991 fn completion_is_context_sensitive() {
4992 let c = completion_candidates(None, "", "co");
4994 assert!(c.contains(&"continue".to_string()));
4995 assert!(c.contains(&"complete".to_string()));
4996 assert!(!c.contains(&"step".to_string()));
4997
4998 let c = completion_candidates(None, "set ", "");
5000 assert!(c.contains(&"mu".to_string()));
5001 assert!(c.contains(&"opt".to_string()));
5002 assert!(c.contains(&"x".to_string()));
5003
5004 let c = completion_candidates(None, "break if ", "inf");
5006 assert!(c.contains(&"inf_pr".to_string()));
5007 assert!(c.contains(&"inf_du".to_string()));
5008 assert!(!c.contains(&"mu".to_string()));
5009
5010 let c = completion_candidates(None, "print ", "");
5012 assert!(c.contains(&"x".to_string()));
5013 assert!(c.contains(&"obj".to_string()));
5014 }
5015
5016 #[test]
5017 fn cmp_op_truth_table() {
5018 assert!(CmpOp::Lt.eval(1.0, 2.0));
5019 assert!(!CmpOp::Lt.eval(2.0, 2.0));
5020 assert!(CmpOp::Le.eval(2.0, 2.0));
5021 assert!(CmpOp::Gt.eval(3.0, 2.0));
5022 assert!(CmpOp::Ge.eval(2.0, 2.0));
5023 assert!(CmpOp::Eq.eval(2.0, 2.0));
5024 assert!(!CmpOp::Eq.eval(2.0, 2.5));
5025 }
5026
5027 #[test]
5028 fn interrupt_is_consumed_once() {
5029 interrupt::set_pending_for_test();
5030 assert!(interrupt::take(), "first take sees the pending Ctrl-C");
5031 assert!(!interrupt::take(), "second take is clear (consumed once)");
5032 }
5033
5034 #[test]
5035 fn on_interrupt_constructor_runs_free_but_interruptible() {
5036 let d = SolverDebugger::on_interrupt(DebugMode::Repl, None);
5037 assert!(!d.pause_iters, "on-interrupt does not pause each iter");
5038 assert!(!d.pause_terminal, "on-interrupt does not pause at terminal");
5039 assert!(d.interruptible, "on-interrupt honors Ctrl-C");
5040 assert!(!d.step, "on-interrupt starts un-armed");
5041 }
5042
5043 #[test]
5044 fn coffee_easter_egg_prints_art_but_stays_hidden() {
5045 let d = SolverDebugger::new(DebugMode::Repl, None);
5046 let out = d.cmd_coffee();
5047 assert!(out.ok);
5048 assert!(out.lines.len() > 5, "multi-line art");
5049 assert!(
5050 out.lines.iter().any(|l| l.contains("COFFEE")),
5051 "the mug says COFFEE"
5052 );
5053 assert!(
5055 !COMMANDS.contains(&"coffee"),
5056 "hidden from help/complete/Tab"
5057 );
5058 assert!(
5060 out.lines.iter().all(|l| !l.contains('\x1b')),
5061 "no color when stderr isn't a TTY"
5062 );
5063 }
5064
5065 #[test]
5066 fn double_ctrl_c_at_prompt_quits_single_cancels_line() {
5067 let mut d = SolverDebugger::new(DebugMode::Repl, None);
5068 assert_eq!(d.on_prompt_interrupt(), "");
5070 assert_eq!(d.on_prompt_interrupt(), "quit");
5072 assert_eq!(d.on_prompt_interrupt(), "");
5074 d.prompt_interrupts = 0;
5077 assert_eq!(d.on_prompt_interrupt(), "", "fresh streak after a command");
5078 }
5079
5080 #[test]
5081 fn stop_at_accepts_names_and_aliases() {
5082 let mut d = SolverDebugger::new(DebugMode::Repl, None);
5083 assert!(d.cmd_stop_at(&["after_search_dir"]).ok);
5084 assert!(d.stop_at.contains("after_search_dir"));
5085 assert!(d.cmd_stop_at(&["mu"]).ok);
5087 assert!(d.stop_at.contains("after_mu"));
5088 assert!(d.cmd_stop_at(&["kkt"]).ok);
5089 assert!(d.stop_at.contains("after_search_dir"));
5090 assert!(!d.cmd_stop_at(&["bogus"]).ok);
5092 assert!(d.cmd_stop_at(&["clear"]).ok);
5094 assert!(d.stop_at.is_empty());
5095 }
5096
5097 #[test]
5098 fn llm_command_defaults_and_overrides() {
5099 unsafe { std::env::remove_var("POUNCE_DBG_LLM") };
5102 let (prog, args, on_stdin) = llm_command("hi");
5103 assert_eq!(prog, "claude");
5104 assert_eq!(args, vec!["-p".to_string()]);
5105 assert!(on_stdin);
5106
5107 unsafe { std::env::set_var("POUNCE_DBG_LLM", "mytool --ask {}") };
5110 let (prog, args, on_stdin) = llm_command("why");
5111 assert_eq!(prog, "mytool");
5112 assert_eq!(args, vec!["--ask".to_string(), "why".to_string()]);
5113 assert!(!on_stdin);
5114
5115 unsafe { std::env::set_var("POUNCE_DBG_LLM", "llm -m gpt") };
5118 let (_, _, on_stdin) = llm_command("q");
5119 assert!(on_stdin);
5120
5121 unsafe { std::env::set_var("POUNCE_DBG_LLM", "codex") };
5126 let (prog, args, on_stdin) = llm_command("why is mu stuck");
5127 assert_eq!(prog, "codex");
5128 assert_eq!(
5129 args,
5130 vec!["exec".to_string(), "why is mu stuck".to_string()]
5131 );
5132 assert!(!on_stdin); unsafe { std::env::set_var("POUNCE_DBG_LLM", "gemini") };
5136 let (prog, args, _) = llm_command("q");
5137 assert_eq!(prog, "gemini");
5138 assert_eq!(args, vec!["-p".to_string(), "q".to_string()]);
5139
5140 unsafe { std::env::set_var("POUNCE_DBG_LLM", "llm") };
5142 let (prog, args, _) = llm_command("q");
5143 assert_eq!(prog, "llm");
5144 assert_eq!(args, vec!["q".to_string()]);
5145
5146 unsafe { std::env::set_var("POUNCE_DBG_LLM", "claude") };
5150 let (prog, args, on_stdin) = llm_command("q");
5151 assert_eq!(prog, "claude");
5152 assert_eq!(args, vec!["-p".to_string()]);
5153 assert!(on_stdin);
5154
5155 unsafe { std::env::set_var("POUNCE_DBG_LLM", "mytool") };
5159 let (prog, args, on_stdin) = llm_command("q");
5160 assert_eq!(prog, "mytool");
5161 assert!(args.is_empty());
5162 assert!(on_stdin);
5163
5164 unsafe { std::env::set_var("POUNCE_DBG_LLM", "pounce-no-such-llm-xyz") };
5168 let err = run_llm("hello").unwrap_err();
5169 assert!(err.contains("not installed or not on PATH"), "{err}");
5170 assert!(err.contains("codex"), "{err}");
5171
5172 unsafe { std::env::remove_var("POUNCE_DBG_LLM") };
5174 }
5175
5176 #[test]
5177 fn detach_disables_all_pausing() {
5178 let mut d = dbg(DebugMode::Repl);
5179 d.detached = true;
5180 d.step = true;
5181 d.breaks = vec![1];
5182 assert!(!d.should_pause(0));
5183 assert!(!d.should_pause(1));
5184 }
5185
5186 #[test]
5187 fn kkt_capture_tracks_attached_state() {
5188 let mut d = dbg(DebugMode::Repl);
5191 assert!(d.wants_kkt_capture());
5192 d.detached = true;
5193 assert!(!d.wants_kkt_capture());
5194 }
5195
5196 fn resid(kind: ResidKind, index: usize, value: f64) -> Residual {
5197 Residual { kind, index, value }
5198 }
5199
5200 #[test]
5201 fn rank_residuals_sorts_by_magnitude_and_truncates() {
5202 use ResidKind::*;
5203 let entries = vec![
5204 resid(Eq, 0, -0.5),
5205 resid(Ineq, 1, 3.0),
5206 resid(DualX, 2, -7.0),
5207 resid(DualS, 3, 1.0),
5208 ];
5209 let top = rank_residuals(entries, 2);
5210 assert_eq!(top.len(), 2);
5211 assert_eq!(top[0].value, -7.0);
5213 assert_eq!(top[0].kind, DualX);
5214 assert_eq!(top[1].value, 3.0);
5215 assert_eq!(top[1].kind, Ineq);
5216 }
5217
5218 #[test]
5219 fn rank_residuals_k_zero_and_k_over_len() {
5220 use ResidKind::*;
5221 let entries = vec![resid(Eq, 0, 1.0), resid(Ineq, 1, 2.0)];
5222 assert!(rank_residuals(entries.clone(), 0).is_empty());
5223 let all = rank_residuals(entries, 99);
5225 assert_eq!(all.len(), 2);
5226 assert_eq!(all[0].value, 2.0);
5227 }
5228
5229 #[test]
5230 fn rank_residuals_is_stable_on_magnitude_ties() {
5231 use ResidKind::*;
5232 let entries = vec![
5234 resid(Ineq, 5, -2.0),
5235 resid(Eq, 1, 2.0),
5236 resid(DualX, 9, -2.0),
5237 ];
5238 let top = rank_residuals(entries, 3);
5239 assert_eq!(
5240 top.iter().map(|r| r.kind).collect::<Vec<_>>(),
5241 vec![Ineq, Eq, DualX]
5242 );
5243 }
5244
5245 fn split_names_fixture() -> SplitNames {
5246 SplitNames {
5247 x_var: vec![Some("T_reactor".into()), None],
5248 eq: vec![Some("mass_balance".into()), Some("energy_balance".into())],
5249 ineq: vec![Some("pressure_cap".into())],
5250 }
5251 }
5252
5253 #[test]
5254 fn resid_name_maps_each_kind_to_its_pool() {
5255 use ResidKind::*;
5256 let names = Some(split_names_fixture());
5257 assert_eq!(
5260 resid_name(&resid(Eq, 1, 0.0), &names),
5261 Some("energy_balance")
5262 );
5263 assert_eq!(
5264 resid_name(&resid(Ineq, 0, 0.0), &names),
5265 Some("pressure_cap")
5266 );
5267 assert_eq!(
5268 resid_name(&resid(DualS, 0, 0.0), &names),
5269 Some("pressure_cap")
5270 );
5271 assert_eq!(resid_name(&resid(DualX, 0, 0.0), &names), Some("T_reactor"));
5272 assert_eq!(resid_name(&resid(DualX, 1, 0.0), &names), None);
5274 assert_eq!(resid_name(&resid(Eq, 9, 0.0), &names), None);
5275 assert_eq!(resid_name(&resid(Eq, 0, 0.0), &None), None);
5277 }
5278
5279 #[test]
5280 fn worst_named_picks_largest_and_labels_it() {
5281 use ResidKind::*;
5282 let names = Some(split_names_fixture());
5283 let resids = vec![resid(Eq, 0, 0.5), resid(Eq, 1, -3.2), resid(Ineq, 0, 1.1)];
5285 assert_eq!(
5286 worst_named(resids, &names),
5287 Some(("c[energy_balance]".to_string(), -3.2))
5288 );
5289 let resids = vec![resid(DualX, 7, 9.0)];
5291 assert_eq!(
5292 worst_named(resids, &None),
5293 Some(("grad_x_L[7]".to_string(), 9.0))
5294 );
5295 assert_eq!(worst_named(vec![], &names), None);
5297 }
5298
5299 use pounce_algorithm::debug_rank::RankCulprit;
5300
5301 fn rank_report_fixture() -> RankReport {
5302 RankReport {
5305 rows: vec![
5306 RankRow {
5307 kind: ResidKind::Eq,
5308 index: 0,
5309 },
5310 RankRow {
5311 kind: ResidKind::Eq,
5312 index: 1,
5313 },
5314 ],
5315 n_cols: 3,
5316 singular_values: vec![2.0, 0.0],
5317 tol: 1e-15,
5318 rank: 1,
5319 cond: f64::INFINITY,
5320 culprits: vec![
5321 RankCulprit {
5322 row: 0,
5323 weight: 0.5,
5324 },
5325 RankCulprit {
5326 row: 1,
5327 weight: 0.5,
5328 },
5329 ],
5330 }
5331 }
5332
5333 #[test]
5334 fn render_rank_report_names_culprits_and_builds_json() {
5335 let names = Some(split_names_fixture());
5336 let rep = rank_report_fixture();
5337 let (lines, data) = render_rank_report(&rep, &names, None, 7);
5339
5340 let text = lines.join("\n");
5341 assert!(text.contains("2 row(s) × 3 column(s)"), "{text}");
5342 assert!(text.contains("numerical rank = 1 / 2"), "{text}");
5343 assert!(text.contains("inf (σ_min = 0)"), "{text}");
5345 assert!(text.contains("c[mass_balance]"), "{text}");
5347 assert!(text.contains("c[energy_balance]"), "{text}");
5348 assert!(text.contains("participation 0.50"), "{text}");
5349 assert!(text.contains("print equation"), "{text}");
5351
5352 assert_eq!(data["iter"], 7);
5355 assert_eq!(data["rank"], 1);
5356 assert_eq!(data["deficiency"], 1);
5357 assert_eq!(data["rank_deficient"], true);
5358 assert!(data["cond"].is_null(), "non-finite cond ⇒ null: {data}");
5359 assert_eq!(data["culprits"][0]["name"], "mass_balance");
5360 assert_eq!(data["culprits"][0]["label"], "c[mass_balance]");
5361 assert!(data["culprits"][0]["equation"].is_null());
5362 assert_eq!(data["culprits"][1]["name"], "energy_balance");
5363 }
5364
5365 #[test]
5366 fn render_rank_report_prints_culprit_equations_inline() {
5367 let names = Some(split_names_fixture());
5368 let rep = rank_report_fixture();
5369 let book = EquationBook::new(
5372 vec!["mass_balance".into(), "energy_balance".into()],
5373 vec![
5374 "x[0] + x[1] - 10 = 0".into(),
5375 "T_reactor*flow - Q = 0".into(),
5376 ],
5377 );
5378 let (lines, data) = render_rank_report(&rep, &names, Some(&book), 7);
5379
5380 let text = lines.join("\n");
5381 assert!(text.contains("x[0] + x[1] - 10 = 0"), "{text}");
5384 assert!(text.contains("T_reactor*flow - Q = 0"), "{text}");
5385 assert!(!text.contains("inspect a row with"), "{text}");
5387
5388 assert_eq!(data["culprits"][0]["equation"], "x[0] + x[1] - 10 = 0");
5390 assert_eq!(data["culprits"][1]["equation"], "T_reactor*flow - Q = 0");
5391 }
5392
5393 #[test]
5394 fn render_rank_report_full_rank_reports_positive_signal() {
5395 let rep = RankReport {
5396 rows: vec![
5397 RankRow {
5398 kind: ResidKind::Eq,
5399 index: 0,
5400 },
5401 RankRow {
5402 kind: ResidKind::Eq,
5403 index: 1,
5404 },
5405 ],
5406 n_cols: 3,
5407 singular_values: vec![2.0, 1.0],
5408 tol: 1e-15,
5409 rank: 2,
5410 cond: 2.0,
5411 culprits: vec![],
5412 };
5413 let (lines, data) = render_rank_report(&rep, &None, None, 3);
5414 let text = lines.join("\n");
5415 assert!(text.contains("full row rank"), "{text}");
5416 assert!(!text.contains("rank-deficient"), "{text}");
5417 assert_eq!(data["rank_deficient"], false);
5418 assert_eq!(data["cond"], 2.0);
5419 assert_eq!(data["culprits"].as_array().map(|a| a.len()), Some(0));
5420 }
5421
5422 #[test]
5423 fn print_equation_resolves_by_name_index_and_errors() {
5424 let mut d = dbg(DebugMode::Repl);
5425 let out = d.cmd_print_equation(&[]);
5427 assert!(!out.ok);
5428 assert!(out.lines[0].contains("needs an .nl model"));
5429
5430 d.set_equation_book(EquationBook::new(
5431 vec!["mass_balance".into(), String::new()],
5432 vec!["x[0] + x[1] = 10".into(), "x[0] - x[1] <= 2".into()],
5433 ));
5434
5435 let out = d.cmd_print_equation(&[]);
5437 assert!(out.ok);
5438 assert!(out.lines[0].contains("2 constraint equation"));
5439
5440 let out = d.cmd_print_equation(&["mass_balance"]);
5442 assert!(out.ok);
5443 assert_eq!(out.lines[0], "mass_balance: x[0] + x[1] = 10");
5444
5445 let out = d.cmd_print_equation(&["1"]);
5447 assert!(out.ok);
5448 assert_eq!(out.lines[0], "c[1]: x[0] - x[1] <= 2");
5449
5450 let out = d.cmd_print_equation(&["nope"]);
5452 assert!(!out.ok);
5453 assert!(out.lines[0].contains("no constraint named or indexed"));
5454 }
5455
5456 fn eq_inc(n_vars: usize, eq_row_inner_idx: Vec<usize>, rows: &[&[usize]]) -> EqualityIncidence {
5460 let mut adj_ptr = vec![0usize];
5461 let mut vars = Vec::new();
5462 for r in rows {
5463 let mut v = r.to_vec();
5464 v.sort_unstable();
5465 v.dedup();
5466 vars.extend_from_slice(&v);
5467 adj_ptr.push(vars.len());
5468 }
5469 EqualityIncidence {
5470 n_vars,
5471 eq_row_inner_idx,
5472 adj_ptr,
5473 vars,
5474 }
5475 }
5476
5477 #[test]
5478 fn structural_singularity_names_overdetermined_equations() {
5479 let inc = eq_inc(2, vec![0, 1, 2], &[&[0, 1], &[0, 1], &[0, 1]]);
5485 let book = StructureBook::new(
5486 inc,
5487 vec!["balance_a".into(), "balance_b".into(), "balance_c".into()],
5488 vec!["flow".into(), "temp".into()],
5489 );
5490 let f = book.findings();
5491 assert_eq!(f.len(), 1);
5492 let (sev, code, msg) = &f[0];
5493 assert_eq!(*sev, "warning");
5494 assert_eq!(*code, "structural_singularity");
5495 assert!(msg.contains("balance_a"), "msg: {msg}");
5496 assert!(msg.contains("balance_b"), "msg: {msg}");
5497 assert!(msg.contains("balance_c"), "msg: {msg}");
5498 assert!(msg.contains("flow") && msg.contains("temp"), "msg: {msg}");
5499 assert!(msg.contains("≥1"), "msg: {msg}");
5500 }
5501
5502 #[test]
5503 fn structural_findings_silent_when_well_posed_and_fall_back_to_indices() {
5504 let inc = eq_inc(2, vec![0, 1], &[&[0], &[1]]);
5508 let book = StructureBook::new(inc, vec![], vec![]);
5509 assert!(book.findings().is_empty());
5510
5511 let inc = eq_inc(1, vec![0, 1, 3], &[&[0], &[0], &[0]]);
5515 let book = StructureBook::new(inc, vec![], vec![]);
5516 let f = book.findings();
5517 assert_eq!(f.len(), 1);
5518 let msg = &f[0].2;
5519 assert!(
5520 msg.contains("c[0]") && msg.contains("c[1]") && msg.contains("c[3]"),
5521 "msg: {msg}"
5522 );
5523 }
5524
5525 #[test]
5526 fn structural_singularity_handles_empty_row_with_no_variables() {
5527 let inc = eq_inc(1, vec![0, 1], &[&[0], &[]]);
5530 let book = StructureBook::new(inc, vec!["real".into(), "ghost".into()], vec!["x".into()]);
5531 let f = book.findings();
5532 assert_eq!(f.len(), 1);
5533 let msg = &f[0].2;
5534 assert!(msg.contains("ghost"), "msg: {msg}");
5535 assert!(msg.contains("no variables"), "msg: {msg}");
5536 }
5537
5538 #[test]
5539 fn parse_floats_accepts_commas_whitespace_and_newlines() {
5540 assert_eq!(parse_floats("1, 2 ,3").unwrap(), vec![1.0, 2.0, 3.0]);
5541 assert_eq!(parse_floats("1\n2\n-3.5").unwrap(), vec![1.0, 2.0, -3.5]);
5542 assert_eq!(parse_floats(" 1.0 2e-1 ").unwrap(), vec![1.0, 0.2]);
5543 assert!(parse_floats("1, nope, 3").is_err());
5544 assert_eq!(parse_floats("").unwrap(), Vec::<f64>::new());
5545 }
5546
5547 #[test]
5548 fn jitter_start_zero_is_the_unperturbed_base_and_is_deterministic() {
5549 let base = vec![1.0, -2.0, 0.0];
5550 assert_eq!(jitter(&base, 0.1, 0), base);
5552 let a = jitter(&base, 0.1, 1);
5554 let b = jitter(&base, 0.1, 1);
5555 assert_eq!(a, b);
5556 assert_ne!(a, base);
5557 for (j, (&p, &x)) in a.iter().zip(&base).enumerate() {
5558 let bound = 0.1 * (x.abs() + 1.0);
5559 assert!(
5560 (p - x).abs() <= bound + 1e-12,
5561 "component {j} moved {} > bound {bound}",
5562 (p - x).abs()
5563 );
5564 }
5565 assert_ne!(jitter(&base, 0.1, 1), jitter(&base, 0.1, 2));
5567 }
5568
5569 #[test]
5570 fn sample_start_draws_inside_finite_boxes_and_jitters_unbounded() {
5571 let base = vec![1.0, 1.0, 0.5];
5572 let lo = vec![0.0, 0.0, -1.0];
5574 let hi = vec![2.0, f64::INFINITY, 1.0];
5575 let b = Some((lo.as_slice(), hi.as_slice()));
5576 assert_eq!(sample_start(&base, b, 0.1, 0), base);
5578 for k in 1..50 {
5579 let s = sample_start(&base, b, 0.1, k);
5580 assert!((0.0..=2.0).contains(&s[0]), "var0 {} out of [0,2]", s[0]);
5582 assert!((-1.0..=1.0).contains(&s[2]), "var2 {} out of [-1,1]", s[2]);
5583 let bound = 0.1 * (base[1].abs() + 1.0);
5585 assert!(
5586 (s[1] - base[1]).abs() <= bound + 1e-12,
5587 "var1 jitter exceeded"
5588 );
5589 }
5590 assert_eq!(
5592 sample_start(&base, b, 0.1, 7),
5593 sample_start(&base, b, 0.1, 7)
5594 );
5595 }
5596
5597 #[test]
5598 fn path_completion_lists_matching_files_with_dir_prefix() {
5599 let dir = std::env::temp_dir().join("pounce_dbg_complete_test");
5600 let _ = std::fs::remove_dir_all(&dir);
5601 std::fs::create_dir_all(&dir).unwrap();
5602 std::fs::write(dir.join("starts.txt"), "0,0\n").unwrap();
5603 std::fs::write(dir.join("start2.txt"), "1,1\n").unwrap();
5604 std::fs::write(dir.join("other.json"), "{}").unwrap();
5605 std::fs::create_dir_all(dir.join("subdir")).unwrap();
5606
5607 let p = dir.to_string_lossy().to_string();
5608 let mut got = path_candidates(&format!("{p}/start"));
5610 got.sort();
5611 assert_eq!(
5612 got,
5613 vec![format!("{p}/start2.txt"), format!("{p}/starts.txt")]
5614 );
5615 let got = path_candidates(&format!("{p}/sub"));
5617 assert_eq!(got, vec![format!("{p}/subdir/")]);
5618 assert_eq!(path_candidates(&format!("{p}/")).len(), 4);
5620 assert!(
5622 completion_candidates(None, "load", &format!("{p}/star"))
5623 .iter()
5624 .all(|c| c.contains("start"))
5625 );
5626
5627 let _ = std::fs::remove_dir_all(&dir);
5628 }
5629}