1use crate::cli::DebugMode;
44use pounce_algorithm::debug::{
45 is_live_tolerance, Checkpoint, DebugAction, DebugCtx, DebugHook, IterateSnapshot, ResidKind,
46 Residual, BLOCK_NAMES,
47};
48use pounce_algorithm::debug_rank::{RankReport, RankRow};
49use pounce_common::reg_options::{DefaultValue, OptionType, RegisteredOptions};
50use pounce_nlp::ipopt_nlp::SplitNames;
51use pounce_presolve::dulmage_mendelsohn::DulmageMendelsohnPartition;
52use pounce_presolve::incidence::EqualityIncidence;
53use pounce_presolve::matching::hopcroft_karp;
54use rustyline::completion::{Completer, Pair};
55use rustyline::error::ReadlineError;
56use rustyline::history::FileHistory;
57use rustyline::{Context, Editor, Helper, Highlighter, Hinter, Validator};
58use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
59use std::io::{IsTerminal, Write};
60use std::path::PathBuf;
61use std::rc::Rc;
62
63const COMMANDS: &[&str] = &[
65 "help",
66 "info",
67 "print",
68 "step",
69 "stepi",
70 "continue",
71 "run",
72 "break",
73 "tbreak",
74 "watchpoint",
75 "commands",
76 "stop-at",
77 "set",
78 "get",
79 "opt",
80 "complete",
81 "viz",
82 "save",
83 "load",
84 "sweep",
85 "multistart",
86 "goto",
87 "restart",
88 "resolve",
89 "ask",
90 "watch",
91 "diff",
92 "diagnose",
93 "source",
94 "progress",
95 "detach",
96 "quit",
97];
98
99const EVENTS: &[&str] = &[
102 "resto_entered",
103 "resto_exited",
104 "regularized",
105 "tiny_step",
106 "ls_rejected",
107 "mu_stalled",
108 "nan",
109];
110
111const MU_STALL_ITERS: u32 = 3;
114
115#[derive(Clone)]
118struct WatchPoint {
119 raw: String,
121 block: String,
122 idx: Option<usize>,
123 threshold: f64,
124 last: Option<Vec<f64>>,
126}
127
128const CHECKPOINTS: &[&str] = &[
130 "iter_start",
131 "after_mu",
132 "after_search_dir",
133 "after_step",
134 "step_rejected",
135 "pre_restoration_entry",
136 "post_restoration_exit",
137 "terminated",
138];
139
140pub struct RestartRequest {
144 pub seed_x: Vec<f64>,
147 pub options: Vec<(String, String)>,
149 pub warm: Option<IterateSnapshot>,
155}
156
157pub type RestartCell = Rc<std::cell::RefCell<Option<RestartRequest>>>;
160
161#[derive(Clone)]
163struct SweepRecord {
164 idx: usize,
166 seed: Vec<f64>,
168 status: String,
170 objective: f64,
172 inf_pr: f64,
174 iters: i32,
176}
177
178struct SweepState {
183 queue: VecDeque<Vec<f64>>,
185 current: Option<Vec<f64>>,
187 records: Vec<SweepRecord>,
189 total: usize,
191 saved_pause_iters: bool,
194}
195
196const SNAPSHOT_CAP: usize = 2000;
199
200fn is_success_status(s: &str) -> bool {
203 matches!(s, "Success" | "StopAtAcceptablePoint")
204}
205
206fn parse_floats(s: &str) -> Result<Vec<f64>, String> {
210 s.split(|c: char| c == ',' || c.is_whitespace())
211 .filter(|t| !t.is_empty())
212 .map(|t| t.parse::<f64>().map_err(|_| format!("bad number `{t}`")))
213 .collect()
214}
215
216fn splitmix_unit(state: &mut u64) -> f64 {
219 *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
220 let mut z = *state;
221 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
222 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
223 z ^= z >> 31;
224 ((z >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0
226}
227
228fn seed_for(k: usize) -> u64 {
230 0x9E37_79B9_7F4A_7C15u64
231 ^ (k as u64)
232 .wrapping_mul(0xD1B5_4A32_D192_ED03)
233 .wrapping_add(1)
234}
235
236fn sample_start(base: &[f64], bounds: Option<(&[f64], &[f64])>, rel: f64, k: usize) -> Vec<f64> {
242 if k == 0 {
243 return base.to_vec();
244 }
245 let mut state = seed_for(k);
246 base.iter()
247 .enumerate()
248 .map(|(i, &xi)| {
249 let unit = splitmix_unit(&mut state); if let Some((lo, hi)) = bounds {
251 let (l, u) = (lo[i], hi[i]);
252 if l.is_finite() && u.is_finite() && u > l {
253 return l + (u - l) * (unit * 0.5 + 0.5);
255 }
256 }
257 xi + rel * (xi.abs() + 1.0) * unit
258 })
259 .collect()
260}
261
262#[cfg(test)]
264fn jitter(base: &[f64], rel: f64, k: usize) -> Vec<f64> {
265 sample_start(base, None, rel, k)
266}
267
268pub mod interrupt {
279 use std::sync::atomic::{AtomicBool, Ordering};
280
281 static PENDING: AtomicBool = AtomicBool::new(false);
282 #[cfg(unix)]
283 static INSTALLED: AtomicBool = AtomicBool::new(false);
284
285 #[cfg(unix)]
286 extern "C" fn handler(_sig: nix::libc::c_int) {
287 if PENDING.swap(true, Ordering::SeqCst) {
290 unsafe { nix::libc::_exit(130) };
292 }
293 }
294
295 #[cfg(unix)]
298 pub fn install() {
299 use nix::sys::signal::{self, SigHandler, Signal};
300 if INSTALLED.swap(true, Ordering::SeqCst) {
301 return;
302 }
303 unsafe {
305 let _ = signal::signal(Signal::SIGINT, SigHandler::Handler(handler));
306 }
307 }
308
309 #[cfg(not(unix))]
314 pub fn install() {}
315
316 pub fn take() -> bool {
318 PENDING.swap(false, Ordering::SeqCst)
319 }
320
321 #[cfg(test)]
323 pub fn set_pending_for_test() {
324 PENDING.store(true, Ordering::SeqCst);
325 }
326}
327
328#[derive(Clone, Copy)]
330enum Flow {
331 Stay,
333 Resume,
335 Stop,
337}
338
339struct CmdOut {
341 ok: bool,
342 lines: Vec<String>,
343 data: Option<serde_json::Value>,
344 flow: Flow,
345}
346
347impl CmdOut {
348 fn ok(lines: Vec<String>) -> Self {
349 Self {
350 ok: true,
351 lines,
352 data: None,
353 flow: Flow::Stay,
354 }
355 }
356 fn err(msg: impl Into<String>) -> Self {
357 Self {
358 ok: false,
359 lines: vec![msg.into()],
360 data: None,
361 flow: Flow::Stay,
362 }
363 }
364 fn with_data(mut self, data: serde_json::Value) -> Self {
365 self.data = Some(data);
366 self
367 }
368 fn flow(mut self, flow: Flow) -> Self {
369 self.flow = flow;
370 self
371 }
372}
373
374const METRICS: &[&str] = &[
381 "iter",
382 "mu",
383 "objective",
384 "inf_pr",
385 "inf_du",
386 "nlp_error",
387 "complementarity",
388];
389
390#[derive(Clone, Copy, Debug, PartialEq, Eq)]
392enum Metric {
393 Mu,
394 InfPr,
395 InfDu,
396 Obj,
397 NlpError,
398 Compl,
399 Iter,
400}
401
402impl Metric {
403 fn parse(s: &str) -> Option<Metric> {
404 Some(match s {
405 "mu" => Metric::Mu,
406 "inf_pr" => Metric::InfPr,
407 "inf_du" => Metric::InfDu,
408 "obj" | "objective" => Metric::Obj,
409 "err" | "nlp_error" => Metric::NlpError,
410 "compl" | "complementarity" => Metric::Compl,
411 "iter" => Metric::Iter,
412 _ => return None,
413 })
414 }
415 fn eval(self, ctx: &DebugCtx) -> f64 {
416 match self {
417 Metric::Mu => ctx.mu(),
418 Metric::InfPr => ctx.inf_pr(),
419 Metric::InfDu => ctx.inf_du(),
420 Metric::Obj => ctx.objective(),
421 Metric::NlpError => ctx.nlp_error(),
422 Metric::Compl => ctx.complementarity(),
423 Metric::Iter => ctx.iter() as f64,
424 }
425 }
426}
427
428#[derive(Clone, Copy, Debug, PartialEq, Eq)]
430enum CmpOp {
431 Lt,
432 Le,
433 Gt,
434 Ge,
435 Eq,
436}
437
438impl CmpOp {
439 fn eval(self, lhs: f64, rhs: f64) -> bool {
440 match self {
441 CmpOp::Lt => lhs < rhs,
442 CmpOp::Le => lhs <= rhs,
443 CmpOp::Gt => lhs > rhs,
444 CmpOp::Ge => lhs >= rhs,
445 CmpOp::Eq => (lhs - rhs).abs() <= 1e-12 * rhs.abs().max(1.0),
451 }
452 }
453}
454
455#[derive(Clone, Debug)]
457struct Atom {
458 metric: Metric,
459 op: CmpOp,
460 rhs: f64,
461}
462
463impl Atom {
464 fn parse(expr: &str) -> Result<Atom, String> {
467 let expr = expr.trim();
468 let mut found: Option<(&str, usize, usize)> = None;
473 for (i, _) in expr.char_indices() {
474 let rest = &expr[i..];
475 if rest.starts_with("<=") || rest.starts_with(">=") || rest.starts_with("==") {
476 found = Some((&expr[i..i + 2], i, 2));
477 break;
478 }
479 if rest.starts_with('<') || rest.starts_with('>') {
480 found = Some((&expr[i..i + 1], i, 1));
481 break;
482 }
483 }
484 let (op, pos, oplen) = found
485 .ok_or_else(|| format!("no comparison operator in `{expr}` (use < <= > >= ==)"))?;
486 let metric_s = expr[..pos].trim();
487 let rhs_s = expr[pos + oplen..].trim();
488 let metric = Metric::parse(metric_s)
489 .ok_or_else(|| format!("unknown metric `{metric_s}` (one of {METRICS:?})"))?;
490 let rhs = rhs_s
491 .parse::<f64>()
492 .map_err(|_| format!("bad threshold `{rhs_s}`"))?;
493 let cmp = match op {
494 "<" => CmpOp::Lt,
495 "<=" => CmpOp::Le,
496 ">" => CmpOp::Gt,
497 ">=" => CmpOp::Ge,
498 "==" => CmpOp::Eq,
499 _ => unreachable!(),
500 };
501 Ok(Atom {
502 metric,
503 op: cmp,
504 rhs,
505 })
506 }
507
508 fn holds(&self, ctx: &DebugCtx) -> bool {
509 self.op.eval(self.metric.eval(ctx), self.rhs)
510 }
511}
512
513#[derive(Clone, Copy, Debug, PartialEq, Eq)]
515enum Join {
516 And,
517 Or,
518}
519
520#[derive(Clone, Debug)]
525struct Condition {
526 first: Atom,
527 rest: Vec<(Join, Atom)>,
528 raw: String,
530}
531
532impl Condition {
533 fn parse(expr: &str) -> Result<Condition, String> {
534 let cleaned: String = expr.chars().filter(|c| !matches!(c, '(' | ')')).collect();
536 let mut atoms: Vec<(Option<Join>, &str)> = Vec::new();
538 let bytes = cleaned.as_bytes();
539 let mut start = 0usize;
540 let mut i = 0usize;
541 let mut pending: Option<Join> = None;
542 while i + 1 < bytes.len() {
543 let two = &cleaned[i..i + 2];
544 let join = match two {
545 "&&" => Some(Join::And),
546 "||" => Some(Join::Or),
547 _ => None,
548 };
549 if let Some(j) = join {
550 atoms.push((pending, &cleaned[start..i]));
551 pending = Some(j);
552 i += 2;
553 start = i;
554 } else {
555 i += 1;
556 }
557 }
558 atoms.push((pending, &cleaned[start..]));
559
560 let mut iter = atoms.into_iter();
561 let Some((_, first_s)) = iter.next() else {
562 return Err("empty condition".into());
563 };
564 let first = Atom::parse(first_s)?;
565 let mut rest = Vec::new();
566 for (join, s) in iter {
567 let join = join.ok_or("malformed compound condition (dangling &&/||)")?;
568 rest.push((join, Atom::parse(s)?));
569 }
570 Ok(Condition {
572 first,
573 rest,
574 raw: cleaned,
575 })
576 }
577
578 fn holds(&self, ctx: &DebugCtx) -> bool {
579 let mut acc = self.first.holds(ctx);
580 for (join, atom) in &self.rest {
581 let v = atom.holds(ctx);
582 acc = match join {
583 Join::And => acc && v,
584 Join::Or => acc || v,
585 };
586 }
587 acc
588 }
589}
590
591fn path_candidates(word: &str) -> Vec<String> {
601 let (dir, prefix) = match word.rfind('/') {
603 Some(i) => (&word[..=i], &word[i + 1..]), None => ("", word),
605 };
606 let read_from = if dir.is_empty() { "." } else { dir };
607 let Ok(entries) = std::fs::read_dir(read_from) else {
608 return Vec::new();
609 };
610 let mut out: Vec<String> = Vec::new();
611 for e in entries.flatten() {
612 let name = e.file_name().to_string_lossy().into_owned();
613 if !name.starts_with(prefix) {
614 continue;
615 }
616 if name.starts_with('.') && !prefix.starts_with('.') {
617 continue;
618 }
619 let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
620 let mut cand = format!("{dir}{name}");
621 if is_dir {
622 cand.push('/');
623 }
624 out.push(cand);
625 }
626 out.sort();
627 out
628}
629
630fn completion_candidates(reg: Option<&RegisteredOptions>, before: &str, word: &str) -> Vec<String> {
631 let toks: Vec<&str> = before.split_whitespace().collect();
632 let starts = |opts: &[&str]| -> Vec<String> {
633 opts.iter()
634 .filter(|c| c.starts_with(word))
635 .map(|c| c.to_string())
636 .collect()
637 };
638 let opt_names = || -> Vec<String> {
639 reg.map(|r| {
640 r.registered_options_in_order()
641 .iter()
642 .map(|o| o.name.clone())
643 .filter(|n| n.starts_with(word))
644 .collect()
645 })
646 .unwrap_or_default()
647 };
648 match toks.as_slice() {
649 [] => starts(COMMANDS),
650 ["set"] => {
651 let mut v = starts(&["mu", "opt"]);
652 v.extend(starts(&BLOCK_NAMES));
653 v
654 }
655 ["set", "opt"] | ["get", "opt"] | ["get"] | ["opt"] | ["options"] => opt_names(),
656 ["set", "opt", name] => reg
658 .and_then(|r| r.get_option(name))
659 .map(|o| {
660 o.valid_strings
661 .iter()
662 .map(|e| e.value.clone())
663 .filter(|v| v.starts_with(word) && v != "*")
664 .collect()
665 })
666 .unwrap_or_default(),
667 ["stop-at"] | ["stopat"] => starts(CHECKPOINTS),
668 ["break", "if"] | ["b", "if"] => starts(METRICS),
669 ["break", "on"] | ["b", "on"] => starts(EVENTS),
670 ["break"] | ["b"] => starts(&["if", "on", "clear", "del"]),
671 ["watchpoint"] | ["wp"] => starts(&BLOCK_NAMES),
672 ["print"] | ["p"] | ["watch"] | ["display"] => {
673 let mut v = starts(&BLOCK_NAMES);
674 v.extend(starts(&[
675 "mu",
676 "obj",
677 "inf_pr",
678 "inf_du",
679 "err",
680 "compl",
681 "iter",
682 "kkt",
683 "active",
684 "inactive",
685 "residuals",
686 "equation",
687 "rank",
688 ]));
689 v
690 }
691 ["viz"] | ["plot"] => {
692 let mut v = starts(&BLOCK_NAMES);
693 v.extend(starts(&["kkt", "L"]));
694 v
695 }
696 ["complete"] => starts(COMMANDS),
697 ["save"] | ["load"] | ["sweep"] | ["source"] => path_candidates(word),
699 ["load", _] => starts(&BLOCK_NAMES),
701 _ => Vec::new(),
702 }
703}
704
705#[derive(Helper, Hinter, Highlighter, Validator)]
709struct DbgHelper {
710 reg: Option<Rc<RegisteredOptions>>,
711}
712
713impl Completer for DbgHelper {
714 type Candidate = Pair;
715 fn complete(
716 &self,
717 line: &str,
718 pos: usize,
719 _ctx: &Context<'_>,
720 ) -> rustyline::Result<(usize, Vec<Pair>)> {
721 let before = &line[..pos];
722 let start = before
723 .rfind(char::is_whitespace)
724 .map(|i| i + 1)
725 .unwrap_or(0);
726 let word = &before[start..];
727 let cands = completion_candidates(self.reg.as_deref(), &before[..start], word);
728 let pairs = cands
729 .into_iter()
730 .map(|c| Pair {
731 display: c.clone(),
732 replacement: c,
733 })
734 .collect();
735 Ok((start, pairs))
736 }
737}
738
739pub struct EquationBook {
749 names: Vec<String>,
752 equations: Vec<String>,
754}
755
756impl EquationBook {
757 pub fn new(names: Vec<String>, equations: Vec<String>) -> Self {
760 Self { names, equations }
761 }
762
763 pub fn len(&self) -> usize {
765 self.equations.len()
766 }
767
768 pub fn is_empty(&self) -> bool {
770 self.equations.is_empty()
771 }
772
773 fn label(&self, i: usize) -> String {
776 match self.names.get(i) {
777 Some(n) if !n.is_empty() => n.clone(),
778 _ => format!("c[{i}]"),
779 }
780 }
781
782 fn resolve(&self, key: &str) -> Option<usize> {
785 if let Some(i) = self.names.iter().position(|n| n == key) {
786 return Some(i);
787 }
788 key.parse::<usize>()
789 .ok()
790 .filter(|&i| i < self.equations.len())
791 }
792}
793
794const MAX_STRUCT_NAMES: usize = 10;
799
800const MAX_SINGULAR_VALUES_SHOWN: usize = 16;
803
804const MAX_RANK_CULPRITS: usize = 12;
807
808pub struct StructureBook {
826 inc: EqualityIncidence,
829 con_names: Vec<String>,
832 var_names: Vec<String>,
835}
836
837impl StructureBook {
838 pub fn new(inc: EqualityIncidence, con_names: Vec<String>, var_names: Vec<String>) -> Self {
844 Self {
845 inc,
846 con_names,
847 var_names,
848 }
849 }
850
851 fn con_label(&self, eq_row: usize) -> String {
854 let orig = self.inc.eq_row_inner_idx[eq_row];
855 match self.con_names.get(orig) {
856 Some(n) if !n.is_empty() => n.clone(),
857 _ => format!("c[{orig}]"),
858 }
859 }
860
861 fn var_label(&self, v: usize) -> String {
864 match self.var_names.get(v) {
865 Some(n) if !n.is_empty() => n.clone(),
866 _ => format!("x[{v}]"),
867 }
868 }
869
870 fn join_capped(labels: &[String]) -> String {
873 if labels.len() <= MAX_STRUCT_NAMES {
874 labels.join(", ")
875 } else {
876 let head = labels[..MAX_STRUCT_NAMES].join(", ");
877 let more = labels.len() - MAX_STRUCT_NAMES;
878 format!("{head}, … (+{more} more)")
879 }
880 }
881
882 fn findings(&self) -> Vec<(&'static str, &'static str, String)> {
892 let mut out = Vec::new();
893 if self.inc.n_eq_rows() == 0 {
894 return out;
895 }
896 let matching = hopcroft_karp(&self.inc);
897 let dm = DulmageMendelsohnPartition::from_matching(&self.inc, &matching);
898 if dm.over_rows.is_empty() {
899 return out;
900 }
901
902 let excess = dm.over_rows.len().saturating_sub(dm.over_cols.len());
906 let eq_labels: Vec<String> = dm.over_rows.iter().map(|&r| self.con_label(r)).collect();
907 let var_labels: Vec<String> = dm.over_cols.iter().map(|&v| self.var_label(v)).collect();
908 let eqs = Self::join_capped(&eq_labels);
909 let shared = if var_labels.is_empty() {
910 "no variables".to_string()
911 } else {
912 Self::join_capped(&var_labels)
913 };
914 out.push((
915 "warning",
916 "structural_singularity",
917 format!(
918 "Constraint Jacobian is structurally singular (Dulmage–Mendelsohn): {} equation(s) \
919 over-determine the {} variable(s) they jointly touch ({}), so ≥{} of them must be \
920 redundant or mutually inconsistent (LICQ fails on this block). Candidate \
921 dependent equations: {}. Inspect them with `print equation <name>`; this names \
922 the rows behind any δ_c dual-regularization / wrong-inertia signal.",
923 dm.over_rows.len(),
924 dm.over_cols.len(),
925 shared,
926 excess.max(1),
927 eqs
928 ),
929 ));
930 out
931 }
932}
933
934pub struct SolverDebugger {
935 mode: DebugMode,
936 reg: Option<Rc<RegisteredOptions>>,
937 step: bool,
939 run_to: Option<i32>,
941 breaks: Vec<i32>,
943 temp_breaks: Vec<i32>,
945 bp_commands: HashMap<i32, Vec<String>>,
948 conds: Vec<Condition>,
950 watchpoints: Vec<WatchPoint>,
952 last_mu: Option<f64>,
954 mu_stall: u32,
955 in_restoration: bool,
958 detached: bool,
960 hello_sent: bool,
963 pause_iters: bool,
966 pause_terminal: bool,
968 terminal_only_on_error: bool,
970 interruptible: bool,
972 emit_progress: bool,
975 sub_step: bool,
978 stop_at: HashSet<&'static str>,
980 break_events: HashSet<&'static str>,
982 snapshots: BTreeMap<i32, IterateSnapshot>,
985 restart: Option<RestartCell>,
988 editor: Option<Editor<DbgHelper, FileHistory>>,
992 hist_path: Option<PathBuf>,
994 pump: Option<StdinPump>,
997 watches: Vec<String>,
1000 pending_script: Option<String>,
1003 staged: Vec<(String, String)>,
1007 sweep: Option<SweepState>,
1010 prompt_interrupts: u8,
1015 equation_book: Option<EquationBook>,
1020 structure_book: Option<StructureBook>,
1025}
1026
1027impl SolverDebugger {
1028 pub fn new(mode: DebugMode, reg: Option<Rc<RegisteredOptions>>) -> Self {
1031 Self {
1032 mode,
1033 reg,
1034 step: true,
1037 run_to: None,
1038 breaks: Vec::new(),
1039 temp_breaks: Vec::new(),
1040 bp_commands: HashMap::new(),
1041 conds: Vec::new(),
1042 watchpoints: Vec::new(),
1043 last_mu: None,
1044 mu_stall: 0,
1045 in_restoration: false,
1046 detached: false,
1047 hello_sent: false,
1048 pause_iters: true,
1049 pause_terminal: true,
1050 terminal_only_on_error: false,
1051 interruptible: true,
1052 emit_progress: true,
1053 sub_step: false,
1054 stop_at: HashSet::new(),
1055 break_events: HashSet::new(),
1056 snapshots: BTreeMap::new(),
1057 restart: None,
1058 editor: None,
1059 hist_path: None,
1060 pump: None,
1061 watches: Vec::new(),
1062 pending_script: None,
1063 staged: Vec::new(),
1064 sweep: None,
1065 prompt_interrupts: 0,
1066 equation_book: None,
1067 structure_book: None,
1068 }
1069 }
1070
1071 pub fn with_script(mut self, path: String) -> Self {
1073 self.pending_script = Some(path);
1074 self
1075 }
1076
1077 pub fn set_equation_book(&mut self, book: EquationBook) {
1081 self.equation_book = Some(book);
1082 }
1083
1084 pub fn set_structure_book(&mut self, book: StructureBook) {
1090 self.structure_book = Some(book);
1091 }
1092
1093 pub fn with_restart(mut self, cell: RestartCell) -> Self {
1096 self.restart = Some(cell);
1097 self
1098 }
1099
1100 pub fn on_error(mode: DebugMode, reg: Option<Rc<RegisteredOptions>>) -> Self {
1103 Self {
1104 step: false,
1105 pause_iters: false,
1106 terminal_only_on_error: true,
1107 ..Self::new(mode, reg)
1108 }
1109 }
1110
1111 pub fn on_interrupt(mode: DebugMode, reg: Option<Rc<RegisteredOptions>>) -> Self {
1115 Self {
1116 step: false,
1117 pause_iters: false,
1118 pause_terminal: false,
1119 ..Self::new(mode, reg)
1120 }
1121 }
1122
1123 pub fn staged_options(&self) -> &[(String, String)] {
1126 &self.staged
1127 }
1128
1129 fn should_pause(&mut self, iter: i32) -> bool {
1130 if self.detached {
1131 return false;
1132 }
1133 if self.step {
1134 return true;
1135 }
1136 if let Some(t) = self.run_to {
1137 if iter >= t {
1138 self.run_to = None;
1139 return true;
1140 }
1141 }
1142 if self.breaks.contains(&iter) {
1143 return true;
1144 }
1145 if let Some(pos) = self.temp_breaks.iter().position(|&b| b == iter) {
1147 self.temp_breaks.remove(pos);
1148 return true;
1149 }
1150 false
1151 }
1152
1153 fn matched_condition(&self, ctx: &DebugCtx) -> Option<String> {
1156 if self.detached {
1157 return None;
1158 }
1159 self.conds
1160 .iter()
1161 .find(|c| c.holds(ctx))
1162 .map(|c| c.raw.clone())
1163 }
1164
1165 fn matched_event(&self, ctx: &DebugCtx) -> Option<&'static str> {
1169 if self.detached || self.break_events.is_empty() {
1170 return None;
1171 }
1172 let cp = ctx.checkpoint();
1173 let tiny = 1e-10;
1175 EVENTS.iter().copied().find(|&e| {
1176 self.break_events.contains(e)
1177 && match e {
1178 "resto_entered" => cp == Checkpoint::PreRestoration,
1179 "resto_exited" => cp == Checkpoint::PostRestoration,
1180 "regularized" => {
1181 cp == Checkpoint::AfterSearchDirection && ctx.regularization() > 0.0
1182 }
1183 "tiny_step" => {
1184 cp == Checkpoint::AfterSearchDirection
1185 && ctx
1186 .delta_block("x")
1187 .map(|v| v.iter().fold(0.0_f64, |m, &x| m.max(x.abs())) < tiny)
1188 .unwrap_or(false)
1189 }
1190 "ls_rejected" => cp == Checkpoint::AfterStep && ctx.ls_count() > 1,
1191 "mu_stalled" => cp == Checkpoint::IterStart && self.mu_stall >= MU_STALL_ITERS,
1192 "nan" => !ctx.nlp_error().is_finite() || !ctx.objective().is_finite(),
1193 _ => false,
1194 }
1195 })
1196 }
1197
1198 fn update_mu_stall(&mut self, mu: f64) {
1200 if let Some(last) = self.last_mu {
1201 if (mu - last).abs() <= 1e-12 * last.abs().max(1.0) {
1202 self.mu_stall += 1;
1203 } else {
1204 self.mu_stall = 0;
1205 }
1206 }
1207 self.last_mu = Some(mu);
1208 }
1209
1210 fn matched_watchpoint(&mut self, ctx: &DebugCtx) -> Option<String> {
1213 if self.detached {
1214 return None;
1215 }
1216 let mut hit = None;
1217 for wp in self.watchpoints.iter_mut() {
1218 let Some(full) = ctx.block(&wp.block) else {
1219 continue;
1220 };
1221 let cur: Vec<f64> = match wp.idx {
1222 Some(i) => match full.get(i) {
1223 Some(&v) => vec![v],
1224 None => continue,
1225 },
1226 None => full,
1227 };
1228 if let Some(prev) = &wp.last {
1229 if prev.len() == cur.len() {
1230 let changed = prev
1231 .iter()
1232 .zip(&cur)
1233 .any(|(p, c)| (p - c).abs() > wp.threshold);
1234 if changed && hit.is_none() {
1235 hit = Some(wp.raw.clone());
1236 }
1237 }
1238 }
1239 wp.last = Some(cur);
1240 }
1241 hit
1242 }
1243
1244 fn dispatch(&mut self, line: &str, ctx: &mut DebugCtx) -> CmdOut {
1247 let owned = tokenize_quoted(line);
1251 let toks: Vec<&str> = owned.iter().map(String::as_str).collect();
1252 let Some(&verb) = toks.first() else {
1253 return CmdOut::ok(vec![]); };
1255 let rest = &toks[1..];
1256 match verb {
1257 "help" | "h" | "?" => self.cmd_help(),
1258 "info" | "i" => self.cmd_info(ctx),
1259 "print" | "p" => self.cmd_print(rest, ctx),
1260 "step" | "s" | "n" | "next" if rest.first() == Some(&"sub") => {
1263 self.sub_step = true;
1264 CmdOut::ok(vec![
1265 "stepping to the next checkpoint (sub-iteration)".into()
1266 ])
1267 .flow(Flow::Resume)
1268 }
1269 "step" | "s" | "n" | "next" => {
1270 self.step = true;
1271 CmdOut::ok(vec!["stepping one iteration".into()]).flow(Flow::Resume)
1272 }
1273 "stepi" | "si" => {
1274 self.sub_step = true;
1275 CmdOut::ok(vec![
1276 "stepping to the next checkpoint (sub-iteration)".into()
1277 ])
1278 .flow(Flow::Resume)
1279 }
1280 "continue" | "c" | "cont" => {
1281 self.step = false;
1282 self.sub_step = false;
1283 self.run_to = None;
1284 CmdOut::ok(vec!["continuing".into()]).flow(Flow::Resume)
1285 }
1286 "run" | "r" => self.cmd_run(rest),
1287 "break" | "b" => self.cmd_break(rest),
1288 "tbreak" | "tb" => match rest.first().and_then(|s| s.parse::<i32>().ok()) {
1289 Some(n) => {
1290 if !self.temp_breaks.contains(&n) {
1291 self.temp_breaks.push(n);
1292 }
1293 CmdOut::ok(vec![format!("temporary breakpoint at iteration {n}")])
1294 }
1295 None => CmdOut::err("usage: tbreak <iteration>"),
1296 },
1297 "watchpoint" | "wp" => self.cmd_watchpoint(rest),
1298 "commands" => self.cmd_commands(rest),
1299 "stop-at" | "stopat" => self.cmd_stop_at(rest),
1300 "progress" => match rest.first().copied() {
1301 Some("on") | None => {
1302 self.emit_progress = true;
1303 CmdOut::ok(vec!["progress events on".into()])
1304 }
1305 Some("off") => {
1306 self.emit_progress = false;
1307 CmdOut::ok(vec!["progress events off".into()])
1308 }
1309 _ => CmdOut::err("usage: progress [on|off]"),
1310 },
1311 "set" => self.cmd_set(rest, ctx),
1312 "get" => self.cmd_get(rest),
1313 "opt" | "options" => self.cmd_opt(rest),
1314 "complete" => self.cmd_complete(rest),
1315 "viz" | "plot" => self.cmd_viz(rest, ctx),
1316 "save" => self.cmd_save(rest, ctx),
1317 "load" => self.cmd_load(rest, ctx),
1318 "sweep" => self.cmd_sweep(rest, ctx),
1319 "multistart" => self.cmd_multistart(rest, ctx),
1320 "goto" | "jump" => self.cmd_goto(rest, ctx),
1321 "restart" => match self.snapshots.keys().next().copied() {
1322 Some(k) => self.restore_to(k, ctx),
1323 None => CmdOut::err("no snapshots captured yet"),
1324 },
1325 "resolve" | "re-solve" => self.cmd_resolve(ctx),
1326 "ask" | "explain" | "claude" => self.cmd_ask(rest, ctx),
1327 "watch" | "display" => self.cmd_watch(rest),
1328 "diff" => self.cmd_diff(ctx),
1329 "diagnose" | "diag" => self.cmd_diagnose(ctx),
1330 "source" => self.cmd_source(rest, ctx),
1331 "detach" => {
1332 self.detached = true;
1333 self.step = false;
1334 self.run_to = None;
1335 CmdOut::ok(vec!["detached — solving to completion".into()]).flow(Flow::Resume)
1336 }
1337 "pause" => CmdOut::ok(vec!["already paused".into()]),
1340 "coffee" | "brew" | "espresso" => self.cmd_coffee(),
1342 "quit" | "q" | "exit" => CmdOut::ok(vec!["stopping solve".into()]).flow(Flow::Stop),
1343 other => CmdOut::err(format!("unknown command `{other}` (try `help`)")),
1344 }
1345 }
1346
1347 fn cmd_coffee(&self) -> CmdOut {
1351 let color = matches!(self.mode, DebugMode::Repl)
1352 && std::io::stderr().is_terminal()
1353 && std::env::var_os("NO_COLOR").is_none();
1354 let paint = |r: u8, g: u8, b: u8, s: &str| -> String {
1355 if color {
1356 format!("\x1b[38;2;{r};{g};{b}m{s}\x1b[0m")
1357 } else {
1358 s.to_string()
1359 }
1360 };
1361 let cup = |s: &str| paint(0xEC, 0xEC, 0xEF, s);
1363 let dark = |s: &str| paint(0x5A, 0x32, 0x1E, s);
1364 let brew = |s: &str| paint(0x96, 0x5F, 0x37, s);
1365 let steam = |s: &str| paint(0xB4, 0xB9, 0xC3, s);
1366 let lines = vec![
1367 String::new(),
1368 format!(" {}", steam(") ) )")),
1369 format!(" {}", steam("( ( (")),
1370 format!(" {}", cup("._________.")),
1371 format!(" {}{}{}", cup("|"), dark("~~~~~~~~"), cup("|_")),
1372 format!(" {}{}{}", cup("| "), brew("COFFEE"), cup("| |")),
1373 format!(" {}{}{}", cup("| "), dark("~~~~~~"), cup("| |")),
1374 format!(" {}", cup("|________|_|")),
1375 format!(" {}", cup("\\________/")),
1376 format!(" {}", brew("a fresh cup for a stuck solve")),
1377 String::new(),
1378 ];
1379 CmdOut::ok(lines).with_data(serde_json::json!({"easter_egg": "coffee"}))
1380 }
1381
1382 fn cmd_help(&self) -> CmdOut {
1383 let lines = vec![
1384 "commands:".into(),
1385 " info | i summary of the current iterate".into(),
1386 " print | p <what> x|s|y_c|y_d|z_l|z_u|v_l|v_u | dx (step) |".into(),
1387 " mu|obj|inf_pr|inf_du|err|compl|iter | kkt | active | inactive".into(),
1388 " print residuals [pr|du] [k] top-k largest-magnitude residuals (default k=10)".into(),
1389 " print equation [name|row] source algebra of a constraint, by model name or row".into(),
1390 " print rank SVD rank of the equality Jacobian; names dependent equations".into(),
1391 " step | s | n run one iteration, pause again".into(),
1392 " stepi | si | step sub run to the next checkpoint (into sub-iteration phases)".into(),
1393 " progress [on|off] toggle per-iteration progress events (JSON mode)".into(),
1394 " stop-at <cp> always pause at a checkpoint: after_mu|after_search_dir|after_step".into(),
1395 " continue | c run to the next breakpoint".into(),
1396 " run | r <N> run until iteration N".into(),
1397 " break | b [N|clear|del N] set/list/clear breakpoints".into(),
1398 " break if <m><op><v> conditional bp; m in mu|inf_pr|inf_du|obj|err|iter,".into(),
1399 " op in < <= > >= == (e.g. break if inf_pr<1e-6)".into(),
1400 " break on <event> event bp: resto_entered|resto_exited|regularized|".into(),
1401 " tiny_step|ls_rejected|mu_stalled|nan".into(),
1402 " tbreak <N> one-shot breakpoint (deletes after firing)".into(),
1403 " watchpoint <blk>[<i>] [τ] pause when a value changes by > τ (alias wp)".into(),
1404 " commands <N> <c>;<c>… auto-run commands when iter N's breakpoint hits".into(),
1405 " set mu <v> overwrite the barrier parameter".into(),
1406 " set <blk>[<i>] <v> overwrite one component (e.g. set x[2] 1.5)".into(),
1407 " set <blk> <v0,v1,...> overwrite a whole block".into(),
1408 " set opt <name> <value> stage a solver option (validated)".into(),
1409 " get opt <name> show an option's effective value (staged or default)".into(),
1410 " opt [filter] list solver options (name/type/default)".into(),
1411 " complete <prefix> completion candidates (commands + options)".into(),
1412 " viz <x|s|dx|...|kkt|L> open the artifact in an external viewer".into(),
1413 " save [path] write the current iterate + residuals to JSON".into(),
1414 " load <file> [block] read a block (default x) from a save artifact / numeric file".into(),
1415 " sweep <file> one solve per start in <file>; tabulate outcomes".into(),
1416 " multistart <N> [rel] N restarts (uniform in each finite box; jitter else)".into(),
1417 " goto <k> | restart rewind to a captured iteration (primal-dual only)".into(),
1418 " resolve re-solve from the current x with staged `set opt`s".into(),
1419 " ask [question] ask an LLM about the state (default Claude Code; set".into(),
1420 " POUNCE_DBG_LLM=claude|codex|gemini|llm or a command template)".into(),
1421 " watch [target|clear|del] auto-print a `print` target at every pause".into(),
1422 " diff what changed in the iterate since the last iteration".into(),
1423 " diagnose | diag live health report: named culprit residuals, KKT inertia, stalls".into(),
1424 " source <file> run debugger commands from a file".into(),
1425 " detach stop pausing; solve to completion".into(),
1426 " quit | q stop the solve now".into(),
1427 ];
1428 CmdOut::ok(lines)
1429 }
1430
1431 fn cmd_info(&self, ctx: &DebugCtx) -> CmdOut {
1432 let dims: Vec<_> = ctx.block_dims();
1433 let dims_json: serde_json::Map<String, serde_json::Value> = dims
1434 .iter()
1435 .map(|(n, d)| ((*n).to_string(), serde_json::json!(d)))
1436 .collect();
1437 let lines = vec![
1438 format!("iter = {}", ctx.iter()),
1439 format!("mu = {:.6e}", ctx.mu()),
1440 format!("objective = {:.8e}", ctx.objective()),
1441 format!("inf_pr = {:.6e}", ctx.inf_pr()),
1442 format!("inf_du = {:.6e}", ctx.inf_du()),
1443 format!("nlp_error = {:.6e}", ctx.nlp_error()),
1444 format!(
1445 "dims = {}",
1446 dims.iter()
1447 .map(|(n, d)| format!("{n}:{d}"))
1448 .collect::<Vec<_>>()
1449 .join(" ")
1450 ),
1451 ];
1452 CmdOut::ok(lines).with_data(serde_json::json!({
1453 "iter": ctx.iter(),
1454 "mu": ctx.mu(),
1455 "objective": ctx.objective(),
1456 "inf_pr": ctx.inf_pr(),
1457 "inf_du": ctx.inf_du(),
1458 "nlp_error": ctx.nlp_error(),
1459 "dims": dims_json,
1460 }))
1461 }
1462
1463 fn cmd_print(&self, rest: &[&str], ctx: &DebugCtx) -> CmdOut {
1464 let Some(&what) = rest.first() else {
1465 return self.cmd_info(ctx);
1466 };
1467 if what == "kkt" {
1468 return self.cmd_print_kkt(ctx);
1469 }
1470 if what == "active" {
1471 return self.cmd_print_bounds(ctx, true);
1472 }
1473 if what == "inactive" {
1474 return self.cmd_print_bounds(ctx, false);
1475 }
1476 if what == "residuals" || what == "resid" {
1477 return self.cmd_print_residuals(&rest[1..], ctx);
1478 }
1479 if what == "equation" || what == "eqn" || what == "eq" {
1480 return self.cmd_print_equation(&rest[1..]);
1481 }
1482 if what == "rank" {
1483 return self.cmd_print_rank(ctx);
1484 }
1485 let delta = what.strip_prefix("d").filter(|b| BLOCK_NAMES.contains(b));
1487 if BLOCK_NAMES.contains(&what) {
1488 match ctx.block(what) {
1489 Some(v) => CmdOut::ok(vec![fmt_vec(what, &v)])
1490 .with_data(serde_json::json!({"name": what, "values": v})),
1491 None => CmdOut::err(format!("no iterate yet for block `{what}`")),
1492 }
1493 } else if let Some(blk) = delta {
1494 match ctx.delta_block(blk) {
1495 Some(v) => CmdOut::ok(vec![fmt_vec(&format!("d{blk}"), &v)])
1496 .with_data(serde_json::json!({"name": format!("d{blk}"), "values": v})),
1497 None => CmdOut::err(format!("no search direction available for `d{blk}` yet")),
1498 }
1499 } else {
1500 let val = match what {
1501 "mu" => ctx.mu(),
1502 "obj" | "objective" => ctx.objective(),
1503 "inf_pr" => ctx.inf_pr(),
1504 "inf_du" => ctx.inf_du(),
1505 "err" | "nlp_error" => ctx.nlp_error(),
1506 "compl" | "complementarity" => ctx.complementarity(),
1507 "iter" => ctx.iter() as f64,
1508 _ => {
1509 return CmdOut::err(format!(
1510 "don't know how to print `{what}` (try a block name or mu|obj|inf_pr|inf_du|err|compl|iter)"
1511 ))
1512 }
1513 };
1514 CmdOut::ok(vec![format!("{what} = {val:.10e}")])
1515 .with_data(serde_json::json!({"name": what, "value": val}))
1516 }
1517 }
1518
1519 fn cmd_print_bounds(&self, ctx: &DebugCtx, active: bool) -> CmdOut {
1525 let tol = 1e-6;
1526 let mut lines = Vec::new();
1527 let mut cats = serde_json::Map::new();
1528 for cat in ["x_l", "x_u", "s_l", "s_u"] {
1529 let Some(sl) = ctx.bound_slack(cat) else {
1530 continue;
1531 };
1532 if sl.is_empty() {
1533 continue;
1534 }
1535 let n = sl.len();
1536 if active {
1537 let min = sl.iter().copied().fold(f64::INFINITY, f64::min);
1538 let near = sl.iter().filter(|&&s| s.abs() < tol).count();
1539 lines.push(format!(
1540 "{cat}: {n} bound(s), {near} near-active (slack<{tol:.0e}), min slack {min:.3e}"
1541 ));
1542 cats.insert(
1543 cat.to_string(),
1544 serde_json::json!({"n": n, "near_active": near, "min_slack": min}),
1545 );
1546 } else {
1547 let max = sl.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1548 let far = sl.iter().filter(|&&s| s.abs() >= tol).count();
1549 lines.push(format!(
1550 "{cat}: {n} bound(s), {far} inactive (slack≥{tol:.0e}), max slack {max:.3e}"
1551 ));
1552 cats.insert(
1553 cat.to_string(),
1554 serde_json::json!({"n": n, "inactive": far, "max_slack": max}),
1555 );
1556 }
1557 }
1558 if lines.is_empty() {
1559 lines.push("no bounded variables or inequality slacks".into());
1560 }
1561 CmdOut::ok(lines).with_data(serde_json::json!({"tol": tol, "categories": cats}))
1562 }
1563
1564 fn cmd_print_residuals(&self, rest: &[&str], ctx: &DebugCtx) -> CmdOut {
1571 let mut k: Option<usize> = None;
1572 let mut filter: Option<bool> = None; for &arg in rest {
1574 if let Ok(n) = arg.parse::<usize>() {
1575 k = Some(n);
1576 } else {
1577 match arg {
1578 "primal" | "pr" => filter = Some(true),
1579 "dual" | "du" => filter = Some(false),
1580 other => {
1581 return CmdOut::err(format!(
1582 "usage: print residuals [primal|dual] [k] (got `{other}`)"
1583 ))
1584 }
1585 }
1586 }
1587 }
1588 let k = k.unwrap_or(10);
1589
1590 let mut all = Vec::new();
1591 if filter != Some(false) {
1592 let Some(primal) = ctx.constraint_residuals() else {
1593 return CmdOut::err("no iterate yet — residuals unavailable");
1594 };
1595 all.extend(primal);
1596 }
1597 if filter != Some(true) {
1598 let Some(dual) = ctx.dual_residuals() else {
1599 return CmdOut::err("no iterate yet — residuals unavailable");
1600 };
1601 all.extend(dual);
1602 }
1603
1604 let total = all.len();
1605 let top = rank_residuals(all, k);
1606 if top.is_empty() {
1607 return CmdOut::ok(vec!["no residuals at this iterate".into()])
1608 .with_data(serde_json::json!({"k": k, "total": total, "top": []}));
1609 }
1610
1611 let names = ctx.split_names();
1617 let name_of = |r: &Residual| resid_name(r, &names);
1618
1619 let lines = top
1620 .iter()
1621 .map(|r| {
1622 let label = match name_of(r) {
1623 Some(name) => format!("{}[{}]", r.kind.tag(), name),
1624 None => format!("{}[{}]", r.kind.tag(), r.index),
1625 };
1626 format!("{:>8} = {:+.6e} |{:.3e}|", label, r.value, r.value.abs())
1627 })
1628 .collect();
1629 let data: Vec<_> = top
1630 .iter()
1631 .map(|r| {
1632 serde_json::json!({
1633 "space": r.kind.tag(),
1634 "primal": r.kind.is_primal(),
1635 "index": r.index,
1636 "name": name_of(r),
1637 "value": r.value,
1638 })
1639 })
1640 .collect();
1641 CmdOut::ok(lines).with_data(serde_json::json!({"k": k, "total": total, "top": data}))
1642 }
1643
1644 fn cmd_print_equation(&self, rest: &[&str]) -> CmdOut {
1653 let Some(book) = self.equation_book.as_ref() else {
1654 return CmdOut::err(
1655 "no equation source — `print equation` needs an .nl model (none was loaded)",
1656 );
1657 };
1658 if book.is_empty() {
1659 return CmdOut::err("the model has no constraint equations to print");
1660 }
1661 let Some(&key) = rest.first() else {
1662 return CmdOut::ok(vec![format!(
1663 "{} constraint equation(s) — `print equation <name|row>` to show one",
1664 book.len()
1665 )])
1666 .with_data(serde_json::json!({"count": book.len()}));
1667 };
1668 let Some(i) = book.resolve(key) else {
1669 return CmdOut::err(format!(
1670 "no constraint named or indexed `{key}` (have {} equation(s); try a name or 0..{})",
1671 book.len(),
1672 book.len().saturating_sub(1)
1673 ));
1674 };
1675 let label = book.label(i);
1676 let Some(eq) = book.equations.get(i) else {
1679 return CmdOut::err(format!(
1680 "constraint `{key}` has no source algebra (index {i} out of range)"
1681 ));
1682 };
1683 CmdOut::ok(vec![format!("{label}: {eq}")]).with_data(serde_json::json!({
1684 "index": i,
1685 "name": book.names.get(i).filter(|n| !n.is_empty()),
1686 "equation": eq,
1687 }))
1688 }
1689
1690 fn cmd_diagnose(&self, ctx: &DebugCtx) -> CmdOut {
1705 const TOL: f64 = 1e-6;
1706 let names = ctx.split_names();
1707 let mut f: Vec<(&'static str, &'static str, String)> = Vec::new();
1709
1710 let inf_pr = ctx.inf_pr();
1712 if inf_pr > TOL {
1713 if let Some(resids) = ctx.constraint_residuals() {
1714 if let Some((label, val)) = worst_named(resids, &names) {
1715 let sev = if inf_pr > 1e-2 { "error" } else { "warning" };
1716 f.push((
1717 sev,
1718 "primal_infeasible",
1719 format!(
1720 "Primal infeasibility {inf_pr:.2e}; worst constraint residual is \
1721 {label} = {val:+.3e}. Inspect this equation's feasibility and scaling \
1722 at the current point (`print equation {label}`)."
1723 ),
1724 ));
1725 }
1726 }
1727 }
1728
1729 let inf_du = ctx.inf_du();
1731 if inf_du > TOL {
1732 if let Some(resids) = ctx.dual_residuals() {
1733 if let Some((label, val)) = worst_named(resids, &names) {
1734 f.push((
1735 "warning",
1736 "dual_infeasible",
1737 format!(
1738 "Dual infeasibility {inf_du:.2e}; largest stationarity residual is \
1739 {label} = {val:+.3e}."
1740 ),
1741 ));
1742 }
1743 }
1744 }
1745
1746 if let Some(k) = ctx.kkt() {
1748 if k.provides_inertia && !k.inertia_correct {
1749 f.push((
1750 "warning",
1751 "inertia_wrong",
1752 format!(
1753 "KKT inertia is wrong (n-={} vs expected {}): the system was \
1754 indefinite/singular and the step had to be stabilized. A persistent \
1755 mismatch points at a rank-deficient Jacobian or an indefinite Hessian.",
1756 k.n_neg, k.expected_neg
1757 ),
1758 ));
1759 }
1760 if k.delta_w > 1e-4 {
1761 f.push((
1762 "info",
1763 "heavy_regularization",
1764 format!(
1765 "Primal regularization δ_w={:.2e} applied — the Hessian was indefinite at \
1766 this step. Normal near saddle points; persistent large δ_w suggests a \
1767 problematic Hessian.",
1768 k.delta_w
1769 ),
1770 ));
1771 }
1772 if k.delta_c > 0.0 {
1773 f.push((
1774 "warning",
1775 "dual_regularization",
1776 format!(
1777 "Dual regularization δ_c={:.2e} applied — the constraint Jacobian is (near) \
1778 rank-deficient (linearly dependent or redundant equalities). Inspect the \
1779 equality residuals by name (`print residuals primal`).",
1780 k.delta_c
1781 ),
1782 ));
1783 }
1784 }
1785
1786 if let Some(book) = self.structure_book.as_ref() {
1790 f.extend(book.findings());
1791 }
1792
1793 if let Some(rep) = ctx.rank_report() {
1799 if rep.is_rank_deficient() {
1800 let culprits: Vec<String> = rep
1801 .culprits
1802 .iter()
1803 .take(MAX_RANK_CULPRITS)
1804 .map(|c| rank_row_label(&rep.rows[c.row], &names))
1805 .collect();
1806 let named = if culprits.is_empty() {
1807 String::new()
1808 } else {
1809 format!(" Implicated equations: {}.", culprits.join(", "))
1810 };
1811 f.push((
1812 "warning",
1813 "rank_deficient_jacobian",
1814 format!(
1815 "Equality Jacobian J_c is numerically rank-deficient at this iterate: \
1816 rank {}/{} (deficiency {}), σ_min={:.2e}, cond={}. Linearly dependent \
1817 or redundant equality constraints — the root cause behind δ_c \
1818 regularization / wrong inertia.{named}",
1819 rep.rank,
1820 rep.n_rows(),
1821 rep.deficiency(),
1822 rep.sigma_min(),
1823 fmt_cond(rep.cond),
1824 ),
1825 ));
1826 }
1827 }
1828
1829 let mut max_mult = 0.0_f64;
1831 for blk in ["y_c", "y_d", "z_l", "z_u", "v_l", "v_u"] {
1832 if let Some(v) = ctx.block(blk) {
1833 max_mult = v.iter().fold(max_mult, |m, &x| m.max(x.abs()));
1834 }
1835 }
1836 if max_mult > 1e8 {
1837 f.push((
1838 "warning",
1839 "large_multipliers",
1840 format!(
1841 "Largest multiplier magnitude is {max_mult:.2e}. Very large multipliers signal a \
1842 constraint-qualification failure or poor scaling — consider rescaling the \
1843 offending rows."
1844 ),
1845 ));
1846 }
1847
1848 let mut pinned = 0usize;
1850 for cat in ["x_l", "x_u"] {
1851 if let Some(sl) = ctx.bound_slack(cat) {
1852 pinned += sl.iter().filter(|&&s| s.abs() < TOL).count();
1853 }
1854 }
1855 if pinned > 0 {
1856 f.push((
1857 "info",
1858 "bounds_pinned",
1859 format!(
1860 "{pinned} variable bound(s) are active (slack < {TOL:.0e}). Active bounds are \
1861 expected at a solution, but a large count early can throttle the line search."
1862 ),
1863 ));
1864 }
1865
1866 let (alpha_pr, _) = ctx.alpha();
1868 if ctx.iter() > 0 && alpha_pr > 0.0 && alpha_pr < 1e-6 {
1869 f.push((
1870 "warning",
1871 "tiny_step",
1872 format!(
1873 "Accepted primal step α_pr={alpha_pr:.2e} is tiny — the line search is barely \
1874 moving. Often a poor search direction or an ill-conditioned KKT system."
1875 ),
1876 ));
1877 }
1878 let ls = ctx.ls_count();
1879 if ls >= 10 {
1880 f.push((
1881 "warning",
1882 "heavy_line_search",
1883 format!(
1884 "Line search needed {ls} trial points for the accepted step — search-direction \
1885 quality may be poor (check Hessian accuracy)."
1886 ),
1887 ));
1888 }
1889
1890 if self.in_restoration {
1892 f.push((
1893 "warning",
1894 "in_restoration",
1895 "Currently inside feasibility restoration: the line search could not make \
1896 progress on the original problem at the working point."
1897 .to_string(),
1898 ));
1899 }
1900 if self.mu_stall >= MU_STALL_ITERS {
1901 f.push((
1902 "warning",
1903 "mu_stalled",
1904 format!(
1905 "μ has not decreased for {} consecutive iterations — the barrier is stuck. \
1906 Try mu_strategy=adaptive or a smaller mu_init.",
1907 self.mu_stall
1908 ),
1909 ));
1910 }
1911
1912 if f.is_empty() {
1914 f.push((
1915 "info",
1916 "healthy",
1917 format!(
1918 "No issues detected at iter {}: inf_pr={:.2e}, inf_du={:.2e}, μ={:.2e}.",
1919 ctx.iter(),
1920 inf_pr,
1921 inf_du,
1922 ctx.mu()
1923 ),
1924 ));
1925 }
1926
1927 let rank = |s: &str| match s {
1929 "error" => 0,
1930 "warning" => 1,
1931 _ => 2,
1932 };
1933 f.sort_by_key(|(sev, _, _)| rank(sev));
1934
1935 let lines: Vec<String> = f
1936 .iter()
1937 .map(|(sev, code, msg)| format!("[{sev:>7}] {code}: {msg}"))
1938 .collect();
1939 let data: Vec<_> = f
1940 .iter()
1941 .map(|(sev, code, msg)| serde_json::json!({"severity": sev, "code": code, "message": msg}))
1942 .collect();
1943 let n = data.len();
1944 CmdOut::ok(lines)
1945 .with_data(serde_json::json!({"iter": ctx.iter(), "findings": data, "n_findings": n}))
1946 }
1947
1948 fn cmd_print_kkt(&self, ctx: &DebugCtx) -> CmdOut {
1951 let Some(k) = ctx.kkt() else {
1952 return CmdOut::err(
1953 "no KKT factorization yet — stop at `after_search_dir` (e.g. `stop-at kkt`)",
1954 );
1955 };
1956 let inertia = if k.provides_inertia {
1957 format!(
1958 "n+={} n-={} (expected n-={}) → {}",
1959 k.n_pos,
1960 k.n_neg,
1961 k.expected_neg,
1962 if k.inertia_correct {
1963 "correct"
1964 } else {
1965 "WRONG (step stabilized)"
1966 }
1967 )
1968 } else {
1969 "n/a (backend reports no inertia)".to_string()
1970 };
1971 let lines = vec![
1972 format!("dim = {}", k.dim),
1973 format!("inertia = {inertia}"),
1974 format!("delta_w = {:.6e} (primal regularization)", k.delta_w),
1975 format!("delta_c = {:.6e} (dual regularization)", k.delta_c),
1976 format!("status = {}", k.status),
1977 ];
1978 CmdOut::ok(lines).with_data(serde_json::json!({
1979 "dim": k.dim,
1980 "n_pos": k.n_pos,
1981 "n_neg": k.n_neg,
1982 "expected_neg": k.expected_neg,
1983 "provides_inertia": k.provides_inertia,
1984 "inertia_correct": k.inertia_correct,
1985 "delta_w": k.delta_w,
1986 "delta_c": k.delta_c,
1987 "status": k.status,
1988 }))
1989 }
1990
1991 fn cmd_print_rank(&self, ctx: &DebugCtx) -> CmdOut {
2000 let Some(rep) = ctx.rank_report() else {
2001 return CmdOut::err(
2002 "no equality-constraint Jacobian to analyze (the problem has no equality \
2003 constraints, or there is no iterate yet)",
2004 );
2005 };
2006 let names = ctx.split_names();
2007 let (lines, data) =
2008 render_rank_report(&rep, &names, self.equation_book.as_ref(), ctx.iter());
2009 CmdOut::ok(lines).with_data(data)
2010 }
2011
2012 fn cmd_run(&mut self, rest: &[&str]) -> CmdOut {
2013 match rest.first().and_then(|s| s.parse::<i32>().ok()) {
2014 Some(n) => {
2015 self.run_to = Some(n);
2016 self.step = false;
2017 CmdOut::ok(vec![format!("running until iteration {n}")]).flow(Flow::Resume)
2018 }
2019 None => CmdOut::err("usage: run <iteration>"),
2020 }
2021 }
2022
2023 fn cmd_break(&mut self, rest: &[&str]) -> CmdOut {
2024 if rest.first().copied() == Some("if") {
2028 let expr: String = rest[1..].concat();
2029 if expr.is_empty() {
2030 return CmdOut::err(
2031 "usage: break if <metric><op><value> (e.g. break if inf_pr<1e-6)",
2032 );
2033 }
2034 return match Condition::parse(&expr) {
2035 Ok(c) => {
2036 let raw = c.raw.clone();
2037 if !self.conds.iter().any(|e| e.raw == raw) {
2038 self.conds.push(c);
2039 }
2040 CmdOut::ok(vec![format!("conditional breakpoint: {raw}")])
2041 .with_data(serde_json::json!({"condition": raw}))
2042 }
2043 Err(e) => CmdOut::err(e),
2044 };
2045 }
2046 if rest.first().copied() == Some("on") {
2048 let Some(&name) = rest.get(1) else {
2049 return CmdOut::err(format!("usage: break on <event> (one of {EVENTS:?})"));
2050 };
2051 let Some(&canon) = EVENTS.iter().find(|&&e| e == name) else {
2052 return CmdOut::err(format!("unknown event `{name}` (one of {EVENTS:?})"));
2053 };
2054 self.break_events.insert(canon);
2055 return CmdOut::ok(vec![format!("break on event `{canon}`")])
2056 .with_data(serde_json::json!({"event": canon}));
2057 }
2058 match rest {
2059 [] => {
2060 let mut bs = self.breaks.clone();
2061 bs.sort_unstable();
2062 let conds: Vec<String> = self.conds.iter().map(|c| c.raw.clone()).collect();
2063 let mut events: Vec<&str> = self.break_events.iter().copied().collect();
2064 events.sort_unstable();
2065 let mut lines = vec![format!("breakpoints: {bs:?}")];
2066 if !conds.is_empty() {
2067 lines.push(format!("conditions: {}", conds.join(", ")));
2068 }
2069 if !events.is_empty() {
2070 lines.push(format!("events: {}", events.join(", ")));
2071 }
2072 CmdOut::ok(lines).with_data(
2073 serde_json::json!({"breakpoints": bs, "conditions": conds, "events": events}),
2074 )
2075 }
2076 ["clear", "cond"] | ["clear", "conditions"] => {
2077 self.conds.clear();
2078 CmdOut::ok(vec!["cleared conditional breakpoints".into()])
2079 }
2080 ["clear", "events"] => {
2081 self.break_events.clear();
2082 CmdOut::ok(vec!["cleared event breakpoints".into()])
2083 }
2084 ["clear"] => {
2085 self.breaks.clear();
2086 self.conds.clear();
2087 self.break_events.clear();
2088 CmdOut::ok(vec!["cleared all breakpoints".into()])
2089 }
2090 ["del", n] | ["delete", n] => match n.parse::<i32>() {
2091 Ok(n) => {
2092 self.breaks.retain(|&b| b != n);
2093 CmdOut::ok(vec![format!("removed breakpoint {n}")])
2094 }
2095 Err(_) => CmdOut::err("usage: break del <iteration>"),
2096 },
2097 [n] => match n.parse::<i32>() {
2098 Ok(n) => {
2099 if !self.breaks.contains(&n) {
2100 self.breaks.push(n);
2101 }
2102 CmdOut::ok(vec![format!("breakpoint at iteration {n}")])
2103 }
2104 Err(_) => CmdOut::err("usage: break <iteration>"),
2105 },
2106 _ => CmdOut::err("usage: break [N | if <m><op><v> | clear | clear cond | del N]"),
2107 }
2108 }
2109
2110 fn cmd_stop_at(&mut self, rest: &[&str]) -> CmdOut {
2114 let canon = |s: &str| -> Option<&'static str> {
2115 match s {
2116 "mu" | "after_mu" => Some("after_mu"),
2117 "kkt" | "search_dir" | "after_search_dir" => Some("after_search_dir"),
2118 "step" | "after_step" => Some("after_step"),
2119 "rejected" | "ls_rejected" | "step_rejected" => Some("step_rejected"),
2120 "resto" | "restoration" | "pre_restoration_entry" => Some("pre_restoration_entry"),
2121 "resto_exit" | "post_restoration_exit" => Some("post_restoration_exit"),
2122 "iter" | "iter_start" => Some("iter_start"),
2123 "terminated" => Some("terminated"),
2124 _ => None,
2125 }
2126 };
2127 match rest {
2128 [] => {
2129 let mut v: Vec<&str> = self.stop_at.iter().copied().collect();
2130 v.sort_unstable();
2131 CmdOut::ok(vec![format!(
2132 "stop-at: {v:?} (available: {CHECKPOINTS:?})"
2133 )])
2134 .with_data(serde_json::json!({"stop_at": v, "available": CHECKPOINTS}))
2135 }
2136 ["clear"] => {
2137 self.stop_at.clear();
2138 CmdOut::ok(vec!["cleared stop-at checkpoints".into()])
2139 }
2140 [name] => match canon(name) {
2141 Some(c) => {
2142 self.stop_at.insert(c);
2143 CmdOut::ok(vec![format!("will stop at checkpoint `{c}`")])
2144 .with_data(serde_json::json!({"stop_at_added": c}))
2145 }
2146 None => CmdOut::err(format!(
2147 "unknown checkpoint `{name}` (one of {CHECKPOINTS:?})"
2148 )),
2149 },
2150 _ => CmdOut::err("usage: stop-at [<checkpoint> | clear]"),
2151 }
2152 }
2153
2154 fn cmd_set(&mut self, rest: &[&str], ctx: &mut DebugCtx) -> CmdOut {
2155 match rest {
2156 ["mu", v] => match v.parse::<f64>() {
2157 Ok(mu) => match ctx.set_mu(mu) {
2158 Ok(()) => CmdOut::ok(vec![format!("mu := {mu:.6e}")]),
2159 Err(e) => CmdOut::err(e),
2160 },
2161 Err(_) => CmdOut::err("usage: set mu <value>"),
2162 },
2163 ["opt", name, value] => self.cmd_set_opt(name, value, ctx),
2164 [target, value] => self.cmd_set_block(target, value, ctx),
2165 _ => CmdOut::err(
2166 "usage: set mu <v> | set <blk>[<i>] <v> | set <blk> <v0,v1,..> | set opt <name> <v>",
2167 ),
2168 }
2169 }
2170
2171 fn cmd_set_block(&mut self, target: &str, value: &str, ctx: &mut DebugCtx) -> CmdOut {
2173 if let Some(open) = target.find('[') {
2175 if !target.ends_with(']') {
2176 return CmdOut::err("malformed component target (expected name[idx])");
2177 }
2178 let name = &target[..open];
2179 let idx_str = &target[open + 1..target.len() - 1];
2180 let Ok(idx) = idx_str.parse::<usize>() else {
2181 return CmdOut::err(format!("bad index `{idx_str}`"));
2182 };
2183 let Ok(val) = value.parse::<f64>() else {
2184 return CmdOut::err(format!("bad value `{value}`"));
2185 };
2186 return match ctx.set_component(name, idx, val) {
2187 Ok(()) => CmdOut::ok(vec![format!("{name}[{idx}] := {val:.6e}")]),
2188 Err(e) => CmdOut::err(e),
2189 };
2190 }
2191 let parsed: Result<Vec<f64>, _> =
2193 value.split(',').map(|s| s.trim().parse::<f64>()).collect();
2194 match parsed {
2195 Ok(vals) => match ctx.set_block(target, &vals) {
2196 Ok(()) => CmdOut::ok(vec![format!("{target} := {} value(s)", vals.len())]),
2197 Err(e) => CmdOut::err(e),
2198 },
2199 Err(_) => CmdOut::err("could not parse comma-separated values"),
2200 }
2201 }
2202
2203 fn cmd_set_opt(&mut self, name: &str, value: &str, ctx: &mut DebugCtx) -> CmdOut {
2204 let Some(reg) = self.reg.as_ref() else {
2205 return CmdOut::err("no options registry available");
2206 };
2207 let Some(opt) = reg.get_option(name) else {
2208 return CmdOut::err(format!("unknown option `{name}` (try `opt {name}`)"));
2209 };
2210 let valid = match opt.option_type {
2212 OptionType::OT_Number => value
2213 .parse::<f64>()
2214 .map(|v| opt.is_valid_number(v))
2215 .unwrap_or(false),
2216 OptionType::OT_Integer => value
2217 .parse::<i32>()
2218 .map(|v| opt.is_valid_integer(v))
2219 .unwrap_or(false),
2220 OptionType::OT_String => opt.is_valid_string(value),
2221 OptionType::OT_Unknown => true,
2222 };
2223 if !valid {
2224 return CmdOut::err(format!("`{value}` is not a valid value for `{name}`"));
2225 }
2226 self.staged.retain(|(k, _)| k != name);
2229 self.staged.push((name.to_string(), value.to_string()));
2230 if is_live_tolerance(name) {
2235 if let Ok(v) = value.parse::<f64>() {
2236 ctx.set_live_tolerance(name, v);
2237 return CmdOut::ok(vec![format!(
2238 "{name} = {value} (applied live — the next `step` uses it)"
2239 )])
2240 .with_data(serde_json::json!({
2241 "option": name, "value": value, "live": true
2242 }));
2243 }
2244 }
2245 CmdOut::ok(vec![format!(
2246 "staged {name} = {value} (validated; takes effect on `resolve` — built strategies don't re-read mid-solve)"
2247 )])
2248 .with_data(serde_json::json!({"option": name, "value": value, "staged": true}))
2249 }
2250
2251 fn cmd_get(&self, rest: &[&str]) -> CmdOut {
2258 let name = match rest {
2260 ["opt", n] => *n,
2261 [n] => *n,
2262 _ => return CmdOut::err("usage: get opt <name>"),
2263 };
2264 let Some(reg) = self.reg.as_ref() else {
2265 return CmdOut::err("no options registry available");
2266 };
2267 let Some(o) = reg.get_option(name) else {
2268 return CmdOut::err(format!("unknown option `{name}` (try `opt {name}`)"));
2269 };
2270 let def = default_str(&o.default);
2271 let staged = self
2272 .staged
2273 .iter()
2274 .find(|(k, _)| k == name)
2275 .map(|(_, v)| v.clone());
2276 let (value, source) = match &staged {
2277 Some(v) => (v.clone(), "staged"),
2278 None => (def.clone(), "default"),
2279 };
2280 CmdOut::ok(vec![format!("{name} = {value} ({source}; default={def})")]).with_data(
2281 serde_json::json!({
2282 "option": name, "value": value, "source": source,
2283 "default": def, "staged": staged,
2284 }),
2285 )
2286 }
2287
2288 fn cmd_opt(&self, rest: &[&str]) -> CmdOut {
2289 let Some(reg) = self.reg.as_ref() else {
2290 return CmdOut::err("no options registry available");
2291 };
2292 let filter = rest.first().copied().unwrap_or("");
2293 let mut lines = Vec::new();
2294 let mut data = Vec::new();
2295 for o in reg.registered_options_in_order() {
2296 if !filter.is_empty()
2297 && !o.name.contains(filter)
2298 && !o
2299 .category
2300 .to_ascii_lowercase()
2301 .contains(&filter.to_ascii_lowercase())
2302 {
2303 continue;
2304 }
2305 let ty = type_str(o.option_type);
2306 let def = default_str(&o.default);
2307 lines.push(format!(
2308 " {:<28} {:<7} default={:<12} {}",
2309 o.name, ty, def, o.short_description
2310 ));
2311 data.push(serde_json::json!({
2312 "name": o.name,
2313 "type": ty,
2314 "default": def,
2315 "category": o.category,
2316 "short": o.short_description,
2317 "valid": o.valid_strings.iter().map(|e| e.value.clone()).collect::<Vec<_>>(),
2318 }));
2319 }
2320 if lines.is_empty() {
2321 return CmdOut::ok(vec![format!("no options match `{filter}`")]);
2322 }
2323 if data.len() == 1 {
2325 if let Some(o) = reg.get_option(filter) {
2326 if !o.long_description.is_empty() {
2327 lines.push(String::new());
2328 lines.push(o.long_description.clone());
2329 }
2330 }
2331 }
2332 CmdOut::ok(lines).with_data(serde_json::json!({"options": data}))
2333 }
2334
2335 fn cmd_complete(&self, rest: &[&str]) -> CmdOut {
2340 let (before, word) = match rest.split_last() {
2341 Some((w, pre)) => (pre.join(" "), *w),
2342 None => (String::new(), ""),
2343 };
2344 let mut cands = completion_candidates(self.reg.as_deref(), &before, word);
2345 cands.sort();
2346 cands.dedup();
2347 CmdOut::ok(vec![cands.join(" ")]).with_data(serde_json::json!({"candidates": cands}))
2348 }
2349
2350 fn cmd_save(&self, rest: &[&str], ctx: &DebugCtx) -> CmdOut {
2354 let iter = ctx.iter();
2355 let path = rest
2356 .first()
2357 .map(PathBuf::from)
2358 .unwrap_or_else(|| std::env::temp_dir().join(format!("pounce-dbg-iter{iter}.json")));
2359 let collect = |delta: bool| -> serde_json::Map<String, serde_json::Value> {
2360 let mut m = serde_json::Map::new();
2361 for &b in BLOCK_NAMES.iter() {
2362 let v = if delta {
2363 ctx.delta_block(b)
2364 } else {
2365 ctx.block(b)
2366 };
2367 if let Some(v) = v {
2368 if !v.is_empty() {
2369 let key = if delta {
2370 format!("d{b}")
2371 } else {
2372 b.to_string()
2373 };
2374 m.insert(key, serde_json::json!(v));
2375 }
2376 }
2377 }
2378 m
2379 };
2380 let payload = serde_json::json!({
2381 "iter": iter,
2382 "mu": ctx.mu(),
2383 "objective": ctx.objective(),
2384 "inf_pr": ctx.inf_pr(),
2385 "inf_du": ctx.inf_du(),
2386 "nlp_error": ctx.nlp_error(),
2387 "iterate": collect(false),
2388 "delta": collect(true),
2389 });
2390 match std::fs::write(&path, format!("{payload}\n")) {
2391 Ok(()) => {
2392 let p = path.to_string_lossy().to_string();
2393 CmdOut::ok(vec![format!("saved iterate to {p}")])
2394 .with_data(serde_json::json!({"path": p}))
2395 }
2396 Err(e) => CmdOut::err(format!("save failed: {e}")),
2397 }
2398 }
2399
2400 fn cmd_load(&mut self, rest: &[&str], ctx: &mut DebugCtx) -> CmdOut {
2407 let Some(&path) = rest.first() else {
2408 return CmdOut::err("usage: load <file> [block] (inverse of `save`)");
2409 };
2410 let content = match std::fs::read_to_string(path) {
2411 Ok(c) => c,
2412 Err(e) => return CmdOut::err(format!("cannot read `{path}`: {e}")),
2413 };
2414 if let Ok(v) = serde_json::from_str::<serde_json::Value>(content.trim()) {
2418 let obj = v
2419 .get("iterate")
2420 .and_then(|o| o.as_object())
2421 .or_else(|| v.as_object());
2422 if let Some(obj) = obj {
2423 let mut loaded: Vec<(String, usize)> = Vec::new();
2424 let mut errs: Vec<String> = Vec::new();
2425 for &b in BLOCK_NAMES.iter() {
2426 let Some(arr) = obj.get(b).and_then(|a| a.as_array()) else {
2427 continue;
2428 };
2429 let vals: Option<Vec<f64>> = arr.iter().map(|x| x.as_f64()).collect();
2430 let Some(vals) = vals else {
2431 errs.push(format!("{b}: non-numeric entries"));
2432 continue;
2433 };
2434 match ctx.set_block(b, &vals) {
2435 Ok(()) => loaded.push((b.to_string(), vals.len())),
2436 Err(e) => errs.push(format!("{b}: {e}")),
2437 }
2438 }
2439 if loaded.is_empty() && errs.is_empty() {
2440 return CmdOut::err(
2441 "no recognizable blocks in JSON (expected `x`, `s`, … at top level or under `iterate`)",
2442 );
2443 }
2444 let mut lines: Vec<String> = loaded
2445 .iter()
2446 .map(|(b, n)| format!("loaded {b} ({n} values)"))
2447 .collect();
2448 lines.extend(errs.iter().map(|e| format!("skipped {e}")));
2449 return CmdOut::ok(lines).with_data(serde_json::json!({
2450 "loaded": loaded.iter().map(|(b, n)| serde_json::json!({"block": b, "n": n})).collect::<Vec<_>>(),
2451 "skipped": errs,
2452 }));
2453 }
2454 }
2455 let block = rest.get(1).copied().unwrap_or("x");
2457 let vals = match parse_floats(&content) {
2458 Ok(v) if !v.is_empty() => v,
2459 Ok(_) => return CmdOut::err("file held no numbers"),
2460 Err(e) => return CmdOut::err(e),
2461 };
2462 match ctx.set_block(block, &vals) {
2463 Ok(()) => CmdOut::ok(vec![format!("loaded {block} ({} values)", vals.len())])
2464 .with_data(serde_json::json!({"block": block, "n": vals.len()})),
2465 Err(e) => CmdOut::err(e),
2466 }
2467 }
2468
2469 fn cmd_sweep(&mut self, rest: &[&str], ctx: &mut DebugCtx) -> CmdOut {
2475 if self.restart.is_none() {
2476 return CmdOut::err("sweep needs re-solve, which is not available in this context");
2477 }
2478 let Some(&path) = rest.first() else {
2479 return CmdOut::err("usage: sweep <file> (one start per line, comma-separated)");
2480 };
2481 let content = match std::fs::read_to_string(path) {
2482 Ok(c) => c,
2483 Err(e) => return CmdOut::err(format!("cannot read `{path}`: {e}")),
2484 };
2485 let dim = ctx.block("x").map(|x| x.len()).unwrap_or(0);
2486 let mut seeds: Vec<Vec<f64>> = Vec::new();
2487 for (lineno, raw) in content.lines().enumerate() {
2488 let line = raw.trim();
2489 if line.is_empty() || line.starts_with('#') || line.starts_with("//") {
2490 continue;
2491 }
2492 match parse_floats(line) {
2493 Ok(v) if v.len() == dim => seeds.push(v),
2494 Ok(v) => {
2495 return CmdOut::err(format!(
2496 "line {}: got {} values, expected {dim} (= dim x)",
2497 lineno + 1,
2498 v.len()
2499 ));
2500 }
2501 Err(e) => return CmdOut::err(format!("line {}: {e}", lineno + 1)),
2502 }
2503 }
2504 self.start_sweep(seeds, &format!("sweep `{path}`"))
2505 }
2506
2507 fn cmd_multistart(&mut self, rest: &[&str], ctx: &mut DebugCtx) -> CmdOut {
2515 if self.restart.is_none() {
2516 return CmdOut::err(
2517 "multistart needs re-solve, which is not available in this context",
2518 );
2519 }
2520 let Some(n) = rest.first().and_then(|s| s.parse::<usize>().ok()) else {
2521 return CmdOut::err("usage: multistart <N> [rel] (N sampled restarts)");
2522 };
2523 if n == 0 {
2524 return CmdOut::err("N must be ≥ 1");
2525 }
2526 let rel = rest
2527 .get(1)
2528 .and_then(|s| s.parse::<f64>().ok())
2529 .unwrap_or(0.1);
2530 let Some(base) = ctx.block("x") else {
2531 return CmdOut::err("no current iterate to sample from");
2532 };
2533 let bounds = ctx
2535 .var_bounds()
2536 .filter(|(lo, hi)| lo.len() == base.len() && hi.len() == base.len());
2537 let n_box = bounds
2538 .as_ref()
2539 .map(|(lo, hi)| {
2540 lo.iter()
2541 .zip(hi)
2542 .filter(|(l, u)| l.is_finite() && u.is_finite() && u > l)
2543 .count()
2544 })
2545 .unwrap_or(0);
2546 let seeds: Vec<Vec<f64>> = (0..n)
2547 .map(|k| {
2548 let b = bounds
2549 .as_ref()
2550 .map(|(lo, hi)| (lo.as_slice(), hi.as_slice()));
2551 sample_start(&base, b, rel, k)
2552 })
2553 .collect();
2554 let n_var = base.len();
2555 let label = if n_box == n_var {
2556 format!("multistart {n} (box-sampled, {n_box}/{n_var} vars bounded)")
2557 } else if n_box > 0 {
2558 format!(
2559 "multistart {n} (box {n_box}/{n_var} vars; {} unbounded → jitter rel={rel})",
2560 n_var - n_box
2561 )
2562 } else {
2563 format!("multistart {n} (no finite boxes → jitter rel={rel})")
2564 };
2565 self.start_sweep(seeds, &label)
2566 }
2567
2568 fn start_sweep(&mut self, seeds: Vec<Vec<f64>>, label: &str) -> CmdOut {
2573 if seeds.is_empty() {
2574 return CmdOut::err("no start points");
2575 }
2576 let Some(cell) = self.restart.as_ref() else {
2577 return CmdOut::err("sweep needs re-solve, which is not available in this context");
2578 };
2579 let total = seeds.len();
2580 let mut queue: VecDeque<Vec<f64>> = seeds.into();
2581 let first = queue.pop_front().expect("non-empty");
2582 *cell.borrow_mut() = Some(RestartRequest {
2583 seed_x: first.clone(),
2584 options: self.staged.clone(),
2585 warm: None,
2586 });
2587 let saved_pause_iters = self.pause_iters;
2590 self.pause_iters = false;
2591 self.step = false;
2592 self.sub_step = false;
2593 self.run_to = None;
2594 self.sweep = Some(SweepState {
2595 queue,
2596 current: Some(first),
2597 records: Vec::new(),
2598 total,
2599 saved_pause_iters,
2600 });
2601 CmdOut::ok(vec![format!("{label}: running {total} start(s)…")])
2602 .with_data(serde_json::json!({"sweep": label, "starts": total}))
2603 .flow(Flow::Stop)
2604 }
2605
2606 fn drive_sweep(&mut self, ctx: &DebugCtx) -> Option<DebugAction> {
2613 let mut sweep = self.sweep.take()?;
2614 let rec = SweepRecord {
2615 idx: sweep.records.len(),
2616 seed: sweep.current.clone().unwrap_or_default(),
2617 status: ctx.status().unwrap_or("?").to_string(),
2618 objective: ctx.objective(),
2619 inf_pr: ctx.inf_pr(),
2620 iters: ctx.iter(),
2621 };
2622 self.emit_sweep_progress(&rec, sweep.total);
2623 sweep.records.push(rec);
2624 if let Some(next) = sweep.queue.pop_front() {
2625 sweep.current = Some(next.clone());
2626 if let Some(cell) = self.restart.as_ref() {
2627 *cell.borrow_mut() = Some(RestartRequest {
2628 seed_x: next,
2629 options: self.staged.clone(),
2630 warm: None,
2631 });
2632 }
2633 self.sweep = Some(sweep);
2634 return Some(DebugAction::Resume);
2635 }
2636 self.pause_iters = sweep.saved_pause_iters;
2638 self.emit_sweep_summary(&sweep);
2639 None
2640 }
2641
2642 fn emit_sweep_progress(&self, rec: &SweepRecord, total: usize) {
2645 match self.mode {
2646 DebugMode::Repl => eprintln!(
2647 " sweep {}/{}: {:<22} iters={:<4} obj={:.6e} inf_pr={:.2e}",
2648 rec.idx + 1,
2649 total,
2650 rec.status,
2651 rec.iters,
2652 rec.objective,
2653 rec.inf_pr,
2654 ),
2655 DebugMode::Json => emit_json(&serde_json::json!({
2656 "event": "sweep_result",
2657 "index": rec.idx,
2658 "total": total,
2659 "status": rec.status,
2660 "iters": rec.iters,
2661 "objective": rec.objective,
2662 "inf_pr": rec.inf_pr,
2663 "seed": rec.seed,
2664 })),
2665 }
2666 }
2667
2668 fn emit_sweep_summary(&self, sweep: &SweepState) {
2671 let succeeded: Vec<&SweepRecord> = sweep
2672 .records
2673 .iter()
2674 .filter(|r| is_success_status(&r.status))
2675 .collect();
2676 let mut distinct: Vec<f64> = Vec::new();
2678 for r in &succeeded {
2679 if !distinct
2680 .iter()
2681 .any(|&o| (o - r.objective).abs() <= 1e-6 * o.abs().max(1.0))
2682 {
2683 distinct.push(r.objective);
2684 }
2685 }
2686 let best = succeeded.iter().min_by(|a, b| {
2687 a.objective
2688 .partial_cmp(&b.objective)
2689 .unwrap_or(std::cmp::Ordering::Equal)
2690 });
2691 match self.mode {
2692 DebugMode::Repl => {
2693 eprintln!(
2694 "\n── sweep complete ── {} solves, {} succeeded, {} distinct minima",
2695 sweep.records.len(),
2696 succeeded.len(),
2697 distinct.len()
2698 );
2699 eprintln!(
2700 " {:>3} {:<22} {:>5} {:>14} {:>9}",
2701 "#", "status", "iters", "objective", "inf_pr"
2702 );
2703 for r in &sweep.records {
2704 eprintln!(
2705 " {:>3} {:<22} {:>5} {:>14.6e} {:>9.2e}",
2706 r.idx, r.status, r.iters, r.objective, r.inf_pr
2707 );
2708 }
2709 if let Some(b) = best {
2710 eprintln!(" best: solve #{} obj={:.8e}", b.idx, b.objective);
2711 }
2712 }
2713 DebugMode::Json => emit_json(&serde_json::json!({
2714 "event": "sweep_summary",
2715 "solves": sweep.records.len(),
2716 "succeeded": succeeded.len(),
2717 "distinct_minima": distinct.len(),
2718 "best_index": best.map(|b| b.idx),
2719 "best_objective": best.map(|b| b.objective),
2720 "records": sweep.records.iter().map(|r| serde_json::json!({
2721 "index": r.idx, "status": r.status, "iters": r.iters,
2722 "objective": r.objective, "inf_pr": r.inf_pr,
2723 })).collect::<Vec<_>>(),
2724 })),
2725 }
2726 }
2727
2728 fn cmd_goto(&mut self, rest: &[&str], ctx: &mut DebugCtx) -> CmdOut {
2730 match rest.first().and_then(|s| s.parse::<i32>().ok()) {
2731 Some(k) => self.restore_to(k, ctx),
2732 None => CmdOut::err("usage: goto <iteration>"),
2733 }
2734 }
2735
2736 fn restore_to(&mut self, k: i32, ctx: &mut DebugCtx) -> CmdOut {
2740 match self.snapshots.get(&k) {
2741 Some(snap) => {
2742 ctx.restore(snap);
2743 CmdOut::ok(vec![format!(
2744 "rewound to iter {k} (primal-dual only; strategy history not restored). \
2745 `continue`/`step` to resume."
2746 )])
2747 .with_data(serde_json::json!({"restored_iter": k}))
2748 }
2749 None => {
2750 let have: Vec<i32> = self.snapshots.keys().copied().collect();
2751 CmdOut::err(format!("no snapshot for iter {k} (captured: {have:?})"))
2752 }
2753 }
2754 }
2755
2756 fn cmd_resolve(&mut self, ctx: &DebugCtx) -> CmdOut {
2764 let Some(cell) = self.restart.as_ref() else {
2765 return CmdOut::err("re-solve is not available in this context");
2766 };
2767 let Some(seed_x) = ctx.block("x") else {
2768 return CmdOut::err("no current iterate to seed from");
2769 };
2770 let warm = ctx.snapshot();
2771 let mu = warm.as_ref().map(|s| s.mu());
2772 let options = self.staged.clone();
2773 let n_opt = options.len();
2774 let warm_msg = match mu {
2775 Some(mu) => format!(
2776 "re-solving warm from the current primal-dual iterate (μ={mu:.3e}) \
2777 with {n_opt} staged option override(s)…"
2778 ),
2779 None => format!(
2780 "re-solving from current x (primal-only) with {n_opt} staged option override(s)…"
2781 ),
2782 };
2783 *cell.borrow_mut() = Some(RestartRequest {
2784 seed_x,
2785 options,
2786 warm,
2787 });
2788 CmdOut::ok(vec![warm_msg])
2789 .with_data(serde_json::json!({
2790 "resolve": true,
2791 "options": n_opt,
2792 "warm": mu.is_some(),
2793 "mu": mu,
2794 }))
2795 .flow(Flow::Stop)
2796 }
2797
2798 fn cmd_ask(&self, rest: &[&str], ctx: &DebugCtx) -> CmdOut {
2804 let question = if rest.is_empty() {
2805 "Explain the current state of this interior-point solve and suggest what to try next."
2806 .to_string()
2807 } else {
2808 rest.join(" ")
2809 };
2810 let prompt = build_ask_prompt(ctx, &question);
2811 match run_llm(&prompt) {
2812 Ok(reply) => {
2813 let lines: Vec<String> = reply.lines().map(|l| l.to_string()).collect();
2814 CmdOut::ok(lines).with_data(serde_json::json!({
2815 "question": question,
2816 "reply": reply,
2817 }))
2818 }
2819 Err(e) => CmdOut::err(e),
2820 }
2821 }
2822
2823 fn cmd_watch(&mut self, rest: &[&str]) -> CmdOut {
2826 match rest {
2827 [] => CmdOut::ok(vec![format!("watches: {:?}", self.watches)])
2828 .with_data(serde_json::json!({"watches": self.watches})),
2829 ["clear"] => {
2830 self.watches.clear();
2831 CmdOut::ok(vec!["cleared watches".into()])
2832 }
2833 ["del", w] | ["delete", w] => {
2834 self.watches.retain(|x| x != w);
2835 CmdOut::ok(vec![format!("unwatched {w}")])
2836 }
2837 [w] => {
2838 let w = w.to_string();
2839 if !self.watches.contains(&w) {
2840 self.watches.push(w.clone());
2841 }
2842 CmdOut::ok(vec![format!("watching {w}")])
2843 }
2844 _ => CmdOut::err("usage: watch [<target> | clear | del <target>]"),
2845 }
2846 }
2847
2848 fn cmd_watchpoint(&mut self, rest: &[&str]) -> CmdOut {
2852 match rest {
2853 [] => {
2854 let v: Vec<&str> = self.watchpoints.iter().map(|w| w.raw.as_str()).collect();
2855 CmdOut::ok(vec![format!("watchpoints: {v:?}")])
2856 .with_data(serde_json::json!({"watchpoints": v}))
2857 }
2858 ["clear"] => {
2859 self.watchpoints.clear();
2860 CmdOut::ok(vec!["cleared watchpoints".into()])
2861 }
2862 ["del", spec] | ["delete", spec] => {
2863 self.watchpoints.retain(|w| w.raw != *spec);
2864 CmdOut::ok(vec![format!("removed watchpoint {spec}")])
2865 }
2866 [spec, rest @ ..] => {
2867 let threshold = rest
2868 .first()
2869 .and_then(|s| s.parse::<f64>().ok())
2870 .unwrap_or(0.0);
2871 let (block, idx) = match spec.find('[') {
2873 Some(open) if spec.ends_with(']') => {
2874 let b = &spec[..open];
2875 match spec[open + 1..spec.len() - 1].parse::<usize>() {
2876 Ok(i) => (b.to_string(), Some(i)),
2877 Err(_) => return CmdOut::err(format!("bad index in `{spec}`")),
2878 }
2879 }
2880 _ => (spec.to_string(), None),
2881 };
2882 if !BLOCK_NAMES.contains(&block.as_str()) {
2883 return CmdOut::err(format!("unknown block `{block}`"));
2884 }
2885 let raw = spec.to_string();
2886 if !self.watchpoints.iter().any(|w| w.raw == raw) {
2887 self.watchpoints.push(WatchPoint {
2888 raw: raw.clone(),
2889 block,
2890 idx,
2891 threshold,
2892 last: None,
2893 });
2894 }
2895 CmdOut::ok(vec![format!("watchpoint on {raw} (Δ>{threshold:.3e})")])
2896 }
2897 }
2898 }
2899
2900 fn cmd_commands(&mut self, rest: &[&str]) -> CmdOut {
2905 let Some(iter) = rest.first().and_then(|s| s.parse::<i32>().ok()) else {
2906 if rest.is_empty() {
2907 let mut items: Vec<(i32, Vec<String>)> = self
2908 .bp_commands
2909 .iter()
2910 .map(|(k, v)| (*k, v.clone()))
2911 .collect();
2912 items.sort_by_key(|(k, _)| *k);
2913 let lines = if items.is_empty() {
2914 vec!["no breakpoint command lists".into()]
2915 } else {
2916 items
2917 .iter()
2918 .map(|(k, v)| format!("iter {k}: {}", v.join(" ; ")))
2919 .collect()
2920 };
2921 return CmdOut::ok(lines);
2922 }
2923 return CmdOut::err(
2924 "usage: commands <iter> <cmd> ; <cmd> … (or: commands <iter> clear)",
2925 );
2926 };
2927 let tail = rest[1..].join(" ");
2928 let tail = tail.trim();
2929 if tail.is_empty() || tail == "clear" {
2930 self.bp_commands.remove(&iter);
2931 return CmdOut::ok(vec![format!("cleared commands for iteration {iter}")]);
2932 }
2933 let cmds: Vec<String> = tail
2934 .split(';')
2935 .map(|s| s.trim().to_string())
2936 .filter(|s| !s.is_empty())
2937 .collect();
2938 self.bp_commands.insert(iter, cmds.clone());
2939 CmdOut::ok(vec![format!(
2940 "commands for iter {iter}: {}",
2941 cmds.join(" ; ")
2942 )])
2943 .with_data(serde_json::json!({"iter": iter, "commands": cmds}))
2944 }
2945
2946 fn cmd_diff(&self, ctx: &DebugCtx) -> CmdOut {
2949 let iter = ctx.iter();
2950 let Some((&piter, prev)) = self.snapshots.range(..iter).next_back() else {
2951 return CmdOut::err("no previous iterate to diff against");
2952 };
2953 let mut lines = vec![format!("Δ since iter {piter}:")];
2954 let dmu = ctx.mu() - prev.mu();
2955 lines.push(format!(" mu = {:.6e} (Δ {:+.3e})", ctx.mu(), dmu));
2956 let mut blocks = serde_json::Map::new();
2957 for b in BLOCK_NAMES {
2958 let (Some(cur), Some(old)) = (ctx.block(b), prev.block(b)) else {
2959 continue;
2960 };
2961 if cur.is_empty() || cur.len() != old.len() {
2962 continue;
2963 }
2964 let mut amax = 0.0_f64;
2965 let mut imax = 0usize;
2966 for (i, (c, o)) in cur.iter().zip(&old).enumerate() {
2967 let d = (c - o).abs();
2968 if d > amax {
2969 amax = d;
2970 imax = i;
2971 }
2972 }
2973 if amax > 0.0 {
2974 lines.push(format!(
2975 " {b}: max|Δ|={amax:.3e} at [{imax}] ({:.4e} → {:.4e})",
2976 old[imax], cur[imax]
2977 ));
2978 blocks.insert(
2979 b.to_string(),
2980 serde_json::json!({"max_abs_change": amax, "argmax": imax}),
2981 );
2982 }
2983 }
2984 if lines.len() == 2 {
2985 lines.push(" (no change)".into());
2986 }
2987 CmdOut::ok(lines).with_data(
2988 serde_json::json!({"from_iter": piter, "to_iter": iter, "dmu": dmu, "blocks": blocks}),
2989 )
2990 }
2991
2992 fn cmd_source(&mut self, rest: &[&str], ctx: &mut DebugCtx) -> CmdOut {
2996 let Some(&path) = rest.first() else {
2997 return CmdOut::err("usage: source <file>");
2998 };
2999 let content = match std::fs::read_to_string(path) {
3000 Ok(c) => c,
3001 Err(e) => return CmdOut::err(format!("cannot read `{path}`: {e}")),
3002 };
3003 let mut lines = Vec::new();
3004 let mut flow = Flow::Stay;
3005 for raw in content.lines() {
3006 let cmd = raw.trim();
3007 if cmd.is_empty() || cmd.starts_with('#') || cmd.starts_with("//") {
3008 continue;
3009 }
3010 lines.push(format!("[source] {cmd}"));
3011 let out = self.dispatch(cmd, ctx);
3012 lines.extend(out.lines);
3013 if !matches!(out.flow, Flow::Stay) {
3014 flow = out.flow;
3015 break;
3016 }
3017 }
3018 CmdOut {
3019 ok: true,
3020 lines,
3021 data: None,
3022 flow,
3023 }
3024 }
3025
3026 fn cmd_viz(&self, rest: &[&str], ctx: &mut DebugCtx) -> CmdOut {
3027 let Some(&target) = rest.first() else {
3028 return CmdOut::err("usage: viz <x|s|y_c|...|dx|kkt|L>");
3029 };
3030 if target == "kkt" {
3033 let Some(k) = ctx.kkt() else {
3034 return CmdOut::err(
3035 "no KKT factorization captured yet — nothing has been factored (iter 0), \
3036 or the debugger is detached. `step` once to capture.",
3037 );
3038 };
3039 let Some((dim, irn, jcn, vals)) = ctx.kkt_matrix() else {
3044 return CmdOut::err(
3045 "KKT matrix not captured here — the debugger is detached \
3046 (running free). `step` once to capture and re-run `viz kkt`.",
3047 );
3048 };
3049 let kiter = k.iter;
3052 let matrix = serde_json::json!({"dim": dim, "irn": irn, "jcn": jcn, "vals": vals,
3053 "format": "triplet_1based_lower"});
3054 let payload = serde_json::json!({
3055 "label": "kkt", "iter": kiter,
3056 "dim": k.dim, "n_pos": k.n_pos, "n_neg": k.n_neg,
3057 "expected_neg": k.expected_neg, "inertia_correct": k.inertia_correct,
3058 "delta_w": k.delta_w, "delta_c": k.delta_c, "status": k.status,
3059 "matrix": matrix,
3060 });
3061 return match write_json_and_open("kkt", kiter, &payload) {
3062 Ok((path, viewer)) => CmdOut::ok(vec![format!(
3063 "wrote {path} (KKT system, iter {kiter}); opened with `{viewer}`"
3064 )])
3065 .with_data(serde_json::json!({"path": path, "viewer": viewer})),
3066 Err(e) => CmdOut::err(e),
3067 };
3068 }
3069 if target == "L" {
3074 match ctx.kkt_l_factor() {
3075 Some((n, perm, l_irn, l_jcn, l_vals)) => {
3076 let kiter = ctx.kkt_captured_iter().unwrap_or_else(|| ctx.iter());
3078 let payload = serde_json::json!({
3079 "label": "L", "iter": kiter, "n": n, "perm": perm,
3080 "l_irn": l_irn, "l_jcn": l_jcn, "l_vals": l_vals,
3081 "format": "strict_lower_1based_permuted",
3082 });
3083 return match write_json_and_open("L", kiter, &payload) {
3084 Ok((path, viewer)) => CmdOut::ok(vec![format!(
3085 "wrote {path} (L factor, iter {kiter}); opened with `{viewer}`"
3086 )])
3087 .with_data(serde_json::json!({"path": path, "viewer": viewer})),
3088 Err(e) => CmdOut::err(e),
3089 };
3090 }
3091 None => {
3092 return CmdOut::err(
3093 "L factor not captured here — nothing factored yet (iter 0), \
3094 or the debugger is detached. `step` once to capture.",
3095 );
3096 }
3097 }
3098 }
3099 let (label, vals) = if BLOCK_NAMES.contains(&target) {
3101 match ctx.block(target) {
3102 Some(v) => (target.to_string(), v),
3103 None => return CmdOut::err(format!("no data for block `{target}`")),
3104 }
3105 } else if let Some(blk) = target.strip_prefix("d").filter(|b| BLOCK_NAMES.contains(b)) {
3106 match ctx.delta_block(blk) {
3107 Some(v) => (format!("d{blk}"), v),
3108 None => return CmdOut::err(format!("no search direction for `d{blk}`")),
3109 }
3110 } else {
3111 return CmdOut::err(format!("don't know how to visualize `{target}`"));
3112 };
3113 match write_and_open(&label, ctx.iter(), &vals) {
3114 Ok((path, viewer)) => CmdOut::ok(vec![format!(
3115 "wrote {} ({} values); opened with `{}`",
3116 path,
3117 vals.len(),
3118 viewer
3119 )])
3120 .with_data(serde_json::json!({"path": path, "viewer": viewer, "n": vals.len()})),
3121 Err(e) => CmdOut::err(e),
3122 }
3123 }
3124
3125 fn emit_pause(&self, ctx: &DebugCtx, reason: Option<&str>) {
3129 let terminal = matches!(ctx.checkpoint(), Checkpoint::Terminated);
3130 match self.mode {
3131 DebugMode::Repl => {
3132 if terminal {
3133 eprintln!(
3134 "\n── pounce-dbg ── TERMINATED ({}) iter {} obj={:.6e} inf_pr={:.2e} inf_du={:.2e}",
3135 ctx.status().unwrap_or("?"),
3136 ctx.iter(),
3137 ctx.objective(),
3138 ctx.inf_pr(),
3139 ctx.inf_du(),
3140 );
3141 } else {
3142 let resto = if self.in_restoration {
3143 " [restoration]"
3144 } else {
3145 ""
3146 };
3147 eprintln!(
3148 "\n── pounce-dbg ── iter {} @{}{} mu={:.3e} obj={:.6e} inf_pr={:.2e} inf_du={:.2e}",
3149 ctx.iter(),
3150 ctx.checkpoint().as_str(),
3151 resto,
3152 ctx.mu(),
3153 ctx.objective(),
3154 ctx.inf_pr(),
3155 ctx.inf_du(),
3156 );
3157 }
3158 if let Some(r) = reason {
3159 eprintln!(" ↳ {r}");
3160 }
3161 for w in &self.watches {
3162 let out = self.cmd_print(&[w.as_str()], ctx);
3163 if out.ok {
3164 for l in &out.lines {
3165 eprintln!(" watch {l}");
3166 }
3167 } else {
3168 eprintln!(" watch {w}: (n/a)");
3172 }
3173 }
3174 }
3175 DebugMode::Json => {
3176 let watches: Vec<serde_json::Value> = self
3177 .watches
3178 .iter()
3179 .map(|w| {
3180 let out = self.cmd_print(&[w.as_str()], ctx);
3181 serde_json::json!({"expr": w, "ok": out.ok, "output": out.lines, "data": out.data})
3182 })
3183 .collect();
3184 let dims: serde_json::Map<String, serde_json::Value> = ctx
3185 .block_dims()
3186 .into_iter()
3187 .map(|(n, d)| (n.to_string(), serde_json::json!(d)))
3188 .collect();
3189 let conds: Vec<String> = self.conds.iter().map(|c| c.raw.clone()).collect();
3190 let ev = serde_json::json!({
3191 "event": "pause",
3192 "checkpoint": ctx.checkpoint().as_str(),
3193 "status": ctx.status(),
3194 "in_restoration": self.in_restoration,
3195 "iter": ctx.iter(),
3196 "mu": ctx.mu(),
3197 "objective": ctx.objective(),
3198 "inf_pr": ctx.inf_pr(),
3199 "inf_du": ctx.inf_du(),
3200 "nlp_error": ctx.nlp_error(),
3201 "complementarity": ctx.complementarity(),
3202 "dims": dims,
3203 "breakpoints": self.breaks,
3204 "conditions": conds,
3205 "reason": reason,
3206 "watches": watches,
3207 });
3208 emit_json(&ev);
3209 }
3210 }
3211 }
3212
3213 fn emit_progress_event(&self, ctx: &DebugCtx) {
3218 let ev = serde_json::json!({
3219 "event": "progress",
3220 "iter": ctx.iter(),
3221 "mu": ctx.mu(),
3222 "inf_pr": ctx.inf_pr(),
3223 "inf_du": ctx.inf_du(),
3224 "objective": ctx.objective(),
3225 "nlp_error": ctx.nlp_error(),
3226 "complementarity": ctx.complementarity(),
3227 });
3228 emit_json(&ev);
3229 }
3230
3231 fn emit_result(&self, command: &str, out: &CmdOut, req_id: Option<&serde_json::Value>) {
3234 match self.mode {
3235 DebugMode::Repl => {
3236 let stderr = std::io::stderr();
3237 let mut h = stderr.lock();
3238 for l in &out.lines {
3239 let _ = writeln!(h, "{l}");
3240 }
3241 if !out.ok {
3242 let _ = writeln!(h, "(error)");
3243 }
3244 }
3245 DebugMode::Json => {
3246 let ev = serde_json::json!({
3247 "event": "result",
3248 "request_id": req_id,
3249 "command": command,
3250 "ok": out.ok,
3251 "output": out.lines,
3252 "data": out.data,
3253 });
3254 emit_json(&ev);
3255 }
3256 }
3257 }
3258
3259 fn emit_hello(&self) {
3264 let ev = serde_json::json!({
3265 "event": "hello",
3266 "protocol": "pounce-dbg/1",
3267 "pounce_version": env!("CARGO_PKG_VERSION"),
3268 "capabilities": {
3269 "inspect": true,
3270 "mutate_iterate": true,
3271 "mutate_mu": true,
3272 "conditional_breakpoints": "compound",
3273 "request_ids": true,
3274 "viz": ["block", "delta", "kkt", "L"],
3275 "save": true,
3276 "load": true,
3277 "sweep": self.restart.is_some(),
3278 "kkt_inspect": true,
3279 "equations": self.equation_book.is_some(),
3282 "diagnose": true,
3284 "structural_diagnose": self.structure_book.is_some(),
3287 "llm_assist": true,
3288 "rewind": "primal_dual",
3289 "resolve": self.restart.is_some(),
3290 "terminal_checkpoint": true,
3291 "interruptible": self.interruptible,
3292 "progress_events": self.emit_progress,
3294 "async_pause": "checkpoint",
3295 "pause_command": true,
3298 },
3299 "checkpoints": CHECKPOINTS,
3300 "events": EVENTS,
3301 "commands": COMMANDS,
3302 "blocks": BLOCK_NAMES,
3303 "metrics": METRICS,
3304 });
3305 emit_json(&ev);
3306 }
3307
3308 fn ensure_editor(&mut self) {
3312 if !matches!(self.mode, DebugMode::Repl)
3313 || self.editor.is_some()
3314 || !std::io::stdin().is_terminal()
3315 {
3316 return;
3317 }
3318 let mut ed: Editor<DbgHelper, FileHistory> = match Editor::new() {
3319 Ok(e) => e,
3320 Err(_) => return,
3321 };
3322 ed.set_helper(Some(DbgHelper {
3323 reg: self.reg.clone(),
3324 }));
3325 let path = std::env::var_os("HOME")
3326 .or_else(|| std::env::var_os("USERPROFILE"))
3327 .map(|h| PathBuf::from(h).join(".pounce_dbg_history"));
3328 if let Some(p) = &path {
3329 let _ = ed.load_history(p);
3330 }
3331 self.hist_path = path;
3332 self.editor = Some(ed);
3333 }
3334
3335 fn on_prompt_interrupt(&mut self) -> String {
3340 self.prompt_interrupts += 1;
3341 if self.prompt_interrupts >= 2 {
3342 self.prompt_interrupts = 0;
3343 eprintln!("(quitting — Ctrl-C)");
3344 "quit".to_string()
3345 } else {
3346 eprintln!("(Ctrl-C — press again, or `quit`/Ctrl-D, to stop the solve)");
3347 String::new()
3348 }
3349 }
3350
3351 fn next_command_line(&mut self) -> Option<String> {
3355 if let DebugMode::Repl = self.mode {
3356 if let Some(ed) = self.editor.as_mut() {
3357 return match ed.readline("pounce-dbg> ") {
3358 Ok(l) => {
3359 self.prompt_interrupts = 0;
3360 let _ = ed.add_history_entry(l.as_str());
3361 if let Some(p) = &self.hist_path {
3362 let _ = ed.save_history(p);
3363 }
3364 Some(l)
3365 }
3366 Err(ReadlineError::Interrupted) => Some(self.on_prompt_interrupt()),
3371 Err(ReadlineError::Eof) => None,
3373 Err(_) => None,
3374 };
3375 }
3376 let _ = write!(std::io::stderr(), "pounce-dbg> ");
3377 let _ = std::io::stderr().flush();
3378 return read_stdin_line();
3379 }
3380 self.pump.get_or_insert_with(StdinPump::start).next()
3383 }
3384}
3385
3386fn read_stdin_line() -> Option<String> {
3388 let mut line = String::new();
3389 match std::io::stdin().read_line(&mut line) {
3390 Ok(0) => None,
3391 Ok(_) => Some(line),
3392 Err(_) => None,
3393 }
3394}
3395
3396fn rank_residuals(mut entries: Vec<Residual>, k: usize) -> Vec<Residual> {
3404 entries.sort_by(|a, b| {
3405 b.value
3406 .abs()
3407 .partial_cmp(&a.value.abs())
3408 .unwrap_or(std::cmp::Ordering::Equal)
3409 });
3410 entries.truncate(k);
3411 entries
3412}
3413
3414fn render_rank_report(
3425 rep: &RankReport,
3426 names: &Option<SplitNames>,
3427 equations: Option<&EquationBook>,
3428 iter: i32,
3429) -> (Vec<String>, serde_json::Value) {
3430 let m = rep.n_rows();
3431 let n = rep.n_cols;
3432 let mut lines = vec![
3433 format!("equality Jacobian J_c: {m} row(s) × {n} column(s)"),
3434 format!(
3435 "numerical rank = {} / {} (deficiency {})",
3436 rep.rank,
3437 m,
3438 rep.deficiency()
3439 ),
3440 format!(
3441 "σ_max = {:.3e} σ_min = {:.3e} cond = {} (rank tol τ = {:.3e})",
3442 rep.sigma_max(),
3443 rep.sigma_min(),
3444 fmt_cond(rep.cond),
3445 rep.tol
3446 ),
3447 ];
3448
3449 let shown: Vec<String> = rep
3451 .singular_values
3452 .iter()
3453 .take(MAX_SINGULAR_VALUES_SHOWN)
3454 .map(|s| format!("{s:.3e}"))
3455 .collect();
3456 let tail = if rep.singular_values.len() > MAX_SINGULAR_VALUES_SHOWN {
3457 " …"
3458 } else {
3459 ""
3460 };
3461 lines.push(format!("singular values: [{}{tail}]", shown.join(", ")));
3462
3463 if rep.is_rank_deficient() {
3464 lines.push(format!(
3465 "rank-deficient: {} equation(s) lie in the near-null space \
3466 (linearly dependent / redundant) — the source of δ_c regularization:",
3467 rep.deficiency()
3468 ));
3469 let mut shown_any_eq = false;
3470 for c in rep.culprits.iter().take(MAX_RANK_CULPRITS) {
3471 let row = &rep.rows[c.row];
3472 let label = rank_row_label(row, names);
3473 lines.push(format!(" {label} (participation {:.2})", c.weight));
3474 if let Some(eq) = culprit_equation(row, names, equations) {
3478 lines.push(format!(" {eq}"));
3479 shown_any_eq = true;
3480 }
3481 }
3482 if rep.culprits.len() > MAX_RANK_CULPRITS {
3483 lines.push(format!(
3484 " … and {} more",
3485 rep.culprits.len() - MAX_RANK_CULPRITS
3486 ));
3487 }
3488 if !shown_any_eq {
3491 lines.push("inspect a row with `print equation <name>` to see its terms".to_string());
3492 }
3493 } else {
3494 lines.push("J_c has full row rank at this iterate.".to_string());
3495 }
3496
3497 let culprits_json: Vec<serde_json::Value> = rep
3498 .culprits
3499 .iter()
3500 .map(|c| {
3501 let row = &rep.rows[c.row];
3502 serde_json::json!({
3503 "row": c.row,
3504 "kind": row.kind.tag(),
3505 "index": row.index,
3506 "name": rank_row_name(row, names),
3507 "label": rank_row_label(row, names),
3508 "weight": c.weight,
3509 "equation": culprit_equation(row, names, equations),
3510 })
3511 })
3512 .collect();
3513
3514 let data = serde_json::json!({
3515 "iter": iter,
3516 "n_rows": m,
3517 "n_cols": n,
3518 "rank": rep.rank,
3519 "deficiency": rep.deficiency(),
3520 "rank_deficient": rep.is_rank_deficient(),
3521 "sigma_max": rep.sigma_max(),
3522 "sigma_min": rep.sigma_min(),
3523 "cond": cond_json(rep.cond),
3524 "tol": rep.tol,
3525 "singular_values": rep.singular_values,
3526 "culprits": culprits_json,
3527 });
3528
3529 (lines, data)
3530}
3531
3532fn culprit_equation(
3539 row: &RankRow,
3540 names: &Option<SplitNames>,
3541 equations: Option<&EquationBook>,
3542) -> Option<String> {
3543 let book = equations?;
3544 let name = rank_row_name(row, names)?;
3545 let i = book.resolve(&name)?;
3546 Some(book.equations.get(i)?.clone())
3547}
3548
3549fn rank_row_name(row: &RankRow, names: &Option<SplitNames>) -> Option<String> {
3554 let r = Residual {
3555 kind: row.kind,
3556 index: row.index,
3557 value: 0.0,
3558 };
3559 resid_name(&r, names).map(|s| s.to_string())
3560}
3561
3562fn rank_row_label(row: &RankRow, names: &Option<SplitNames>) -> String {
3565 match rank_row_name(row, names) {
3566 Some(name) => format!("{}[{}]", row.kind.tag(), name),
3567 None => format!("{}[{}]", row.kind.tag(), row.index),
3568 }
3569}
3570
3571fn fmt_cond(cond: f64) -> String {
3574 if cond.is_finite() {
3575 format!("{cond:.3e}")
3576 } else {
3577 "inf (σ_min = 0)".to_string()
3578 }
3579}
3580
3581fn cond_json(cond: f64) -> serde_json::Value {
3584 if cond.is_finite() {
3585 serde_json::json!(cond)
3586 } else {
3587 serde_json::Value::Null
3588 }
3589}
3590
3591fn resid_name<'a>(r: &Residual, names: &'a Option<SplitNames>) -> Option<&'a str> {
3592 let n = names.as_ref()?;
3593 let pool = match r.kind {
3594 ResidKind::Eq => &n.eq,
3595 ResidKind::Ineq | ResidKind::DualS => &n.ineq,
3596 ResidKind::DualX => &n.x_var,
3597 };
3598 pool.get(r.index).and_then(|o| o.as_deref())
3599}
3600
3601fn worst_named(resids: Vec<Residual>, names: &Option<SplitNames>) -> Option<(String, f64)> {
3605 let top = rank_residuals(resids, 1);
3606 let r = top.first()?;
3607 let label = match resid_name(r, names) {
3608 Some(name) => format!("{}[{}]", r.kind.tag(), name),
3609 None => format!("{}[{}]", r.kind.tag(), r.index),
3610 };
3611 Some((label, r.value))
3612}
3613
3614pub fn print_open_banner(mode: DebugMode) {
3618 if !matches!(mode, DebugMode::Repl) {
3619 return;
3620 }
3621 let color = std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none();
3622 let paint = |r: u8, g: u8, b: u8, bold: bool, s: &str| -> String {
3623 if color {
3624 let w = if bold { "1;" } else { "" };
3625 format!("\x1b[{w}38;2;{r};{g};{b}m{s}\x1b[0m")
3626 } else {
3627 s.to_string()
3628 }
3629 };
3630 let orange = |s: &str| paint(0xE8, 0x7A, 0x1E, true, s);
3632 let gold = |s: &str| paint(0xFF, 0xB0, 0x00, true, s);
3633 let dim = |s: &str| paint(0x7A, 0x7E, 0x88, false, s);
3634 let item = |key: &str, gloss: &str| format!("{} {}", orange(key), dim(gloss));
3636
3637 let err = std::io::stderr();
3638 let mut h = err.lock();
3639 let _ = writeln!(h);
3640 for row in crate::print::logo_rows(color) {
3643 let _ = writeln!(h, " {row}");
3644 }
3645 let _ = writeln!(h);
3646 let _ = writeln!(
3647 h,
3648 " {} {}",
3649 gold("interior-point debugger"),
3650 dim(&format!(
3651 "· pounce {} · pdb for the IPM",
3652 env!("CARGO_PKG_VERSION")
3653 ))
3654 );
3655 let _ = writeln!(h);
3656 let _ = writeln!(
3658 h,
3659 " {} {} {} {} {}",
3660 item("s", "step"),
3661 item("c", "continue"),
3662 item("b", "N break"),
3663 item("r", "N run"),
3664 item("q", "quit"),
3665 );
3666 let _ = writeln!(
3667 h,
3668 " {} {} {} {} {}",
3669 item("p", "x print"),
3670 item("i", "info"),
3671 item("set", "x[i] v"),
3672 item("watch", "x"),
3673 item("viz", "kkt"),
3674 );
3675 let _ = writeln!(
3676 h,
3677 " {} {} {}",
3678 dim("type"),
3679 gold("help"),
3680 dim("for all commands · `ask` to consult Claude · Ctrl-C breaks in"),
3681 );
3682 let _ = writeln!(h);
3683}
3684
3685fn is_pause_command(line: &str) -> bool {
3688 parse_command(line, DebugMode::Json).command.trim() == "pause"
3689}
3690
3691struct StdinPump {
3696 inner: std::sync::Arc<(
3697 std::sync::Mutex<VecDeque<Option<String>>>,
3698 std::sync::Condvar,
3699 )>,
3700}
3701
3702impl StdinPump {
3703 fn start() -> Self {
3704 let inner = std::sync::Arc::new((
3705 std::sync::Mutex::new(VecDeque::new()),
3706 std::sync::Condvar::new(),
3707 ));
3708 let w = std::sync::Arc::clone(&inner);
3709 std::thread::spawn(move || {
3710 use std::io::BufRead;
3711 let stdin = std::io::stdin();
3712 let mut lock = stdin.lock();
3713 let (m, cv) = &*w;
3714 loop {
3715 let mut line = String::new();
3716 let item = match lock.read_line(&mut line) {
3717 Ok(0) | Err(_) => None, Ok(_) => Some(line),
3719 };
3720 let done = item.is_none();
3721 m.lock()
3722 .unwrap_or_else(std::sync::PoisonError::into_inner)
3723 .push_back(item);
3724 cv.notify_one();
3725 if done {
3726 break;
3727 }
3728 }
3729 });
3730 Self { inner }
3731 }
3732
3733 fn next(&self) -> Option<String> {
3735 let (m, cv) = &*self.inner;
3736 let mut q = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
3737 loop {
3738 match q.front() {
3739 None => {
3740 q = cv
3741 .wait(q)
3742 .unwrap_or_else(std::sync::PoisonError::into_inner)
3743 }
3744 Some(None) => return None, Some(Some(_)) => return q.pop_front().flatten(),
3746 }
3747 }
3748 }
3749
3750 fn try_take_pause(&self) -> bool {
3753 let (m, _) = &*self.inner;
3754 let mut q = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
3755 if let Some(Some(front)) = q.front() {
3756 if is_pause_command(front) {
3757 q.pop_front();
3758 return true;
3759 }
3760 }
3761 false
3762 }
3763}
3764
3765impl DebugHook for SolverDebugger {
3766 fn wants_kkt_capture(&self) -> bool {
3770 !self.detached
3771 }
3772
3773 fn at_checkpoint(&mut self, ctx: &mut DebugCtx) -> DebugAction {
3774 if matches!(self.mode, DebugMode::Json) && !self.hello_sent {
3777 self.emit_hello();
3778 self.hello_sent = true;
3779 }
3780 if let Checkpoint::Terminated = ctx.checkpoint() {
3784 if self.sweep.is_some() {
3788 if let Some(action) = self.drive_sweep(ctx) {
3789 return action;
3790 }
3791 }
3792 let failed = ctx.status().map(|s| !is_success_status(s)).unwrap_or(false);
3793 let should =
3794 self.pause_terminal && !self.detached && (!self.terminal_only_on_error || failed);
3795 if !should {
3796 return DebugAction::Resume;
3797 }
3798 self.ensure_editor();
3799 self.emit_pause(ctx, None);
3800 return self.prompt_loop(ctx);
3801 }
3802
3803 let cp = ctx.checkpoint();
3804 match cp {
3806 Checkpoint::PreRestoration => self.in_restoration = true,
3807 Checkpoint::PostRestoration => self.in_restoration = false,
3808 _ => {}
3809 }
3810 let is_iter_start = matches!(cp, Checkpoint::IterStart);
3811
3812 if is_iter_start {
3816 if let Some(snap) = ctx.snapshot() {
3817 self.snapshots.insert(snap.iter(), snap);
3818 while self.snapshots.len() > SNAPSHOT_CAP {
3819 let Some(&oldest) = self.snapshots.keys().next() else {
3820 break;
3821 };
3822 self.snapshots.remove(&oldest);
3823 }
3824 }
3825 self.update_mu_stall(ctx.mu());
3827 }
3828
3829 let mut reason: Option<String> = None;
3833 let mut pause = self.sub_step || self.stop_at.contains(cp.as_str());
3834
3835 if let Some(ev) = self.matched_event(ctx) {
3839 pause = true;
3840 reason = Some(format!("event: {ev}"));
3841 }
3842
3843 if is_iter_start {
3844 if self.interruptible && interrupt::take() {
3845 pause = true;
3846 reason = Some("interrupt (Ctrl-C)".into());
3847 }
3848 if let Some(p) = self.pump.as_ref() {
3851 if p.try_take_pause() {
3852 pause = true;
3853 reason = Some("pause (requested)".into());
3854 }
3855 }
3856 if self.pause_iters {
3857 if self.should_pause(ctx.iter()) {
3858 pause = true;
3859 }
3860 if let Some(c) = self.matched_condition(ctx) {
3861 pause = true;
3862 reason = Some(c);
3863 }
3864 }
3865 if let Some(w) = self.matched_watchpoint(ctx) {
3868 pause = true;
3869 reason = Some(format!("watchpoint: {w}"));
3870 }
3871 }
3872
3873 if !pause {
3874 if is_iter_start && self.emit_progress && matches!(self.mode, DebugMode::Json) {
3878 self.emit_progress_event(ctx);
3879 }
3880 return DebugAction::Resume;
3881 }
3882 self.step = false;
3884 self.sub_step = false;
3885 self.emit_pause(ctx, reason.as_deref());
3886
3887 if is_iter_start {
3891 if let Some(cmds) = self.bp_commands.get(&ctx.iter()).cloned() {
3892 for c in cmds {
3893 let out = self.dispatch(&c, ctx);
3894 self.emit_result(&c, &out, None);
3895 match out.flow {
3896 Flow::Resume => return DebugAction::Resume,
3897 Flow::Stop => return DebugAction::Stop,
3898 Flow::Stay => {}
3899 }
3900 }
3901 }
3902 }
3903
3904 self.ensure_editor();
3905 self.prompt_loop(ctx)
3906 }
3907}
3908
3909impl SolverDebugger {
3910 fn prompt_loop(&mut self, ctx: &mut DebugCtx) -> DebugAction {
3912 if let Some(path) = self.pending_script.take() {
3915 let out = self.cmd_source(&[path.as_str()], ctx);
3916 self.emit_result("source", &out, None);
3917 match out.flow {
3918 Flow::Resume => return DebugAction::Resume,
3919 Flow::Stop => return DebugAction::Stop,
3920 Flow::Stay => {}
3921 }
3922 }
3923 loop {
3924 let line = match self.next_command_line() {
3925 Some(l) => l,
3926 None => {
3927 return match self.mode {
3932 DebugMode::Repl => {
3933 self.detached = true;
3934 DebugAction::Resume
3935 }
3936 DebugMode::Json => DebugAction::Stop,
3937 };
3938 }
3939 };
3940 let parsed = parse_command(&line, self.mode);
3941 let cmd = parsed.command.trim().to_string();
3942 if cmd.is_empty() {
3943 continue;
3944 }
3945 let out = self.dispatch(&cmd, ctx);
3946 self.emit_result(&cmd, &out, parsed.id.as_ref());
3947 match out.flow {
3948 Flow::Stay => continue,
3949 Flow::Resume => return DebugAction::Resume,
3950 Flow::Stop => return DebugAction::Stop,
3951 }
3952 }
3953 }
3954}
3955
3956struct ParsedCmd {
3960 command: String,
3961 id: Option<serde_json::Value>,
3962}
3963
3964fn tokenize_quoted(line: &str) -> Vec<String> {
3970 let mut out = Vec::new();
3971 let mut cur = String::new();
3972 let mut in_quote = false;
3973 let mut has_tok = false;
3974 for c in line.chars() {
3975 match c {
3976 '"' => {
3977 in_quote = !in_quote;
3978 has_tok = true; }
3980 c if c.is_whitespace() && !in_quote => {
3981 if has_tok {
3982 out.push(std::mem::take(&mut cur));
3983 has_tok = false;
3984 }
3985 }
3986 c => {
3987 cur.push(c);
3988 has_tok = true;
3989 }
3990 }
3991 }
3992 if has_tok {
3993 out.push(cur);
3994 }
3995 out
3996}
3997
3998fn parse_command(line: &str, mode: DebugMode) -> ParsedCmd {
4002 let trimmed = line.trim();
4003 if let DebugMode::Json = mode {
4004 if trimmed.starts_with('{') {
4005 if let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed) {
4006 let cmd = v.get("cmd").and_then(|c| c.as_str()).unwrap_or("");
4007 let mut s = cmd.to_string();
4008 if let Some(args) = v.get("args").and_then(|a| a.as_array()) {
4009 for a in args {
4010 s.push(' ');
4011 let tok = a
4012 .as_str()
4013 .map(str::to_string)
4014 .unwrap_or_else(|| a.to_string());
4015 if tok.contains(char::is_whitespace) {
4018 s.push('"');
4019 s.push_str(&tok);
4020 s.push('"');
4021 } else {
4022 s.push_str(&tok);
4023 }
4024 }
4025 }
4026 return ParsedCmd {
4027 command: s,
4028 id: v.get("id").cloned(),
4029 };
4030 }
4031 }
4032 }
4033 ParsedCmd {
4034 command: trimmed.to_string(),
4035 id: None,
4036 }
4037}
4038
4039fn emit_json(v: &serde_json::Value) {
4040 let stdout = std::io::stdout();
4041 let mut h = stdout.lock();
4042 let _ = writeln!(h, "{v}");
4043 let _ = h.flush();
4044}
4045
4046fn fmt_vec(name: &str, v: &[f64]) -> String {
4047 const MAX: usize = 12;
4048 if v.len() <= MAX {
4049 format!(
4050 "{name} = [{}]",
4051 v.iter()
4052 .map(|x| format!("{x:.6e}"))
4053 .collect::<Vec<_>>()
4054 .join(", ")
4055 )
4056 } else {
4057 let head = v[..MAX]
4058 .iter()
4059 .map(|x| format!("{x:.6e}"))
4060 .collect::<Vec<_>>()
4061 .join(", ");
4062 format!("{name} = [{head}, … ({} total)]", v.len())
4063 }
4064}
4065
4066fn type_str(t: OptionType) -> &'static str {
4067 match t {
4068 OptionType::OT_Number => "Number",
4069 OptionType::OT_Integer => "Integer",
4070 OptionType::OT_String => "String",
4071 OptionType::OT_Unknown => "Unknown",
4072 }
4073}
4074
4075fn default_str(d: &DefaultValue) -> String {
4076 match d {
4077 DefaultValue::None => "-".into(),
4078 DefaultValue::Number(v) => format!("{v}"),
4079 DefaultValue::Integer(v) => format!("{v}"),
4080 DefaultValue::String(s) => s.clone(),
4081 }
4082}
4083
4084fn write_and_open(label: &str, iter: i32, vals: &[f64]) -> Result<(String, String), String> {
4089 let payload = serde_json::json!({"label": label, "iter": iter, "values": vals});
4090 write_json_and_open(label, iter, &payload)
4091}
4092
4093fn build_ask_prompt(ctx: &DebugCtx, question: &str) -> String {
4096 use std::fmt::Write as _;
4097 let mut p = String::new();
4098 p.push_str(
4099 "You are helping debug a paused run of POUNCE, a pure-Rust port of the Ipopt \
4100 interior-point NLP solver. The solve is stopped at a debugger checkpoint. \
4101 Use the state below to answer concisely and suggest concrete next steps \
4102 (options to try, what to inspect). State:\n\n",
4103 );
4104 let _ = writeln!(p, "checkpoint = {}", ctx.checkpoint().as_str());
4105 if let Some(s) = ctx.status() {
4106 let _ = writeln!(p, "status = {s}");
4107 }
4108 let _ = writeln!(p, "iter = {}", ctx.iter());
4109 let _ = writeln!(p, "mu = {:.6e}", ctx.mu());
4110 let _ = writeln!(p, "objective = {:.8e}", ctx.objective());
4111 let _ = writeln!(p, "inf_pr = {:.6e}", ctx.inf_pr());
4112 let _ = writeln!(p, "inf_du = {:.6e}", ctx.inf_du());
4113 let _ = writeln!(p, "nlp_error = {:.6e}", ctx.nlp_error());
4114 let (ap, ad) = ctx.alpha();
4115 let _ = writeln!(p, "alpha_pr = {ap:.4e}, alpha_du = {ad:.4e}");
4116 let _ = writeln!(p, "ls_trials = {}", ctx.ls_count());
4117 let dims: Vec<String> = ctx
4118 .block_dims()
4119 .into_iter()
4120 .map(|(n, d)| format!("{n}:{d}"))
4121 .collect();
4122 let _ = writeln!(p, "dims = {}", dims.join(" "));
4123 if let Some(k) = ctx.kkt() {
4124 let _ = writeln!(
4125 p,
4126 "kkt = dim {} inertia n+={} n-={} (expected n-={}, {}) delta_w={:.3e} delta_c={:.3e} status={}",
4127 k.dim,
4128 k.n_pos,
4129 k.n_neg,
4130 k.expected_neg,
4131 if k.inertia_correct { "correct" } else { "WRONG" },
4132 k.delta_w,
4133 k.delta_c,
4134 k.status
4135 );
4136 }
4137 let _ = write!(p, "\nQuestion: {question}\n");
4138 p
4139}
4140
4141const LLM_PROVIDERS: &[&str] = &["claude", "codex", "gemini", "llm"];
4147
4148fn llm_preset(name: &str, prompt: &str) -> Option<(String, Vec<String>, bool)> {
4149 match name {
4150 "claude" => Some(("claude".to_string(), vec!["-p".to_string()], true)),
4152 "codex" => Some((
4154 "codex".to_string(),
4155 vec!["exec".to_string(), prompt.to_string()],
4156 false,
4157 )),
4158 "gemini" => Some((
4160 "gemini".to_string(),
4161 vec!["-p".to_string(), prompt.to_string()],
4162 false,
4163 )),
4164 "llm" => Some(("llm".to_string(), vec![prompt.to_string()], false)),
4166 _ => None,
4167 }
4168}
4169
4170fn llm_command(prompt: &str) -> (String, Vec<String>, bool) {
4176 let raw = std::env::var("POUNCE_DBG_LLM").unwrap_or_default();
4177 let tmpl = raw.trim();
4178 if tmpl.is_empty() {
4179 return llm_preset("claude", prompt).expect("claude is a known provider");
4181 }
4182 if !tmpl.contains(char::is_whitespace) {
4185 if let Some(preset) = llm_preset(tmpl, prompt) {
4186 return preset;
4187 }
4188 }
4189 let mut parts = tmpl
4191 .split_whitespace()
4192 .map(str::to_string)
4193 .collect::<Vec<_>>();
4194 let prog = parts.remove(0);
4195 let mut substituted = false;
4196 for a in parts.iter_mut() {
4197 if a.contains("{}") {
4198 *a = a.replace("{}", prompt);
4199 substituted = true;
4200 }
4201 }
4202 (prog, parts, !substituted)
4203}
4204
4205fn run_llm(prompt: &str) -> Result<String, String> {
4208 use std::io::Write as _;
4209 use std::process::{Command, Stdio};
4210 let (prog, args, on_stdin) = llm_command(prompt);
4211 let mut cmd = Command::new(&prog);
4212 cmd.args(&args)
4213 .stdout(Stdio::piped())
4214 .stderr(Stdio::piped());
4215 cmd.stdin(if on_stdin {
4216 Stdio::piped()
4217 } else {
4218 Stdio::null()
4219 });
4220 let mut child = cmd.spawn().map_err(|e| {
4221 if e.kind() == std::io::ErrorKind::NotFound {
4222 format!(
4226 "LLM CLI `{prog}` is not installed or not on PATH. Install it, \
4227 or set POUNCE_DBG_LLM to another provider \
4228 ({}) or a full command template (e.g. `my-llm --ask {{}}`).",
4229 LLM_PROVIDERS.join(" | ")
4230 )
4231 } else {
4232 format!("could not launch `{prog}`: {e}")
4233 }
4234 })?;
4235 if on_stdin {
4236 if let Some(mut si) = child.stdin.take() {
4238 let _ = si.write_all(prompt.as_bytes());
4239 }
4240 }
4241 let out = child
4242 .wait_with_output()
4243 .map_err(|e| format!("`{prog}` failed: {e}"))?;
4244 if !out.status.success() {
4245 let err = String::from_utf8_lossy(&out.stderr);
4246 return Err(format!(
4247 "`{prog}` exited with {}: {}",
4248 out.status,
4249 err.trim()
4250 ));
4251 }
4252 let reply = String::from_utf8_lossy(&out.stdout).trim().to_string();
4253 if reply.is_empty() {
4254 Err(format!("`{prog}` returned no output"))
4255 } else {
4256 Ok(reply)
4257 }
4258}
4259
4260fn write_json_and_open(
4263 label: &str,
4264 iter: i32,
4265 payload: &serde_json::Value,
4266) -> Result<(String, String), String> {
4267 let dir = std::env::temp_dir();
4268 let path = dir.join(format!("pounce-dbg-{label}-iter{iter}.json"));
4269 std::fs::write(&path, payload.to_string()).map_err(|e| format!("write failed: {e}"))?;
4270 let path_s = path.to_string_lossy().to_string();
4271
4272 let mut candidates: Vec<(String, Vec<String>, String)> = Vec::new();
4282 match std::env::var("POUNCE_DBG_VIEWER") {
4283 Ok(tmpl) if !tmpl.trim().is_empty() => {
4284 let mut parts = tmpl
4285 .split_whitespace()
4286 .map(String::from)
4287 .collect::<Vec<_>>();
4288 let prog = parts.remove(0);
4289 let mut replaced = false;
4290 for a in parts.iter_mut() {
4291 if a.contains("{}") {
4292 *a = a.replace("{}", &path_s);
4293 replaced = true;
4294 }
4295 }
4296 if !replaced {
4297 parts.push(path_s.clone());
4298 }
4299 candidates.push((prog, parts, path_s.clone()));
4300 }
4301 _ => {
4302 candidates.push((
4303 "pounce-dbg-viz".to_string(),
4304 vec![path_s.clone()],
4305 path_s.clone(),
4306 ));
4307 let opener = if cfg!(target_os = "macos") {
4308 "open"
4309 } else {
4310 "xdg-open"
4311 };
4312 let artifact = write_html_viz(label, iter, payload).unwrap_or_else(|_| path_s.clone());
4315 candidates.push((opener.to_string(), vec![artifact.clone()], artifact));
4316 }
4317 }
4318
4319 let mut last_err = String::new();
4320 for (program, args, artifact) in &candidates {
4321 match std::process::Command::new(program).args(args).spawn() {
4322 Ok(_) => return Ok((artifact.clone(), format!("{program} {}", args.join(" ")))),
4323 Err(e) => last_err = format!("`{program}`: {e}"),
4324 }
4325 }
4326 Err(format!(
4327 "wrote {path_s} but could not launch a viewer ({last_err}). \
4328 Install the interactive viewer (`pip install 'pounce-solver[viz]'`) \
4329 or set POUNCE_DBG_VIEWER, e.g. `python my_plot.py {{}}`."
4330 ))
4331}
4332
4333fn write_html_viz(label: &str, iter: i32, payload: &serde_json::Value) -> Result<String, String> {
4340 let dir = std::env::temp_dir();
4341 let path = dir.join(format!("pounce-dbg-{label}-iter{iter}.html"));
4342 let html = VIZ_HTML_TEMPLATE.replace("__PAYLOAD__", &payload.to_string());
4343 std::fs::write(&path, html).map_err(|e| format!("write failed: {e}"))?;
4344 Ok(path.to_string_lossy().to_string())
4345}
4346
4347const VIZ_HTML_TEMPLATE: &str = r##"<!doctype html>
4352<html lang="en"><head><meta charset="utf-8">
4353<title>pounce-dbg viz</title>
4354<style>
4355 html,body{margin:0;background:#0e1116;color:#d6dae0;
4356 font:13px/1.5 -apple-system,BlinkMacSystemFont,"SF Mono",Menlo,monospace}
4357 .wrap{padding:18px 20px;max-width:880px;margin:0 auto}
4358 h1{font-size:15px;margin:0 0 4px;font-weight:600}
4359 .sub{color:#7d8694;margin:0 0 12px}
4360 .stats{color:#9aa4b2;white-space:pre-wrap;margin:0 0 14px;
4361 background:#161b22;border:1px solid #21262d;border-radius:6px;padding:10px 12px}
4362 canvas{background:#161b22;border:1px solid #30363d;border-radius:6px;
4363 max-width:100%;height:auto;image-rendering:pixelated}
4364 .legend{margin-top:10px;color:#9aa4b2}
4365 .pos{color:#4ea1ff}.neg{color:#ff6b6b}.bad{color:#ff6b6b;font-weight:600}
4366 .ok{color:#56d364;font-weight:600}
4367</style></head><body><div class="wrap">
4368<h1 id="title">pounce-dbg</h1>
4369<div class="sub" id="sub"></div>
4370<div class="stats" id="stats"></div>
4371<canvas id="c" width="820" height="820"></canvas>
4372<div class="legend" id="legend"></div>
4373</div>
4374<script>
4375const D = __PAYLOAD__;
4376const cv = document.getElementById('c');
4377const ctx = cv.getContext('2d');
4378const $ = id => document.getElementById(id);
4379const fmt = x => (x===null||x===undefined) ? '—'
4380 : (Math.abs(x) >= 1e4 || (x!==0 && Math.abs(x) < 1e-3) ? x.toExponential(3) : (+x).toPrecision(6));
4381
4382function clearCanvas(){ ctx.fillStyle='#161b22'; ctx.fillRect(0,0,cv.width,cv.height); }
4383
4384function spy(irn, jcn, vals, dim, symmetric, title){
4385 $('sub').textContent = title;
4386 clearCanvas();
4387 const W=cv.width, H=cv.height, pad=42;
4388 const span=Math.max(1, dim);
4389 const cell=(Math.min(W,H)-2*pad)/span;
4390 const px=Math.max(0.7, cell);
4391 // frame + light grid ticks
4392 ctx.strokeStyle='#30363d'; ctx.lineWidth=1;
4393 ctx.strokeRect(pad-0.5, pad-0.5, span*cell+1, span*cell+1);
4394 ctx.fillStyle='#6e7681'; ctx.font='11px monospace';
4395 ctx.fillText('0', pad-12, pad+9);
4396 ctx.fillText(String(dim), pad+span*cell-8, pad-8);
4397 ctx.fillText('row', pad-34, pad+span*cell/2);
4398 ctx.fillText('col', pad+span*cell/2-8, pad-22);
4399 let nnz=0;
4400 for(let k=0;k<irn.length;k++){
4401 const i=irn[k]-1, j=jcn[k]-1, v=vals?vals[k]:1;
4402 ctx.fillStyle = v>=0 ? 'rgba(78,161,255,0.92)' : 'rgba(255,107,107,0.92)';
4403 ctx.fillRect(pad+j*cell, pad+i*cell, px, px); nnz++;
4404 if(symmetric && i!==j){ ctx.fillRect(pad+i*cell, pad+j*cell, px, px); nnz++; }
4405 }
4406 $('legend').innerHTML =
4407 `<span class="pos">■</span> positive <span class="neg">■</span> negative`
4408 + ` · ${dim}×${dim}, ${nnz} plotted nonzeros`
4409 + (symmetric ? ' (lower triangle mirrored)' : '');
4410}
4411
4412function bars(values, title){
4413 $('sub').textContent = title;
4414 clearCanvas();
4415 const W=cv.width, H=cv.height, pad=42;
4416 const n=values.length;
4417 const maxAbs=Math.max(1e-300, ...values.map(v=>Math.abs(v)));
4418 const x0=pad, y0=H-pad, plotW=W-2*pad, plotH=H-2*pad, mid=pad+plotH/2;
4419 const bw=Math.max(0.7, plotW/Math.max(1,n));
4420 // zero axis
4421 ctx.strokeStyle='#30363d'; ctx.beginPath();
4422 ctx.moveTo(pad, mid); ctx.lineTo(W-pad, mid); ctx.stroke();
4423 ctx.fillStyle='#6e7681'; ctx.font='11px monospace';
4424 ctx.fillText('+'+fmt(maxAbs), 4, pad+10);
4425 ctx.fillText('-'+fmt(maxAbs), 4, H-pad-2);
4426 ctx.fillText('0', 4, mid+4);
4427 for(let k=0;k<n;k++){
4428 const v=values[k], h=(Math.abs(v)/maxAbs)*(plotH/2);
4429 ctx.fillStyle = v>=0 ? 'rgba(78,161,255,0.92)' : 'rgba(255,107,107,0.92)';
4430 if(v>=0) ctx.fillRect(pad+k*bw, mid-h, bw, h);
4431 else ctx.fillRect(pad+k*bw, mid, bw, h);
4432 }
4433 $('legend').innerHTML = `${n} components · max |val| = ${fmt(maxAbs)}`;
4434}
4435
4436const lbl = D.label || 'viz';
4437const iter = (D.iter!==undefined) ? D.iter : '?';
4438$('title').textContent = `pounce-dbg · viz ${lbl} · iter ${iter}`;
4439
4440if(D.matrix && D.matrix.irn){
4441 const m=D.matrix;
4442 const inertia = (D.inertia_correct===false)
4443 ? `<span class="bad">WRONG</span>` : `<span class="ok">correct</span>`;
4444 $('stats').innerHTML =
4445 `KKT augmented system dim=${D.dim}\n`+
4446 `inertia n+=${D.n_pos} n-=${D.n_neg} (expected n-=${D.expected_neg}, ${inertia})\n`+
4447 `regularization delta_w=${fmt(D.delta_w)} delta_c=${fmt(D.delta_c)}\n`+
4448 `factorization status: ${D.status}`;
4449 spy(m.irn, m.jcn, m.vals, m.dim, true, 'sparsity pattern (sign-colored)');
4450} else if(D.l_irn){
4451 $('stats').textContent =
4452 `LDLᵀ factor n=${D.n} nnz(L)=${D.l_irn.length} format=${D.format||''}`;
4453 spy(D.l_irn, D.l_jcn, D.l_vals, D.n, false, 'L factor sparsity (permuted, strict lower)');
4454} else if(D.values){
4455 $('stats').textContent = `vector ${lbl} length=${D.values.length}`;
4456 bars(D.values, 'component magnitudes (zero-centered)');
4457} else {
4458 $('stats').textContent = 'unrecognized payload — raw JSON:\n'+JSON.stringify(D,null,2);
4459}
4460</script></body></html>
4461"##;
4462
4463#[cfg(test)]
4464mod tests {
4465 use super::*;
4466
4467 fn dbg(mode: DebugMode) -> SolverDebugger {
4468 SolverDebugger::new(mode, None)
4469 }
4470
4471 #[test]
4472 fn json_command_object_is_flattened() {
4473 assert_eq!(
4474 parse_command("{\"cmd\":\"print x\"}", DebugMode::Json).command,
4475 "print x"
4476 );
4477 let p = parse_command(
4478 "{\"cmd\":\"set\",\"args\":[\"x[0]\",\"1.5\"],\"id\":7}",
4479 DebugMode::Json,
4480 );
4481 assert_eq!(p.command, "set x[0] 1.5");
4482 assert_eq!(p.id, Some(serde_json::json!(7)));
4484 let s = parse_command("step\n", DebugMode::Json);
4486 assert_eq!(s.command, "step");
4487 assert!(s.id.is_none());
4488 assert_eq!(
4489 parse_command(" print x \n", DebugMode::Repl).command,
4490 "print x"
4491 );
4492 }
4493
4494 #[test]
4495 fn pauses_at_first_checkpoint_then_only_when_rearmed() {
4496 let mut d = dbg(DebugMode::Repl);
4497 assert!(d.should_pause(0));
4499 d.step = false;
4501 assert!(!d.should_pause(1));
4502 assert!(!d.should_pause(2));
4503 }
4504
4505 #[test]
4506 fn breakpoints_and_run_to_arm_pauses() {
4507 let mut d = dbg(DebugMode::Repl);
4508 d.step = false;
4509 d.breaks = vec![3, 7];
4510 assert!(!d.should_pause(2));
4511 assert!(d.should_pause(3));
4512 assert!(d.should_pause(7));
4513 d.run_to = Some(5);
4515 assert!(!d.should_pause(4));
4516 assert!(d.should_pause(5));
4517 assert_eq!(d.run_to, None);
4518 assert!(!d.should_pause(6));
4519 }
4520
4521 #[test]
4522 fn atom_parses_metric_op_threshold() {
4523 let a = Atom::parse("mu<1e-4").unwrap();
4524 assert_eq!(a.metric, Metric::Mu);
4525 assert_eq!(a.op, CmpOp::Lt);
4526 assert_eq!(a.rhs, 1e-4);
4527
4528 let a = Atom::parse("inf_pr<=1e-6").unwrap();
4530 assert_eq!(a.metric, Metric::InfPr);
4531 assert_eq!(a.op, CmpOp::Le);
4532
4533 let a = Atom::parse("iter==10").unwrap();
4534 assert_eq!(a.metric, Metric::Iter);
4535 assert_eq!(a.op, CmpOp::Eq);
4536 assert_eq!(a.rhs, 10.0);
4537 }
4538
4539 #[test]
4540 fn atom_parse_rejects_garbage() {
4541 assert!(Atom::parse("inf_pr 1e-6").is_err()); assert!(Atom::parse("bogus<1").is_err()); assert!(Atom::parse("mu<abc").is_err()); }
4545
4546 #[test]
4547 fn compound_condition_parses_and_evaluates_left_to_right() {
4548 let c = Condition::parse("mu<1e-4&&inf_pr>1e-3").unwrap();
4550 assert_eq!(c.rest.len(), 1);
4551 assert_eq!(c.rest[0].0, Join::And);
4552
4553 let c = Condition::parse("iter>10&&(inf_du>1e-2||obj<0)").unwrap();
4555 assert_eq!(c.rest.len(), 2);
4556 assert_eq!(c.rest[0].0, Join::And);
4557 assert_eq!(c.rest[1].0, Join::Or);
4558 assert_eq!(c.raw, "iter>10&&inf_du>1e-2||obj<0");
4559
4560 assert!(Condition::parse("mu<1e-4&&bogus>0").is_err());
4562 }
4563
4564 #[test]
4565 fn completion_is_context_sensitive() {
4566 let c = completion_candidates(None, "", "co");
4568 assert!(c.contains(&"continue".to_string()));
4569 assert!(c.contains(&"complete".to_string()));
4570 assert!(!c.contains(&"step".to_string()));
4571
4572 let c = completion_candidates(None, "set ", "");
4574 assert!(c.contains(&"mu".to_string()));
4575 assert!(c.contains(&"opt".to_string()));
4576 assert!(c.contains(&"x".to_string()));
4577
4578 let c = completion_candidates(None, "break if ", "inf");
4580 assert!(c.contains(&"inf_pr".to_string()));
4581 assert!(c.contains(&"inf_du".to_string()));
4582 assert!(!c.contains(&"mu".to_string()));
4583
4584 let c = completion_candidates(None, "print ", "");
4586 assert!(c.contains(&"x".to_string()));
4587 assert!(c.contains(&"obj".to_string()));
4588 }
4589
4590 #[test]
4591 fn cmp_op_truth_table() {
4592 assert!(CmpOp::Lt.eval(1.0, 2.0));
4593 assert!(!CmpOp::Lt.eval(2.0, 2.0));
4594 assert!(CmpOp::Le.eval(2.0, 2.0));
4595 assert!(CmpOp::Gt.eval(3.0, 2.0));
4596 assert!(CmpOp::Ge.eval(2.0, 2.0));
4597 assert!(CmpOp::Eq.eval(2.0, 2.0));
4598 assert!(!CmpOp::Eq.eval(2.0, 2.5));
4599 }
4600
4601 #[test]
4602 fn interrupt_is_consumed_once() {
4603 interrupt::set_pending_for_test();
4604 assert!(interrupt::take(), "first take sees the pending Ctrl-C");
4605 assert!(!interrupt::take(), "second take is clear (consumed once)");
4606 }
4607
4608 #[test]
4609 fn on_interrupt_constructor_runs_free_but_interruptible() {
4610 let d = SolverDebugger::on_interrupt(DebugMode::Repl, None);
4611 assert!(!d.pause_iters, "on-interrupt does not pause each iter");
4612 assert!(!d.pause_terminal, "on-interrupt does not pause at terminal");
4613 assert!(d.interruptible, "on-interrupt honors Ctrl-C");
4614 assert!(!d.step, "on-interrupt starts un-armed");
4615 }
4616
4617 #[test]
4618 fn coffee_easter_egg_prints_art_but_stays_hidden() {
4619 let d = SolverDebugger::new(DebugMode::Repl, None);
4620 let out = d.cmd_coffee();
4621 assert!(out.ok);
4622 assert!(out.lines.len() > 5, "multi-line art");
4623 assert!(
4624 out.lines.iter().any(|l| l.contains("COFFEE")),
4625 "the mug says COFFEE"
4626 );
4627 assert!(
4629 !COMMANDS.contains(&"coffee"),
4630 "hidden from help/complete/Tab"
4631 );
4632 assert!(
4634 out.lines.iter().all(|l| !l.contains('\x1b')),
4635 "no color when stderr isn't a TTY"
4636 );
4637 }
4638
4639 #[test]
4640 fn double_ctrl_c_at_prompt_quits_single_cancels_line() {
4641 let mut d = SolverDebugger::new(DebugMode::Repl, None);
4642 assert_eq!(d.on_prompt_interrupt(), "");
4644 assert_eq!(d.on_prompt_interrupt(), "quit");
4646 assert_eq!(d.on_prompt_interrupt(), "");
4648 d.prompt_interrupts = 0;
4651 assert_eq!(d.on_prompt_interrupt(), "", "fresh streak after a command");
4652 }
4653
4654 #[test]
4655 fn stop_at_accepts_names_and_aliases() {
4656 let mut d = SolverDebugger::new(DebugMode::Repl, None);
4657 assert!(d.cmd_stop_at(&["after_search_dir"]).ok);
4658 assert!(d.stop_at.contains("after_search_dir"));
4659 assert!(d.cmd_stop_at(&["mu"]).ok);
4661 assert!(d.stop_at.contains("after_mu"));
4662 assert!(d.cmd_stop_at(&["kkt"]).ok);
4663 assert!(d.stop_at.contains("after_search_dir"));
4664 assert!(!d.cmd_stop_at(&["bogus"]).ok);
4666 assert!(d.cmd_stop_at(&["clear"]).ok);
4668 assert!(d.stop_at.is_empty());
4669 }
4670
4671 #[test]
4672 fn llm_command_defaults_and_overrides() {
4673 std::env::remove_var("POUNCE_DBG_LLM");
4675 let (prog, args, on_stdin) = llm_command("hi");
4676 assert_eq!(prog, "claude");
4677 assert_eq!(args, vec!["-p".to_string()]);
4678 assert!(on_stdin);
4679
4680 std::env::set_var("POUNCE_DBG_LLM", "mytool --ask {}");
4682 let (prog, args, on_stdin) = llm_command("why");
4683 assert_eq!(prog, "mytool");
4684 assert_eq!(args, vec!["--ask".to_string(), "why".to_string()]);
4685 assert!(!on_stdin);
4686
4687 std::env::set_var("POUNCE_DBG_LLM", "llm -m gpt");
4689 let (_, _, on_stdin) = llm_command("q");
4690 assert!(on_stdin);
4691
4692 std::env::set_var("POUNCE_DBG_LLM", "codex");
4696 let (prog, args, on_stdin) = llm_command("why is mu stuck");
4697 assert_eq!(prog, "codex");
4698 assert_eq!(
4699 args,
4700 vec!["exec".to_string(), "why is mu stuck".to_string()]
4701 );
4702 assert!(!on_stdin); std::env::set_var("POUNCE_DBG_LLM", "gemini");
4705 let (prog, args, _) = llm_command("q");
4706 assert_eq!(prog, "gemini");
4707 assert_eq!(args, vec!["-p".to_string(), "q".to_string()]);
4708
4709 std::env::set_var("POUNCE_DBG_LLM", "llm");
4710 let (prog, args, _) = llm_command("q");
4711 assert_eq!(prog, "llm");
4712 assert_eq!(args, vec!["q".to_string()]);
4713
4714 std::env::set_var("POUNCE_DBG_LLM", "claude");
4717 let (prog, args, on_stdin) = llm_command("q");
4718 assert_eq!(prog, "claude");
4719 assert_eq!(args, vec!["-p".to_string()]);
4720 assert!(on_stdin);
4721
4722 std::env::set_var("POUNCE_DBG_LLM", "mytool");
4725 let (prog, args, on_stdin) = llm_command("q");
4726 assert_eq!(prog, "mytool");
4727 assert!(args.is_empty());
4728 assert!(on_stdin);
4729
4730 std::env::set_var("POUNCE_DBG_LLM", "pounce-no-such-llm-xyz");
4733 let err = run_llm("hello").unwrap_err();
4734 assert!(err.contains("not installed or not on PATH"), "{err}");
4735 assert!(err.contains("codex"), "{err}");
4736
4737 std::env::remove_var("POUNCE_DBG_LLM");
4738 }
4739
4740 #[test]
4741 fn detach_disables_all_pausing() {
4742 let mut d = dbg(DebugMode::Repl);
4743 d.detached = true;
4744 d.step = true;
4745 d.breaks = vec![1];
4746 assert!(!d.should_pause(0));
4747 assert!(!d.should_pause(1));
4748 }
4749
4750 #[test]
4751 fn kkt_capture_tracks_attached_state() {
4752 let mut d = dbg(DebugMode::Repl);
4755 assert!(d.wants_kkt_capture());
4756 d.detached = true;
4757 assert!(!d.wants_kkt_capture());
4758 }
4759
4760 fn resid(kind: ResidKind, index: usize, value: f64) -> Residual {
4761 Residual { kind, index, value }
4762 }
4763
4764 #[test]
4765 fn rank_residuals_sorts_by_magnitude_and_truncates() {
4766 use ResidKind::*;
4767 let entries = vec![
4768 resid(Eq, 0, -0.5),
4769 resid(Ineq, 1, 3.0),
4770 resid(DualX, 2, -7.0),
4771 resid(DualS, 3, 1.0),
4772 ];
4773 let top = rank_residuals(entries, 2);
4774 assert_eq!(top.len(), 2);
4775 assert_eq!(top[0].value, -7.0);
4777 assert_eq!(top[0].kind, DualX);
4778 assert_eq!(top[1].value, 3.0);
4779 assert_eq!(top[1].kind, Ineq);
4780 }
4781
4782 #[test]
4783 fn rank_residuals_k_zero_and_k_over_len() {
4784 use ResidKind::*;
4785 let entries = vec![resid(Eq, 0, 1.0), resid(Ineq, 1, 2.0)];
4786 assert!(rank_residuals(entries.clone(), 0).is_empty());
4787 let all = rank_residuals(entries, 99);
4789 assert_eq!(all.len(), 2);
4790 assert_eq!(all[0].value, 2.0);
4791 }
4792
4793 #[test]
4794 fn rank_residuals_is_stable_on_magnitude_ties() {
4795 use ResidKind::*;
4796 let entries = vec![
4798 resid(Ineq, 5, -2.0),
4799 resid(Eq, 1, 2.0),
4800 resid(DualX, 9, -2.0),
4801 ];
4802 let top = rank_residuals(entries, 3);
4803 assert_eq!(
4804 top.iter().map(|r| r.kind).collect::<Vec<_>>(),
4805 vec![Ineq, Eq, DualX]
4806 );
4807 }
4808
4809 fn split_names_fixture() -> SplitNames {
4810 SplitNames {
4811 x_var: vec![Some("T_reactor".into()), None],
4812 eq: vec![Some("mass_balance".into()), Some("energy_balance".into())],
4813 ineq: vec![Some("pressure_cap".into())],
4814 }
4815 }
4816
4817 #[test]
4818 fn resid_name_maps_each_kind_to_its_pool() {
4819 use ResidKind::*;
4820 let names = Some(split_names_fixture());
4821 assert_eq!(
4824 resid_name(&resid(Eq, 1, 0.0), &names),
4825 Some("energy_balance")
4826 );
4827 assert_eq!(
4828 resid_name(&resid(Ineq, 0, 0.0), &names),
4829 Some("pressure_cap")
4830 );
4831 assert_eq!(
4832 resid_name(&resid(DualS, 0, 0.0), &names),
4833 Some("pressure_cap")
4834 );
4835 assert_eq!(resid_name(&resid(DualX, 0, 0.0), &names), Some("T_reactor"));
4836 assert_eq!(resid_name(&resid(DualX, 1, 0.0), &names), None);
4838 assert_eq!(resid_name(&resid(Eq, 9, 0.0), &names), None);
4839 assert_eq!(resid_name(&resid(Eq, 0, 0.0), &None), None);
4841 }
4842
4843 #[test]
4844 fn worst_named_picks_largest_and_labels_it() {
4845 use ResidKind::*;
4846 let names = Some(split_names_fixture());
4847 let resids = vec![resid(Eq, 0, 0.5), resid(Eq, 1, -3.2), resid(Ineq, 0, 1.1)];
4849 assert_eq!(
4850 worst_named(resids, &names),
4851 Some(("c[energy_balance]".to_string(), -3.2))
4852 );
4853 let resids = vec![resid(DualX, 7, 9.0)];
4855 assert_eq!(
4856 worst_named(resids, &None),
4857 Some(("grad_x_L[7]".to_string(), 9.0))
4858 );
4859 assert_eq!(worst_named(vec![], &names), None);
4861 }
4862
4863 use pounce_algorithm::debug_rank::RankCulprit;
4864
4865 fn rank_report_fixture() -> RankReport {
4866 RankReport {
4869 rows: vec![
4870 RankRow {
4871 kind: ResidKind::Eq,
4872 index: 0,
4873 },
4874 RankRow {
4875 kind: ResidKind::Eq,
4876 index: 1,
4877 },
4878 ],
4879 n_cols: 3,
4880 singular_values: vec![2.0, 0.0],
4881 tol: 1e-15,
4882 rank: 1,
4883 cond: f64::INFINITY,
4884 culprits: vec![
4885 RankCulprit {
4886 row: 0,
4887 weight: 0.5,
4888 },
4889 RankCulprit {
4890 row: 1,
4891 weight: 0.5,
4892 },
4893 ],
4894 }
4895 }
4896
4897 #[test]
4898 fn render_rank_report_names_culprits_and_builds_json() {
4899 let names = Some(split_names_fixture());
4900 let rep = rank_report_fixture();
4901 let (lines, data) = render_rank_report(&rep, &names, None, 7);
4903
4904 let text = lines.join("\n");
4905 assert!(text.contains("2 row(s) × 3 column(s)"), "{text}");
4906 assert!(text.contains("numerical rank = 1 / 2"), "{text}");
4907 assert!(text.contains("inf (σ_min = 0)"), "{text}");
4909 assert!(text.contains("c[mass_balance]"), "{text}");
4911 assert!(text.contains("c[energy_balance]"), "{text}");
4912 assert!(text.contains("participation 0.50"), "{text}");
4913 assert!(text.contains("print equation"), "{text}");
4915
4916 assert_eq!(data["iter"], 7);
4919 assert_eq!(data["rank"], 1);
4920 assert_eq!(data["deficiency"], 1);
4921 assert_eq!(data["rank_deficient"], true);
4922 assert!(data["cond"].is_null(), "non-finite cond ⇒ null: {data}");
4923 assert_eq!(data["culprits"][0]["name"], "mass_balance");
4924 assert_eq!(data["culprits"][0]["label"], "c[mass_balance]");
4925 assert!(data["culprits"][0]["equation"].is_null());
4926 assert_eq!(data["culprits"][1]["name"], "energy_balance");
4927 }
4928
4929 #[test]
4930 fn render_rank_report_prints_culprit_equations_inline() {
4931 let names = Some(split_names_fixture());
4932 let rep = rank_report_fixture();
4933 let book = EquationBook::new(
4936 vec!["mass_balance".into(), "energy_balance".into()],
4937 vec![
4938 "x[0] + x[1] - 10 = 0".into(),
4939 "T_reactor*flow - Q = 0".into(),
4940 ],
4941 );
4942 let (lines, data) = render_rank_report(&rep, &names, Some(&book), 7);
4943
4944 let text = lines.join("\n");
4945 assert!(text.contains("x[0] + x[1] - 10 = 0"), "{text}");
4948 assert!(text.contains("T_reactor*flow - Q = 0"), "{text}");
4949 assert!(!text.contains("inspect a row with"), "{text}");
4951
4952 assert_eq!(data["culprits"][0]["equation"], "x[0] + x[1] - 10 = 0");
4954 assert_eq!(data["culprits"][1]["equation"], "T_reactor*flow - Q = 0");
4955 }
4956
4957 #[test]
4958 fn render_rank_report_full_rank_reports_positive_signal() {
4959 let rep = RankReport {
4960 rows: vec![
4961 RankRow {
4962 kind: ResidKind::Eq,
4963 index: 0,
4964 },
4965 RankRow {
4966 kind: ResidKind::Eq,
4967 index: 1,
4968 },
4969 ],
4970 n_cols: 3,
4971 singular_values: vec![2.0, 1.0],
4972 tol: 1e-15,
4973 rank: 2,
4974 cond: 2.0,
4975 culprits: vec![],
4976 };
4977 let (lines, data) = render_rank_report(&rep, &None, None, 3);
4978 let text = lines.join("\n");
4979 assert!(text.contains("full row rank"), "{text}");
4980 assert!(!text.contains("rank-deficient"), "{text}");
4981 assert_eq!(data["rank_deficient"], false);
4982 assert_eq!(data["cond"], 2.0);
4983 assert_eq!(data["culprits"].as_array().map(|a| a.len()), Some(0));
4984 }
4985
4986 #[test]
4987 fn print_equation_resolves_by_name_index_and_errors() {
4988 let mut d = dbg(DebugMode::Repl);
4989 let out = d.cmd_print_equation(&[]);
4991 assert!(!out.ok);
4992 assert!(out.lines[0].contains("needs an .nl model"));
4993
4994 d.set_equation_book(EquationBook::new(
4995 vec!["mass_balance".into(), String::new()],
4996 vec!["x[0] + x[1] = 10".into(), "x[0] - x[1] <= 2".into()],
4997 ));
4998
4999 let out = d.cmd_print_equation(&[]);
5001 assert!(out.ok);
5002 assert!(out.lines[0].contains("2 constraint equation"));
5003
5004 let out = d.cmd_print_equation(&["mass_balance"]);
5006 assert!(out.ok);
5007 assert_eq!(out.lines[0], "mass_balance: x[0] + x[1] = 10");
5008
5009 let out = d.cmd_print_equation(&["1"]);
5011 assert!(out.ok);
5012 assert_eq!(out.lines[0], "c[1]: x[0] - x[1] <= 2");
5013
5014 let out = d.cmd_print_equation(&["nope"]);
5016 assert!(!out.ok);
5017 assert!(out.lines[0].contains("no constraint named or indexed"));
5018 }
5019
5020 fn eq_inc(n_vars: usize, eq_row_inner_idx: Vec<usize>, rows: &[&[usize]]) -> EqualityIncidence {
5024 let mut adj_ptr = vec![0usize];
5025 let mut vars = Vec::new();
5026 for r in rows {
5027 let mut v = r.to_vec();
5028 v.sort_unstable();
5029 v.dedup();
5030 vars.extend_from_slice(&v);
5031 adj_ptr.push(vars.len());
5032 }
5033 EqualityIncidence {
5034 n_vars,
5035 eq_row_inner_idx,
5036 adj_ptr,
5037 vars,
5038 }
5039 }
5040
5041 #[test]
5042 fn structural_singularity_names_overdetermined_equations() {
5043 let inc = eq_inc(2, vec![0, 1, 2], &[&[0, 1], &[0, 1], &[0, 1]]);
5049 let book = StructureBook::new(
5050 inc,
5051 vec!["balance_a".into(), "balance_b".into(), "balance_c".into()],
5052 vec!["flow".into(), "temp".into()],
5053 );
5054 let f = book.findings();
5055 assert_eq!(f.len(), 1);
5056 let (sev, code, msg) = &f[0];
5057 assert_eq!(*sev, "warning");
5058 assert_eq!(*code, "structural_singularity");
5059 assert!(msg.contains("balance_a"), "msg: {msg}");
5060 assert!(msg.contains("balance_b"), "msg: {msg}");
5061 assert!(msg.contains("balance_c"), "msg: {msg}");
5062 assert!(msg.contains("flow") && msg.contains("temp"), "msg: {msg}");
5063 assert!(msg.contains("≥1"), "msg: {msg}");
5064 }
5065
5066 #[test]
5067 fn structural_findings_silent_when_well_posed_and_fall_back_to_indices() {
5068 let inc = eq_inc(2, vec![0, 1], &[&[0], &[1]]);
5072 let book = StructureBook::new(inc, vec![], vec![]);
5073 assert!(book.findings().is_empty());
5074
5075 let inc = eq_inc(1, vec![0, 1, 3], &[&[0], &[0], &[0]]);
5079 let book = StructureBook::new(inc, vec![], vec![]);
5080 let f = book.findings();
5081 assert_eq!(f.len(), 1);
5082 let msg = &f[0].2;
5083 assert!(
5084 msg.contains("c[0]") && msg.contains("c[1]") && msg.contains("c[3]"),
5085 "msg: {msg}"
5086 );
5087 }
5088
5089 #[test]
5090 fn structural_singularity_handles_empty_row_with_no_variables() {
5091 let inc = eq_inc(1, vec![0, 1], &[&[0], &[]]);
5094 let book = StructureBook::new(inc, vec!["real".into(), "ghost".into()], vec!["x".into()]);
5095 let f = book.findings();
5096 assert_eq!(f.len(), 1);
5097 let msg = &f[0].2;
5098 assert!(msg.contains("ghost"), "msg: {msg}");
5099 assert!(msg.contains("no variables"), "msg: {msg}");
5100 }
5101
5102 #[test]
5103 fn parse_floats_accepts_commas_whitespace_and_newlines() {
5104 assert_eq!(parse_floats("1, 2 ,3").unwrap(), vec![1.0, 2.0, 3.0]);
5105 assert_eq!(parse_floats("1\n2\n-3.5").unwrap(), vec![1.0, 2.0, -3.5]);
5106 assert_eq!(parse_floats(" 1.0 2e-1 ").unwrap(), vec![1.0, 0.2]);
5107 assert!(parse_floats("1, nope, 3").is_err());
5108 assert_eq!(parse_floats("").unwrap(), Vec::<f64>::new());
5109 }
5110
5111 #[test]
5112 fn jitter_start_zero_is_the_unperturbed_base_and_is_deterministic() {
5113 let base = vec![1.0, -2.0, 0.0];
5114 assert_eq!(jitter(&base, 0.1, 0), base);
5116 let a = jitter(&base, 0.1, 1);
5118 let b = jitter(&base, 0.1, 1);
5119 assert_eq!(a, b);
5120 assert_ne!(a, base);
5121 for (j, (&p, &x)) in a.iter().zip(&base).enumerate() {
5122 let bound = 0.1 * (x.abs() + 1.0);
5123 assert!(
5124 (p - x).abs() <= bound + 1e-12,
5125 "component {j} moved {} > bound {bound}",
5126 (p - x).abs()
5127 );
5128 }
5129 assert_ne!(jitter(&base, 0.1, 1), jitter(&base, 0.1, 2));
5131 }
5132
5133 #[test]
5134 fn sample_start_draws_inside_finite_boxes_and_jitters_unbounded() {
5135 let base = vec![1.0, 1.0, 0.5];
5136 let lo = vec![0.0, 0.0, -1.0];
5138 let hi = vec![2.0, f64::INFINITY, 1.0];
5139 let b = Some((lo.as_slice(), hi.as_slice()));
5140 assert_eq!(sample_start(&base, b, 0.1, 0), base);
5142 for k in 1..50 {
5143 let s = sample_start(&base, b, 0.1, k);
5144 assert!((0.0..=2.0).contains(&s[0]), "var0 {} out of [0,2]", s[0]);
5146 assert!((-1.0..=1.0).contains(&s[2]), "var2 {} out of [-1,1]", s[2]);
5147 let bound = 0.1 * (base[1].abs() + 1.0);
5149 assert!(
5150 (s[1] - base[1]).abs() <= bound + 1e-12,
5151 "var1 jitter exceeded"
5152 );
5153 }
5154 assert_eq!(
5156 sample_start(&base, b, 0.1, 7),
5157 sample_start(&base, b, 0.1, 7)
5158 );
5159 }
5160
5161 #[test]
5162 fn path_completion_lists_matching_files_with_dir_prefix() {
5163 let dir = std::env::temp_dir().join("pounce_dbg_complete_test");
5164 let _ = std::fs::remove_dir_all(&dir);
5165 std::fs::create_dir_all(&dir).unwrap();
5166 std::fs::write(dir.join("starts.txt"), "0,0\n").unwrap();
5167 std::fs::write(dir.join("start2.txt"), "1,1\n").unwrap();
5168 std::fs::write(dir.join("other.json"), "{}").unwrap();
5169 std::fs::create_dir_all(dir.join("subdir")).unwrap();
5170
5171 let p = dir.to_string_lossy().to_string();
5172 let mut got = path_candidates(&format!("{p}/start"));
5174 got.sort();
5175 assert_eq!(
5176 got,
5177 vec![format!("{p}/start2.txt"), format!("{p}/starts.txt")]
5178 );
5179 let got = path_candidates(&format!("{p}/sub"));
5181 assert_eq!(got, vec![format!("{p}/subdir/")]);
5182 assert_eq!(path_candidates(&format!("{p}/")).len(), 4);
5184 assert!(completion_candidates(None, "load", &format!("{p}/star"))
5186 .iter()
5187 .all(|c| c.contains("start")));
5188
5189 let _ = std::fs::remove_dir_all(&dir);
5190 }
5191}