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 quit: bool,
1032 hello_sent: bool,
1035 pause_iters: bool,
1038 pause_terminal: bool,
1040 terminal_only_on_error: bool,
1042 interruptible: bool,
1044 emit_progress: bool,
1047 inline_artifacts: bool,
1052 sub_step: bool,
1055 stop_at: HashSet<&'static str>,
1057 break_events: HashSet<&'static str>,
1059 snapshots: BTreeMap<i32, Box<dyn pounce_common::debug::IterSnapshot>>,
1062 restart: Option<RestartCell>,
1065 #[cfg_attr(target_family = "wasm", allow(dead_code))]
1069 editor: Option<ReplEditor>,
1070 #[cfg_attr(target_family = "wasm", allow(dead_code))]
1072 hist_path: Option<PathBuf>,
1073 pump: Option<StdinPump>,
1076 watches: Vec<String>,
1079 pending_script: Option<String>,
1082 script_given: bool,
1086 staged: Vec<(String, String)>,
1090 sweep: Option<SweepState>,
1093 #[cfg_attr(target_family = "wasm", allow(dead_code))]
1098 prompt_interrupts: u8,
1099 equation_book: Option<EquationBook>,
1104 structure_book: Option<StructureBook>,
1109 script_queue: Option<SharedScript>,
1115}
1116
1117pub type SharedScript = Rc<std::cell::RefCell<VecDeque<String>>>;
1121
1122impl SolverDebugger {
1123 pub fn new(mode: DebugMode, reg: Option<Rc<RegisteredOptions>>) -> Self {
1126 Self {
1127 mode,
1128 reg,
1129 step: true,
1132 run_to: None,
1133 breaks: Vec::new(),
1134 temp_breaks: Vec::new(),
1135 bp_commands: HashMap::new(),
1136 conds: Vec::new(),
1137 watchpoints: Vec::new(),
1138 last_mu: None,
1139 mu_stall: 0,
1140 in_restoration: false,
1141 detached: false,
1142 quit: false,
1143 hello_sent: false,
1144 pause_iters: true,
1145 pause_terminal: true,
1146 terminal_only_on_error: false,
1147 interruptible: !cfg!(target_family = "wasm"),
1149 emit_progress: true,
1150 inline_artifacts: cfg!(target_family = "wasm"),
1151 sub_step: false,
1152 stop_at: HashSet::new(),
1153 break_events: HashSet::new(),
1154 snapshots: BTreeMap::new(),
1155 restart: None,
1156 editor: None,
1157 hist_path: None,
1158 pump: None,
1159 watches: Vec::new(),
1160 pending_script: None,
1161 script_given: false,
1162 staged: Vec::new(),
1163 sweep: None,
1164 prompt_interrupts: 0,
1165 equation_book: None,
1166 structure_book: None,
1167 script_queue: None,
1168 }
1169 }
1170
1171 pub fn quiet(mode: DebugMode, reg: Option<Rc<RegisteredOptions>>) -> Self {
1177 let mut d = Self::new(mode, reg);
1178 d.step = false;
1179 d.pause_iters = false;
1180 d.pause_terminal = false;
1181 d.detached = true;
1182 d
1183 }
1184
1185 pub fn with_script(mut self, path: String) -> Self {
1187 self.pending_script = Some(path);
1188 self.script_given = true;
1189 self
1190 }
1191
1192 pub fn set_equation_book(&mut self, book: EquationBook) {
1196 self.equation_book = Some(book);
1197 }
1198
1199 pub fn set_structure_book(&mut self, book: StructureBook) {
1205 self.structure_book = Some(book);
1206 }
1207
1208 pub fn with_shared_script(mut self, queue: SharedScript) -> Self {
1212 self.script_queue = Some(queue);
1213 self
1214 }
1215
1216 pub fn with_restart(mut self, cell: RestartCell) -> Self {
1219 self.restart = Some(cell);
1220 self
1221 }
1222
1223 pub fn on_error(mode: DebugMode, reg: Option<Rc<RegisteredOptions>>) -> Self {
1226 Self {
1227 step: false,
1228 pause_iters: false,
1229 terminal_only_on_error: true,
1230 ..Self::new(mode, reg)
1231 }
1232 }
1233
1234 pub fn on_interrupt(mode: DebugMode, reg: Option<Rc<RegisteredOptions>>) -> Self {
1238 Self {
1239 step: false,
1240 pause_iters: false,
1241 pause_terminal: false,
1242 ..Self::new(mode, reg)
1243 }
1244 }
1245
1246 pub fn staged_options(&self) -> &[(String, String)] {
1249 &self.staged
1250 }
1251
1252 fn should_pause(&mut self, iter: i32) -> bool {
1253 if self.detached {
1254 return false;
1255 }
1256 if self.step {
1257 return true;
1258 }
1259 if let Some(t) = self.run_to {
1260 if iter >= t {
1261 self.run_to = None;
1262 return true;
1263 }
1264 }
1265 if self.breaks.contains(&iter) {
1266 return true;
1267 }
1268 if let Some(pos) = self.temp_breaks.iter().position(|&b| b == iter) {
1270 self.temp_breaks.remove(pos);
1271 return true;
1272 }
1273 false
1274 }
1275
1276 fn matched_condition(&self, ctx: &dyn DebugState) -> Option<String> {
1279 if self.detached {
1280 return None;
1281 }
1282 self.conds
1283 .iter()
1284 .find(|c| c.holds(ctx))
1285 .map(|c| c.raw.clone())
1286 }
1287
1288 fn matched_event(&self, ctx: &dyn DebugState) -> Option<&'static str> {
1292 if self.detached || self.break_events.is_empty() {
1293 return None;
1294 }
1295 let cp = ctx.checkpoint();
1296 let tiny = 1e-10;
1298 EVENTS.iter().copied().find(|&e| {
1299 self.break_events.contains(e)
1300 && match e {
1301 "resto_entered" => cp == Checkpoint::PreRestoration,
1302 "resto_exited" => cp == Checkpoint::PostRestoration,
1303 "regularized" => {
1304 cp == Checkpoint::AfterSearchDirection && ctx.regularization() > 0.0
1305 }
1306 "tiny_step" => {
1307 cp == Checkpoint::AfterSearchDirection
1308 && ctx
1309 .delta_block("x")
1310 .map(|v| v.iter().fold(0.0_f64, |m, &x| m.max(x.abs())) < tiny)
1311 .unwrap_or(false)
1312 }
1313 "ls_rejected" => cp == Checkpoint::AfterStep && ctx.ls_count() > 1,
1314 "mu_stalled" => cp == Checkpoint::IterStart && self.mu_stall >= MU_STALL_ITERS,
1315 "nan" => !ctx.nlp_error().is_finite() || !ctx.objective().is_finite(),
1316 _ => false,
1317 }
1318 })
1319 }
1320
1321 fn update_mu_stall(&mut self, mu: f64) {
1323 if let Some(last) = self.last_mu {
1324 if (mu - last).abs() <= 1e-12 * last.abs().max(1.0) {
1325 self.mu_stall += 1;
1326 } else {
1327 self.mu_stall = 0;
1328 }
1329 }
1330 self.last_mu = Some(mu);
1331 }
1332
1333 fn matched_watchpoint(&mut self, ctx: &dyn DebugState) -> Option<String> {
1336 if self.detached {
1337 return None;
1338 }
1339 let mut hit = None;
1340 for wp in self.watchpoints.iter_mut() {
1341 let Some(full) = ctx.block(&wp.block) else {
1342 continue;
1343 };
1344 let cur: Vec<f64> = match wp.idx {
1345 Some(i) => match full.get(i) {
1346 Some(&v) => vec![v],
1347 None => continue,
1348 },
1349 None => full,
1350 };
1351 if let Some(prev) = &wp.last {
1352 if prev.len() == cur.len() {
1353 let changed = prev
1354 .iter()
1355 .zip(&cur)
1356 .any(|(p, c)| (p - c).abs() > wp.threshold);
1357 if changed && hit.is_none() {
1358 hit = Some(wp.raw.clone());
1359 }
1360 }
1361 }
1362 wp.last = Some(cur);
1363 }
1364 hit
1365 }
1366
1367 fn dispatch(&mut self, line: &str, ctx: &mut dyn DebugState) -> CmdOut {
1370 let owned = tokenize_quoted(line);
1374 let toks: Vec<&str> = owned.iter().map(String::as_str).collect();
1375 let Some(&verb) = toks.first() else {
1376 return CmdOut::ok(vec![]); };
1378 let rest = &toks[1..];
1379 match verb {
1380 "help" | "h" | "?" => self.cmd_help(),
1381 "info" | "i" => self.cmd_info(ctx),
1382 "print" | "p" => self.cmd_print(rest, ctx),
1383 "step" | "s" | "n" | "next" if rest.first() == Some(&"sub") => {
1386 self.sub_step = true;
1387 CmdOut::ok(vec![
1388 "stepping to the next checkpoint (sub-iteration)".into(),
1389 ])
1390 .flow(Flow::Resume)
1391 }
1392 "step" | "s" | "n" | "next" => {
1393 self.step = true;
1394 CmdOut::ok(vec!["stepping one iteration".into()]).flow(Flow::Resume)
1395 }
1396 "stepi" | "si" => {
1397 self.sub_step = true;
1398 CmdOut::ok(vec![
1399 "stepping to the next checkpoint (sub-iteration)".into(),
1400 ])
1401 .flow(Flow::Resume)
1402 }
1403 "continue" | "c" | "cont" => {
1404 self.step = false;
1405 self.sub_step = false;
1406 self.run_to = None;
1407 CmdOut::ok(vec!["continuing".into()]).flow(Flow::Resume)
1408 }
1409 "run" | "r" => self.cmd_run(rest),
1410 "break" | "b" => self.cmd_break(rest),
1411 "tbreak" | "tb" => match rest.first().and_then(|s| s.parse::<i32>().ok()) {
1412 Some(n) => {
1413 if !self.temp_breaks.contains(&n) {
1414 self.temp_breaks.push(n);
1415 }
1416 CmdOut::ok(vec![format!("temporary breakpoint at iteration {n}")])
1417 }
1418 None => CmdOut::err("usage: tbreak <iteration>"),
1419 },
1420 "watchpoint" | "wp" => self.cmd_watchpoint(rest, ctx),
1421 "commands" => self.cmd_commands(rest),
1422 "stop-at" | "stopat" => self.cmd_stop_at(rest),
1423 "progress" => match rest.first().copied() {
1424 Some("on") | None => {
1425 self.emit_progress = true;
1426 CmdOut::ok(vec!["progress events on".into()])
1427 }
1428 Some("off") => {
1429 self.emit_progress = false;
1430 CmdOut::ok(vec!["progress events off".into()])
1431 }
1432 _ => CmdOut::err("usage: progress [on|off]"),
1433 },
1434 "artifacts" => match rest.first().copied() {
1435 Some("inline") => {
1436 self.inline_artifacts = true;
1437 CmdOut::ok(vec!["viz / save artifacts returned inline".into()])
1438 }
1439 Some("file") => {
1440 self.inline_artifacts = false;
1441 CmdOut::ok(vec!["viz / save artifacts written to files".into()])
1442 }
1443 None => CmdOut::ok(vec![format!(
1444 "artifacts: {}",
1445 if self.inline_artifacts {
1446 "inline"
1447 } else {
1448 "file"
1449 }
1450 )])
1451 .with_data(serde_json::json!({"inline": self.inline_artifacts})),
1452 _ => CmdOut::err("usage: artifacts [inline|file]"),
1453 },
1454 "set" => self.cmd_set(rest, ctx),
1455 "get" => self.cmd_get(rest),
1456 "opt" | "options" => self.cmd_opt(rest),
1457 "complete" => self.cmd_complete(rest),
1458 "viz" | "plot" => self.cmd_viz(rest, ctx),
1459 "save" => self.cmd_save(rest, ctx),
1460 "load" => match as_nlp_mut(ctx) {
1461 Some(c) => self.cmd_load(rest, c),
1462 None => nlp_only("load"),
1463 },
1464 "sweep" => match as_nlp_mut(ctx) {
1465 Some(c) => self.cmd_sweep(rest, c),
1466 None => nlp_only("sweep"),
1467 },
1468 "multistart" => match as_nlp_mut(ctx) {
1469 Some(c) => self.cmd_multistart(rest, c),
1470 None => nlp_only("multistart"),
1471 },
1472 "goto" | "jump" => self.cmd_goto(rest, ctx),
1473 "restart" => match self.snapshots.keys().next().copied() {
1474 Some(k) => self.restore_to(k, ctx),
1475 None => CmdOut::err("no snapshots captured yet"),
1476 },
1477 "resolve" | "re-solve" => match as_nlp(ctx) {
1478 Some(c) => self.cmd_resolve(c),
1479 None => nlp_only("resolve"),
1480 },
1481 "ask" | "explain" | "claude" => self.cmd_ask(rest, ctx),
1482 "watch" | "display" => self.cmd_watch(rest),
1483 "diff" => self.cmd_diff(ctx),
1484 "diagnose" | "diag" => match as_nlp(ctx) {
1485 Some(c) => self.cmd_diagnose(c),
1486 None => nlp_only("diagnose"),
1487 },
1488 "source" => self.cmd_source(rest, ctx),
1489 "detach" => {
1490 self.detached = true;
1491 self.step = false;
1492 self.run_to = None;
1493 CmdOut::ok(vec!["detached — solving to completion".into()]).flow(Flow::Resume)
1494 }
1495 "pause" => CmdOut::ok(vec!["already paused".into()]),
1498 "coffee" | "brew" | "espresso" => self.cmd_coffee(),
1500 "quit" | "q" | "exit" => {
1501 self.quit = matches!(self.mode, DebugMode::Json);
1502 CmdOut::ok(vec!["stopping solve".into()]).flow(Flow::Stop)
1503 }
1504 other => CmdOut::err(format!("unknown command `{other}` (try `help`)")),
1505 }
1506 }
1507
1508 fn cmd_coffee(&self) -> CmdOut {
1512 let color = matches!(self.mode, DebugMode::Repl)
1513 && std::io::stderr().is_terminal()
1514 && std::env::var_os("NO_COLOR").is_none();
1515 let paint = |r: u8, g: u8, b: u8, s: &str| -> String {
1516 if color {
1517 format!("\x1b[38;2;{r};{g};{b}m{s}\x1b[0m")
1518 } else {
1519 s.to_string()
1520 }
1521 };
1522 let cup = |s: &str| paint(0xEC, 0xEC, 0xEF, s);
1524 let dark = |s: &str| paint(0x5A, 0x32, 0x1E, s);
1525 let brew = |s: &str| paint(0x96, 0x5F, 0x37, s);
1526 let steam = |s: &str| paint(0xB4, 0xB9, 0xC3, s);
1527 let lines = vec![
1528 String::new(),
1529 format!(" {}", steam(") ) )")),
1530 format!(" {}", steam("( ( (")),
1531 format!(" {}", cup("._________.")),
1532 format!(" {}{}{}", cup("|"), dark("~~~~~~~~"), cup("|_")),
1533 format!(" {}{}{}", cup("| "), brew("COFFEE"), cup("| |")),
1534 format!(" {}{}{}", cup("| "), dark("~~~~~~"), cup("| |")),
1535 format!(" {}", cup("|________|_|")),
1536 format!(" {}", cup("\\________/")),
1537 format!(" {}", brew("a fresh cup for a stuck solve")),
1538 String::new(),
1539 ];
1540 CmdOut::ok(lines).with_data(serde_json::json!({"easter_egg": "coffee"}))
1541 }
1542
1543 fn cmd_help(&self) -> CmdOut {
1544 let lines = vec![
1545 "commands:".into(),
1546 " info | i summary of the current iterate".into(),
1547 " print | p <what> x|s|y_c|y_d|z_l|z_u|v_l|v_u | dx (step) |".into(),
1548 " mu|obj|inf_pr|inf_du|err|compl|iter | kkt | active | inactive".into(),
1549 " print residuals [pr|du] [k] top-k largest-magnitude residuals (default k=10)".into(),
1550 " print equation [name|row] source algebra of a constraint, by model name or row".into(),
1551 " print rank SVD rank of the equality Jacobian; names dependent equations".into(),
1552 " step | s | n run one iteration, pause again".into(),
1553 " stepi | si | step sub run to the next checkpoint (into sub-iteration phases)".into(),
1554 " progress [on|off] toggle per-iteration progress events (JSON mode)".into(),
1555 " stop-at <cp> always pause at a checkpoint: after_mu|after_search_dir|after_step".into(),
1556 " continue | c run to the next breakpoint".into(),
1557 " run | r <N> run until iteration N".into(),
1558 " break | b [N|clear|del N] set/list/clear breakpoints".into(),
1559 " break if <m><op><v> conditional bp; m in mu|inf_pr|inf_du|obj|err|iter,".into(),
1560 " op in < <= > >= == (e.g. break if inf_pr<1e-6)".into(),
1561 " break on <event> event bp: resto_entered|resto_exited|regularized|".into(),
1562 " tiny_step|ls_rejected|mu_stalled|nan".into(),
1563 " tbreak <N> one-shot breakpoint (deletes after firing)".into(),
1564 " watchpoint <blk>[<i>] [τ] pause when a value changes by > τ (alias wp)".into(),
1565 " commands <N> <c>;<c>… auto-run commands when iter N's breakpoint hits".into(),
1566 " set mu <v> overwrite the barrier parameter".into(),
1567 " set <blk>[<i>] <v> overwrite one component (e.g. set x[2] 1.5)".into(),
1568 " set <blk> <v0,v1,...> overwrite a whole block".into(),
1569 " set opt <name> <value> stage a solver option (validated)".into(),
1570 " get opt <name> show an option's effective value (staged or default)".into(),
1571 " opt [filter] list solver options (name/type/default)".into(),
1572 " complete <prefix> completion candidates (commands + options)".into(),
1573 " viz <x|s|dx|...|kkt|L> open the artifact in an external viewer".into(),
1574 " save [path] write the current iterate + residuals to JSON".into(),
1575 " artifacts [inline|file] return viz/save payloads in the result instead of files".into(),
1576 " load <file> [block] read a block (default x) from a save artifact / numeric file".into(),
1577 " sweep <file> one solve per start in <file>; tabulate outcomes".into(),
1578 " multistart <N> [rel] N restarts (uniform in each finite box; jitter else)".into(),
1579 " goto <k> | restart rewind to a captured iteration (primal-dual only)".into(),
1580 " resolve re-solve from the current x with staged `set opt`s".into(),
1581 " ask [question] ask an LLM about the state (default Claude Code; set".into(),
1582 " POUNCE_DBG_LLM=claude|codex|gemini|llm or a command template)".into(),
1583 " ask --prompt-only [q] return the prompt `ask` would send, without running an LLM".into(),
1584 " watch [target|clear|del] auto-print a `print` target at every pause".into(),
1585 " diff what changed in the iterate since the last iteration".into(),
1586 " diagnose | diag live health report: named culprit residuals, KKT inertia, stalls".into(),
1587 " source <file> run debugger commands from a file".into(),
1588 " detach stop pausing; solve to completion".into(),
1589 " quit | q stop the solve now".into(),
1590 ];
1591 CmdOut::ok(lines)
1592 }
1593
1594 fn cmd_info(&self, ctx: &dyn DebugState) -> CmdOut {
1595 let dims: Vec<_> = ctx.block_dims();
1596 let dims_json: serde_json::Map<String, serde_json::Value> = dims
1597 .iter()
1598 .map(|(n, d)| ((*n).to_string(), serde_json::json!(d)))
1599 .collect();
1600 let lines = vec![
1601 format!("iter = {}", ctx.iter()),
1602 format!("mu = {:.6e}", ctx.mu()),
1603 format!("objective = {:.8e}", ctx.objective()),
1604 format!("inf_pr = {:.6e}", ctx.inf_pr()),
1605 format!("inf_du = {:.6e}", ctx.inf_du()),
1606 format!("nlp_error = {:.6e}", ctx.nlp_error()),
1607 format!(
1608 "dims = {}",
1609 dims.iter()
1610 .map(|(n, d)| format!("{n}:{d}"))
1611 .collect::<Vec<_>>()
1612 .join(" ")
1613 ),
1614 ];
1615 let mut data = serde_json::json!({ "dims": dims_json });
1616 insert_metric_fields(&mut data, ctx);
1620 CmdOut::ok(lines).with_data(data)
1621 }
1622
1623 fn cmd_print(&self, rest: &[&str], ctx: &dyn DebugState) -> CmdOut {
1624 let Some(&what) = rest.first() else {
1625 return self.cmd_info(ctx);
1626 };
1627 if what == "kkt" {
1634 return match as_nlp(ctx) {
1635 Some(_) => self.cmd_print_kkt(ctx),
1636 None => nlp_only("print kkt"),
1637 };
1638 }
1639 if what == "active" || what == "inactive" {
1640 return match as_nlp(ctx) {
1641 Some(_) => self.cmd_print_bounds(ctx, what == "active"),
1642 None => nlp_only(&format!("print {what}")),
1643 };
1644 }
1645 if what == "residuals" || what == "resid" {
1646 return match as_nlp(ctx) {
1647 Some(_) => self.cmd_print_residuals(&rest[1..], ctx),
1648 None => nlp_only("print residuals"),
1649 };
1650 }
1651 if what == "equation" || what == "eqn" || what == "eq" {
1652 return self.cmd_print_equation(&rest[1..]);
1653 }
1654 if what == "rank" {
1655 return match as_nlp(ctx) {
1656 Some(c) => self.cmd_print_rank(c),
1657 None => nlp_only("print rank"),
1658 };
1659 }
1660 let delta = what.strip_prefix("d").filter(|b| is_block(ctx, b));
1662 if is_block(ctx, what) {
1663 match ctx.block(what) {
1664 Some(v) => CmdOut::ok(vec![fmt_vec(what, &v)])
1665 .with_data(serde_json::json!({"name": what, "values": v})),
1666 None => CmdOut::err(format!("no iterate yet for block `{what}`")),
1667 }
1668 } else if let Some(blk) = delta {
1669 match ctx.delta_block(blk) {
1670 Some(v) => CmdOut::ok(vec![fmt_vec(&format!("d{blk}"), &v)])
1671 .with_data(serde_json::json!({"name": format!("d{blk}"), "values": v})),
1672 None => CmdOut::err(format!("no search direction available for `d{blk}` yet")),
1673 }
1674 } else {
1675 let val = match what {
1676 "mu" => ctx.mu(),
1677 "obj" | "objective" => ctx.objective(),
1678 "inf_pr" => ctx.inf_pr(),
1679 "inf_du" => ctx.inf_du(),
1680 "err" | "nlp_error" => ctx.nlp_error(),
1681 "compl" | "complementarity" => ctx.complementarity(),
1682 "iter" => ctx.iter() as f64,
1683 _ => {
1684 return CmdOut::err(format!(
1685 "don't know how to print `{what}` (try a block name or mu|obj|inf_pr|inf_du|err|compl|iter)"
1686 ));
1687 }
1688 };
1689 CmdOut::ok(vec![format!("{what} = {val:.10e}")])
1690 .with_data(serde_json::json!({"name": what, "value": val}))
1691 }
1692 }
1693
1694 fn cmd_print_bounds(&self, ctx: &dyn DebugState, active: bool) -> CmdOut {
1700 let tol = 1e-6;
1701 let mut lines = Vec::new();
1702 let mut cats = serde_json::Map::new();
1703 for cat in ["x_l", "x_u", "s_l", "s_u"] {
1704 let Some(sl) = ctx.bound_slack(cat) else {
1705 continue;
1706 };
1707 if sl.is_empty() {
1708 continue;
1709 }
1710 let n = sl.len();
1711 if active {
1712 let min = sl.iter().copied().fold(f64::INFINITY, f64::min);
1713 let near = sl.iter().filter(|&&s| s.abs() < tol).count();
1714 lines.push(format!(
1715 "{cat}: {n} bound(s), {near} near-active (slack<{tol:.0e}), min slack {min:.3e}"
1716 ));
1717 cats.insert(
1718 cat.to_string(),
1719 serde_json::json!({"n": n, "near_active": near, "min_slack": min}),
1720 );
1721 } else {
1722 let max = sl.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1723 let far = sl.iter().filter(|&&s| s.abs() >= tol).count();
1724 lines.push(format!(
1725 "{cat}: {n} bound(s), {far} inactive (slack≥{tol:.0e}), max slack {max:.3e}"
1726 ));
1727 cats.insert(
1728 cat.to_string(),
1729 serde_json::json!({"n": n, "inactive": far, "max_slack": max}),
1730 );
1731 }
1732 }
1733 if lines.is_empty() {
1734 lines.push("no bounded variables or inequality slacks".into());
1735 }
1736 CmdOut::ok(lines).with_data(serde_json::json!({"tol": tol, "categories": cats}))
1737 }
1738
1739 fn cmd_print_residuals(&self, rest: &[&str], ctx: &dyn DebugState) -> CmdOut {
1746 let mut k: Option<usize> = None;
1747 let mut filter: Option<bool> = None; for &arg in rest {
1749 if let Ok(n) = arg.parse::<usize>() {
1750 k = Some(n);
1751 } else {
1752 match arg {
1753 "primal" | "pr" => filter = Some(true),
1754 "dual" | "du" => filter = Some(false),
1755 other => {
1756 return CmdOut::err(format!(
1757 "usage: print residuals [primal|dual] [k] (got `{other}`)"
1758 ));
1759 }
1760 }
1761 }
1762 }
1763 let k = k.unwrap_or(10);
1764
1765 let mut all = Vec::new();
1766 if filter != Some(false) {
1767 let Some(primal) = ctx.constraint_residuals() else {
1768 return CmdOut::err("no iterate yet — residuals unavailable");
1769 };
1770 all.extend(primal);
1771 }
1772 if filter != Some(true) {
1773 let Some(dual) = ctx.dual_residuals() else {
1774 return CmdOut::err("no iterate yet — residuals unavailable");
1775 };
1776 all.extend(dual);
1777 }
1778
1779 let total = all.len();
1780 let top = rank_residuals(all, k);
1781 if top.is_empty() {
1782 return CmdOut::ok(vec!["no residuals at this iterate".into()])
1783 .with_data(serde_json::json!({"k": k, "total": total, "top": []}));
1784 }
1785
1786 let names = ctx
1794 .as_any()
1795 .and_then(|a| a.downcast_ref::<DebugCtx>())
1796 .and_then(|c| c.split_names());
1797 let name_of = |r: &Residual| resid_name(r, &names);
1798
1799 let lines = top
1800 .iter()
1801 .map(|r| {
1802 let label = match name_of(r) {
1803 Some(name) => format!("{}[{}]", r.kind.tag(), name),
1804 None => format!("{}[{}]", r.kind.tag(), r.index),
1805 };
1806 format!("{:>8} = {:+.6e} |{:.3e}|", label, r.value, r.value.abs())
1807 })
1808 .collect();
1809 let data: Vec<_> = top
1810 .iter()
1811 .map(|r| {
1812 serde_json::json!({
1813 "space": r.kind.tag(),
1814 "primal": r.kind.is_primal(),
1815 "index": r.index,
1816 "name": name_of(r),
1817 "value": r.value,
1818 })
1819 })
1820 .collect();
1821 CmdOut::ok(lines).with_data(serde_json::json!({"k": k, "total": total, "top": data}))
1822 }
1823
1824 fn cmd_print_equation(&self, rest: &[&str]) -> CmdOut {
1833 let Some(book) = self.equation_book.as_ref() else {
1834 return CmdOut::err(
1835 "no equation source — `print equation` needs an .nl model (none was loaded)",
1836 );
1837 };
1838 if book.is_empty() {
1839 return CmdOut::err("the model has no constraint equations to print");
1840 }
1841 let Some(&key) = rest.first() else {
1842 return CmdOut::ok(vec![format!(
1843 "{} constraint equation(s) — `print equation <name|row>` to show one",
1844 book.len()
1845 )])
1846 .with_data(serde_json::json!({"count": book.len()}));
1847 };
1848 let Some(i) = book.resolve(key) else {
1849 return CmdOut::err(format!(
1850 "no constraint named or indexed `{key}` (have {} equation(s); try a name or 0..{})",
1851 book.len(),
1852 book.len().saturating_sub(1)
1853 ));
1854 };
1855 let label = book.label(i);
1856 let Some(eq) = book.equations.get(i) else {
1859 return CmdOut::err(format!(
1860 "constraint `{key}` has no source algebra (index {i} out of range)"
1861 ));
1862 };
1863 CmdOut::ok(vec![format!("{label}: {eq}")]).with_data(serde_json::json!({
1864 "index": i,
1865 "name": book.names.get(i).filter(|n| !n.is_empty()),
1866 "equation": eq,
1867 }))
1868 }
1869
1870 fn cmd_diagnose(&self, ctx: &DebugCtx) -> CmdOut {
1885 const TOL: f64 = 1e-6;
1886 let names = ctx.split_names();
1887 let mut f: Vec<(&'static str, &'static str, String)> = Vec::new();
1889
1890 let inf_pr = ctx.inf_pr();
1892 if inf_pr > TOL {
1893 if let Some(resids) = ctx.constraint_residuals() {
1894 if let Some((label, val)) = worst_named(resids, &names) {
1895 let sev = if inf_pr > 1e-2 { "error" } else { "warning" };
1896 f.push((
1897 sev,
1898 "primal_infeasible",
1899 format!(
1900 "Primal infeasibility {inf_pr:.2e}; worst constraint residual is \
1901 {label} = {val:+.3e}. Inspect this equation's feasibility and scaling \
1902 at the current point (`print equation {label}`)."
1903 ),
1904 ));
1905 }
1906 }
1907 }
1908
1909 let inf_du = ctx.inf_du();
1911 if inf_du > TOL {
1912 if let Some(resids) = ctx.dual_residuals() {
1913 if let Some((label, val)) = worst_named(resids, &names) {
1914 f.push((
1915 "warning",
1916 "dual_infeasible",
1917 format!(
1918 "Dual infeasibility {inf_du:.2e}; largest stationarity residual is \
1919 {label} = {val:+.3e}."
1920 ),
1921 ));
1922 }
1923 }
1924 }
1925
1926 if let Some(k) = ctx.kkt() {
1928 if k.provides_inertia && !k.inertia_correct {
1929 f.push((
1930 "warning",
1931 "inertia_wrong",
1932 format!(
1933 "KKT inertia is wrong (n-={} vs expected {}): the system was \
1934 indefinite/singular and the step had to be stabilized. A persistent \
1935 mismatch points at a rank-deficient Jacobian or an indefinite Hessian.",
1936 k.n_neg, k.expected_neg
1937 ),
1938 ));
1939 }
1940 if k.delta_w > 1e-4 {
1941 f.push((
1942 "info",
1943 "heavy_regularization",
1944 format!(
1945 "Primal regularization δ_w={:.2e} applied — the Hessian was indefinite at \
1946 this step. Normal near saddle points; persistent large δ_w suggests a \
1947 problematic Hessian.",
1948 k.delta_w
1949 ),
1950 ));
1951 }
1952 if k.delta_c > 0.0 {
1953 f.push((
1954 "warning",
1955 "dual_regularization",
1956 format!(
1957 "Dual regularization δ_c={:.2e} applied — the constraint Jacobian is (near) \
1958 rank-deficient (linearly dependent or redundant equalities). Inspect the \
1959 equality residuals by name (`print residuals primal`).",
1960 k.delta_c
1961 ),
1962 ));
1963 }
1964 }
1965
1966 if let Some(book) = self.structure_book.as_ref() {
1970 f.extend(book.findings());
1971 }
1972
1973 if let Some(rep) = ctx.rank_report() {
1979 if rep.is_rank_deficient() {
1980 let culprits: Vec<String> = rep
1981 .culprits
1982 .iter()
1983 .take(MAX_RANK_CULPRITS)
1984 .map(|c| rank_row_label(&rep.rows[c.row], &names))
1985 .collect();
1986 let named = if culprits.is_empty() {
1987 String::new()
1988 } else {
1989 format!(" Implicated equations: {}.", culprits.join(", "))
1990 };
1991 f.push((
1992 "warning",
1993 "rank_deficient_jacobian",
1994 format!(
1995 "Equality Jacobian J_c is numerically rank-deficient at this iterate: \
1996 rank {}/{} (deficiency {}), σ_min={:.2e}, cond={}. Linearly dependent \
1997 or redundant equality constraints — the root cause behind δ_c \
1998 regularization / wrong inertia.{named}",
1999 rep.rank,
2000 rep.n_rows(),
2001 rep.deficiency(),
2002 rep.sigma_min(),
2003 fmt_cond(rep.cond),
2004 ),
2005 ));
2006 }
2007 }
2008
2009 let mut max_mult = 0.0_f64;
2011 for blk in ["y_c", "y_d", "z_l", "z_u", "v_l", "v_u"] {
2012 if let Some(v) = ctx.block(blk) {
2013 max_mult = v.iter().fold(max_mult, |m, &x| m.max(x.abs()));
2014 }
2015 }
2016 if max_mult > 1e8 {
2017 f.push((
2018 "warning",
2019 "large_multipliers",
2020 format!(
2021 "Largest multiplier magnitude is {max_mult:.2e}. Very large multipliers signal a \
2022 constraint-qualification failure or poor scaling — consider rescaling the \
2023 offending rows."
2024 ),
2025 ));
2026 }
2027
2028 let mut pinned = 0usize;
2030 for cat in ["x_l", "x_u"] {
2031 if let Some(sl) = ctx.bound_slack(cat) {
2032 pinned += sl.iter().filter(|&&s| s.abs() < TOL).count();
2033 }
2034 }
2035 if pinned > 0 {
2036 f.push((
2037 "info",
2038 "bounds_pinned",
2039 format!(
2040 "{pinned} variable bound(s) are active (slack < {TOL:.0e}). Active bounds are \
2041 expected at a solution, but a large count early can throttle the line search."
2042 ),
2043 ));
2044 }
2045
2046 let (alpha_pr, _) = ctx.alpha();
2048 if ctx.iter() > 0 && alpha_pr > 0.0 && alpha_pr < 1e-6 {
2049 f.push((
2050 "warning",
2051 "tiny_step",
2052 format!(
2053 "Accepted primal step α_pr={alpha_pr:.2e} is tiny — the line search is barely \
2054 moving. Often a poor search direction or an ill-conditioned KKT system."
2055 ),
2056 ));
2057 }
2058 let ls = ctx.ls_count();
2059 if ls >= 10 {
2060 f.push((
2061 "warning",
2062 "heavy_line_search",
2063 format!(
2064 "Line search needed {ls} trial points for the accepted step — search-direction \
2065 quality may be poor (check Hessian accuracy)."
2066 ),
2067 ));
2068 }
2069
2070 if self.in_restoration {
2072 f.push((
2073 "warning",
2074 "in_restoration",
2075 "Currently inside feasibility restoration: the line search could not make \
2076 progress on the original problem at the working point."
2077 .to_string(),
2078 ));
2079 }
2080 if self.mu_stall >= MU_STALL_ITERS {
2081 f.push((
2082 "warning",
2083 "mu_stalled",
2084 format!(
2085 "μ has not decreased for {} consecutive iterations — the barrier is stuck. \
2086 Try mu_strategy=adaptive or a smaller mu_init.",
2087 self.mu_stall
2088 ),
2089 ));
2090 }
2091
2092 if f.is_empty() {
2094 f.push((
2095 "info",
2096 "healthy",
2097 format!(
2098 "No issues detected at iter {}: inf_pr={:.2e}, inf_du={:.2e}, μ={:.2e}.",
2099 ctx.iter(),
2100 inf_pr,
2101 inf_du,
2102 ctx.mu()
2103 ),
2104 ));
2105 }
2106
2107 let rank = |s: &str| match s {
2109 "error" => 0,
2110 "warning" => 1,
2111 _ => 2,
2112 };
2113 f.sort_by_key(|(sev, _, _)| rank(sev));
2114
2115 let lines: Vec<String> = f
2116 .iter()
2117 .map(|(sev, code, msg)| format!("[{sev:>7}] {code}: {msg}"))
2118 .collect();
2119 let data: Vec<_> = f
2120 .iter()
2121 .map(|(sev, code, msg)| serde_json::json!({"severity": sev, "code": code, "message": msg}))
2122 .collect();
2123 let n = data.len();
2124 CmdOut::ok(lines)
2125 .with_data(serde_json::json!({"iter": ctx.iter(), "findings": data, "n_findings": n}))
2126 }
2127
2128 fn cmd_print_kkt(&self, ctx: &dyn DebugState) -> CmdOut {
2131 let Some(k) = ctx.kkt() else {
2132 return CmdOut::err(
2133 "no KKT factorization yet — stop at `after_search_dir` (e.g. `stop-at kkt`)",
2134 );
2135 };
2136 let inertia = if k.provides_inertia {
2137 format!(
2138 "n+={} n-={} (expected n-={}) → {}",
2139 k.n_pos,
2140 k.n_neg,
2141 k.expected_neg,
2142 if k.inertia_correct {
2143 "correct"
2144 } else {
2145 "WRONG (step stabilized)"
2146 }
2147 )
2148 } else {
2149 "n/a (backend reports no inertia)".to_string()
2150 };
2151 let lines = vec![
2152 format!("dim = {}", k.dim),
2153 format!("inertia = {inertia}"),
2154 format!("delta_w = {:.6e} (primal regularization)", k.delta_w),
2155 format!("delta_c = {:.6e} (dual regularization)", k.delta_c),
2156 format!("status = {}", k.status),
2157 ];
2158 CmdOut::ok(lines).with_data(serde_json::json!({
2159 "dim": k.dim,
2160 "n_pos": k.n_pos,
2161 "n_neg": k.n_neg,
2162 "expected_neg": k.expected_neg,
2163 "provides_inertia": k.provides_inertia,
2164 "inertia_correct": k.inertia_correct,
2165 "delta_w": k.delta_w,
2166 "delta_c": k.delta_c,
2167 "status": k.status,
2168 }))
2169 }
2170
2171 fn cmd_print_rank(&self, ctx: &DebugCtx) -> CmdOut {
2180 let Some(rep) = ctx.rank_report() else {
2181 return CmdOut::err(
2182 "no equality-constraint Jacobian to analyze (the problem has no equality \
2183 constraints, or there is no iterate yet)",
2184 );
2185 };
2186 let names = ctx.split_names();
2187 let (lines, data) =
2188 render_rank_report(&rep, &names, self.equation_book.as_ref(), ctx.iter());
2189 CmdOut::ok(lines).with_data(data)
2190 }
2191
2192 fn cmd_run(&mut self, rest: &[&str]) -> CmdOut {
2193 match rest.first().and_then(|s| s.parse::<i32>().ok()) {
2194 Some(n) => {
2195 self.run_to = Some(n);
2196 self.step = false;
2197 CmdOut::ok(vec![format!("running until iteration {n}")]).flow(Flow::Resume)
2198 }
2199 None => CmdOut::err("usage: run <iteration>"),
2200 }
2201 }
2202
2203 fn cmd_break(&mut self, rest: &[&str]) -> CmdOut {
2204 if rest.first().copied() == Some("if") {
2208 let expr: String = rest[1..].concat();
2209 if expr.is_empty() {
2210 return CmdOut::err(
2211 "usage: break if <metric><op><value> (e.g. break if inf_pr<1e-6)",
2212 );
2213 }
2214 return match Condition::parse(&expr) {
2215 Ok(c) => {
2216 let raw = c.raw.clone();
2217 if !self.conds.iter().any(|e| e.raw == raw) {
2218 self.conds.push(c);
2219 }
2220 CmdOut::ok(vec![format!("conditional breakpoint: {raw}")])
2221 .with_data(serde_json::json!({"condition": raw}))
2222 }
2223 Err(e) => CmdOut::err(e),
2224 };
2225 }
2226 if rest.first().copied() == Some("on") {
2228 let Some(&name) = rest.get(1) else {
2229 return CmdOut::err(format!("usage: break on <event> (one of {EVENTS:?})"));
2230 };
2231 let Some(&canon) = EVENTS.iter().find(|&&e| e == name) else {
2232 return CmdOut::err(format!("unknown event `{name}` (one of {EVENTS:?})"));
2233 };
2234 self.break_events.insert(canon);
2235 return CmdOut::ok(vec![format!("break on event `{canon}`")])
2236 .with_data(serde_json::json!({"event": canon}));
2237 }
2238 match rest {
2239 [] => {
2240 let mut bs = self.breaks.clone();
2241 bs.sort_unstable();
2242 let conds: Vec<String> = self.conds.iter().map(|c| c.raw.clone()).collect();
2243 let mut events: Vec<&str> = self.break_events.iter().copied().collect();
2244 events.sort_unstable();
2245 let mut lines = vec![format!("breakpoints: {bs:?}")];
2246 if !conds.is_empty() {
2247 lines.push(format!("conditions: {}", conds.join(", ")));
2248 }
2249 if !events.is_empty() {
2250 lines.push(format!("events: {}", events.join(", ")));
2251 }
2252 CmdOut::ok(lines).with_data(
2253 serde_json::json!({"breakpoints": bs, "conditions": conds, "events": events}),
2254 )
2255 }
2256 ["clear", "cond"] | ["clear", "conditions"] => {
2257 self.conds.clear();
2258 CmdOut::ok(vec!["cleared conditional breakpoints".into()])
2259 }
2260 ["clear", "events"] => {
2261 self.break_events.clear();
2262 CmdOut::ok(vec!["cleared event breakpoints".into()])
2263 }
2264 ["clear"] => {
2265 self.breaks.clear();
2266 self.conds.clear();
2267 self.break_events.clear();
2268 CmdOut::ok(vec!["cleared all breakpoints".into()])
2269 }
2270 ["del", n] | ["delete", n] => match n.parse::<i32>() {
2271 Ok(n) => {
2272 self.breaks.retain(|&b| b != n);
2273 CmdOut::ok(vec![format!("removed breakpoint {n}")])
2274 }
2275 Err(_) => CmdOut::err("usage: break del <iteration>"),
2276 },
2277 [n] => match n.parse::<i32>() {
2278 Ok(n) => {
2279 if !self.breaks.contains(&n) {
2280 self.breaks.push(n);
2281 }
2282 CmdOut::ok(vec![format!("breakpoint at iteration {n}")])
2283 }
2284 Err(_) => CmdOut::err("usage: break <iteration>"),
2285 },
2286 _ => CmdOut::err("usage: break [N | if <m><op><v> | clear | clear cond | del N]"),
2287 }
2288 }
2289
2290 fn cmd_stop_at(&mut self, rest: &[&str]) -> CmdOut {
2294 let canon = |s: &str| -> Option<&'static str> {
2295 match s {
2296 "mu" | "after_mu" => Some("after_mu"),
2297 "kkt" | "search_dir" | "after_search_dir" => Some("after_search_dir"),
2298 "step" | "after_step" => Some("after_step"),
2299 "rejected" | "ls_rejected" | "step_rejected" => Some("step_rejected"),
2300 "resto" | "restoration" | "pre_restoration_entry" => Some("pre_restoration_entry"),
2301 "resto_exit" | "post_restoration_exit" => Some("post_restoration_exit"),
2302 "iter" | "iter_start" => Some("iter_start"),
2303 "terminated" => Some("terminated"),
2304 _ => None,
2305 }
2306 };
2307 match rest {
2308 [] => {
2309 let mut v: Vec<&str> = self.stop_at.iter().copied().collect();
2310 v.sort_unstable();
2311 CmdOut::ok(vec![format!(
2312 "stop-at: {v:?} (available: {CHECKPOINTS:?})"
2313 )])
2314 .with_data(serde_json::json!({"stop_at": v, "available": CHECKPOINTS}))
2315 }
2316 ["clear"] => {
2317 self.stop_at.clear();
2318 CmdOut::ok(vec!["cleared stop-at checkpoints".into()])
2319 }
2320 [name] => match canon(name) {
2321 Some(c) => {
2322 self.stop_at.insert(c);
2323 CmdOut::ok(vec![format!("will stop at checkpoint `{c}`")])
2324 .with_data(serde_json::json!({"stop_at_added": c}))
2325 }
2326 None => CmdOut::err(format!(
2327 "unknown checkpoint `{name}` (one of {CHECKPOINTS:?})"
2328 )),
2329 },
2330 _ => CmdOut::err("usage: stop-at [<checkpoint> | clear]"),
2331 }
2332 }
2333
2334 fn cmd_set(&mut self, rest: &[&str], ctx: &mut dyn DebugState) -> CmdOut {
2335 match rest {
2336 ["mu", v] => match v.parse::<f64>() {
2337 Ok(mu) => match ctx.set_mu(mu) {
2338 Ok(()) => CmdOut::ok(vec![format!("mu := {mu:.6e}")]),
2339 Err(e) => CmdOut::err(e),
2340 },
2341 Err(_) => CmdOut::err("usage: set mu <value>"),
2342 },
2343 ["opt", name, value] => match as_nlp_mut(ctx) {
2344 Some(c) => self.cmd_set_opt(name, value, c),
2345 None => nlp_only("set opt"),
2346 },
2347 [target, value] => self.cmd_set_block(target, value, ctx),
2348 _ => CmdOut::err(
2349 "usage: set mu <v> | set <blk>[<i>] <v> | set <blk> <v0,v1,..> | set opt <name> <v>",
2350 ),
2351 }
2352 }
2353
2354 fn cmd_set_block(&mut self, target: &str, value: &str, ctx: &mut dyn DebugState) -> CmdOut {
2356 if let Some(open) = target.find('[') {
2358 if !target.ends_with(']') {
2359 return CmdOut::err("malformed component target (expected name[idx])");
2360 }
2361 let name = &target[..open];
2362 let idx_str = &target[open + 1..target.len() - 1];
2363 let Ok(idx) = idx_str.parse::<usize>() else {
2364 return CmdOut::err(format!("bad index `{idx_str}`"));
2365 };
2366 let Ok(val) = value.parse::<f64>() else {
2367 return CmdOut::err(format!("bad value `{value}`"));
2368 };
2369 return match ctx.set_component(name, idx, val) {
2370 Ok(()) => CmdOut::ok(vec![format!("{name}[{idx}] := {val:.6e}")]),
2371 Err(e) => CmdOut::err(e),
2372 };
2373 }
2374 let parsed: Result<Vec<f64>, _> =
2376 value.split(',').map(|s| s.trim().parse::<f64>()).collect();
2377 match parsed {
2378 Ok(vals) => match ctx.set_block(target, &vals) {
2379 Ok(()) => CmdOut::ok(vec![format!("{target} := {} value(s)", vals.len())]),
2380 Err(e) => CmdOut::err(e),
2381 },
2382 Err(_) => CmdOut::err("could not parse comma-separated values"),
2383 }
2384 }
2385
2386 fn cmd_set_opt(&mut self, name: &str, value: &str, ctx: &mut DebugCtx) -> CmdOut {
2387 let Some(reg) = self.reg.as_ref() else {
2388 return CmdOut::err("no options registry available");
2389 };
2390 let Some(opt) = reg.get_option(name) else {
2391 return CmdOut::err(format!("unknown option `{name}` (try `opt {name}`)"));
2392 };
2393 let valid = match opt.option_type {
2395 OptionType::OT_Number => value
2396 .parse::<f64>()
2397 .map(|v| opt.is_valid_number(v))
2398 .unwrap_or(false),
2399 OptionType::OT_Integer => value
2400 .parse::<i32>()
2401 .map(|v| opt.is_valid_integer(v))
2402 .unwrap_or(false),
2403 OptionType::OT_String => opt.is_valid_string(value),
2404 OptionType::OT_Unknown => true,
2405 };
2406 if !valid {
2407 return CmdOut::err(format!("`{value}` is not a valid value for `{name}`"));
2408 }
2409 self.staged.retain(|(k, _)| k != name);
2412 self.staged.push((name.to_string(), value.to_string()));
2413 if is_live_tolerance(name) {
2418 if let Ok(v) = value.parse::<f64>() {
2419 ctx.set_live_tolerance(name, v);
2420 return CmdOut::ok(vec![format!(
2421 "{name} = {value} (applied live — the next `step` uses it)"
2422 )])
2423 .with_data(serde_json::json!({
2424 "option": name, "value": value, "live": true
2425 }));
2426 }
2427 }
2428 CmdOut::ok(vec![format!(
2429 "staged {name} = {value} (validated; takes effect on `resolve` — built strategies don't re-read mid-solve)"
2430 )])
2431 .with_data(serde_json::json!({"option": name, "value": value, "staged": true}))
2432 }
2433
2434 fn cmd_get(&self, rest: &[&str]) -> CmdOut {
2441 let name = match rest {
2443 ["opt", n] => *n,
2444 [n] => *n,
2445 _ => return CmdOut::err("usage: get opt <name>"),
2446 };
2447 let Some(reg) = self.reg.as_ref() else {
2448 return CmdOut::err("no options registry available");
2449 };
2450 let Some(o) = reg.get_option(name) else {
2451 return CmdOut::err(format!("unknown option `{name}` (try `opt {name}`)"));
2452 };
2453 let def = default_str(&o.default);
2454 let staged = self
2455 .staged
2456 .iter()
2457 .find(|(k, _)| k == name)
2458 .map(|(_, v)| v.clone());
2459 let (value, source) = match &staged {
2460 Some(v) => (v.clone(), "staged"),
2461 None => (def.clone(), "default"),
2462 };
2463 CmdOut::ok(vec![format!("{name} = {value} ({source}; default={def})")]).with_data(
2464 serde_json::json!({
2465 "option": name, "value": value, "source": source,
2466 "default": def, "staged": staged,
2467 }),
2468 )
2469 }
2470
2471 fn cmd_opt(&self, rest: &[&str]) -> CmdOut {
2472 let Some(reg) = self.reg.as_ref() else {
2473 return CmdOut::err("no options registry available");
2474 };
2475 let filter = rest.first().copied().unwrap_or("");
2476 let mut lines = Vec::new();
2477 let mut data = Vec::new();
2478 for o in reg.registered_options_in_order() {
2479 if !filter.is_empty()
2480 && !o.name.contains(filter)
2481 && !o
2482 .category
2483 .to_ascii_lowercase()
2484 .contains(&filter.to_ascii_lowercase())
2485 {
2486 continue;
2487 }
2488 let ty = type_str(o.option_type);
2489 let def = default_str(&o.default);
2490 lines.push(format!(
2491 " {:<28} {:<7} default={:<12} {}",
2492 o.name, ty, def, o.short_description
2493 ));
2494 data.push(serde_json::json!({
2495 "name": o.name,
2496 "type": ty,
2497 "default": def,
2498 "category": o.category,
2499 "short": o.short_description,
2500 "valid": o.valid_strings.iter().map(|e| e.value.clone()).collect::<Vec<_>>(),
2501 }));
2502 }
2503 if lines.is_empty() {
2504 return CmdOut::ok(vec![format!("no options match `{filter}`")]);
2505 }
2506 if data.len() == 1 {
2508 if let Some(o) = reg.get_option(filter) {
2509 if !o.long_description.is_empty() {
2510 lines.push(String::new());
2511 lines.push(o.long_description.clone());
2512 }
2513 }
2514 }
2515 CmdOut::ok(lines).with_data(serde_json::json!({"options": data}))
2516 }
2517
2518 fn cmd_complete(&self, rest: &[&str]) -> CmdOut {
2523 let (before, word) = match rest.split_last() {
2524 Some((w, pre)) => (pre.join(" "), *w),
2525 None => (String::new(), ""),
2526 };
2527 let mut cands = completion_candidates(self.reg.as_deref(), &before, word);
2528 cands.sort();
2529 cands.dedup();
2530 CmdOut::ok(vec![cands.join(" ")]).with_data(serde_json::json!({"candidates": cands}))
2531 }
2532
2533 fn cmd_save(&self, rest: &[&str], ctx: &dyn DebugState) -> CmdOut {
2537 let iter = ctx.iter();
2538 let collect = |delta: bool| -> serde_json::Map<String, serde_json::Value> {
2539 let mut m = serde_json::Map::new();
2540 for b in block_names(ctx) {
2541 let v = if delta {
2542 ctx.delta_block(b)
2543 } else {
2544 ctx.block(b)
2545 };
2546 if let Some(v) = v {
2547 if !v.is_empty() {
2548 let key = if delta {
2549 format!("d{b}")
2550 } else {
2551 b.to_string()
2552 };
2553 m.insert(key, serde_json::json!(v));
2554 }
2555 }
2556 }
2557 m
2558 };
2559 let payload = serde_json::json!({
2560 "iter": iter,
2561 "mu": ctx.mu(),
2562 "objective": ctx.objective(),
2563 "inf_pr": ctx.inf_pr(),
2564 "inf_du": ctx.inf_du(),
2565 "nlp_error": ctx.nlp_error(),
2566 "iterate": collect(false),
2567 "delta": collect(true),
2568 });
2569 if self.inline_artifacts && rest.is_empty() {
2572 return CmdOut::ok(vec![format!("iterate at iter {iter} (inline)")]).with_data(payload);
2573 }
2574 let path = rest
2575 .first()
2576 .map(PathBuf::from)
2577 .unwrap_or_else(|| std::env::temp_dir().join(format!("pounce-dbg-iter{iter}.json")));
2578 match std::fs::write(&path, format!("{payload}\n")) {
2579 Ok(()) => {
2580 let p = path.to_string_lossy().to_string();
2581 CmdOut::ok(vec![format!("saved iterate to {p}")])
2582 .with_data(serde_json::json!({"path": p}))
2583 }
2584 Err(e) => CmdOut::err(format!("save failed: {e}")),
2585 }
2586 }
2587
2588 fn cmd_load(&mut self, rest: &[&str], ctx: &mut DebugCtx) -> CmdOut {
2595 let Some(&path) = rest.first() else {
2596 return CmdOut::err("usage: load <file> [block] (inverse of `save`)");
2597 };
2598 let content = match std::fs::read_to_string(path) {
2599 Ok(c) => c,
2600 Err(e) => return CmdOut::err(format!("cannot read `{path}`: {e}")),
2601 };
2602 if let Ok(v) = serde_json::from_str::<serde_json::Value>(content.trim()) {
2606 let obj = v
2607 .get("iterate")
2608 .and_then(|o| o.as_object())
2609 .or_else(|| v.as_object());
2610 if let Some(obj) = obj {
2611 let mut loaded: Vec<(String, usize)> = Vec::new();
2612 let mut errs: Vec<String> = Vec::new();
2613 for &b in BLOCK_NAMES.iter() {
2614 let Some(arr) = obj.get(b).and_then(|a| a.as_array()) else {
2615 continue;
2616 };
2617 let vals: Option<Vec<f64>> = arr.iter().map(|x| x.as_f64()).collect();
2618 let Some(vals) = vals else {
2619 errs.push(format!("{b}: non-numeric entries"));
2620 continue;
2621 };
2622 match ctx.set_block(b, &vals) {
2623 Ok(()) => loaded.push((b.to_string(), vals.len())),
2624 Err(e) => errs.push(format!("{b}: {e}")),
2625 }
2626 }
2627 if loaded.is_empty() && errs.is_empty() {
2628 return CmdOut::err(
2629 "no recognizable blocks in JSON (expected `x`, `s`, … at top level or under `iterate`)",
2630 );
2631 }
2632 let mut lines: Vec<String> = loaded
2633 .iter()
2634 .map(|(b, n)| format!("loaded {b} ({n} values)"))
2635 .collect();
2636 lines.extend(errs.iter().map(|e| format!("skipped {e}")));
2637 return CmdOut::ok(lines).with_data(serde_json::json!({
2638 "loaded": loaded.iter().map(|(b, n)| serde_json::json!({"block": b, "n": n})).collect::<Vec<_>>(),
2639 "skipped": errs,
2640 }));
2641 }
2642 }
2643 let block = rest.get(1).copied().unwrap_or("x");
2645 let vals = match parse_floats(&content) {
2646 Ok(v) if !v.is_empty() => v,
2647 Ok(_) => return CmdOut::err("file held no numbers"),
2648 Err(e) => return CmdOut::err(e),
2649 };
2650 match ctx.set_block(block, &vals) {
2651 Ok(()) => CmdOut::ok(vec![format!("loaded {block} ({} values)", vals.len())])
2652 .with_data(serde_json::json!({"block": block, "n": vals.len()})),
2653 Err(e) => CmdOut::err(e),
2654 }
2655 }
2656
2657 fn cmd_sweep(&mut self, rest: &[&str], ctx: &mut DebugCtx) -> CmdOut {
2663 if self.restart.is_none() {
2664 return CmdOut::err("sweep needs re-solve, which is not available in this context");
2665 }
2666 let Some(&path) = rest.first() else {
2667 return CmdOut::err("usage: sweep <file> (one start per line, comma-separated)");
2668 };
2669 let content = match std::fs::read_to_string(path) {
2670 Ok(c) => c,
2671 Err(e) => return CmdOut::err(format!("cannot read `{path}`: {e}")),
2672 };
2673 let dim = ctx.block("x").map(|x| x.len()).unwrap_or(0);
2674 let mut seeds: Vec<Vec<f64>> = Vec::new();
2675 for (lineno, raw) in content.lines().enumerate() {
2676 let line = raw.trim();
2677 if line.is_empty() || line.starts_with('#') || line.starts_with("//") {
2678 continue;
2679 }
2680 match parse_floats(line) {
2681 Ok(v) if v.len() == dim => seeds.push(v),
2682 Ok(v) => {
2683 return CmdOut::err(format!(
2684 "line {}: got {} values, expected {dim} (= dim x)",
2685 lineno + 1,
2686 v.len()
2687 ));
2688 }
2689 Err(e) => return CmdOut::err(format!("line {}: {e}", lineno + 1)),
2690 }
2691 }
2692 self.start_sweep(seeds, &format!("sweep `{path}`"))
2693 }
2694
2695 fn cmd_multistart(&mut self, rest: &[&str], ctx: &mut DebugCtx) -> CmdOut {
2703 if self.restart.is_none() {
2704 return CmdOut::err(
2705 "multistart needs re-solve, which is not available in this context",
2706 );
2707 }
2708 let Some(n) = rest.first().and_then(|s| s.parse::<usize>().ok()) else {
2709 return CmdOut::err("usage: multistart <N> [rel] (N sampled restarts)");
2710 };
2711 if n == 0 {
2712 return CmdOut::err("N must be ≥ 1");
2713 }
2714 let rel = rest
2715 .get(1)
2716 .and_then(|s| s.parse::<f64>().ok())
2717 .unwrap_or(0.1);
2718 let Some(base) = ctx.block("x") else {
2719 return CmdOut::err("no current iterate to sample from");
2720 };
2721 let bounds = ctx
2723 .var_bounds()
2724 .filter(|(lo, hi)| lo.len() == base.len() && hi.len() == base.len());
2725 let n_box = bounds
2726 .as_ref()
2727 .map(|(lo, hi)| {
2728 lo.iter()
2729 .zip(hi)
2730 .filter(|(l, u)| l.is_finite() && u.is_finite() && u > l)
2731 .count()
2732 })
2733 .unwrap_or(0);
2734 let seeds: Vec<Vec<f64>> = (0..n)
2735 .map(|k| {
2736 let b = bounds
2737 .as_ref()
2738 .map(|(lo, hi)| (lo.as_slice(), hi.as_slice()));
2739 sample_start(&base, b, rel, k)
2740 })
2741 .collect();
2742 let n_var = base.len();
2743 let label = if n_box == n_var {
2744 format!("multistart {n} (box-sampled, {n_box}/{n_var} vars bounded)")
2745 } else if n_box > 0 {
2746 format!(
2747 "multistart {n} (box {n_box}/{n_var} vars; {} unbounded → jitter rel={rel})",
2748 n_var - n_box
2749 )
2750 } else {
2751 format!("multistart {n} (no finite boxes → jitter rel={rel})")
2752 };
2753 self.start_sweep(seeds, &label)
2754 }
2755
2756 fn start_sweep(&mut self, seeds: Vec<Vec<f64>>, label: &str) -> CmdOut {
2761 if seeds.is_empty() {
2762 return CmdOut::err("no start points");
2763 }
2764 let Some(cell) = self.restart.as_ref() else {
2765 return CmdOut::err("sweep needs re-solve, which is not available in this context");
2766 };
2767 let total = seeds.len();
2768 let mut queue: VecDeque<Vec<f64>> = seeds.into();
2769 let first = queue.pop_front().expect("non-empty");
2770 *cell.borrow_mut() = Some(RestartRequest {
2771 seed_x: first.clone(),
2772 options: self.staged.clone(),
2773 warm: None,
2774 });
2775 let saved_pause_iters = self.pause_iters;
2778 self.pause_iters = false;
2779 self.step = false;
2780 self.sub_step = false;
2781 self.run_to = None;
2782 self.sweep = Some(SweepState {
2783 queue,
2784 current: Some(first),
2785 records: Vec::new(),
2786 total,
2787 saved_pause_iters,
2788 });
2789 CmdOut::ok(vec![format!("{label}: running {total} start(s)…")])
2790 .with_data(serde_json::json!({"sweep": label, "starts": total}))
2791 .flow(Flow::Stop)
2792 }
2793
2794 fn drive_sweep(&mut self, ctx: &DebugCtx) -> Option<DebugAction> {
2801 let mut sweep = self.sweep.take()?;
2802 let rec = SweepRecord {
2803 idx: sweep.records.len(),
2804 seed: sweep.current.clone().unwrap_or_default(),
2805 status: ctx.status().unwrap_or("?").to_string(),
2806 objective: ctx.objective(),
2807 inf_pr: ctx.inf_pr(),
2808 iters: ctx.iter(),
2809 };
2810 self.emit_sweep_progress(&rec, sweep.total);
2811 sweep.records.push(rec);
2812 if let Some(next) = sweep.queue.pop_front() {
2813 sweep.current = Some(next.clone());
2814 if let Some(cell) = self.restart.as_ref() {
2815 *cell.borrow_mut() = Some(RestartRequest {
2816 seed_x: next,
2817 options: self.staged.clone(),
2818 warm: None,
2819 });
2820 }
2821 self.sweep = Some(sweep);
2822 return Some(DebugAction::Resume);
2823 }
2824 self.pause_iters = sweep.saved_pause_iters;
2826 self.emit_sweep_summary(&sweep);
2827 None
2828 }
2829
2830 fn emit_sweep_progress(&self, rec: &SweepRecord, total: usize) {
2833 match self.mode {
2834 DebugMode::Repl => eprintln!(
2835 " sweep {}/{}: {:<22} iters={:<4} obj={:.6e} inf_pr={:.2e}",
2836 rec.idx + 1,
2837 total,
2838 rec.status,
2839 rec.iters,
2840 rec.objective,
2841 rec.inf_pr,
2842 ),
2843 DebugMode::Json => emit_json(&serde_json::json!({
2844 "event": "sweep_result",
2845 "index": rec.idx,
2846 "total": total,
2847 "status": rec.status,
2848 "iters": rec.iters,
2849 "objective": rec.objective,
2850 "inf_pr": rec.inf_pr,
2851 "seed": rec.seed,
2852 })),
2853 }
2854 }
2855
2856 fn emit_sweep_summary(&self, sweep: &SweepState) {
2859 let succeeded: Vec<&SweepRecord> = sweep
2860 .records
2861 .iter()
2862 .filter(|r| is_success_status(&r.status))
2863 .collect();
2864 let mut distinct: Vec<f64> = Vec::new();
2866 for r in &succeeded {
2867 if !distinct
2868 .iter()
2869 .any(|&o| (o - r.objective).abs() <= 1e-6 * o.abs().max(1.0))
2870 {
2871 distinct.push(r.objective);
2872 }
2873 }
2874 let best = succeeded.iter().min_by(|a, b| {
2875 a.objective
2876 .partial_cmp(&b.objective)
2877 .unwrap_or(std::cmp::Ordering::Equal)
2878 });
2879 match self.mode {
2880 DebugMode::Repl => {
2881 eprintln!(
2882 "\n── sweep complete ── {} solves, {} succeeded, {} distinct end points (by objective; not curvature-classified)",
2883 sweep.records.len(),
2884 succeeded.len(),
2885 distinct.len()
2886 );
2887 eprintln!(
2888 " {:>3} {:<22} {:>5} {:>14} {:>9}",
2889 "#", "status", "iters", "objective", "inf_pr"
2890 );
2891 for r in &sweep.records {
2892 eprintln!(
2893 " {:>3} {:<22} {:>5} {:>14.6e} {:>9.2e}",
2894 r.idx, r.status, r.iters, r.objective, r.inf_pr
2895 );
2896 }
2897 if let Some(b) = best {
2898 eprintln!(" best: solve #{} obj={:.8e}", b.idx, b.objective);
2899 }
2900 }
2901 DebugMode::Json => emit_json(&serde_json::json!({
2902 "event": "sweep_summary",
2903 "solves": sweep.records.len(),
2904 "succeeded": succeeded.len(),
2905 "distinct_points": distinct.len(),
2909 "distinct_minima": distinct.len(),
2910 "best_index": best.map(|b| b.idx),
2911 "best_objective": best.map(|b| b.objective),
2912 "records": sweep.records.iter().map(|r| serde_json::json!({
2913 "index": r.idx, "status": r.status, "iters": r.iters,
2914 "objective": r.objective, "inf_pr": r.inf_pr,
2915 })).collect::<Vec<_>>(),
2916 })),
2917 }
2918 }
2919
2920 fn cmd_goto(&mut self, rest: &[&str], ctx: &mut dyn DebugState) -> CmdOut {
2922 match rest.first().and_then(|s| s.parse::<i32>().ok()) {
2923 Some(k) => self.restore_to(k, ctx),
2924 None => CmdOut::err("usage: goto <iteration>"),
2925 }
2926 }
2927
2928 fn restore_to(&mut self, k: i32, ctx: &mut dyn DebugState) -> CmdOut {
2932 match self.snapshots.get(&k) {
2933 Some(snap) => {
2934 if !ctx.restore(snap.as_ref()) {
2935 return CmdOut::err(format!(
2936 "this solver does not support rewinding to iter {k}"
2937 ));
2938 }
2939 CmdOut::ok(vec![format!(
2940 "rewound to iter {k} (primal-dual only; strategy history not restored). \
2941 `continue`/`step` to resume."
2942 )])
2943 .with_data(serde_json::json!({"restored_iter": k}))
2944 }
2945 None => {
2946 let have: Vec<i32> = self.snapshots.keys().copied().collect();
2947 CmdOut::err(format!("no snapshot for iter {k} (captured: {have:?})"))
2948 }
2949 }
2950 }
2951
2952 fn cmd_resolve(&mut self, ctx: &DebugCtx) -> CmdOut {
2960 let Some(cell) = self.restart.as_ref() else {
2961 return CmdOut::err("re-solve is not available in this context");
2962 };
2963 let Some(seed_x) = ctx.block("x") else {
2964 return CmdOut::err("no current iterate to seed from");
2965 };
2966 let warm = ctx.snapshot();
2967 let mu = warm.as_ref().map(|s| s.mu());
2968 let options = self.staged.clone();
2969 let n_opt = options.len();
2970 let warm_msg = match mu {
2971 Some(mu) => format!(
2972 "re-solving warm from the current primal-dual iterate (μ={mu:.3e}) \
2973 with {n_opt} staged option override(s)…"
2974 ),
2975 None => format!(
2976 "re-solving from current x (primal-only) with {n_opt} staged option override(s)…"
2977 ),
2978 };
2979 *cell.borrow_mut() = Some(RestartRequest {
2980 seed_x,
2981 options,
2982 warm,
2983 });
2984 CmdOut::ok(vec![warm_msg])
2985 .with_data(serde_json::json!({
2986 "resolve": true,
2987 "options": n_opt,
2988 "warm": mu.is_some(),
2989 "mu": mu,
2990 }))
2991 .flow(Flow::Stop)
2992 }
2993
2994 fn cmd_ask(&self, rest: &[&str], ctx: &dyn DebugState) -> CmdOut {
3000 let (prompt_only, rest) = match rest.split_first() {
3004 Some((&"--prompt-only", tail)) => (true, tail),
3005 _ => (false, rest),
3006 };
3007 let question = if rest.is_empty() {
3008 "Explain the current state of this interior-point solve and suggest what to try next."
3009 .to_string()
3010 } else {
3011 rest.join(" ")
3012 };
3013 let prompt = build_ask_prompt(ctx, &question);
3014 if prompt_only {
3015 let lines = prompt.lines().map(str::to_string).collect();
3016 return CmdOut::ok(lines).with_data(serde_json::json!({
3017 "question": question,
3018 "prompt": prompt,
3019 }));
3020 }
3021 match run_llm(&prompt) {
3022 Ok(reply) => {
3023 let lines: Vec<String> = reply.lines().map(|l| l.to_string()).collect();
3024 CmdOut::ok(lines).with_data(serde_json::json!({
3025 "question": question,
3026 "reply": reply,
3027 }))
3028 }
3029 Err(e) => CmdOut::err(e),
3030 }
3031 }
3032
3033 fn cmd_watch(&mut self, rest: &[&str]) -> CmdOut {
3036 match rest {
3037 [] => CmdOut::ok(vec![format!("watches: {:?}", self.watches)])
3038 .with_data(serde_json::json!({"watches": self.watches})),
3039 ["clear"] => {
3040 self.watches.clear();
3041 CmdOut::ok(vec!["cleared watches".into()])
3042 }
3043 ["del", w] | ["delete", w] => {
3044 self.watches.retain(|x| x != w);
3045 CmdOut::ok(vec![format!("unwatched {w}")])
3046 }
3047 [w] => {
3048 let w = w.to_string();
3049 if !self.watches.contains(&w) {
3050 self.watches.push(w.clone());
3051 }
3052 CmdOut::ok(vec![format!("watching {w}")])
3053 }
3054 _ => CmdOut::err("usage: watch [<target> | clear | del <target>]"),
3055 }
3056 }
3057
3058 fn cmd_watchpoint(&mut self, rest: &[&str], ctx: &dyn DebugState) -> CmdOut {
3062 match rest {
3063 [] => {
3064 let v: Vec<&str> = self.watchpoints.iter().map(|w| w.raw.as_str()).collect();
3065 CmdOut::ok(vec![format!("watchpoints: {v:?}")])
3066 .with_data(serde_json::json!({"watchpoints": v}))
3067 }
3068 ["clear"] => {
3069 self.watchpoints.clear();
3070 CmdOut::ok(vec!["cleared watchpoints".into()])
3071 }
3072 ["del", spec] | ["delete", spec] => {
3073 self.watchpoints.retain(|w| w.raw != *spec);
3074 CmdOut::ok(vec![format!("removed watchpoint {spec}")])
3075 }
3076 [spec, rest @ ..] => {
3077 let threshold = rest
3078 .first()
3079 .and_then(|s| s.parse::<f64>().ok())
3080 .unwrap_or(0.0);
3081 let (block, idx) = match spec.find('[') {
3083 Some(open) if spec.ends_with(']') => {
3084 let b = &spec[..open];
3085 match spec[open + 1..spec.len() - 1].parse::<usize>() {
3086 Ok(i) => (b.to_string(), Some(i)),
3087 Err(_) => return CmdOut::err(format!("bad index in `{spec}`")),
3088 }
3089 }
3090 _ => (spec.to_string(), None),
3091 };
3092 if !is_block(ctx, block.as_str()) {
3093 return CmdOut::err(format!("unknown block `{block}`"));
3094 }
3095 let raw = spec.to_string();
3096 if !self.watchpoints.iter().any(|w| w.raw == raw) {
3097 self.watchpoints.push(WatchPoint {
3098 raw: raw.clone(),
3099 block,
3100 idx,
3101 threshold,
3102 last: None,
3103 });
3104 }
3105 CmdOut::ok(vec![format!("watchpoint on {raw} (Δ>{threshold:.3e})")])
3106 }
3107 }
3108 }
3109
3110 fn cmd_commands(&mut self, rest: &[&str]) -> CmdOut {
3115 let Some(iter) = rest.first().and_then(|s| s.parse::<i32>().ok()) else {
3116 if rest.is_empty() {
3117 let mut items: Vec<(i32, Vec<String>)> = self
3118 .bp_commands
3119 .iter()
3120 .map(|(k, v)| (*k, v.clone()))
3121 .collect();
3122 items.sort_by_key(|(k, _)| *k);
3123 let lines = if items.is_empty() {
3124 vec!["no breakpoint command lists".into()]
3125 } else {
3126 items
3127 .iter()
3128 .map(|(k, v)| format!("iter {k}: {}", v.join(" ; ")))
3129 .collect()
3130 };
3131 return CmdOut::ok(lines);
3132 }
3133 return CmdOut::err(
3134 "usage: commands <iter> <cmd> ; <cmd> … (or: commands <iter> clear)",
3135 );
3136 };
3137 let tail = rest[1..].join(" ");
3138 let tail = tail.trim();
3139 if tail.is_empty() || tail == "clear" {
3140 self.bp_commands.remove(&iter);
3141 return CmdOut::ok(vec![format!("cleared commands for iteration {iter}")]);
3142 }
3143 let cmds: Vec<String> = tail
3144 .split(';')
3145 .map(|s| s.trim().to_string())
3146 .filter(|s| !s.is_empty())
3147 .collect();
3148 self.bp_commands.insert(iter, cmds.clone());
3149 CmdOut::ok(vec![format!(
3150 "commands for iter {iter}: {}",
3151 cmds.join(" ; ")
3152 )])
3153 .with_data(serde_json::json!({"iter": iter, "commands": cmds}))
3154 }
3155
3156 fn cmd_diff(&self, ctx: &dyn DebugState) -> CmdOut {
3159 let iter = ctx.iter();
3160 let Some((&piter, prev)) = self.snapshots.range(..iter).next_back() else {
3161 return CmdOut::err("no previous iterate to diff against");
3162 };
3163 let mut lines = vec![format!("Δ since iter {piter}:")];
3164 let dmu = ctx.mu() - prev.mu();
3165 lines.push(format!(" mu = {:.6e} (Δ {:+.3e})", ctx.mu(), dmu));
3166 let mut blocks = serde_json::Map::new();
3167 for b in block_names(ctx) {
3168 let (Some(cur), Some(old)) = (ctx.block(b), prev.block(b)) else {
3169 continue;
3170 };
3171 if cur.is_empty() || cur.len() != old.len() {
3172 continue;
3173 }
3174 let mut amax = 0.0_f64;
3175 let mut imax = 0usize;
3176 for (i, (c, o)) in cur.iter().zip(&old).enumerate() {
3177 let d = (c - o).abs();
3178 if d > amax {
3179 amax = d;
3180 imax = i;
3181 }
3182 }
3183 if amax > 0.0 {
3184 lines.push(format!(
3185 " {b}: max|Δ|={amax:.3e} at [{imax}] ({:.4e} → {:.4e})",
3186 old[imax], cur[imax]
3187 ));
3188 blocks.insert(
3189 b.to_string(),
3190 serde_json::json!({"max_abs_change": amax, "argmax": imax}),
3191 );
3192 }
3193 }
3194 if lines.len() == 2 {
3195 lines.push(" (no change)".into());
3196 }
3197 CmdOut::ok(lines).with_data(
3198 serde_json::json!({"from_iter": piter, "to_iter": iter, "dmu": dmu, "blocks": blocks}),
3199 )
3200 }
3201
3202 fn cmd_source(&mut self, rest: &[&str], ctx: &mut dyn DebugState) -> CmdOut {
3206 let Some(&path) = rest.first() else {
3207 return CmdOut::err("usage: source <file>");
3208 };
3209 let content = match std::fs::read_to_string(path) {
3210 Ok(c) => c,
3211 Err(e) => return CmdOut::err(format!("cannot read `{path}`: {e}")),
3212 };
3213 let mut lines = Vec::new();
3214 let mut flow = Flow::Stay;
3215 for raw in content.lines() {
3216 let cmd = raw.trim();
3217 if cmd.is_empty() || cmd.starts_with('#') || cmd.starts_with("//") {
3218 continue;
3219 }
3220 lines.push(format!("[source] {cmd}"));
3221 let out = self.dispatch(cmd, ctx);
3222 lines.extend(out.lines);
3223 if !matches!(out.flow, Flow::Stay) {
3224 flow = out.flow;
3225 break;
3226 }
3227 }
3228 CmdOut {
3229 ok: true,
3230 lines,
3231 data: None,
3232 flow,
3233 }
3234 }
3235
3236 fn cmd_viz(&self, rest: &[&str], ctx: &mut dyn DebugState) -> CmdOut {
3237 let Some(&target) = rest.first() else {
3238 return CmdOut::err("usage: viz <x|s|y_c|...|dx|kkt|L>");
3239 };
3240 if target == "kkt" {
3243 if as_nlp(ctx).is_none() {
3245 return nlp_only("viz kkt");
3246 }
3247 let Some(k) = ctx.kkt() else {
3248 return CmdOut::err(
3249 "no KKT factorization captured yet — nothing has been factored (iter 0), \
3250 or the debugger is detached. `step` once to capture.",
3251 );
3252 };
3253 let Some((dim, irn, jcn, vals)) = ctx.kkt_matrix() else {
3258 return CmdOut::err(
3259 "KKT matrix not captured here — the debugger is detached \
3260 (running free). `step` once to capture and re-run `viz kkt`.",
3261 );
3262 };
3263 let kiter = k.iter;
3266 let matrix = serde_json::json!({"dim": dim, "irn": irn, "jcn": jcn, "vals": vals,
3267 "format": "triplet_1based_lower"});
3268 let payload = serde_json::json!({
3269 "label": "kkt", "iter": kiter,
3270 "dim": k.dim, "n_pos": k.n_pos, "n_neg": k.n_neg,
3271 "expected_neg": k.expected_neg, "inertia_correct": k.inertia_correct,
3272 "delta_w": k.delta_w, "delta_c": k.delta_c, "status": k.status,
3273 "matrix": matrix,
3274 });
3275 if self.inline_artifacts {
3276 return CmdOut::ok(vec![format!(
3277 "KKT system, iter {kiter} (dim {dim}, inline)"
3278 )])
3279 .with_data(payload);
3280 }
3281 return match write_json_and_open("kkt", kiter, &payload) {
3282 Ok((path, viewer)) => CmdOut::ok(vec![format!(
3283 "wrote {path} (KKT system, iter {kiter}); opened with `{viewer}`"
3284 )])
3285 .with_data(serde_json::json!({"path": path, "viewer": viewer})),
3286 Err(e) => CmdOut::err(e),
3287 };
3288 }
3289 if target == "L" {
3294 if as_nlp(ctx).is_none() {
3295 return nlp_only("viz L");
3296 }
3297 match ctx.kkt_l_factor() {
3298 Some((n, perm, l_irn, l_jcn, l_vals)) => {
3299 let kiter = ctx.kkt_captured_iter().unwrap_or_else(|| ctx.iter());
3301 let payload = serde_json::json!({
3302 "label": "L", "iter": kiter, "n": n, "perm": perm,
3303 "l_irn": l_irn, "l_jcn": l_jcn, "l_vals": l_vals,
3304 "format": "strict_lower_1based_permuted",
3305 });
3306 if self.inline_artifacts {
3307 return CmdOut::ok(vec![format!("L factor, iter {kiter} (n {n}, inline)")])
3308 .with_data(payload);
3309 }
3310 return match write_json_and_open("L", kiter, &payload) {
3311 Ok((path, viewer)) => CmdOut::ok(vec![format!(
3312 "wrote {path} (L factor, iter {kiter}); opened with `{viewer}`"
3313 )])
3314 .with_data(serde_json::json!({"path": path, "viewer": viewer})),
3315 Err(e) => CmdOut::err(e),
3316 };
3317 }
3318 None => {
3319 return CmdOut::err(
3320 "L factor not captured here — nothing factored yet (iter 0), \
3321 or the debugger is detached. `step` once to capture.",
3322 );
3323 }
3324 }
3325 }
3326 let (label, vals) = if is_block(ctx, target) {
3328 match ctx.block(target) {
3329 Some(v) => (target.to_string(), v),
3330 None => return CmdOut::err(format!("no data for block `{target}`")),
3331 }
3332 } else if let Some(blk) = target.strip_prefix("d").filter(|b| is_block(ctx, b)) {
3333 match ctx.delta_block(blk) {
3334 Some(v) => (format!("d{blk}"), v),
3335 None => return CmdOut::err(format!("no search direction for `d{blk}`")),
3336 }
3337 } else {
3338 return CmdOut::err(format!("don't know how to visualize `{target}`"));
3339 };
3340 if self.inline_artifacts {
3341 return CmdOut::ok(vec![format!(
3342 "{label}, iter {} ({} values, inline)",
3343 ctx.iter(),
3344 vals.len()
3345 )])
3346 .with_data(serde_json::json!({"label": label, "iter": ctx.iter(), "values": vals}));
3347 }
3348 match write_and_open(&label, ctx.iter(), &vals) {
3349 Ok((path, viewer)) => CmdOut::ok(vec![format!(
3350 "wrote {} ({} values); opened with `{}`",
3351 path,
3352 vals.len(),
3353 viewer
3354 )])
3355 .with_data(serde_json::json!({"path": path, "viewer": viewer, "n": vals.len()})),
3356 Err(e) => CmdOut::err(e),
3357 }
3358 }
3359
3360 fn emit_pause(&self, ctx: &dyn DebugState, reason: Option<&str>) {
3364 let terminal = matches!(ctx.checkpoint(), Checkpoint::Terminated);
3365 match self.mode {
3366 DebugMode::Repl => {
3367 if terminal {
3368 let which = if self.in_restoration {
3371 "restoration inner solve"
3372 } else {
3373 "solve"
3374 };
3375 eprintln!(
3376 "\n── pounce-dbg ── TERMINATED {which} ({}) iter {} obj={:.6e} inf_pr={:.2e} inf_du={:.2e}",
3377 ctx.status().unwrap_or("?"),
3378 ctx.iter(),
3379 ctx.objective(),
3380 ctx.inf_pr(),
3381 ctx.inf_du(),
3382 );
3383 } else {
3384 let resto = if self.in_restoration {
3385 " [restoration]"
3386 } else {
3387 ""
3388 };
3389 eprintln!(
3390 "\n── pounce-dbg ── iter {} @{}{} mu={:.3e} obj={:.6e} inf_pr={:.2e} inf_du={:.2e}",
3391 ctx.iter(),
3392 ctx.checkpoint().as_str(),
3393 resto,
3394 ctx.mu(),
3395 ctx.objective(),
3396 ctx.inf_pr(),
3397 ctx.inf_du(),
3398 );
3399 }
3400 if let Some(r) = reason {
3401 eprintln!(" ↳ {r}");
3402 }
3403 for w in &self.watches {
3404 let out = self.cmd_print(&[w.as_str()], ctx);
3405 if out.ok {
3406 for l in &out.lines {
3407 eprintln!(" watch {l}");
3408 }
3409 } else {
3410 eprintln!(" watch {w}: (n/a)");
3414 }
3415 }
3416 }
3417 DebugMode::Json => {
3418 let watches: Vec<serde_json::Value> = self
3419 .watches
3420 .iter()
3421 .map(|w| {
3422 let out = self.cmd_print(&[w.as_str()], ctx);
3423 serde_json::json!({"expr": w, "ok": out.ok, "output": out.lines, "data": out.data})
3424 })
3425 .collect();
3426 let dims: serde_json::Map<String, serde_json::Value> = ctx
3427 .block_dims()
3428 .into_iter()
3429 .map(|(n, d)| (n.to_string(), serde_json::json!(d)))
3430 .collect();
3431 let conds: Vec<String> = self.conds.iter().map(|c| c.raw.clone()).collect();
3432 let mut ev = serde_json::json!({
3433 "event": "pause",
3434 "checkpoint": ctx.checkpoint().as_str(),
3435 "status": ctx.status(),
3436 "in_restoration": self.in_restoration,
3437 "phase": if self.in_restoration { "restoration" } else { "main" },
3442 "final": terminal && !self.in_restoration,
3443 "dims": dims,
3444 "breakpoints": self.breaks,
3445 "conditions": conds,
3446 "reason": reason,
3447 "watches": watches,
3448 });
3449 insert_metric_fields(&mut ev, ctx);
3452 emit_json(&ev);
3453 }
3454 }
3455 }
3456
3457 fn emit_progress_event(&self, ctx: &dyn DebugState) {
3462 let mut ev = serde_json::json!({ "event": "progress" });
3463 insert_metric_fields(&mut ev, ctx);
3465 emit_json(&ev);
3466 }
3467
3468 fn emit_result(&self, command: &str, out: &CmdOut, req_id: Option<&serde_json::Value>) {
3471 match self.mode {
3472 DebugMode::Repl => {
3473 let stderr = std::io::stderr();
3474 let mut h = stderr.lock();
3475 for l in &out.lines {
3476 let _ = writeln!(h, "{l}");
3477 }
3478 if !out.ok {
3479 let _ = writeln!(h, "(error)");
3480 }
3481 }
3482 DebugMode::Json => {
3483 let ev = serde_json::json!({
3484 "event": "result",
3485 "request_id": req_id,
3486 "command": command,
3487 "ok": out.ok,
3488 "output": out.lines,
3489 "data": out.data,
3490 });
3491 emit_json(&ev);
3492 }
3493 }
3494 }
3495
3496 fn emit_hello(&self, ctx: &dyn DebugState) {
3507 let nlp = as_nlp(ctx).is_some();
3508 let viz: &[&str] = if nlp {
3510 &["block", "delta", "kkt", "L"]
3511 } else {
3512 &["block", "delta"]
3513 };
3514 let ev = serde_json::json!({
3515 "event": "hello",
3516 "protocol": "pounce-dbg/1",
3517 "pounce_version": env!("CARGO_PKG_VERSION"),
3518 "capabilities": {
3519 "inspect": true,
3520 "mutate_iterate": true,
3521 "mutate_mu": nlp,
3524 "conditional_breakpoints": "compound",
3525 "request_ids": true,
3526 "viz": viz,
3527 "save": true,
3528 "load": nlp,
3529 "sweep": nlp && self.restart.is_some(),
3530 "kkt_inspect": nlp,
3531 "equations": self.equation_book.is_some(),
3534 "diagnose": nlp,
3536 "structural_diagnose": nlp && self.structure_book.is_some(),
3539 "llm_assist": true,
3540 "rewind": "primal_dual",
3541 "resolve": nlp && self.restart.is_some(),
3542 "terminal_checkpoint": true,
3543 "interruptible": self.interruptible,
3544 "progress_events": self.emit_progress,
3546 "inline_artifacts": self.inline_artifacts,
3547 "ask_prompt_only": true,
3548 "async_pause": "checkpoint",
3549 "pause_command": !cfg!(target_family = "wasm"),
3554 },
3555 "checkpoints": CHECKPOINTS,
3556 "events": EVENTS,
3557 "commands": COMMANDS,
3558 "blocks": block_names(ctx),
3559 "metrics": METRICS,
3560 });
3561 emit_json(&ev);
3562 }
3563
3564 #[cfg(target_family = "wasm")]
3566 fn ensure_editor(&mut self) {}
3567
3568 #[cfg(not(target_family = "wasm"))]
3572 fn ensure_editor(&mut self) {
3573 if !matches!(self.mode, DebugMode::Repl)
3574 || self.editor.is_some()
3575 || !std::io::stdin().is_terminal()
3576 {
3577 return;
3578 }
3579 let mut ed: Editor<DbgHelper, FileHistory> = match Editor::new() {
3580 Ok(e) => e,
3581 Err(_) => return,
3582 };
3583 ed.set_helper(Some(DbgHelper {
3584 reg: self.reg.clone(),
3585 }));
3586 let path = std::env::var_os("HOME")
3587 .or_else(|| std::env::var_os("USERPROFILE"))
3588 .map(|h| PathBuf::from(h).join(".pounce_dbg_history"));
3589 if let Some(p) = &path {
3590 let _ = ed.load_history(p);
3591 }
3592 self.hist_path = path;
3593 self.editor = Some(ed);
3594 }
3595
3596 #[cfg_attr(target_family = "wasm", allow(dead_code))]
3601 fn on_prompt_interrupt(&mut self) -> String {
3602 self.prompt_interrupts += 1;
3603 if self.prompt_interrupts >= 2 {
3604 self.prompt_interrupts = 0;
3605 eprintln!("(quitting — Ctrl-C)");
3606 "quit".to_string()
3607 } else {
3608 eprintln!("(Ctrl-C — press again, or `quit`/Ctrl-D, to stop the solve)");
3609 String::new()
3610 }
3611 }
3612
3613 fn next_command_line(&mut self) -> Option<String> {
3617 if let Some(q) = &self.script_queue {
3621 let cmd = q.borrow_mut().pop_front();
3622 if let Some(c) = &cmd {
3623 let _ = writeln!(std::io::stderr(), "pounce-dbg> {c}");
3624 }
3625 return cmd;
3626 }
3627 if let DebugMode::Repl = self.mode {
3628 #[cfg(not(target_family = "wasm"))]
3629 if let Some(ed) = self.editor.as_mut() {
3630 return match ed.readline("pounce-dbg> ") {
3631 Ok(l) => {
3632 self.prompt_interrupts = 0;
3633 let _ = ed.add_history_entry(l.as_str());
3634 if let Some(p) = &self.hist_path {
3635 let _ = ed.save_history(p);
3636 }
3637 Some(l)
3638 }
3639 Err(ReadlineError::Interrupted) => Some(self.on_prompt_interrupt()),
3644 Err(ReadlineError::Eof) => None,
3646 Err(_) => None,
3647 };
3648 }
3649 if self.script_given && !std::io::stdin().is_terminal() {
3654 return None;
3655 }
3656 let _ = write!(std::io::stderr(), "pounce-dbg> ");
3657 let _ = std::io::stderr().flush();
3658 return read_stdin_line();
3659 }
3660 self.pump.get_or_insert_with(StdinPump::start).next()
3663 }
3664}
3665
3666fn read_stdin_line() -> Option<String> {
3668 let mut line = String::new();
3669 match std::io::stdin().read_line(&mut line) {
3670 Ok(0) => None,
3671 Ok(_) => Some(line),
3672 Err(_) => None,
3673 }
3674}
3675
3676fn rank_residuals(mut entries: Vec<Residual>, k: usize) -> Vec<Residual> {
3684 entries.sort_by(|a, b| {
3685 b.value
3686 .abs()
3687 .partial_cmp(&a.value.abs())
3688 .unwrap_or(std::cmp::Ordering::Equal)
3689 });
3690 entries.truncate(k);
3691 entries
3692}
3693
3694fn render_rank_report(
3705 rep: &RankReport,
3706 names: &Option<SplitNames>,
3707 equations: Option<&EquationBook>,
3708 iter: i32,
3709) -> (Vec<String>, serde_json::Value) {
3710 let m = rep.n_rows();
3711 let n = rep.n_cols;
3712 let mut lines = vec![
3713 format!("equality Jacobian J_c: {m} row(s) × {n} column(s)"),
3714 format!(
3715 "numerical rank = {} / {} (deficiency {})",
3716 rep.rank,
3717 m,
3718 rep.deficiency()
3719 ),
3720 format!(
3721 "σ_max = {:.3e} σ_min = {:.3e} cond = {} (rank tol τ = {:.3e})",
3722 rep.sigma_max(),
3723 rep.sigma_min(),
3724 fmt_cond(rep.cond),
3725 rep.tol
3726 ),
3727 ];
3728
3729 let shown: Vec<String> = rep
3731 .singular_values
3732 .iter()
3733 .take(MAX_SINGULAR_VALUES_SHOWN)
3734 .map(|s| format!("{s:.3e}"))
3735 .collect();
3736 let tail = if rep.singular_values.len() > MAX_SINGULAR_VALUES_SHOWN {
3737 " …"
3738 } else {
3739 ""
3740 };
3741 lines.push(format!("singular values: [{}{tail}]", shown.join(", ")));
3742
3743 if rep.is_rank_deficient() {
3744 lines.push(format!(
3745 "rank-deficient: {} equation(s) lie in the near-null space \
3746 (linearly dependent / redundant) — the source of δ_c regularization:",
3747 rep.deficiency()
3748 ));
3749 let mut shown_any_eq = false;
3750 for c in rep.culprits.iter().take(MAX_RANK_CULPRITS) {
3751 let row = &rep.rows[c.row];
3752 let label = rank_row_label(row, names);
3753 lines.push(format!(" {label} (participation {:.2})", c.weight));
3754 if let Some(eq) = culprit_equation(row, names, equations) {
3758 lines.push(format!(" {eq}"));
3759 shown_any_eq = true;
3760 }
3761 }
3762 if rep.culprits.len() > MAX_RANK_CULPRITS {
3763 lines.push(format!(
3764 " … and {} more",
3765 rep.culprits.len() - MAX_RANK_CULPRITS
3766 ));
3767 }
3768 if !shown_any_eq {
3771 lines.push("inspect a row with `print equation <name>` to see its terms".to_string());
3772 }
3773 } else {
3774 lines.push("J_c has full row rank at this iterate.".to_string());
3775 }
3776
3777 let culprits_json: Vec<serde_json::Value> = rep
3778 .culprits
3779 .iter()
3780 .map(|c| {
3781 let row = &rep.rows[c.row];
3782 serde_json::json!({
3783 "row": c.row,
3784 "kind": row.kind.tag(),
3785 "index": row.index,
3786 "name": rank_row_name(row, names),
3787 "label": rank_row_label(row, names),
3788 "weight": c.weight,
3789 "equation": culprit_equation(row, names, equations),
3790 })
3791 })
3792 .collect();
3793
3794 let data = serde_json::json!({
3795 "iter": iter,
3796 "n_rows": m,
3797 "n_cols": n,
3798 "rank": rep.rank,
3799 "deficiency": rep.deficiency(),
3800 "rank_deficient": rep.is_rank_deficient(),
3801 "sigma_max": rep.sigma_max(),
3802 "sigma_min": rep.sigma_min(),
3803 "cond": cond_json(rep.cond),
3804 "tol": rep.tol,
3805 "singular_values": rep.singular_values,
3806 "culprits": culprits_json,
3807 });
3808
3809 (lines, data)
3810}
3811
3812fn culprit_equation(
3819 row: &RankRow,
3820 names: &Option<SplitNames>,
3821 equations: Option<&EquationBook>,
3822) -> Option<String> {
3823 let book = equations?;
3824 let name = rank_row_name(row, names)?;
3825 let i = book.resolve(&name)?;
3826 Some(book.equations.get(i)?.clone())
3827}
3828
3829fn rank_row_name(row: &RankRow, names: &Option<SplitNames>) -> Option<String> {
3834 let r = Residual {
3835 kind: row.kind,
3836 index: row.index,
3837 value: 0.0,
3838 };
3839 resid_name(&r, names).map(|s| s.to_string())
3840}
3841
3842fn rank_row_label(row: &RankRow, names: &Option<SplitNames>) -> String {
3845 match rank_row_name(row, names) {
3846 Some(name) => format!("{}[{}]", row.kind.tag(), name),
3847 None => format!("{}[{}]", row.kind.tag(), row.index),
3848 }
3849}
3850
3851fn fmt_cond(cond: f64) -> String {
3854 if cond.is_finite() {
3855 format!("{cond:.3e}")
3856 } else {
3857 "inf (σ_min = 0)".to_string()
3858 }
3859}
3860
3861fn cond_json(cond: f64) -> serde_json::Value {
3864 if cond.is_finite() {
3865 serde_json::json!(cond)
3866 } else {
3867 serde_json::Value::Null
3868 }
3869}
3870
3871fn resid_name<'a>(r: &Residual, names: &'a Option<SplitNames>) -> Option<&'a str> {
3872 let n = names.as_ref()?;
3873 let pool = match r.kind {
3874 ResidKind::Eq => &n.eq,
3875 ResidKind::Ineq | ResidKind::DualS => &n.ineq,
3876 ResidKind::DualX => &n.x_var,
3877 };
3878 pool.get(r.index).and_then(|o| o.as_deref())
3879}
3880
3881fn worst_named(resids: Vec<Residual>, names: &Option<SplitNames>) -> Option<(String, f64)> {
3885 let top = rank_residuals(resids, 1);
3886 let r = top.first()?;
3887 let label = match resid_name(r, names) {
3888 Some(name) => format!("{}[{}]", r.kind.tag(), name),
3889 None => format!("{}[{}]", r.kind.tag(), r.index),
3890 };
3891 Some((label, r.value))
3892}
3893
3894pub fn print_open_banner(mode: DebugMode) {
3898 if !matches!(mode, DebugMode::Repl) {
3899 return;
3900 }
3901 let color = std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none();
3902 let paint = |r: u8, g: u8, b: u8, bold: bool, s: &str| -> String {
3903 if color {
3904 let w = if bold { "1;" } else { "" };
3905 format!("\x1b[{w}38;2;{r};{g};{b}m{s}\x1b[0m")
3906 } else {
3907 s.to_string()
3908 }
3909 };
3910 let orange = |s: &str| paint(0xE8, 0x7A, 0x1E, true, s);
3912 let gold = |s: &str| paint(0xFF, 0xB0, 0x00, true, s);
3913 let dim = |s: &str| paint(0x7A, 0x7E, 0x88, false, s);
3914 let item = |key: &str, gloss: &str| format!("{} {}", orange(key), dim(gloss));
3916
3917 let err = std::io::stderr();
3918 let mut h = err.lock();
3919 let _ = writeln!(h);
3920 for row in crate::print::logo_rows(color) {
3923 let _ = writeln!(h, " {row}");
3924 }
3925 let _ = writeln!(h);
3926 let _ = writeln!(
3927 h,
3928 " {} {}",
3929 gold("interior-point debugger"),
3930 dim(&format!(
3931 "· pounce {} · pdb for the IPM",
3932 env!("CARGO_PKG_VERSION")
3933 ))
3934 );
3935 let _ = writeln!(h);
3936 let _ = writeln!(
3938 h,
3939 " {} {} {} {} {}",
3940 item("s", "step"),
3941 item("c", "continue"),
3942 item("b", "N break"),
3943 item("r", "N run"),
3944 item("q", "quit"),
3945 );
3946 let _ = writeln!(
3947 h,
3948 " {} {} {} {} {}",
3949 item("p", "x print"),
3950 item("i", "info"),
3951 item("set", "x[i] v"),
3952 item("watch", "x"),
3953 item("viz", "kkt"),
3954 );
3955 let _ = writeln!(
3956 h,
3957 " {} {} {}",
3958 dim("type"),
3959 gold("help"),
3960 dim("for all commands · `ask` to consult Claude · Ctrl-C breaks in"),
3961 );
3962 let _ = writeln!(h);
3963}
3964
3965#[cfg_attr(target_family = "wasm", allow(dead_code))]
3968fn is_pause_command(line: &str) -> bool {
3969 parse_command(line, DebugMode::Json).command.trim() == "pause"
3970}
3971
3972#[cfg(not(target_family = "wasm"))]
3977struct StdinPump {
3978 inner: std::sync::Arc<(
3979 std::sync::Mutex<VecDeque<Option<String>>>,
3980 std::sync::Condvar,
3981 )>,
3982}
3983
3984#[cfg(not(target_family = "wasm"))]
3985impl StdinPump {
3986 fn start() -> Self {
3987 let inner = std::sync::Arc::new((
3988 std::sync::Mutex::new(VecDeque::new()),
3989 std::sync::Condvar::new(),
3990 ));
3991 let w = std::sync::Arc::clone(&inner);
3992 std::thread::spawn(move || {
3993 use std::io::BufRead;
3994 let stdin = std::io::stdin();
3995 let mut lock = stdin.lock();
3996 let (m, cv) = &*w;
3997 loop {
3998 let mut line = String::new();
3999 let item = match lock.read_line(&mut line) {
4000 Ok(0) | Err(_) => None, Ok(_) => Some(line),
4002 };
4003 let done = item.is_none();
4004 m.lock()
4005 .unwrap_or_else(std::sync::PoisonError::into_inner)
4006 .push_back(item);
4007 cv.notify_one();
4008 if done {
4009 break;
4010 }
4011 }
4012 });
4013 Self { inner }
4014 }
4015
4016 fn next(&self) -> Option<String> {
4018 let (m, cv) = &*self.inner;
4019 let mut q = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
4020 loop {
4021 match q.front() {
4022 None => {
4023 q = cv
4024 .wait(q)
4025 .unwrap_or_else(std::sync::PoisonError::into_inner)
4026 }
4027 Some(None) => return None, Some(Some(_)) => return q.pop_front().flatten(),
4029 }
4030 }
4031 }
4032
4033 fn try_take_pause(&self) -> bool {
4036 let (m, _) = &*self.inner;
4037 let mut q = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
4038 if let Some(Some(front)) = q.front() {
4039 if is_pause_command(front) {
4040 q.pop_front();
4041 return true;
4042 }
4043 }
4044 false
4045 }
4046}
4047
4048#[cfg(target_family = "wasm")]
4054struct StdinPump {
4055 eof: std::cell::Cell<bool>,
4056}
4057
4058#[cfg(target_family = "wasm")]
4059impl StdinPump {
4060 fn start() -> Self {
4061 Self {
4062 eof: std::cell::Cell::new(false),
4063 }
4064 }
4065
4066 fn next(&self) -> Option<String> {
4068 if self.eof.get() {
4069 return None;
4070 }
4071 let line = read_stdin_line();
4072 if line.is_none() {
4073 self.eof.set(true);
4074 }
4075 line
4076 }
4077
4078 fn try_take_pause(&self) -> bool {
4080 false
4081 }
4082}
4083
4084impl DebugHook for SolverDebugger {
4085 fn wants_kkt_capture(&self) -> bool {
4089 !self.detached
4090 }
4091
4092 fn arm(&mut self) {
4096 self.step = true;
4097 self.detached = false;
4098 self.pause_iters = true;
4099 self.pause_terminal = true;
4100 }
4101
4102 fn at_checkpoint(&mut self, ctx: &mut dyn DebugState) -> DebugAction {
4103 if self.quit {
4106 return DebugAction::Stop;
4107 }
4108 if matches!(self.mode, DebugMode::Json) && !self.hello_sent {
4111 self.emit_hello(&*ctx);
4112 self.hello_sent = true;
4113 }
4114 if let Checkpoint::Terminated = ctx.checkpoint() {
4118 if self.sweep.is_some() && !self.in_restoration {
4122 if let Some(c) = as_nlp(ctx) {
4125 if let Some(action) = self.drive_sweep(c) {
4126 return action;
4127 }
4128 }
4129 }
4130 let failed = ctx.status().map(|s| !is_success_status(s)).unwrap_or(false);
4131 let should =
4132 self.pause_terminal && !self.detached && (!self.terminal_only_on_error || failed);
4133 if !should {
4134 return DebugAction::Resume;
4135 }
4136 self.ensure_editor();
4137 self.emit_pause(ctx, None);
4138 return self.prompt_loop(ctx);
4139 }
4140
4141 let cp = ctx.checkpoint();
4142 match cp {
4144 Checkpoint::PreRestoration => self.in_restoration = true,
4145 Checkpoint::PostRestoration => self.in_restoration = false,
4146 _ => {}
4147 }
4148 let is_iter_start = matches!(cp, Checkpoint::IterStart);
4149
4150 if is_iter_start {
4154 if let Some(snap) = ctx.snapshot() {
4155 self.snapshots.insert(ctx.iter(), snap);
4156 while self.snapshots.len() > SNAPSHOT_CAP {
4157 let Some(&oldest) = self.snapshots.keys().next() else {
4158 break;
4159 };
4160 self.snapshots.remove(&oldest);
4161 }
4162 }
4163 self.update_mu_stall(ctx.mu());
4165 }
4166
4167 let mut reason: Option<String> = None;
4172 let mut pause = !self.detached && (self.sub_step || self.stop_at.contains(cp.as_str()));
4173
4174 if let Some(ev) = self.matched_event(ctx) {
4178 pause = true;
4179 reason = Some(format!("event: {ev}"));
4180 }
4181
4182 if is_iter_start {
4183 if self.interruptible && interrupt::take() {
4184 pause = true;
4185 reason = Some("interrupt (Ctrl-C)".into());
4186 }
4187 if let Some(p) = self.pump.as_ref() {
4190 if p.try_take_pause() {
4191 pause = true;
4192 reason = Some("pause (requested)".into());
4193 }
4194 }
4195 if self.pause_iters {
4196 if self.should_pause(ctx.iter()) {
4197 pause = true;
4198 }
4199 if let Some(c) = self.matched_condition(ctx) {
4200 pause = true;
4201 reason = Some(c);
4202 }
4203 }
4204 if let Some(w) = self.matched_watchpoint(ctx) {
4207 pause = true;
4208 reason = Some(format!("watchpoint: {w}"));
4209 }
4210 }
4211
4212 if !pause {
4213 if is_iter_start && self.emit_progress && matches!(self.mode, DebugMode::Json) {
4217 self.emit_progress_event(ctx);
4218 }
4219 return DebugAction::Resume;
4220 }
4221 self.step = false;
4223 self.sub_step = false;
4224 self.emit_pause(ctx, reason.as_deref());
4225
4226 if is_iter_start {
4230 if let Some(cmds) = self.bp_commands.get(&ctx.iter()).cloned() {
4231 for c in cmds {
4232 let out = self.dispatch(&c, ctx);
4233 self.emit_result(&c, &out, None);
4234 match out.flow {
4235 Flow::Resume => return DebugAction::Resume,
4236 Flow::Stop => return DebugAction::Stop,
4237 Flow::Stay => {}
4238 }
4239 }
4240 }
4241 }
4242
4243 self.ensure_editor();
4244 self.prompt_loop(ctx)
4245 }
4246}
4247
4248impl SolverDebugger {
4249 fn prompt_loop(&mut self, ctx: &mut dyn DebugState) -> DebugAction {
4251 if let Some(path) = self.pending_script.take() {
4254 let out = self.cmd_source(&[path.as_str()], ctx);
4255 self.emit_result("source", &out, None);
4256 match out.flow {
4257 Flow::Resume => return DebugAction::Resume,
4258 Flow::Stop => return DebugAction::Stop,
4259 Flow::Stay => {}
4260 }
4261 }
4262 loop {
4263 let line = match self.next_command_line() {
4264 Some(l) => l,
4265 None => {
4266 return match self.mode {
4271 DebugMode::Repl => {
4272 self.detached = true;
4273 DebugAction::Resume
4274 }
4275 DebugMode::Json => {
4276 self.quit = true;
4277 DebugAction::Stop
4278 }
4279 };
4280 }
4281 };
4282 let parsed = parse_command(&line, self.mode);
4283 let cmd = parsed.command.trim().to_string();
4284 if cmd.is_empty() {
4285 continue;
4286 }
4287 let out = self.dispatch(&cmd, ctx);
4288 self.emit_result(&cmd, &out, parsed.id.as_ref());
4289 match out.flow {
4290 Flow::Stay => continue,
4291 Flow::Resume => return DebugAction::Resume,
4292 Flow::Stop => return DebugAction::Stop,
4293 }
4294 }
4295 }
4296}
4297
4298struct ParsedCmd {
4302 command: String,
4303 id: Option<serde_json::Value>,
4304}
4305
4306fn tokenize_quoted(line: &str) -> Vec<String> {
4312 let mut out = Vec::new();
4313 let mut cur = String::new();
4314 let mut in_quote = false;
4315 let mut has_tok = false;
4316 for c in line.chars() {
4317 match c {
4318 '"' => {
4319 in_quote = !in_quote;
4320 has_tok = true; }
4322 c if c.is_whitespace() && !in_quote => {
4323 if has_tok {
4324 out.push(std::mem::take(&mut cur));
4325 has_tok = false;
4326 }
4327 }
4328 c => {
4329 cur.push(c);
4330 has_tok = true;
4331 }
4332 }
4333 }
4334 if has_tok {
4335 out.push(cur);
4336 }
4337 out
4338}
4339
4340fn parse_command(line: &str, mode: DebugMode) -> ParsedCmd {
4344 let trimmed = line.trim();
4345 if let DebugMode::Json = mode {
4346 if trimmed.starts_with('{') {
4347 if let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed) {
4348 let cmd = v.get("cmd").and_then(|c| c.as_str()).unwrap_or("");
4349 let mut s = cmd.to_string();
4350 if let Some(args) = v.get("args").and_then(|a| a.as_array()) {
4351 for a in args {
4352 s.push(' ');
4353 let tok = a
4354 .as_str()
4355 .map(str::to_string)
4356 .unwrap_or_else(|| a.to_string());
4357 if tok.contains(char::is_whitespace) {
4360 s.push('"');
4361 s.push_str(&tok);
4362 s.push('"');
4363 } else {
4364 s.push_str(&tok);
4365 }
4366 }
4367 }
4368 return ParsedCmd {
4369 command: s,
4370 id: v.get("id").cloned(),
4371 };
4372 }
4373 }
4374 }
4375 ParsedCmd {
4376 command: trimmed.to_string(),
4377 id: None,
4378 }
4379}
4380
4381fn emit_json(v: &serde_json::Value) {
4382 let stdout = std::io::stdout();
4383 let mut h = stdout.lock();
4384 let _ = writeln!(h, "{v}");
4385 let _ = h.flush();
4386}
4387
4388fn as_nlp<'a>(ctx: &'a dyn DebugState) -> Option<&'a DebugCtx> {
4393 ctx.as_any().and_then(|a| a.downcast_ref::<DebugCtx>())
4394}
4395
4396fn as_nlp_mut<'a>(ctx: &'a mut dyn DebugState) -> Option<&'a mut DebugCtx> {
4398 ctx.as_any_mut().and_then(|a| a.downcast_mut::<DebugCtx>())
4399}
4400
4401fn nlp_only(cmd: &str) -> CmdOut {
4403 CmdOut::err(format!(
4404 "`{cmd}` is only available for the NLP solver (not the convex/conic solver)"
4405 ))
4406}
4407
4408fn block_names(ctx: &dyn DebugState) -> Vec<&'static str> {
4413 ctx.block_dims().into_iter().map(|(n, _)| n).collect()
4414}
4415
4416fn is_block(ctx: &dyn DebugState, name: &str) -> bool {
4418 block_names(ctx).iter().any(|n| *n == name)
4419}
4420
4421fn fmt_vec(name: &str, v: &[f64]) -> String {
4422 const MAX: usize = 12;
4423 if v.len() <= MAX {
4424 format!(
4425 "{name} = [{}]",
4426 v.iter()
4427 .map(|x| format!("{x:.6e}"))
4428 .collect::<Vec<_>>()
4429 .join(", ")
4430 )
4431 } else {
4432 let head = v[..MAX]
4433 .iter()
4434 .map(|x| format!("{x:.6e}"))
4435 .collect::<Vec<_>>()
4436 .join(", ");
4437 format!("{name} = [{head}, … ({} total)]", v.len())
4438 }
4439}
4440
4441fn type_str(t: OptionType) -> &'static str {
4442 match t {
4443 OptionType::OT_Number => "Number",
4444 OptionType::OT_Integer => "Integer",
4445 OptionType::OT_String => "String",
4446 OptionType::OT_Unknown => "Unknown",
4447 }
4448}
4449
4450fn default_str(d: &DefaultValue) -> String {
4451 match d {
4452 DefaultValue::None => "-".into(),
4453 DefaultValue::Number(v) => format!("{v}"),
4454 DefaultValue::Integer(v) => format!("{v}"),
4455 DefaultValue::String(s) => s.clone(),
4456 }
4457}
4458
4459fn write_and_open(label: &str, iter: i32, vals: &[f64]) -> Result<(String, String), String> {
4464 let payload = serde_json::json!({"label": label, "iter": iter, "values": vals});
4465 write_json_and_open(label, iter, &payload)
4466}
4467
4468fn build_ask_prompt(ctx: &dyn DebugState, question: &str) -> String {
4471 use std::fmt::Write as _;
4472 let mut p = String::new();
4473 p.push_str(
4474 "You are helping debug a paused run of POUNCE, a pure-Rust interior-point \
4475 optimization solver whose NLP core is ported from Ipopt. The solve is \
4476 stopped at a debugger checkpoint. \
4477 Use the state below to answer concisely and suggest concrete next steps \
4478 (options to try, what to inspect). State:\n\n",
4479 );
4480 let _ = writeln!(p, "checkpoint = {}", ctx.checkpoint().as_str());
4481 if let Some(s) = ctx.status() {
4482 let _ = writeln!(p, "status = {s}");
4483 }
4484 let _ = writeln!(p, "iter = {}", ctx.iter());
4485 let _ = writeln!(p, "mu = {:.6e}", ctx.mu());
4486 let _ = writeln!(p, "objective = {:.8e}", ctx.objective());
4487 let _ = writeln!(p, "inf_pr = {:.6e}", ctx.inf_pr());
4488 let _ = writeln!(p, "inf_du = {:.6e}", ctx.inf_du());
4489 let _ = writeln!(p, "nlp_error = {:.6e}", ctx.nlp_error());
4490 let (ap, ad) = ctx.alpha();
4491 let _ = writeln!(p, "alpha_pr = {ap:.4e}, alpha_du = {ad:.4e}");
4492 let _ = writeln!(p, "ls_trials = {}", ctx.ls_count());
4493 let dims: Vec<String> = ctx
4494 .block_dims()
4495 .into_iter()
4496 .map(|(n, d)| format!("{n}:{d}"))
4497 .collect();
4498 let _ = writeln!(p, "dims = {}", dims.join(" "));
4499 if let Some(k) = ctx.kkt() {
4500 let _ = writeln!(
4501 p,
4502 "kkt = dim {} inertia n+={} n-={} (expected n-={}, {}) delta_w={:.3e} delta_c={:.3e} status={}",
4503 k.dim,
4504 k.n_pos,
4505 k.n_neg,
4506 k.expected_neg,
4507 if k.inertia_correct {
4508 "correct"
4509 } else {
4510 "WRONG"
4511 },
4512 k.delta_w,
4513 k.delta_c,
4514 k.status
4515 );
4516 }
4517 let _ = write!(p, "\nQuestion: {question}\n");
4518 p
4519}
4520
4521const LLM_PROVIDERS: &[&str] = &["claude", "codex", "gemini", "llm"];
4527
4528fn llm_preset(name: &str, prompt: &str) -> Option<(String, Vec<String>, bool)> {
4529 match name {
4530 "claude" => Some(("claude".to_string(), vec!["-p".to_string()], true)),
4532 "codex" => Some((
4534 "codex".to_string(),
4535 vec!["exec".to_string(), prompt.to_string()],
4536 false,
4537 )),
4538 "gemini" => Some((
4540 "gemini".to_string(),
4541 vec!["-p".to_string(), prompt.to_string()],
4542 false,
4543 )),
4544 "llm" => Some(("llm".to_string(), vec![prompt.to_string()], false)),
4546 _ => None,
4547 }
4548}
4549
4550fn llm_command(prompt: &str) -> (String, Vec<String>, bool) {
4556 let raw = std::env::var("POUNCE_DBG_LLM").unwrap_or_default();
4557 let tmpl = raw.trim();
4558 if tmpl.is_empty() {
4559 return llm_preset("claude", prompt).expect("claude is a known provider");
4561 }
4562 if !tmpl.contains(char::is_whitespace) {
4565 if let Some(preset) = llm_preset(tmpl, prompt) {
4566 return preset;
4567 }
4568 }
4569 let mut parts = tmpl
4571 .split_whitespace()
4572 .map(str::to_string)
4573 .collect::<Vec<_>>();
4574 let prog = parts.remove(0);
4575 let mut substituted = false;
4576 for a in parts.iter_mut() {
4577 if a.contains("{}") {
4578 *a = a.replace("{}", prompt);
4579 substituted = true;
4580 }
4581 }
4582 (prog, parts, !substituted)
4583}
4584
4585fn run_llm(prompt: &str) -> Result<String, String> {
4588 use std::io::Write as _;
4589 use std::process::{Command, Stdio};
4590 let (prog, args, on_stdin) = llm_command(prompt);
4591 let mut cmd = Command::new(&prog);
4592 cmd.args(&args)
4593 .stdout(Stdio::piped())
4594 .stderr(Stdio::piped());
4595 cmd.stdin(if on_stdin {
4596 Stdio::piped()
4597 } else {
4598 Stdio::null()
4599 });
4600 let mut child = cmd.spawn().map_err(|e| {
4601 if e.kind() == std::io::ErrorKind::NotFound {
4602 format!(
4606 "LLM CLI `{prog}` is not installed or not on PATH. Install it, \
4607 or set POUNCE_DBG_LLM to another provider \
4608 ({}) or a full command template (e.g. `my-llm --ask {{}}`).",
4609 LLM_PROVIDERS.join(" | ")
4610 )
4611 } else {
4612 format!("could not launch `{prog}`: {e}")
4613 }
4614 })?;
4615 if on_stdin {
4616 if let Some(mut si) = child.stdin.take() {
4618 let _ = si.write_all(prompt.as_bytes());
4619 }
4620 }
4621 let out = child
4622 .wait_with_output()
4623 .map_err(|e| format!("`{prog}` failed: {e}"))?;
4624 if !out.status.success() {
4625 let err = String::from_utf8_lossy(&out.stderr);
4626 return Err(format!(
4627 "`{prog}` exited with {}: {}",
4628 out.status,
4629 err.trim()
4630 ));
4631 }
4632 let reply = String::from_utf8_lossy(&out.stdout).trim().to_string();
4633 if reply.is_empty() {
4634 Err(format!("`{prog}` returned no output"))
4635 } else {
4636 Ok(reply)
4637 }
4638}
4639
4640fn write_json_and_open(
4643 label: &str,
4644 iter: i32,
4645 payload: &serde_json::Value,
4646) -> Result<(String, String), String> {
4647 let dir = std::env::temp_dir();
4648 let path = dir.join(format!("pounce-dbg-{label}-iter{iter}.json"));
4649 std::fs::write(&path, payload.to_string()).map_err(|e| format!("write failed: {e}"))?;
4650 let path_s = path.to_string_lossy().to_string();
4651
4652 let mut candidates: Vec<(String, Vec<String>, String)> = Vec::new();
4662 match std::env::var("POUNCE_DBG_VIEWER") {
4663 Ok(tmpl) if !tmpl.trim().is_empty() => {
4664 let mut parts = tmpl
4665 .split_whitespace()
4666 .map(String::from)
4667 .collect::<Vec<_>>();
4668 let prog = parts.remove(0);
4669 let mut replaced = false;
4670 for a in parts.iter_mut() {
4671 if a.contains("{}") {
4672 *a = a.replace("{}", &path_s);
4673 replaced = true;
4674 }
4675 }
4676 if !replaced {
4677 parts.push(path_s.clone());
4678 }
4679 candidates.push((prog, parts, path_s.clone()));
4680 }
4681 _ => {
4682 candidates.push((
4683 "pounce-dbg-viz".to_string(),
4684 vec![path_s.clone()],
4685 path_s.clone(),
4686 ));
4687 let opener = if cfg!(target_os = "macos") {
4688 "open"
4689 } else {
4690 "xdg-open"
4691 };
4692 let artifact = write_html_viz(label, iter, payload).unwrap_or_else(|_| path_s.clone());
4695 candidates.push((opener.to_string(), vec![artifact.clone()], artifact));
4696 }
4697 }
4698
4699 let mut last_err = String::new();
4700 for (program, args, artifact) in &candidates {
4701 match std::process::Command::new(program).args(args).spawn() {
4702 Ok(_) => return Ok((artifact.clone(), format!("{program} {}", args.join(" ")))),
4703 Err(e) => last_err = format!("`{program}`: {e}"),
4704 }
4705 }
4706 Err(format!(
4707 "wrote {path_s} but could not launch a viewer ({last_err}). \
4708 Install the interactive viewer (`pip install 'pounce-solver[viz]'`) \
4709 or set POUNCE_DBG_VIEWER, e.g. `python my_plot.py {{}}`."
4710 ))
4711}
4712
4713fn write_html_viz(label: &str, iter: i32, payload: &serde_json::Value) -> Result<String, String> {
4720 let dir = std::env::temp_dir();
4721 let path = dir.join(format!("pounce-dbg-{label}-iter{iter}.html"));
4722 let html = VIZ_HTML_TEMPLATE.replace("__PAYLOAD__", &payload.to_string());
4723 std::fs::write(&path, html).map_err(|e| format!("write failed: {e}"))?;
4724 Ok(path.to_string_lossy().to_string())
4725}
4726
4727const VIZ_HTML_TEMPLATE: &str = r##"<!doctype html>
4732<html lang="en"><head><meta charset="utf-8">
4733<title>pounce-dbg viz</title>
4734<style>
4735 html,body{margin:0;background:#0e1116;color:#d6dae0;
4736 font:13px/1.5 -apple-system,BlinkMacSystemFont,"SF Mono",Menlo,monospace}
4737 .wrap{padding:18px 20px;max-width:880px;margin:0 auto}
4738 h1{font-size:15px;margin:0 0 4px;font-weight:600}
4739 .sub{color:#7d8694;margin:0 0 12px}
4740 .stats{color:#9aa4b2;white-space:pre-wrap;margin:0 0 14px;
4741 background:#161b22;border:1px solid #21262d;border-radius:6px;padding:10px 12px}
4742 canvas{background:#161b22;border:1px solid #30363d;border-radius:6px;
4743 max-width:100%;height:auto;image-rendering:pixelated}
4744 .legend{margin-top:10px;color:#9aa4b2}
4745 .pos{color:#4ea1ff}.neg{color:#ff6b6b}.bad{color:#ff6b6b;font-weight:600}
4746 .ok{color:#56d364;font-weight:600}
4747</style></head><body><div class="wrap">
4748<h1 id="title">pounce-dbg</h1>
4749<div class="sub" id="sub"></div>
4750<div class="stats" id="stats"></div>
4751<canvas id="c" width="820" height="820"></canvas>
4752<div class="legend" id="legend"></div>
4753</div>
4754<script>
4755const D = __PAYLOAD__;
4756const cv = document.getElementById('c');
4757const ctx = cv.getContext('2d');
4758const $ = id => document.getElementById(id);
4759const fmt = x => (x===null||x===undefined) ? '—'
4760 : (Math.abs(x) >= 1e4 || (x!==0 && Math.abs(x) < 1e-3) ? x.toExponential(3) : (+x).toPrecision(6));
4761
4762function clearCanvas(){ ctx.fillStyle='#161b22'; ctx.fillRect(0,0,cv.width,cv.height); }
4763
4764function spy(irn, jcn, vals, dim, symmetric, title){
4765 $('sub').textContent = title;
4766 clearCanvas();
4767 const W=cv.width, H=cv.height, pad=42;
4768 const span=Math.max(1, dim);
4769 const cell=(Math.min(W,H)-2*pad)/span;
4770 const px=Math.max(0.7, cell);
4771 // frame + light grid ticks
4772 ctx.strokeStyle='#30363d'; ctx.lineWidth=1;
4773 ctx.strokeRect(pad-0.5, pad-0.5, span*cell+1, span*cell+1);
4774 ctx.fillStyle='#6e7681'; ctx.font='11px monospace';
4775 ctx.fillText('0', pad-12, pad+9);
4776 ctx.fillText(String(dim), pad+span*cell-8, pad-8);
4777 ctx.fillText('row', pad-34, pad+span*cell/2);
4778 ctx.fillText('col', pad+span*cell/2-8, pad-22);
4779 let nnz=0;
4780 for(let k=0;k<irn.length;k++){
4781 const i=irn[k]-1, j=jcn[k]-1, v=vals?vals[k]:1;
4782 ctx.fillStyle = v>=0 ? 'rgba(78,161,255,0.92)' : 'rgba(255,107,107,0.92)';
4783 ctx.fillRect(pad+j*cell, pad+i*cell, px, px); nnz++;
4784 if(symmetric && i!==j){ ctx.fillRect(pad+i*cell, pad+j*cell, px, px); nnz++; }
4785 }
4786 $('legend').innerHTML =
4787 `<span class="pos">■</span> positive <span class="neg">■</span> negative`
4788 + ` · ${dim}×${dim}, ${nnz} plotted nonzeros`
4789 + (symmetric ? ' (lower triangle mirrored)' : '');
4790}
4791
4792function bars(values, title){
4793 $('sub').textContent = title;
4794 clearCanvas();
4795 const W=cv.width, H=cv.height, pad=42;
4796 const n=values.length;
4797 const maxAbs=Math.max(1e-300, ...values.map(v=>Math.abs(v)));
4798 const x0=pad, y0=H-pad, plotW=W-2*pad, plotH=H-2*pad, mid=pad+plotH/2;
4799 const bw=Math.max(0.7, plotW/Math.max(1,n));
4800 // zero axis
4801 ctx.strokeStyle='#30363d'; ctx.beginPath();
4802 ctx.moveTo(pad, mid); ctx.lineTo(W-pad, mid); ctx.stroke();
4803 ctx.fillStyle='#6e7681'; ctx.font='11px monospace';
4804 ctx.fillText('+'+fmt(maxAbs), 4, pad+10);
4805 ctx.fillText('-'+fmt(maxAbs), 4, H-pad-2);
4806 ctx.fillText('0', 4, mid+4);
4807 for(let k=0;k<n;k++){
4808 const v=values[k], h=(Math.abs(v)/maxAbs)*(plotH/2);
4809 ctx.fillStyle = v>=0 ? 'rgba(78,161,255,0.92)' : 'rgba(255,107,107,0.92)';
4810 if(v>=0) ctx.fillRect(pad+k*bw, mid-h, bw, h);
4811 else ctx.fillRect(pad+k*bw, mid, bw, h);
4812 }
4813 $('legend').innerHTML = `${n} components · max |val| = ${fmt(maxAbs)}`;
4814}
4815
4816const lbl = D.label || 'viz';
4817const iter = (D.iter!==undefined) ? D.iter : '?';
4818$('title').textContent = `pounce-dbg · viz ${lbl} · iter ${iter}`;
4819
4820if(D.matrix && D.matrix.irn){
4821 const m=D.matrix;
4822 const inertia = (D.inertia_correct===false)
4823 ? `<span class="bad">WRONG</span>` : `<span class="ok">correct</span>`;
4824 $('stats').innerHTML =
4825 `KKT augmented system dim=${D.dim}\n`+
4826 `inertia n+=${D.n_pos} n-=${D.n_neg} (expected n-=${D.expected_neg}, ${inertia})\n`+
4827 `regularization delta_w=${fmt(D.delta_w)} delta_c=${fmt(D.delta_c)}\n`+
4828 `factorization status: ${D.status}`;
4829 spy(m.irn, m.jcn, m.vals, m.dim, true, 'sparsity pattern (sign-colored)');
4830} else if(D.l_irn){
4831 $('stats').textContent =
4832 `LDLᵀ factor n=${D.n} nnz(L)=${D.l_irn.length} format=${D.format||''}`;
4833 spy(D.l_irn, D.l_jcn, D.l_vals, D.n, false, 'L factor sparsity (permuted, strict lower)');
4834} else if(D.values){
4835 $('stats').textContent = `vector ${lbl} length=${D.values.length}`;
4836 bars(D.values, 'component magnitudes (zero-centered)');
4837} else {
4838 $('stats').textContent = 'unrecognized payload — raw JSON:\n'+JSON.stringify(D,null,2);
4839}
4840</script></body></html>
4841"##;
4842
4843#[cfg(test)]
4844mod tests {
4845 use super::*;
4846
4847 fn dbg(mode: DebugMode) -> SolverDebugger {
4848 SolverDebugger::new(mode, None)
4849 }
4850
4851 #[test]
4852 fn json_command_object_is_flattened() {
4853 assert_eq!(
4854 parse_command("{\"cmd\":\"print x\"}", DebugMode::Json).command,
4855 "print x"
4856 );
4857 let p = parse_command(
4858 "{\"cmd\":\"set\",\"args\":[\"x[0]\",\"1.5\"],\"id\":7}",
4859 DebugMode::Json,
4860 );
4861 assert_eq!(p.command, "set x[0] 1.5");
4862 assert_eq!(p.id, Some(serde_json::json!(7)));
4864 let s = parse_command("step\n", DebugMode::Json);
4866 assert_eq!(s.command, "step");
4867 assert!(s.id.is_none());
4868 assert_eq!(
4869 parse_command(" print x \n", DebugMode::Repl).command,
4870 "print x"
4871 );
4872 }
4873
4874 #[test]
4875 fn pauses_at_first_checkpoint_then_only_when_rearmed() {
4876 let mut d = dbg(DebugMode::Repl);
4877 assert!(d.should_pause(0));
4879 d.step = false;
4881 assert!(!d.should_pause(1));
4882 assert!(!d.should_pause(2));
4883 }
4884
4885 #[test]
4886 fn breakpoints_and_run_to_arm_pauses() {
4887 let mut d = dbg(DebugMode::Repl);
4888 d.step = false;
4889 d.breaks = vec![3, 7];
4890 assert!(!d.should_pause(2));
4891 assert!(d.should_pause(3));
4892 assert!(d.should_pause(7));
4893 d.run_to = Some(5);
4895 assert!(!d.should_pause(4));
4896 assert!(d.should_pause(5));
4897 assert_eq!(d.run_to, None);
4898 assert!(!d.should_pause(6));
4899 }
4900
4901 #[test]
4902 fn atom_parses_metric_op_threshold() {
4903 let a = Atom::parse("mu<1e-4").unwrap();
4904 assert_eq!(a.metric, Metric::Mu);
4905 assert_eq!(a.op, CmpOp::Lt);
4906 assert_eq!(a.rhs, 1e-4);
4907
4908 let a = Atom::parse("inf_pr<=1e-6").unwrap();
4910 assert_eq!(a.metric, Metric::InfPr);
4911 assert_eq!(a.op, CmpOp::Le);
4912
4913 let a = Atom::parse("iter==10").unwrap();
4914 assert_eq!(a.metric, Metric::Iter);
4915 assert_eq!(a.op, CmpOp::Eq);
4916 assert_eq!(a.rhs, 10.0);
4917 }
4918
4919 struct MinimalState;
4925 impl DebugState for MinimalState {
4926 fn checkpoint(&self) -> Checkpoint {
4927 Checkpoint::IterStart
4928 }
4929 fn iter(&self) -> i32 {
4930 7
4931 }
4932 fn mu(&self) -> f64 {
4933 1e-3
4934 }
4935 fn objective(&self) -> f64 {
4936 42.0
4937 }
4938 fn inf_pr(&self) -> f64 {
4939 1e-4
4940 }
4941 fn inf_du(&self) -> f64 {
4942 2e-4
4943 }
4944 fn complementarity(&self) -> f64 {
4945 5e-4
4946 }
4947 fn alpha(&self) -> (f64, f64) {
4948 (1.0, 1.0)
4949 }
4950 fn block_dims(&self) -> Vec<(&'static str, usize)> {
4951 vec![]
4952 }
4953 fn block(&self, _name: &str) -> Option<Vec<f64>> {
4954 None
4955 }
4956 fn delta_block(&self, _name: &str) -> Option<Vec<f64>> {
4957 None
4958 }
4959 }
4960
4961 #[test]
4967 fn metric_fields_match_advertised_vocabulary() {
4968 let fields = metric_fields(&MinimalState);
4969
4970 let names: Vec<&str> = fields.iter().map(|(n, _)| *n).collect();
4972 assert_eq!(names, METRICS);
4973
4974 for &name in METRICS {
4977 assert!(
4978 name == "iter" || Metric::parse(name).is_some(),
4979 "METRICS entry `{name}` has no matching Metric arm"
4980 );
4981 }
4982
4983 let map: std::collections::HashMap<_, _> = fields.into_iter().collect();
4984 assert_eq!(map["iter"], serde_json::json!(7));
4986 assert_eq!(map["objective"], serde_json::json!(42.0));
4987 assert_eq!(map["nlp_error"], serde_json::Value::Null);
4990 }
4991
4992 #[test]
4993 fn atom_parse_rejects_garbage() {
4994 assert!(Atom::parse("inf_pr 1e-6").is_err()); assert!(Atom::parse("bogus<1").is_err()); assert!(Atom::parse("mu<abc").is_err()); }
4998
4999 #[test]
5000 fn compound_condition_parses_and_evaluates_left_to_right() {
5001 let c = Condition::parse("mu<1e-4&&inf_pr>1e-3").unwrap();
5003 assert_eq!(c.rest.len(), 1);
5004 assert_eq!(c.rest[0].0, Join::And);
5005
5006 let c = Condition::parse("iter>10&&(inf_du>1e-2||obj<0)").unwrap();
5008 assert_eq!(c.rest.len(), 2);
5009 assert_eq!(c.rest[0].0, Join::And);
5010 assert_eq!(c.rest[1].0, Join::Or);
5011 assert_eq!(c.raw, "iter>10&&inf_du>1e-2||obj<0");
5012
5013 assert!(Condition::parse("mu<1e-4&&bogus>0").is_err());
5015 }
5016
5017 #[test]
5018 fn completion_is_context_sensitive() {
5019 let c = completion_candidates(None, "", "co");
5021 assert!(c.contains(&"continue".to_string()));
5022 assert!(c.contains(&"complete".to_string()));
5023 assert!(!c.contains(&"step".to_string()));
5024
5025 let c = completion_candidates(None, "set ", "");
5027 assert!(c.contains(&"mu".to_string()));
5028 assert!(c.contains(&"opt".to_string()));
5029 assert!(c.contains(&"x".to_string()));
5030
5031 let c = completion_candidates(None, "break if ", "inf");
5033 assert!(c.contains(&"inf_pr".to_string()));
5034 assert!(c.contains(&"inf_du".to_string()));
5035 assert!(!c.contains(&"mu".to_string()));
5036
5037 let c = completion_candidates(None, "print ", "");
5039 assert!(c.contains(&"x".to_string()));
5040 assert!(c.contains(&"obj".to_string()));
5041 }
5042
5043 #[test]
5044 fn cmp_op_truth_table() {
5045 assert!(CmpOp::Lt.eval(1.0, 2.0));
5046 assert!(!CmpOp::Lt.eval(2.0, 2.0));
5047 assert!(CmpOp::Le.eval(2.0, 2.0));
5048 assert!(CmpOp::Gt.eval(3.0, 2.0));
5049 assert!(CmpOp::Ge.eval(2.0, 2.0));
5050 assert!(CmpOp::Eq.eval(2.0, 2.0));
5051 assert!(!CmpOp::Eq.eval(2.0, 2.5));
5052 }
5053
5054 #[test]
5055 fn interrupt_is_consumed_once() {
5056 interrupt::set_pending_for_test();
5057 assert!(interrupt::take(), "first take sees the pending Ctrl-C");
5058 assert!(!interrupt::take(), "second take is clear (consumed once)");
5059 }
5060
5061 #[test]
5062 fn on_interrupt_constructor_runs_free_but_interruptible() {
5063 let d = SolverDebugger::on_interrupt(DebugMode::Repl, None);
5064 assert!(!d.pause_iters, "on-interrupt does not pause each iter");
5065 assert!(!d.pause_terminal, "on-interrupt does not pause at terminal");
5066 assert!(d.interruptible, "on-interrupt honors Ctrl-C");
5067 assert!(!d.step, "on-interrupt starts un-armed");
5068 }
5069
5070 #[test]
5071 fn coffee_easter_egg_prints_art_but_stays_hidden() {
5072 let d = SolverDebugger::new(DebugMode::Repl, None);
5073 let out = d.cmd_coffee();
5074 assert!(out.ok);
5075 assert!(out.lines.len() > 5, "multi-line art");
5076 assert!(
5077 out.lines.iter().any(|l| l.contains("COFFEE")),
5078 "the mug says COFFEE"
5079 );
5080 assert!(
5082 !COMMANDS.contains(&"coffee"),
5083 "hidden from help/complete/Tab"
5084 );
5085 assert!(
5087 out.lines.iter().all(|l| !l.contains('\x1b')),
5088 "no color when stderr isn't a TTY"
5089 );
5090 }
5091
5092 #[test]
5097 fn quit_latches_in_json_mode_only() {
5098 let mut repl = SolverDebugger::new(DebugMode::Repl, None);
5099 let _ = repl.dispatch("quit", &mut MinimalState);
5100 assert!(!repl.quit, "REPL quit must keep the post-mortem pause");
5101 let mut json = SolverDebugger::new(DebugMode::Json, None);
5102 let _ = json.dispatch("quit", &mut MinimalState);
5103 assert!(json.quit, "JSON quit must latch");
5104 }
5105
5106 #[test]
5107 fn double_ctrl_c_at_prompt_quits_single_cancels_line() {
5108 let mut d = SolverDebugger::new(DebugMode::Repl, None);
5109 assert_eq!(d.on_prompt_interrupt(), "");
5111 assert_eq!(d.on_prompt_interrupt(), "quit");
5113 assert_eq!(d.on_prompt_interrupt(), "");
5115 d.prompt_interrupts = 0;
5118 assert_eq!(d.on_prompt_interrupt(), "", "fresh streak after a command");
5119 }
5120
5121 #[test]
5122 fn stop_at_accepts_names_and_aliases() {
5123 let mut d = SolverDebugger::new(DebugMode::Repl, None);
5124 assert!(d.cmd_stop_at(&["after_search_dir"]).ok);
5125 assert!(d.stop_at.contains("after_search_dir"));
5126 assert!(d.cmd_stop_at(&["mu"]).ok);
5128 assert!(d.stop_at.contains("after_mu"));
5129 assert!(d.cmd_stop_at(&["kkt"]).ok);
5130 assert!(d.stop_at.contains("after_search_dir"));
5131 assert!(!d.cmd_stop_at(&["bogus"]).ok);
5133 assert!(d.cmd_stop_at(&["clear"]).ok);
5135 assert!(d.stop_at.is_empty());
5136 }
5137
5138 #[test]
5139 fn llm_command_defaults_and_overrides() {
5140 unsafe { std::env::remove_var("POUNCE_DBG_LLM") };
5143 let (prog, args, on_stdin) = llm_command("hi");
5144 assert_eq!(prog, "claude");
5145 assert_eq!(args, vec!["-p".to_string()]);
5146 assert!(on_stdin);
5147
5148 unsafe { std::env::set_var("POUNCE_DBG_LLM", "mytool --ask {}") };
5151 let (prog, args, on_stdin) = llm_command("why");
5152 assert_eq!(prog, "mytool");
5153 assert_eq!(args, vec!["--ask".to_string(), "why".to_string()]);
5154 assert!(!on_stdin);
5155
5156 unsafe { std::env::set_var("POUNCE_DBG_LLM", "llm -m gpt") };
5159 let (_, _, on_stdin) = llm_command("q");
5160 assert!(on_stdin);
5161
5162 unsafe { std::env::set_var("POUNCE_DBG_LLM", "codex") };
5167 let (prog, args, on_stdin) = llm_command("why is mu stuck");
5168 assert_eq!(prog, "codex");
5169 assert_eq!(
5170 args,
5171 vec!["exec".to_string(), "why is mu stuck".to_string()]
5172 );
5173 assert!(!on_stdin); unsafe { std::env::set_var("POUNCE_DBG_LLM", "gemini") };
5177 let (prog, args, _) = llm_command("q");
5178 assert_eq!(prog, "gemini");
5179 assert_eq!(args, vec!["-p".to_string(), "q".to_string()]);
5180
5181 unsafe { std::env::set_var("POUNCE_DBG_LLM", "llm") };
5183 let (prog, args, _) = llm_command("q");
5184 assert_eq!(prog, "llm");
5185 assert_eq!(args, vec!["q".to_string()]);
5186
5187 unsafe { std::env::set_var("POUNCE_DBG_LLM", "claude") };
5191 let (prog, args, on_stdin) = llm_command("q");
5192 assert_eq!(prog, "claude");
5193 assert_eq!(args, vec!["-p".to_string()]);
5194 assert!(on_stdin);
5195
5196 unsafe { std::env::set_var("POUNCE_DBG_LLM", "mytool") };
5200 let (prog, args, on_stdin) = llm_command("q");
5201 assert_eq!(prog, "mytool");
5202 assert!(args.is_empty());
5203 assert!(on_stdin);
5204
5205 unsafe { std::env::set_var("POUNCE_DBG_LLM", "pounce-no-such-llm-xyz") };
5209 let err = run_llm("hello").unwrap_err();
5210 assert!(err.contains("not installed or not on PATH"), "{err}");
5211 assert!(err.contains("codex"), "{err}");
5212
5213 unsafe { std::env::remove_var("POUNCE_DBG_LLM") };
5215 }
5216
5217 #[test]
5218 fn detach_disables_all_pausing() {
5219 let mut d = dbg(DebugMode::Repl);
5220 d.detached = true;
5221 d.step = true;
5222 d.breaks = vec![1];
5223 assert!(!d.should_pause(0));
5224 assert!(!d.should_pause(1));
5225 }
5226
5227 #[test]
5228 fn kkt_capture_tracks_attached_state() {
5229 let mut d = dbg(DebugMode::Repl);
5232 assert!(d.wants_kkt_capture());
5233 d.detached = true;
5234 assert!(!d.wants_kkt_capture());
5235 }
5236
5237 fn resid(kind: ResidKind, index: usize, value: f64) -> Residual {
5238 Residual { kind, index, value }
5239 }
5240
5241 #[test]
5242 fn rank_residuals_sorts_by_magnitude_and_truncates() {
5243 use ResidKind::*;
5244 let entries = vec![
5245 resid(Eq, 0, -0.5),
5246 resid(Ineq, 1, 3.0),
5247 resid(DualX, 2, -7.0),
5248 resid(DualS, 3, 1.0),
5249 ];
5250 let top = rank_residuals(entries, 2);
5251 assert_eq!(top.len(), 2);
5252 assert_eq!(top[0].value, -7.0);
5254 assert_eq!(top[0].kind, DualX);
5255 assert_eq!(top[1].value, 3.0);
5256 assert_eq!(top[1].kind, Ineq);
5257 }
5258
5259 #[test]
5260 fn rank_residuals_k_zero_and_k_over_len() {
5261 use ResidKind::*;
5262 let entries = vec![resid(Eq, 0, 1.0), resid(Ineq, 1, 2.0)];
5263 assert!(rank_residuals(entries.clone(), 0).is_empty());
5264 let all = rank_residuals(entries, 99);
5266 assert_eq!(all.len(), 2);
5267 assert_eq!(all[0].value, 2.0);
5268 }
5269
5270 #[test]
5271 fn rank_residuals_is_stable_on_magnitude_ties() {
5272 use ResidKind::*;
5273 let entries = vec![
5275 resid(Ineq, 5, -2.0),
5276 resid(Eq, 1, 2.0),
5277 resid(DualX, 9, -2.0),
5278 ];
5279 let top = rank_residuals(entries, 3);
5280 assert_eq!(
5281 top.iter().map(|r| r.kind).collect::<Vec<_>>(),
5282 vec![Ineq, Eq, DualX]
5283 );
5284 }
5285
5286 fn split_names_fixture() -> SplitNames {
5287 SplitNames {
5288 x_var: vec![Some("T_reactor".into()), None],
5289 eq: vec![Some("mass_balance".into()), Some("energy_balance".into())],
5290 ineq: vec![Some("pressure_cap".into())],
5291 }
5292 }
5293
5294 #[test]
5295 fn resid_name_maps_each_kind_to_its_pool() {
5296 use ResidKind::*;
5297 let names = Some(split_names_fixture());
5298 assert_eq!(
5301 resid_name(&resid(Eq, 1, 0.0), &names),
5302 Some("energy_balance")
5303 );
5304 assert_eq!(
5305 resid_name(&resid(Ineq, 0, 0.0), &names),
5306 Some("pressure_cap")
5307 );
5308 assert_eq!(
5309 resid_name(&resid(DualS, 0, 0.0), &names),
5310 Some("pressure_cap")
5311 );
5312 assert_eq!(resid_name(&resid(DualX, 0, 0.0), &names), Some("T_reactor"));
5313 assert_eq!(resid_name(&resid(DualX, 1, 0.0), &names), None);
5315 assert_eq!(resid_name(&resid(Eq, 9, 0.0), &names), None);
5316 assert_eq!(resid_name(&resid(Eq, 0, 0.0), &None), None);
5318 }
5319
5320 #[test]
5321 fn worst_named_picks_largest_and_labels_it() {
5322 use ResidKind::*;
5323 let names = Some(split_names_fixture());
5324 let resids = vec![resid(Eq, 0, 0.5), resid(Eq, 1, -3.2), resid(Ineq, 0, 1.1)];
5326 assert_eq!(
5327 worst_named(resids, &names),
5328 Some(("c[energy_balance]".to_string(), -3.2))
5329 );
5330 let resids = vec![resid(DualX, 7, 9.0)];
5332 assert_eq!(
5333 worst_named(resids, &None),
5334 Some(("grad_x_L[7]".to_string(), 9.0))
5335 );
5336 assert_eq!(worst_named(vec![], &names), None);
5338 }
5339
5340 use pounce_algorithm::debug_rank::RankCulprit;
5341
5342 fn rank_report_fixture() -> RankReport {
5343 RankReport {
5346 rows: vec![
5347 RankRow {
5348 kind: ResidKind::Eq,
5349 index: 0,
5350 },
5351 RankRow {
5352 kind: ResidKind::Eq,
5353 index: 1,
5354 },
5355 ],
5356 n_cols: 3,
5357 singular_values: vec![2.0, 0.0],
5358 tol: 1e-15,
5359 rank: 1,
5360 cond: f64::INFINITY,
5361 culprits: vec![
5362 RankCulprit {
5363 row: 0,
5364 weight: 0.5,
5365 },
5366 RankCulprit {
5367 row: 1,
5368 weight: 0.5,
5369 },
5370 ],
5371 }
5372 }
5373
5374 #[test]
5375 fn render_rank_report_names_culprits_and_builds_json() {
5376 let names = Some(split_names_fixture());
5377 let rep = rank_report_fixture();
5378 let (lines, data) = render_rank_report(&rep, &names, None, 7);
5380
5381 let text = lines.join("\n");
5382 assert!(text.contains("2 row(s) × 3 column(s)"), "{text}");
5383 assert!(text.contains("numerical rank = 1 / 2"), "{text}");
5384 assert!(text.contains("inf (σ_min = 0)"), "{text}");
5386 assert!(text.contains("c[mass_balance]"), "{text}");
5388 assert!(text.contains("c[energy_balance]"), "{text}");
5389 assert!(text.contains("participation 0.50"), "{text}");
5390 assert!(text.contains("print equation"), "{text}");
5392
5393 assert_eq!(data["iter"], 7);
5396 assert_eq!(data["rank"], 1);
5397 assert_eq!(data["deficiency"], 1);
5398 assert_eq!(data["rank_deficient"], true);
5399 assert!(data["cond"].is_null(), "non-finite cond ⇒ null: {data}");
5400 assert_eq!(data["culprits"][0]["name"], "mass_balance");
5401 assert_eq!(data["culprits"][0]["label"], "c[mass_balance]");
5402 assert!(data["culprits"][0]["equation"].is_null());
5403 assert_eq!(data["culprits"][1]["name"], "energy_balance");
5404 }
5405
5406 #[test]
5407 fn render_rank_report_prints_culprit_equations_inline() {
5408 let names = Some(split_names_fixture());
5409 let rep = rank_report_fixture();
5410 let book = EquationBook::new(
5413 vec!["mass_balance".into(), "energy_balance".into()],
5414 vec![
5415 "x[0] + x[1] - 10 = 0".into(),
5416 "T_reactor*flow - Q = 0".into(),
5417 ],
5418 );
5419 let (lines, data) = render_rank_report(&rep, &names, Some(&book), 7);
5420
5421 let text = lines.join("\n");
5422 assert!(text.contains("x[0] + x[1] - 10 = 0"), "{text}");
5425 assert!(text.contains("T_reactor*flow - Q = 0"), "{text}");
5426 assert!(!text.contains("inspect a row with"), "{text}");
5428
5429 assert_eq!(data["culprits"][0]["equation"], "x[0] + x[1] - 10 = 0");
5431 assert_eq!(data["culprits"][1]["equation"], "T_reactor*flow - Q = 0");
5432 }
5433
5434 #[test]
5435 fn render_rank_report_full_rank_reports_positive_signal() {
5436 let rep = RankReport {
5437 rows: vec![
5438 RankRow {
5439 kind: ResidKind::Eq,
5440 index: 0,
5441 },
5442 RankRow {
5443 kind: ResidKind::Eq,
5444 index: 1,
5445 },
5446 ],
5447 n_cols: 3,
5448 singular_values: vec![2.0, 1.0],
5449 tol: 1e-15,
5450 rank: 2,
5451 cond: 2.0,
5452 culprits: vec![],
5453 };
5454 let (lines, data) = render_rank_report(&rep, &None, None, 3);
5455 let text = lines.join("\n");
5456 assert!(text.contains("full row rank"), "{text}");
5457 assert!(!text.contains("rank-deficient"), "{text}");
5458 assert_eq!(data["rank_deficient"], false);
5459 assert_eq!(data["cond"], 2.0);
5460 assert_eq!(data["culprits"].as_array().map(|a| a.len()), Some(0));
5461 }
5462
5463 #[test]
5464 fn print_equation_resolves_by_name_index_and_errors() {
5465 let mut d = dbg(DebugMode::Repl);
5466 let out = d.cmd_print_equation(&[]);
5468 assert!(!out.ok);
5469 assert!(out.lines[0].contains("needs an .nl model"));
5470
5471 d.set_equation_book(EquationBook::new(
5472 vec!["mass_balance".into(), String::new()],
5473 vec!["x[0] + x[1] = 10".into(), "x[0] - x[1] <= 2".into()],
5474 ));
5475
5476 let out = d.cmd_print_equation(&[]);
5478 assert!(out.ok);
5479 assert!(out.lines[0].contains("2 constraint equation"));
5480
5481 let out = d.cmd_print_equation(&["mass_balance"]);
5483 assert!(out.ok);
5484 assert_eq!(out.lines[0], "mass_balance: x[0] + x[1] = 10");
5485
5486 let out = d.cmd_print_equation(&["1"]);
5488 assert!(out.ok);
5489 assert_eq!(out.lines[0], "c[1]: x[0] - x[1] <= 2");
5490
5491 let out = d.cmd_print_equation(&["nope"]);
5493 assert!(!out.ok);
5494 assert!(out.lines[0].contains("no constraint named or indexed"));
5495 }
5496
5497 fn eq_inc(n_vars: usize, eq_row_inner_idx: Vec<usize>, rows: &[&[usize]]) -> EqualityIncidence {
5501 let mut adj_ptr = vec![0usize];
5502 let mut vars = Vec::new();
5503 for r in rows {
5504 let mut v = r.to_vec();
5505 v.sort_unstable();
5506 v.dedup();
5507 vars.extend_from_slice(&v);
5508 adj_ptr.push(vars.len());
5509 }
5510 EqualityIncidence {
5511 n_vars,
5512 eq_row_inner_idx,
5513 adj_ptr,
5514 vars,
5515 }
5516 }
5517
5518 #[test]
5519 fn structural_singularity_names_overdetermined_equations() {
5520 let inc = eq_inc(2, vec![0, 1, 2], &[&[0, 1], &[0, 1], &[0, 1]]);
5526 let book = StructureBook::new(
5527 inc,
5528 vec!["balance_a".into(), "balance_b".into(), "balance_c".into()],
5529 vec!["flow".into(), "temp".into()],
5530 );
5531 let f = book.findings();
5532 assert_eq!(f.len(), 1);
5533 let (sev, code, msg) = &f[0];
5534 assert_eq!(*sev, "warning");
5535 assert_eq!(*code, "structural_singularity");
5536 assert!(msg.contains("balance_a"), "msg: {msg}");
5537 assert!(msg.contains("balance_b"), "msg: {msg}");
5538 assert!(msg.contains("balance_c"), "msg: {msg}");
5539 assert!(msg.contains("flow") && msg.contains("temp"), "msg: {msg}");
5540 assert!(msg.contains("≥1"), "msg: {msg}");
5541 }
5542
5543 #[test]
5544 fn structural_findings_silent_when_well_posed_and_fall_back_to_indices() {
5545 let inc = eq_inc(2, vec![0, 1], &[&[0], &[1]]);
5549 let book = StructureBook::new(inc, vec![], vec![]);
5550 assert!(book.findings().is_empty());
5551
5552 let inc = eq_inc(1, vec![0, 1, 3], &[&[0], &[0], &[0]]);
5556 let book = StructureBook::new(inc, vec![], vec![]);
5557 let f = book.findings();
5558 assert_eq!(f.len(), 1);
5559 let msg = &f[0].2;
5560 assert!(
5561 msg.contains("c[0]") && msg.contains("c[1]") && msg.contains("c[3]"),
5562 "msg: {msg}"
5563 );
5564 }
5565
5566 #[test]
5567 fn structural_singularity_handles_empty_row_with_no_variables() {
5568 let inc = eq_inc(1, vec![0, 1], &[&[0], &[]]);
5571 let book = StructureBook::new(inc, vec!["real".into(), "ghost".into()], vec!["x".into()]);
5572 let f = book.findings();
5573 assert_eq!(f.len(), 1);
5574 let msg = &f[0].2;
5575 assert!(msg.contains("ghost"), "msg: {msg}");
5576 assert!(msg.contains("no variables"), "msg: {msg}");
5577 }
5578
5579 #[test]
5580 fn parse_floats_accepts_commas_whitespace_and_newlines() {
5581 assert_eq!(parse_floats("1, 2 ,3").unwrap(), vec![1.0, 2.0, 3.0]);
5582 assert_eq!(parse_floats("1\n2\n-3.5").unwrap(), vec![1.0, 2.0, -3.5]);
5583 assert_eq!(parse_floats(" 1.0 2e-1 ").unwrap(), vec![1.0, 0.2]);
5584 assert!(parse_floats("1, nope, 3").is_err());
5585 assert_eq!(parse_floats("").unwrap(), Vec::<f64>::new());
5586 }
5587
5588 #[test]
5589 fn jitter_start_zero_is_the_unperturbed_base_and_is_deterministic() {
5590 let base = vec![1.0, -2.0, 0.0];
5591 assert_eq!(jitter(&base, 0.1, 0), base);
5593 let a = jitter(&base, 0.1, 1);
5595 let b = jitter(&base, 0.1, 1);
5596 assert_eq!(a, b);
5597 assert_ne!(a, base);
5598 for (j, (&p, &x)) in a.iter().zip(&base).enumerate() {
5599 let bound = 0.1 * (x.abs() + 1.0);
5600 assert!(
5601 (p - x).abs() <= bound + 1e-12,
5602 "component {j} moved {} > bound {bound}",
5603 (p - x).abs()
5604 );
5605 }
5606 assert_ne!(jitter(&base, 0.1, 1), jitter(&base, 0.1, 2));
5608 }
5609
5610 #[test]
5611 fn sample_start_draws_inside_finite_boxes_and_jitters_unbounded() {
5612 let base = vec![1.0, 1.0, 0.5];
5613 let lo = vec![0.0, 0.0, -1.0];
5615 let hi = vec![2.0, f64::INFINITY, 1.0];
5616 let b = Some((lo.as_slice(), hi.as_slice()));
5617 assert_eq!(sample_start(&base, b, 0.1, 0), base);
5619 for k in 1..50 {
5620 let s = sample_start(&base, b, 0.1, k);
5621 assert!((0.0..=2.0).contains(&s[0]), "var0 {} out of [0,2]", s[0]);
5623 assert!((-1.0..=1.0).contains(&s[2]), "var2 {} out of [-1,1]", s[2]);
5624 let bound = 0.1 * (base[1].abs() + 1.0);
5626 assert!(
5627 (s[1] - base[1]).abs() <= bound + 1e-12,
5628 "var1 jitter exceeded"
5629 );
5630 }
5631 assert_eq!(
5633 sample_start(&base, b, 0.1, 7),
5634 sample_start(&base, b, 0.1, 7)
5635 );
5636 }
5637
5638 #[test]
5639 fn path_completion_lists_matching_files_with_dir_prefix() {
5640 let dir = std::env::temp_dir().join("pounce_dbg_complete_test");
5641 let _ = std::fs::remove_dir_all(&dir);
5642 std::fs::create_dir_all(&dir).unwrap();
5643 std::fs::write(dir.join("starts.txt"), "0,0\n").unwrap();
5644 std::fs::write(dir.join("start2.txt"), "1,1\n").unwrap();
5645 std::fs::write(dir.join("other.json"), "{}").unwrap();
5646 std::fs::create_dir_all(dir.join("subdir")).unwrap();
5647
5648 let p = dir.to_string_lossy().to_string();
5649 let mut got = path_candidates(&format!("{p}/start"));
5651 got.sort();
5652 assert_eq!(
5653 got,
5654 vec![format!("{p}/start2.txt"), format!("{p}/starts.txt")]
5655 );
5656 let got = path_candidates(&format!("{p}/sub"));
5658 assert_eq!(got, vec![format!("{p}/subdir/")]);
5659 assert_eq!(path_candidates(&format!("{p}/")).len(), 4);
5661 assert!(
5663 completion_candidates(None, "load", &format!("{p}/star"))
5664 .iter()
5665 .all(|c| c.contains("start"))
5666 );
5667
5668 let _ = std::fs::remove_dir_all(&dir);
5669 }
5670}