1use crate::cli::DebugMode;
44use pounce_algorithm::debug::{
45 BLOCK_NAMES, DebugCtx, IterateSnapshot, ResidKind, Residual, is_live_tolerance,
46};
47use pounce_algorithm::debug_rank::{RankReport, RankRow};
48use pounce_common::debug::{Checkpoint, DebugAction, DebugHook, DebugState};
49use pounce_common::reg_options::{DefaultValue, OptionType, RegisteredOptions};
50use pounce_nlp::ipopt_nlp::SplitNames;
51use pounce_presolve::dulmage_mendelsohn::DulmageMendelsohnPartition;
52use pounce_presolve::incidence::EqualityIncidence;
53use pounce_presolve::matching::hopcroft_karp;
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: &dyn DebugState) -> f64 {
416 match self {
417 Metric::Mu => ctx.mu(),
418 Metric::InfPr => ctx.inf_pr(),
419 Metric::InfDu => ctx.inf_du(),
420 Metric::Obj => ctx.objective(),
421 Metric::NlpError => ctx.nlp_error(),
422 Metric::Compl => ctx.complementarity(),
423 Metric::Iter => ctx.iter() as f64,
424 }
425 }
426}
427
428fn metric_fields(ctx: &dyn DebugState) -> Vec<(&'static str, serde_json::Value)> {
444 METRICS
445 .iter()
446 .map(|&name| {
447 let value = if name == "iter" {
448 serde_json::json!(ctx.iter())
449 } else {
450 let metric = Metric::parse(name)
451 .expect("every METRICS entry must have a matching Metric arm");
452 serde_json::json!(metric.eval(ctx))
453 };
454 (name, value)
455 })
456 .collect()
457}
458
459fn insert_metric_fields(ev: &mut serde_json::Value, ctx: &dyn DebugState) {
461 if let serde_json::Value::Object(map) = ev {
462 for (name, value) in metric_fields(ctx) {
463 map.insert(name.to_string(), value);
464 }
465 }
466}
467
468#[derive(Clone, Copy, Debug, PartialEq, Eq)]
470enum CmpOp {
471 Lt,
472 Le,
473 Gt,
474 Ge,
475 Eq,
476}
477
478impl CmpOp {
479 fn eval(self, lhs: f64, rhs: f64) -> bool {
480 match self {
481 CmpOp::Lt => lhs < rhs,
482 CmpOp::Le => lhs <= rhs,
483 CmpOp::Gt => lhs > rhs,
484 CmpOp::Ge => lhs >= rhs,
485 CmpOp::Eq => (lhs - rhs).abs() <= 1e-12 * rhs.abs().max(1.0),
491 }
492 }
493}
494
495#[derive(Clone, Debug)]
497struct Atom {
498 metric: Metric,
499 op: CmpOp,
500 rhs: f64,
501}
502
503impl Atom {
504 fn parse(expr: &str) -> Result<Atom, String> {
507 let expr = expr.trim();
508 let mut found: Option<(&str, usize, usize)> = None;
513 for (i, _) in expr.char_indices() {
514 let rest = &expr[i..];
515 if rest.starts_with("<=") || rest.starts_with(">=") || rest.starts_with("==") {
516 found = Some((&expr[i..i + 2], i, 2));
517 break;
518 }
519 if rest.starts_with('<') || rest.starts_with('>') {
520 found = Some((&expr[i..i + 1], i, 1));
521 break;
522 }
523 }
524 let (op, pos, oplen) = found
525 .ok_or_else(|| format!("no comparison operator in `{expr}` (use < <= > >= ==)"))?;
526 let metric_s = expr[..pos].trim();
527 let rhs_s = expr[pos + oplen..].trim();
528 let metric = Metric::parse(metric_s)
529 .ok_or_else(|| format!("unknown metric `{metric_s}` (one of {METRICS:?})"))?;
530 let rhs = rhs_s
531 .parse::<f64>()
532 .map_err(|_| format!("bad threshold `{rhs_s}`"))?;
533 let cmp = match op {
534 "<" => CmpOp::Lt,
535 "<=" => CmpOp::Le,
536 ">" => CmpOp::Gt,
537 ">=" => CmpOp::Ge,
538 "==" => CmpOp::Eq,
539 _ => unreachable!(),
540 };
541 Ok(Atom {
542 metric,
543 op: cmp,
544 rhs,
545 })
546 }
547
548 fn holds(&self, ctx: &dyn DebugState) -> bool {
549 self.op.eval(self.metric.eval(ctx), self.rhs)
550 }
551}
552
553#[derive(Clone, Copy, Debug, PartialEq, Eq)]
555enum Join {
556 And,
557 Or,
558}
559
560#[derive(Clone, Debug)]
565struct Condition {
566 first: Atom,
567 rest: Vec<(Join, Atom)>,
568 raw: String,
570}
571
572impl Condition {
573 fn parse(expr: &str) -> Result<Condition, String> {
574 let cleaned: String = expr.chars().filter(|c| !matches!(c, '(' | ')')).collect();
576 let mut atoms: Vec<(Option<Join>, &str)> = Vec::new();
578 let bytes = cleaned.as_bytes();
579 let mut start = 0usize;
580 let mut i = 0usize;
581 let mut pending: Option<Join> = None;
582 while i + 1 < bytes.len() {
583 let two = &cleaned[i..i + 2];
584 let join = match two {
585 "&&" => Some(Join::And),
586 "||" => Some(Join::Or),
587 _ => None,
588 };
589 if let Some(j) = join {
590 atoms.push((pending, &cleaned[start..i]));
591 pending = Some(j);
592 i += 2;
593 start = i;
594 } else {
595 i += 1;
596 }
597 }
598 atoms.push((pending, &cleaned[start..]));
599
600 let mut iter = atoms.into_iter();
601 let Some((_, first_s)) = iter.next() else {
602 return Err("empty condition".into());
603 };
604 let first = Atom::parse(first_s)?;
605 let mut rest = Vec::new();
606 for (join, s) in iter {
607 let join = join.ok_or("malformed compound condition (dangling &&/||)")?;
608 rest.push((join, Atom::parse(s)?));
609 }
610 Ok(Condition {
612 first,
613 rest,
614 raw: cleaned,
615 })
616 }
617
618 fn holds(&self, ctx: &dyn DebugState) -> bool {
619 let mut acc = self.first.holds(ctx);
620 for (join, atom) in &self.rest {
621 let v = atom.holds(ctx);
622 acc = match join {
623 Join::And => acc && v,
624 Join::Or => acc || v,
625 };
626 }
627 acc
628 }
629}
630
631fn path_candidates(word: &str) -> Vec<String> {
641 let (dir, prefix) = match word.rfind('/') {
643 Some(i) => (&word[..=i], &word[i + 1..]), None => ("", word),
645 };
646 let read_from = if dir.is_empty() { "." } else { dir };
647 let Ok(entries) = std::fs::read_dir(read_from) else {
648 return Vec::new();
649 };
650 let mut out: Vec<String> = Vec::new();
651 for e in entries.flatten() {
652 let name = e.file_name().to_string_lossy().into_owned();
653 if !name.starts_with(prefix) {
654 continue;
655 }
656 if name.starts_with('.') && !prefix.starts_with('.') {
657 continue;
658 }
659 let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
660 let mut cand = format!("{dir}{name}");
661 if is_dir {
662 cand.push('/');
663 }
664 out.push(cand);
665 }
666 out.sort();
667 out
668}
669
670fn completion_candidates(reg: Option<&RegisteredOptions>, before: &str, word: &str) -> Vec<String> {
671 let toks: Vec<&str> = before.split_whitespace().collect();
672 let starts = |opts: &[&str]| -> Vec<String> {
673 opts.iter()
674 .filter(|c| c.starts_with(word))
675 .map(|c| c.to_string())
676 .collect()
677 };
678 let opt_names = || -> Vec<String> {
679 reg.map(|r| {
680 r.registered_options_in_order()
681 .iter()
682 .map(|o| o.name.clone())
683 .filter(|n| n.starts_with(word))
684 .collect()
685 })
686 .unwrap_or_default()
687 };
688 match toks.as_slice() {
689 [] => starts(COMMANDS),
690 ["set"] => {
691 let mut v = starts(&["mu", "opt"]);
692 v.extend(starts(&BLOCK_NAMES));
693 v
694 }
695 ["set", "opt"] | ["get", "opt"] | ["get"] | ["opt"] | ["options"] => opt_names(),
696 ["set", "opt", name] => reg
698 .and_then(|r| r.get_option(name))
699 .map(|o| {
700 o.valid_strings
701 .iter()
702 .map(|e| e.value.clone())
703 .filter(|v| v.starts_with(word) && v != "*")
704 .collect()
705 })
706 .unwrap_or_default(),
707 ["stop-at"] | ["stopat"] => starts(CHECKPOINTS),
708 ["break", "if"] | ["b", "if"] => starts(METRICS),
709 ["break", "on"] | ["b", "on"] => starts(EVENTS),
710 ["break"] | ["b"] => starts(&["if", "on", "clear", "del"]),
711 ["watchpoint"] | ["wp"] => starts(&BLOCK_NAMES),
712 ["print"] | ["p"] | ["watch"] | ["display"] => {
713 let mut v = starts(&BLOCK_NAMES);
714 v.extend(starts(&[
715 "mu",
716 "obj",
717 "inf_pr",
718 "inf_du",
719 "err",
720 "compl",
721 "iter",
722 "kkt",
723 "active",
724 "inactive",
725 "residuals",
726 "equation",
727 "rank",
728 ]));
729 v
730 }
731 ["viz"] | ["plot"] => {
732 let mut v = starts(&BLOCK_NAMES);
733 v.extend(starts(&["kkt", "L"]));
734 v
735 }
736 ["complete"] => starts(COMMANDS),
737 ["save"] | ["load"] | ["sweep"] | ["source"] => path_candidates(word),
739 ["load", _] => starts(&BLOCK_NAMES),
741 _ => Vec::new(),
742 }
743}
744
745#[derive(Helper, Hinter, Highlighter, Validator)]
749struct DbgHelper {
750 reg: Option<Rc<RegisteredOptions>>,
751}
752
753impl Completer for DbgHelper {
754 type Candidate = Pair;
755 fn complete(
756 &self,
757 line: &str,
758 pos: usize,
759 _ctx: &Context<'_>,
760 ) -> rustyline::Result<(usize, Vec<Pair>)> {
761 let before = &line[..pos];
762 let start = before
763 .rfind(char::is_whitespace)
764 .map(|i| i + 1)
765 .unwrap_or(0);
766 let word = &before[start..];
767 let cands = completion_candidates(self.reg.as_deref(), &before[..start], word);
768 let pairs = cands
769 .into_iter()
770 .map(|c| Pair {
771 display: c.clone(),
772 replacement: c,
773 })
774 .collect();
775 Ok((start, pairs))
776 }
777}
778
779pub struct EquationBook {
789 names: Vec<String>,
792 equations: Vec<String>,
794}
795
796impl EquationBook {
797 pub fn new(names: Vec<String>, equations: Vec<String>) -> Self {
800 Self { names, equations }
801 }
802
803 pub fn len(&self) -> usize {
805 self.equations.len()
806 }
807
808 pub fn is_empty(&self) -> bool {
810 self.equations.is_empty()
811 }
812
813 fn label(&self, i: usize) -> String {
816 match self.names.get(i) {
817 Some(n) if !n.is_empty() => n.clone(),
818 _ => format!("c[{i}]"),
819 }
820 }
821
822 fn resolve(&self, key: &str) -> Option<usize> {
825 if let Some(i) = self.names.iter().position(|n| n == key) {
826 return Some(i);
827 }
828 key.parse::<usize>()
829 .ok()
830 .filter(|&i| i < self.equations.len())
831 }
832}
833
834const MAX_STRUCT_NAMES: usize = 10;
839
840const MAX_SINGULAR_VALUES_SHOWN: usize = 16;
843
844const MAX_RANK_CULPRITS: usize = 12;
847
848pub struct StructureBook {
866 inc: EqualityIncidence,
869 con_names: Vec<String>,
872 var_names: Vec<String>,
875}
876
877impl StructureBook {
878 pub fn new(inc: EqualityIncidence, con_names: Vec<String>, var_names: Vec<String>) -> Self {
884 Self {
885 inc,
886 con_names,
887 var_names,
888 }
889 }
890
891 fn con_label(&self, eq_row: usize) -> String {
894 let orig = self.inc.eq_row_inner_idx[eq_row];
895 match self.con_names.get(orig) {
896 Some(n) if !n.is_empty() => n.clone(),
897 _ => format!("c[{orig}]"),
898 }
899 }
900
901 fn var_label(&self, v: usize) -> String {
904 match self.var_names.get(v) {
905 Some(n) if !n.is_empty() => n.clone(),
906 _ => format!("x[{v}]"),
907 }
908 }
909
910 fn join_capped(labels: &[String]) -> String {
913 if labels.len() <= MAX_STRUCT_NAMES {
914 labels.join(", ")
915 } else {
916 let head = labels[..MAX_STRUCT_NAMES].join(", ");
917 let more = labels.len() - MAX_STRUCT_NAMES;
918 format!("{head}, … (+{more} more)")
919 }
920 }
921
922 fn findings(&self) -> Vec<(&'static str, &'static str, String)> {
932 let mut out = Vec::new();
933 if self.inc.n_eq_rows() == 0 {
934 return out;
935 }
936 let matching = hopcroft_karp(&self.inc);
937 let dm = DulmageMendelsohnPartition::from_matching(&self.inc, &matching);
938 if dm.over_rows.is_empty() {
939 return out;
940 }
941
942 let excess = dm.over_rows.len().saturating_sub(dm.over_cols.len());
946 let eq_labels: Vec<String> = dm.over_rows.iter().map(|&r| self.con_label(r)).collect();
947 let var_labels: Vec<String> = dm.over_cols.iter().map(|&v| self.var_label(v)).collect();
948 let eqs = Self::join_capped(&eq_labels);
949 let shared = if var_labels.is_empty() {
950 "no variables".to_string()
951 } else {
952 Self::join_capped(&var_labels)
953 };
954 out.push((
955 "warning",
956 "structural_singularity",
957 format!(
958 "Constraint Jacobian is structurally singular (Dulmage–Mendelsohn): {} equation(s) \
959 over-determine the {} variable(s) they jointly touch ({}), so ≥{} of them must be \
960 redundant or mutually inconsistent (LICQ fails on this block). Candidate \
961 dependent equations: {}. Inspect them with `print equation <name>`; this names \
962 the rows behind any δ_c dual-regularization / wrong-inertia signal.",
963 dm.over_rows.len(),
964 dm.over_cols.len(),
965 shared,
966 excess.max(1),
967 eqs
968 ),
969 ));
970 out
971 }
972}
973
974pub struct SolverDebugger {
975 mode: DebugMode,
976 reg: Option<Rc<RegisteredOptions>>,
977 step: bool,
979 run_to: Option<i32>,
981 breaks: Vec<i32>,
983 temp_breaks: Vec<i32>,
985 bp_commands: HashMap<i32, Vec<String>>,
988 conds: Vec<Condition>,
990 watchpoints: Vec<WatchPoint>,
992 last_mu: Option<f64>,
994 mu_stall: u32,
995 in_restoration: bool,
998 detached: bool,
1000 hello_sent: bool,
1003 pause_iters: bool,
1006 pause_terminal: bool,
1008 terminal_only_on_error: bool,
1010 interruptible: bool,
1012 emit_progress: bool,
1015 sub_step: bool,
1018 stop_at: HashSet<&'static str>,
1020 break_events: HashSet<&'static str>,
1022 snapshots: BTreeMap<i32, Box<dyn pounce_common::debug::IterSnapshot>>,
1025 restart: Option<RestartCell>,
1028 editor: Option<Editor<DbgHelper, FileHistory>>,
1032 hist_path: Option<PathBuf>,
1034 pump: Option<StdinPump>,
1037 watches: Vec<String>,
1040 pending_script: Option<String>,
1043 script_given: bool,
1047 staged: Vec<(String, String)>,
1051 sweep: Option<SweepState>,
1054 prompt_interrupts: u8,
1059 equation_book: Option<EquationBook>,
1064 structure_book: Option<StructureBook>,
1069 script_queue: Option<SharedScript>,
1075}
1076
1077pub type SharedScript = Rc<std::cell::RefCell<VecDeque<String>>>;
1081
1082impl SolverDebugger {
1083 pub fn new(mode: DebugMode, reg: Option<Rc<RegisteredOptions>>) -> Self {
1086 Self {
1087 mode,
1088 reg,
1089 step: true,
1092 run_to: None,
1093 breaks: Vec::new(),
1094 temp_breaks: Vec::new(),
1095 bp_commands: HashMap::new(),
1096 conds: Vec::new(),
1097 watchpoints: Vec::new(),
1098 last_mu: None,
1099 mu_stall: 0,
1100 in_restoration: false,
1101 detached: false,
1102 hello_sent: false,
1103 pause_iters: true,
1104 pause_terminal: true,
1105 terminal_only_on_error: false,
1106 interruptible: true,
1107 emit_progress: true,
1108 sub_step: false,
1109 stop_at: HashSet::new(),
1110 break_events: HashSet::new(),
1111 snapshots: BTreeMap::new(),
1112 restart: None,
1113 editor: None,
1114 hist_path: None,
1115 pump: None,
1116 watches: Vec::new(),
1117 pending_script: None,
1118 script_given: false,
1119 staged: Vec::new(),
1120 sweep: None,
1121 prompt_interrupts: 0,
1122 equation_book: None,
1123 structure_book: None,
1124 script_queue: None,
1125 }
1126 }
1127
1128 pub fn quiet(mode: DebugMode, reg: Option<Rc<RegisteredOptions>>) -> Self {
1134 let mut d = Self::new(mode, reg);
1135 d.step = false;
1136 d.pause_iters = false;
1137 d.pause_terminal = false;
1138 d.detached = true;
1139 d
1140 }
1141
1142 pub fn with_script(mut self, path: String) -> Self {
1144 self.pending_script = Some(path);
1145 self.script_given = true;
1146 self
1147 }
1148
1149 pub fn set_equation_book(&mut self, book: EquationBook) {
1153 self.equation_book = Some(book);
1154 }
1155
1156 pub fn set_structure_book(&mut self, book: StructureBook) {
1162 self.structure_book = Some(book);
1163 }
1164
1165 pub fn with_shared_script(mut self, queue: SharedScript) -> Self {
1169 self.script_queue = Some(queue);
1170 self
1171 }
1172
1173 pub fn with_restart(mut self, cell: RestartCell) -> Self {
1176 self.restart = Some(cell);
1177 self
1178 }
1179
1180 pub fn on_error(mode: DebugMode, reg: Option<Rc<RegisteredOptions>>) -> Self {
1183 Self {
1184 step: false,
1185 pause_iters: false,
1186 terminal_only_on_error: true,
1187 ..Self::new(mode, reg)
1188 }
1189 }
1190
1191 pub fn on_interrupt(mode: DebugMode, reg: Option<Rc<RegisteredOptions>>) -> Self {
1195 Self {
1196 step: false,
1197 pause_iters: false,
1198 pause_terminal: false,
1199 ..Self::new(mode, reg)
1200 }
1201 }
1202
1203 pub fn staged_options(&self) -> &[(String, String)] {
1206 &self.staged
1207 }
1208
1209 fn should_pause(&mut self, iter: i32) -> bool {
1210 if self.detached {
1211 return false;
1212 }
1213 if self.step {
1214 return true;
1215 }
1216 if let Some(t) = self.run_to {
1217 if iter >= t {
1218 self.run_to = None;
1219 return true;
1220 }
1221 }
1222 if self.breaks.contains(&iter) {
1223 return true;
1224 }
1225 if let Some(pos) = self.temp_breaks.iter().position(|&b| b == iter) {
1227 self.temp_breaks.remove(pos);
1228 return true;
1229 }
1230 false
1231 }
1232
1233 fn matched_condition(&self, ctx: &dyn DebugState) -> Option<String> {
1236 if self.detached {
1237 return None;
1238 }
1239 self.conds
1240 .iter()
1241 .find(|c| c.holds(ctx))
1242 .map(|c| c.raw.clone())
1243 }
1244
1245 fn matched_event(&self, ctx: &dyn DebugState) -> Option<&'static str> {
1249 if self.detached || self.break_events.is_empty() {
1250 return None;
1251 }
1252 let cp = ctx.checkpoint();
1253 let tiny = 1e-10;
1255 EVENTS.iter().copied().find(|&e| {
1256 self.break_events.contains(e)
1257 && match e {
1258 "resto_entered" => cp == Checkpoint::PreRestoration,
1259 "resto_exited" => cp == Checkpoint::PostRestoration,
1260 "regularized" => {
1261 cp == Checkpoint::AfterSearchDirection && ctx.regularization() > 0.0
1262 }
1263 "tiny_step" => {
1264 cp == Checkpoint::AfterSearchDirection
1265 && ctx
1266 .delta_block("x")
1267 .map(|v| v.iter().fold(0.0_f64, |m, &x| m.max(x.abs())) < tiny)
1268 .unwrap_or(false)
1269 }
1270 "ls_rejected" => cp == Checkpoint::AfterStep && ctx.ls_count() > 1,
1271 "mu_stalled" => cp == Checkpoint::IterStart && self.mu_stall >= MU_STALL_ITERS,
1272 "nan" => !ctx.nlp_error().is_finite() || !ctx.objective().is_finite(),
1273 _ => false,
1274 }
1275 })
1276 }
1277
1278 fn update_mu_stall(&mut self, mu: f64) {
1280 if let Some(last) = self.last_mu {
1281 if (mu - last).abs() <= 1e-12 * last.abs().max(1.0) {
1282 self.mu_stall += 1;
1283 } else {
1284 self.mu_stall = 0;
1285 }
1286 }
1287 self.last_mu = Some(mu);
1288 }
1289
1290 fn matched_watchpoint(&mut self, ctx: &dyn DebugState) -> Option<String> {
1293 if self.detached {
1294 return None;
1295 }
1296 let mut hit = None;
1297 for wp in self.watchpoints.iter_mut() {
1298 let Some(full) = ctx.block(&wp.block) else {
1299 continue;
1300 };
1301 let cur: Vec<f64> = match wp.idx {
1302 Some(i) => match full.get(i) {
1303 Some(&v) => vec![v],
1304 None => continue,
1305 },
1306 None => full,
1307 };
1308 if let Some(prev) = &wp.last {
1309 if prev.len() == cur.len() {
1310 let changed = prev
1311 .iter()
1312 .zip(&cur)
1313 .any(|(p, c)| (p - c).abs() > wp.threshold);
1314 if changed && hit.is_none() {
1315 hit = Some(wp.raw.clone());
1316 }
1317 }
1318 }
1319 wp.last = Some(cur);
1320 }
1321 hit
1322 }
1323
1324 fn dispatch(&mut self, line: &str, ctx: &mut dyn DebugState) -> CmdOut {
1327 let owned = tokenize_quoted(line);
1331 let toks: Vec<&str> = owned.iter().map(String::as_str).collect();
1332 let Some(&verb) = toks.first() else {
1333 return CmdOut::ok(vec![]); };
1335 let rest = &toks[1..];
1336 match verb {
1337 "help" | "h" | "?" => self.cmd_help(),
1338 "info" | "i" => self.cmd_info(ctx),
1339 "print" | "p" => self.cmd_print(rest, ctx),
1340 "step" | "s" | "n" | "next" if rest.first() == Some(&"sub") => {
1343 self.sub_step = true;
1344 CmdOut::ok(vec![
1345 "stepping to the next checkpoint (sub-iteration)".into(),
1346 ])
1347 .flow(Flow::Resume)
1348 }
1349 "step" | "s" | "n" | "next" => {
1350 self.step = true;
1351 CmdOut::ok(vec!["stepping one iteration".into()]).flow(Flow::Resume)
1352 }
1353 "stepi" | "si" => {
1354 self.sub_step = true;
1355 CmdOut::ok(vec![
1356 "stepping to the next checkpoint (sub-iteration)".into(),
1357 ])
1358 .flow(Flow::Resume)
1359 }
1360 "continue" | "c" | "cont" => {
1361 self.step = false;
1362 self.sub_step = false;
1363 self.run_to = None;
1364 CmdOut::ok(vec!["continuing".into()]).flow(Flow::Resume)
1365 }
1366 "run" | "r" => self.cmd_run(rest),
1367 "break" | "b" => self.cmd_break(rest),
1368 "tbreak" | "tb" => match rest.first().and_then(|s| s.parse::<i32>().ok()) {
1369 Some(n) => {
1370 if !self.temp_breaks.contains(&n) {
1371 self.temp_breaks.push(n);
1372 }
1373 CmdOut::ok(vec![format!("temporary breakpoint at iteration {n}")])
1374 }
1375 None => CmdOut::err("usage: tbreak <iteration>"),
1376 },
1377 "watchpoint" | "wp" => self.cmd_watchpoint(rest, ctx),
1378 "commands" => self.cmd_commands(rest),
1379 "stop-at" | "stopat" => self.cmd_stop_at(rest),
1380 "progress" => match rest.first().copied() {
1381 Some("on") | None => {
1382 self.emit_progress = true;
1383 CmdOut::ok(vec!["progress events on".into()])
1384 }
1385 Some("off") => {
1386 self.emit_progress = false;
1387 CmdOut::ok(vec!["progress events off".into()])
1388 }
1389 _ => CmdOut::err("usage: progress [on|off]"),
1390 },
1391 "set" => self.cmd_set(rest, ctx),
1392 "get" => self.cmd_get(rest),
1393 "opt" | "options" => self.cmd_opt(rest),
1394 "complete" => self.cmd_complete(rest),
1395 "viz" | "plot" => self.cmd_viz(rest, ctx),
1396 "save" => self.cmd_save(rest, ctx),
1397 "load" => match as_nlp_mut(ctx) {
1398 Some(c) => self.cmd_load(rest, c),
1399 None => nlp_only("load"),
1400 },
1401 "sweep" => match as_nlp_mut(ctx) {
1402 Some(c) => self.cmd_sweep(rest, c),
1403 None => nlp_only("sweep"),
1404 },
1405 "multistart" => match as_nlp_mut(ctx) {
1406 Some(c) => self.cmd_multistart(rest, c),
1407 None => nlp_only("multistart"),
1408 },
1409 "goto" | "jump" => self.cmd_goto(rest, ctx),
1410 "restart" => match self.snapshots.keys().next().copied() {
1411 Some(k) => self.restore_to(k, ctx),
1412 None => CmdOut::err("no snapshots captured yet"),
1413 },
1414 "resolve" | "re-solve" => match as_nlp(ctx) {
1415 Some(c) => self.cmd_resolve(c),
1416 None => nlp_only("resolve"),
1417 },
1418 "ask" | "explain" | "claude" => self.cmd_ask(rest, ctx),
1419 "watch" | "display" => self.cmd_watch(rest),
1420 "diff" => self.cmd_diff(ctx),
1421 "diagnose" | "diag" => match as_nlp(ctx) {
1422 Some(c) => self.cmd_diagnose(c),
1423 None => nlp_only("diagnose"),
1424 },
1425 "source" => self.cmd_source(rest, ctx),
1426 "detach" => {
1427 self.detached = true;
1428 self.step = false;
1429 self.run_to = None;
1430 CmdOut::ok(vec!["detached — solving to completion".into()]).flow(Flow::Resume)
1431 }
1432 "pause" => CmdOut::ok(vec!["already paused".into()]),
1435 "coffee" | "brew" | "espresso" => self.cmd_coffee(),
1437 "quit" | "q" | "exit" => CmdOut::ok(vec!["stopping solve".into()]).flow(Flow::Stop),
1438 other => CmdOut::err(format!("unknown command `{other}` (try `help`)")),
1439 }
1440 }
1441
1442 fn cmd_coffee(&self) -> CmdOut {
1446 let color = matches!(self.mode, DebugMode::Repl)
1447 && std::io::stderr().is_terminal()
1448 && std::env::var_os("NO_COLOR").is_none();
1449 let paint = |r: u8, g: u8, b: u8, s: &str| -> String {
1450 if color {
1451 format!("\x1b[38;2;{r};{g};{b}m{s}\x1b[0m")
1452 } else {
1453 s.to_string()
1454 }
1455 };
1456 let cup = |s: &str| paint(0xEC, 0xEC, 0xEF, s);
1458 let dark = |s: &str| paint(0x5A, 0x32, 0x1E, s);
1459 let brew = |s: &str| paint(0x96, 0x5F, 0x37, s);
1460 let steam = |s: &str| paint(0xB4, 0xB9, 0xC3, s);
1461 let lines = vec![
1462 String::new(),
1463 format!(" {}", steam(") ) )")),
1464 format!(" {}", steam("( ( (")),
1465 format!(" {}", cup("._________.")),
1466 format!(" {}{}{}", cup("|"), dark("~~~~~~~~"), cup("|_")),
1467 format!(" {}{}{}", cup("| "), brew("COFFEE"), cup("| |")),
1468 format!(" {}{}{}", cup("| "), dark("~~~~~~"), cup("| |")),
1469 format!(" {}", cup("|________|_|")),
1470 format!(" {}", cup("\\________/")),
1471 format!(" {}", brew("a fresh cup for a stuck solve")),
1472 String::new(),
1473 ];
1474 CmdOut::ok(lines).with_data(serde_json::json!({"easter_egg": "coffee"}))
1475 }
1476
1477 fn cmd_help(&self) -> CmdOut {
1478 let lines = vec![
1479 "commands:".into(),
1480 " info | i summary of the current iterate".into(),
1481 " print | p <what> x|s|y_c|y_d|z_l|z_u|v_l|v_u | dx (step) |".into(),
1482 " mu|obj|inf_pr|inf_du|err|compl|iter | kkt | active | inactive".into(),
1483 " print residuals [pr|du] [k] top-k largest-magnitude residuals (default k=10)".into(),
1484 " print equation [name|row] source algebra of a constraint, by model name or row".into(),
1485 " print rank SVD rank of the equality Jacobian; names dependent equations".into(),
1486 " step | s | n run one iteration, pause again".into(),
1487 " stepi | si | step sub run to the next checkpoint (into sub-iteration phases)".into(),
1488 " progress [on|off] toggle per-iteration progress events (JSON mode)".into(),
1489 " stop-at <cp> always pause at a checkpoint: after_mu|after_search_dir|after_step".into(),
1490 " continue | c run to the next breakpoint".into(),
1491 " run | r <N> run until iteration N".into(),
1492 " break | b [N|clear|del N] set/list/clear breakpoints".into(),
1493 " break if <m><op><v> conditional bp; m in mu|inf_pr|inf_du|obj|err|iter,".into(),
1494 " op in < <= > >= == (e.g. break if inf_pr<1e-6)".into(),
1495 " break on <event> event bp: resto_entered|resto_exited|regularized|".into(),
1496 " tiny_step|ls_rejected|mu_stalled|nan".into(),
1497 " tbreak <N> one-shot breakpoint (deletes after firing)".into(),
1498 " watchpoint <blk>[<i>] [τ] pause when a value changes by > τ (alias wp)".into(),
1499 " commands <N> <c>;<c>… auto-run commands when iter N's breakpoint hits".into(),
1500 " set mu <v> overwrite the barrier parameter".into(),
1501 " set <blk>[<i>] <v> overwrite one component (e.g. set x[2] 1.5)".into(),
1502 " set <blk> <v0,v1,...> overwrite a whole block".into(),
1503 " set opt <name> <value> stage a solver option (validated)".into(),
1504 " get opt <name> show an option's effective value (staged or default)".into(),
1505 " opt [filter] list solver options (name/type/default)".into(),
1506 " complete <prefix> completion candidates (commands + options)".into(),
1507 " viz <x|s|dx|...|kkt|L> open the artifact in an external viewer".into(),
1508 " save [path] write the current iterate + residuals to JSON".into(),
1509 " load <file> [block] read a block (default x) from a save artifact / numeric file".into(),
1510 " sweep <file> one solve per start in <file>; tabulate outcomes".into(),
1511 " multistart <N> [rel] N restarts (uniform in each finite box; jitter else)".into(),
1512 " goto <k> | restart rewind to a captured iteration (primal-dual only)".into(),
1513 " resolve re-solve from the current x with staged `set opt`s".into(),
1514 " ask [question] ask an LLM about the state (default Claude Code; set".into(),
1515 " POUNCE_DBG_LLM=claude|codex|gemini|llm or a command template)".into(),
1516 " watch [target|clear|del] auto-print a `print` target at every pause".into(),
1517 " diff what changed in the iterate since the last iteration".into(),
1518 " diagnose | diag live health report: named culprit residuals, KKT inertia, stalls".into(),
1519 " source <file> run debugger commands from a file".into(),
1520 " detach stop pausing; solve to completion".into(),
1521 " quit | q stop the solve now".into(),
1522 ];
1523 CmdOut::ok(lines)
1524 }
1525
1526 fn cmd_info(&self, ctx: &dyn DebugState) -> CmdOut {
1527 let dims: Vec<_> = ctx.block_dims();
1528 let dims_json: serde_json::Map<String, serde_json::Value> = dims
1529 .iter()
1530 .map(|(n, d)| ((*n).to_string(), serde_json::json!(d)))
1531 .collect();
1532 let lines = vec![
1533 format!("iter = {}", ctx.iter()),
1534 format!("mu = {:.6e}", ctx.mu()),
1535 format!("objective = {:.8e}", ctx.objective()),
1536 format!("inf_pr = {:.6e}", ctx.inf_pr()),
1537 format!("inf_du = {:.6e}", ctx.inf_du()),
1538 format!("nlp_error = {:.6e}", ctx.nlp_error()),
1539 format!(
1540 "dims = {}",
1541 dims.iter()
1542 .map(|(n, d)| format!("{n}:{d}"))
1543 .collect::<Vec<_>>()
1544 .join(" ")
1545 ),
1546 ];
1547 let mut data = serde_json::json!({ "dims": dims_json });
1548 insert_metric_fields(&mut data, ctx);
1552 CmdOut::ok(lines).with_data(data)
1553 }
1554
1555 fn cmd_print(&self, rest: &[&str], ctx: &dyn DebugState) -> CmdOut {
1556 let Some(&what) = rest.first() else {
1557 return self.cmd_info(ctx);
1558 };
1559 if what == "kkt" {
1566 return match as_nlp(ctx) {
1567 Some(_) => self.cmd_print_kkt(ctx),
1568 None => nlp_only("print kkt"),
1569 };
1570 }
1571 if what == "active" || what == "inactive" {
1572 return match as_nlp(ctx) {
1573 Some(_) => self.cmd_print_bounds(ctx, what == "active"),
1574 None => nlp_only(&format!("print {what}")),
1575 };
1576 }
1577 if what == "residuals" || what == "resid" {
1578 return match as_nlp(ctx) {
1579 Some(_) => self.cmd_print_residuals(&rest[1..], ctx),
1580 None => nlp_only("print residuals"),
1581 };
1582 }
1583 if what == "equation" || what == "eqn" || what == "eq" {
1584 return self.cmd_print_equation(&rest[1..]);
1585 }
1586 if what == "rank" {
1587 return match as_nlp(ctx) {
1588 Some(c) => self.cmd_print_rank(c),
1589 None => nlp_only("print rank"),
1590 };
1591 }
1592 let delta = what.strip_prefix("d").filter(|b| is_block(ctx, b));
1594 if is_block(ctx, what) {
1595 match ctx.block(what) {
1596 Some(v) => CmdOut::ok(vec![fmt_vec(what, &v)])
1597 .with_data(serde_json::json!({"name": what, "values": v})),
1598 None => CmdOut::err(format!("no iterate yet for block `{what}`")),
1599 }
1600 } else if let Some(blk) = delta {
1601 match ctx.delta_block(blk) {
1602 Some(v) => CmdOut::ok(vec![fmt_vec(&format!("d{blk}"), &v)])
1603 .with_data(serde_json::json!({"name": format!("d{blk}"), "values": v})),
1604 None => CmdOut::err(format!("no search direction available for `d{blk}` yet")),
1605 }
1606 } else {
1607 let val = match what {
1608 "mu" => ctx.mu(),
1609 "obj" | "objective" => ctx.objective(),
1610 "inf_pr" => ctx.inf_pr(),
1611 "inf_du" => ctx.inf_du(),
1612 "err" | "nlp_error" => ctx.nlp_error(),
1613 "compl" | "complementarity" => ctx.complementarity(),
1614 "iter" => ctx.iter() as f64,
1615 _ => {
1616 return CmdOut::err(format!(
1617 "don't know how to print `{what}` (try a block name or mu|obj|inf_pr|inf_du|err|compl|iter)"
1618 ));
1619 }
1620 };
1621 CmdOut::ok(vec![format!("{what} = {val:.10e}")])
1622 .with_data(serde_json::json!({"name": what, "value": val}))
1623 }
1624 }
1625
1626 fn cmd_print_bounds(&self, ctx: &dyn DebugState, active: bool) -> CmdOut {
1632 let tol = 1e-6;
1633 let mut lines = Vec::new();
1634 let mut cats = serde_json::Map::new();
1635 for cat in ["x_l", "x_u", "s_l", "s_u"] {
1636 let Some(sl) = ctx.bound_slack(cat) else {
1637 continue;
1638 };
1639 if sl.is_empty() {
1640 continue;
1641 }
1642 let n = sl.len();
1643 if active {
1644 let min = sl.iter().copied().fold(f64::INFINITY, f64::min);
1645 let near = sl.iter().filter(|&&s| s.abs() < tol).count();
1646 lines.push(format!(
1647 "{cat}: {n} bound(s), {near} near-active (slack<{tol:.0e}), min slack {min:.3e}"
1648 ));
1649 cats.insert(
1650 cat.to_string(),
1651 serde_json::json!({"n": n, "near_active": near, "min_slack": min}),
1652 );
1653 } else {
1654 let max = sl.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1655 let far = sl.iter().filter(|&&s| s.abs() >= tol).count();
1656 lines.push(format!(
1657 "{cat}: {n} bound(s), {far} inactive (slack≥{tol:.0e}), max slack {max:.3e}"
1658 ));
1659 cats.insert(
1660 cat.to_string(),
1661 serde_json::json!({"n": n, "inactive": far, "max_slack": max}),
1662 );
1663 }
1664 }
1665 if lines.is_empty() {
1666 lines.push("no bounded variables or inequality slacks".into());
1667 }
1668 CmdOut::ok(lines).with_data(serde_json::json!({"tol": tol, "categories": cats}))
1669 }
1670
1671 fn cmd_print_residuals(&self, rest: &[&str], ctx: &dyn DebugState) -> CmdOut {
1678 let mut k: Option<usize> = None;
1679 let mut filter: Option<bool> = None; for &arg in rest {
1681 if let Ok(n) = arg.parse::<usize>() {
1682 k = Some(n);
1683 } else {
1684 match arg {
1685 "primal" | "pr" => filter = Some(true),
1686 "dual" | "du" => filter = Some(false),
1687 other => {
1688 return CmdOut::err(format!(
1689 "usage: print residuals [primal|dual] [k] (got `{other}`)"
1690 ));
1691 }
1692 }
1693 }
1694 }
1695 let k = k.unwrap_or(10);
1696
1697 let mut all = Vec::new();
1698 if filter != Some(false) {
1699 let Some(primal) = ctx.constraint_residuals() else {
1700 return CmdOut::err("no iterate yet — residuals unavailable");
1701 };
1702 all.extend(primal);
1703 }
1704 if filter != Some(true) {
1705 let Some(dual) = ctx.dual_residuals() else {
1706 return CmdOut::err("no iterate yet — residuals unavailable");
1707 };
1708 all.extend(dual);
1709 }
1710
1711 let total = all.len();
1712 let top = rank_residuals(all, k);
1713 if top.is_empty() {
1714 return CmdOut::ok(vec!["no residuals at this iterate".into()])
1715 .with_data(serde_json::json!({"k": k, "total": total, "top": []}));
1716 }
1717
1718 let names = ctx
1726 .as_any()
1727 .and_then(|a| a.downcast_ref::<DebugCtx>())
1728 .and_then(|c| c.split_names());
1729 let name_of = |r: &Residual| resid_name(r, &names);
1730
1731 let lines = top
1732 .iter()
1733 .map(|r| {
1734 let label = match name_of(r) {
1735 Some(name) => format!("{}[{}]", r.kind.tag(), name),
1736 None => format!("{}[{}]", r.kind.tag(), r.index),
1737 };
1738 format!("{:>8} = {:+.6e} |{:.3e}|", label, r.value, r.value.abs())
1739 })
1740 .collect();
1741 let data: Vec<_> = top
1742 .iter()
1743 .map(|r| {
1744 serde_json::json!({
1745 "space": r.kind.tag(),
1746 "primal": r.kind.is_primal(),
1747 "index": r.index,
1748 "name": name_of(r),
1749 "value": r.value,
1750 })
1751 })
1752 .collect();
1753 CmdOut::ok(lines).with_data(serde_json::json!({"k": k, "total": total, "top": data}))
1754 }
1755
1756 fn cmd_print_equation(&self, rest: &[&str]) -> CmdOut {
1765 let Some(book) = self.equation_book.as_ref() else {
1766 return CmdOut::err(
1767 "no equation source — `print equation` needs an .nl model (none was loaded)",
1768 );
1769 };
1770 if book.is_empty() {
1771 return CmdOut::err("the model has no constraint equations to print");
1772 }
1773 let Some(&key) = rest.first() else {
1774 return CmdOut::ok(vec![format!(
1775 "{} constraint equation(s) — `print equation <name|row>` to show one",
1776 book.len()
1777 )])
1778 .with_data(serde_json::json!({"count": book.len()}));
1779 };
1780 let Some(i) = book.resolve(key) else {
1781 return CmdOut::err(format!(
1782 "no constraint named or indexed `{key}` (have {} equation(s); try a name or 0..{})",
1783 book.len(),
1784 book.len().saturating_sub(1)
1785 ));
1786 };
1787 let label = book.label(i);
1788 let Some(eq) = book.equations.get(i) else {
1791 return CmdOut::err(format!(
1792 "constraint `{key}` has no source algebra (index {i} out of range)"
1793 ));
1794 };
1795 CmdOut::ok(vec![format!("{label}: {eq}")]).with_data(serde_json::json!({
1796 "index": i,
1797 "name": book.names.get(i).filter(|n| !n.is_empty()),
1798 "equation": eq,
1799 }))
1800 }
1801
1802 fn cmd_diagnose(&self, ctx: &DebugCtx) -> CmdOut {
1817 const TOL: f64 = 1e-6;
1818 let names = ctx.split_names();
1819 let mut f: Vec<(&'static str, &'static str, String)> = Vec::new();
1821
1822 let inf_pr = ctx.inf_pr();
1824 if inf_pr > TOL {
1825 if let Some(resids) = ctx.constraint_residuals() {
1826 if let Some((label, val)) = worst_named(resids, &names) {
1827 let sev = if inf_pr > 1e-2 { "error" } else { "warning" };
1828 f.push((
1829 sev,
1830 "primal_infeasible",
1831 format!(
1832 "Primal infeasibility {inf_pr:.2e}; worst constraint residual is \
1833 {label} = {val:+.3e}. Inspect this equation's feasibility and scaling \
1834 at the current point (`print equation {label}`)."
1835 ),
1836 ));
1837 }
1838 }
1839 }
1840
1841 let inf_du = ctx.inf_du();
1843 if inf_du > TOL {
1844 if let Some(resids) = ctx.dual_residuals() {
1845 if let Some((label, val)) = worst_named(resids, &names) {
1846 f.push((
1847 "warning",
1848 "dual_infeasible",
1849 format!(
1850 "Dual infeasibility {inf_du:.2e}; largest stationarity residual is \
1851 {label} = {val:+.3e}."
1852 ),
1853 ));
1854 }
1855 }
1856 }
1857
1858 if let Some(k) = ctx.kkt() {
1860 if k.provides_inertia && !k.inertia_correct {
1861 f.push((
1862 "warning",
1863 "inertia_wrong",
1864 format!(
1865 "KKT inertia is wrong (n-={} vs expected {}): the system was \
1866 indefinite/singular and the step had to be stabilized. A persistent \
1867 mismatch points at a rank-deficient Jacobian or an indefinite Hessian.",
1868 k.n_neg, k.expected_neg
1869 ),
1870 ));
1871 }
1872 if k.delta_w > 1e-4 {
1873 f.push((
1874 "info",
1875 "heavy_regularization",
1876 format!(
1877 "Primal regularization δ_w={:.2e} applied — the Hessian was indefinite at \
1878 this step. Normal near saddle points; persistent large δ_w suggests a \
1879 problematic Hessian.",
1880 k.delta_w
1881 ),
1882 ));
1883 }
1884 if k.delta_c > 0.0 {
1885 f.push((
1886 "warning",
1887 "dual_regularization",
1888 format!(
1889 "Dual regularization δ_c={:.2e} applied — the constraint Jacobian is (near) \
1890 rank-deficient (linearly dependent or redundant equalities). Inspect the \
1891 equality residuals by name (`print residuals primal`).",
1892 k.delta_c
1893 ),
1894 ));
1895 }
1896 }
1897
1898 if let Some(book) = self.structure_book.as_ref() {
1902 f.extend(book.findings());
1903 }
1904
1905 if let Some(rep) = ctx.rank_report() {
1911 if rep.is_rank_deficient() {
1912 let culprits: Vec<String> = rep
1913 .culprits
1914 .iter()
1915 .take(MAX_RANK_CULPRITS)
1916 .map(|c| rank_row_label(&rep.rows[c.row], &names))
1917 .collect();
1918 let named = if culprits.is_empty() {
1919 String::new()
1920 } else {
1921 format!(" Implicated equations: {}.", culprits.join(", "))
1922 };
1923 f.push((
1924 "warning",
1925 "rank_deficient_jacobian",
1926 format!(
1927 "Equality Jacobian J_c is numerically rank-deficient at this iterate: \
1928 rank {}/{} (deficiency {}), σ_min={:.2e}, cond={}. Linearly dependent \
1929 or redundant equality constraints — the root cause behind δ_c \
1930 regularization / wrong inertia.{named}",
1931 rep.rank,
1932 rep.n_rows(),
1933 rep.deficiency(),
1934 rep.sigma_min(),
1935 fmt_cond(rep.cond),
1936 ),
1937 ));
1938 }
1939 }
1940
1941 let mut max_mult = 0.0_f64;
1943 for blk in ["y_c", "y_d", "z_l", "z_u", "v_l", "v_u"] {
1944 if let Some(v) = ctx.block(blk) {
1945 max_mult = v.iter().fold(max_mult, |m, &x| m.max(x.abs()));
1946 }
1947 }
1948 if max_mult > 1e8 {
1949 f.push((
1950 "warning",
1951 "large_multipliers",
1952 format!(
1953 "Largest multiplier magnitude is {max_mult:.2e}. Very large multipliers signal a \
1954 constraint-qualification failure or poor scaling — consider rescaling the \
1955 offending rows."
1956 ),
1957 ));
1958 }
1959
1960 let mut pinned = 0usize;
1962 for cat in ["x_l", "x_u"] {
1963 if let Some(sl) = ctx.bound_slack(cat) {
1964 pinned += sl.iter().filter(|&&s| s.abs() < TOL).count();
1965 }
1966 }
1967 if pinned > 0 {
1968 f.push((
1969 "info",
1970 "bounds_pinned",
1971 format!(
1972 "{pinned} variable bound(s) are active (slack < {TOL:.0e}). Active bounds are \
1973 expected at a solution, but a large count early can throttle the line search."
1974 ),
1975 ));
1976 }
1977
1978 let (alpha_pr, _) = ctx.alpha();
1980 if ctx.iter() > 0 && alpha_pr > 0.0 && alpha_pr < 1e-6 {
1981 f.push((
1982 "warning",
1983 "tiny_step",
1984 format!(
1985 "Accepted primal step α_pr={alpha_pr:.2e} is tiny — the line search is barely \
1986 moving. Often a poor search direction or an ill-conditioned KKT system."
1987 ),
1988 ));
1989 }
1990 let ls = ctx.ls_count();
1991 if ls >= 10 {
1992 f.push((
1993 "warning",
1994 "heavy_line_search",
1995 format!(
1996 "Line search needed {ls} trial points for the accepted step — search-direction \
1997 quality may be poor (check Hessian accuracy)."
1998 ),
1999 ));
2000 }
2001
2002 if self.in_restoration {
2004 f.push((
2005 "warning",
2006 "in_restoration",
2007 "Currently inside feasibility restoration: the line search could not make \
2008 progress on the original problem at the working point."
2009 .to_string(),
2010 ));
2011 }
2012 if self.mu_stall >= MU_STALL_ITERS {
2013 f.push((
2014 "warning",
2015 "mu_stalled",
2016 format!(
2017 "μ has not decreased for {} consecutive iterations — the barrier is stuck. \
2018 Try mu_strategy=adaptive or a smaller mu_init.",
2019 self.mu_stall
2020 ),
2021 ));
2022 }
2023
2024 if f.is_empty() {
2026 f.push((
2027 "info",
2028 "healthy",
2029 format!(
2030 "No issues detected at iter {}: inf_pr={:.2e}, inf_du={:.2e}, μ={:.2e}.",
2031 ctx.iter(),
2032 inf_pr,
2033 inf_du,
2034 ctx.mu()
2035 ),
2036 ));
2037 }
2038
2039 let rank = |s: &str| match s {
2041 "error" => 0,
2042 "warning" => 1,
2043 _ => 2,
2044 };
2045 f.sort_by_key(|(sev, _, _)| rank(sev));
2046
2047 let lines: Vec<String> = f
2048 .iter()
2049 .map(|(sev, code, msg)| format!("[{sev:>7}] {code}: {msg}"))
2050 .collect();
2051 let data: Vec<_> = f
2052 .iter()
2053 .map(|(sev, code, msg)| serde_json::json!({"severity": sev, "code": code, "message": msg}))
2054 .collect();
2055 let n = data.len();
2056 CmdOut::ok(lines)
2057 .with_data(serde_json::json!({"iter": ctx.iter(), "findings": data, "n_findings": n}))
2058 }
2059
2060 fn cmd_print_kkt(&self, ctx: &dyn DebugState) -> CmdOut {
2063 let Some(k) = ctx.kkt() else {
2064 return CmdOut::err(
2065 "no KKT factorization yet — stop at `after_search_dir` (e.g. `stop-at kkt`)",
2066 );
2067 };
2068 let inertia = if k.provides_inertia {
2069 format!(
2070 "n+={} n-={} (expected n-={}) → {}",
2071 k.n_pos,
2072 k.n_neg,
2073 k.expected_neg,
2074 if k.inertia_correct {
2075 "correct"
2076 } else {
2077 "WRONG (step stabilized)"
2078 }
2079 )
2080 } else {
2081 "n/a (backend reports no inertia)".to_string()
2082 };
2083 let lines = vec![
2084 format!("dim = {}", k.dim),
2085 format!("inertia = {inertia}"),
2086 format!("delta_w = {:.6e} (primal regularization)", k.delta_w),
2087 format!("delta_c = {:.6e} (dual regularization)", k.delta_c),
2088 format!("status = {}", k.status),
2089 ];
2090 CmdOut::ok(lines).with_data(serde_json::json!({
2091 "dim": k.dim,
2092 "n_pos": k.n_pos,
2093 "n_neg": k.n_neg,
2094 "expected_neg": k.expected_neg,
2095 "provides_inertia": k.provides_inertia,
2096 "inertia_correct": k.inertia_correct,
2097 "delta_w": k.delta_w,
2098 "delta_c": k.delta_c,
2099 "status": k.status,
2100 }))
2101 }
2102
2103 fn cmd_print_rank(&self, ctx: &DebugCtx) -> CmdOut {
2112 let Some(rep) = ctx.rank_report() else {
2113 return CmdOut::err(
2114 "no equality-constraint Jacobian to analyze (the problem has no equality \
2115 constraints, or there is no iterate yet)",
2116 );
2117 };
2118 let names = ctx.split_names();
2119 let (lines, data) =
2120 render_rank_report(&rep, &names, self.equation_book.as_ref(), ctx.iter());
2121 CmdOut::ok(lines).with_data(data)
2122 }
2123
2124 fn cmd_run(&mut self, rest: &[&str]) -> CmdOut {
2125 match rest.first().and_then(|s| s.parse::<i32>().ok()) {
2126 Some(n) => {
2127 self.run_to = Some(n);
2128 self.step = false;
2129 CmdOut::ok(vec![format!("running until iteration {n}")]).flow(Flow::Resume)
2130 }
2131 None => CmdOut::err("usage: run <iteration>"),
2132 }
2133 }
2134
2135 fn cmd_break(&mut self, rest: &[&str]) -> CmdOut {
2136 if rest.first().copied() == Some("if") {
2140 let expr: String = rest[1..].concat();
2141 if expr.is_empty() {
2142 return CmdOut::err(
2143 "usage: break if <metric><op><value> (e.g. break if inf_pr<1e-6)",
2144 );
2145 }
2146 return match Condition::parse(&expr) {
2147 Ok(c) => {
2148 let raw = c.raw.clone();
2149 if !self.conds.iter().any(|e| e.raw == raw) {
2150 self.conds.push(c);
2151 }
2152 CmdOut::ok(vec![format!("conditional breakpoint: {raw}")])
2153 .with_data(serde_json::json!({"condition": raw}))
2154 }
2155 Err(e) => CmdOut::err(e),
2156 };
2157 }
2158 if rest.first().copied() == Some("on") {
2160 let Some(&name) = rest.get(1) else {
2161 return CmdOut::err(format!("usage: break on <event> (one of {EVENTS:?})"));
2162 };
2163 let Some(&canon) = EVENTS.iter().find(|&&e| e == name) else {
2164 return CmdOut::err(format!("unknown event `{name}` (one of {EVENTS:?})"));
2165 };
2166 self.break_events.insert(canon);
2167 return CmdOut::ok(vec![format!("break on event `{canon}`")])
2168 .with_data(serde_json::json!({"event": canon}));
2169 }
2170 match rest {
2171 [] => {
2172 let mut bs = self.breaks.clone();
2173 bs.sort_unstable();
2174 let conds: Vec<String> = self.conds.iter().map(|c| c.raw.clone()).collect();
2175 let mut events: Vec<&str> = self.break_events.iter().copied().collect();
2176 events.sort_unstable();
2177 let mut lines = vec![format!("breakpoints: {bs:?}")];
2178 if !conds.is_empty() {
2179 lines.push(format!("conditions: {}", conds.join(", ")));
2180 }
2181 if !events.is_empty() {
2182 lines.push(format!("events: {}", events.join(", ")));
2183 }
2184 CmdOut::ok(lines).with_data(
2185 serde_json::json!({"breakpoints": bs, "conditions": conds, "events": events}),
2186 )
2187 }
2188 ["clear", "cond"] | ["clear", "conditions"] => {
2189 self.conds.clear();
2190 CmdOut::ok(vec!["cleared conditional breakpoints".into()])
2191 }
2192 ["clear", "events"] => {
2193 self.break_events.clear();
2194 CmdOut::ok(vec!["cleared event breakpoints".into()])
2195 }
2196 ["clear"] => {
2197 self.breaks.clear();
2198 self.conds.clear();
2199 self.break_events.clear();
2200 CmdOut::ok(vec!["cleared all breakpoints".into()])
2201 }
2202 ["del", n] | ["delete", n] => match n.parse::<i32>() {
2203 Ok(n) => {
2204 self.breaks.retain(|&b| b != n);
2205 CmdOut::ok(vec![format!("removed breakpoint {n}")])
2206 }
2207 Err(_) => CmdOut::err("usage: break del <iteration>"),
2208 },
2209 [n] => match n.parse::<i32>() {
2210 Ok(n) => {
2211 if !self.breaks.contains(&n) {
2212 self.breaks.push(n);
2213 }
2214 CmdOut::ok(vec![format!("breakpoint at iteration {n}")])
2215 }
2216 Err(_) => CmdOut::err("usage: break <iteration>"),
2217 },
2218 _ => CmdOut::err("usage: break [N | if <m><op><v> | clear | clear cond | del N]"),
2219 }
2220 }
2221
2222 fn cmd_stop_at(&mut self, rest: &[&str]) -> CmdOut {
2226 let canon = |s: &str| -> Option<&'static str> {
2227 match s {
2228 "mu" | "after_mu" => Some("after_mu"),
2229 "kkt" | "search_dir" | "after_search_dir" => Some("after_search_dir"),
2230 "step" | "after_step" => Some("after_step"),
2231 "rejected" | "ls_rejected" | "step_rejected" => Some("step_rejected"),
2232 "resto" | "restoration" | "pre_restoration_entry" => Some("pre_restoration_entry"),
2233 "resto_exit" | "post_restoration_exit" => Some("post_restoration_exit"),
2234 "iter" | "iter_start" => Some("iter_start"),
2235 "terminated" => Some("terminated"),
2236 _ => None,
2237 }
2238 };
2239 match rest {
2240 [] => {
2241 let mut v: Vec<&str> = self.stop_at.iter().copied().collect();
2242 v.sort_unstable();
2243 CmdOut::ok(vec![format!(
2244 "stop-at: {v:?} (available: {CHECKPOINTS:?})"
2245 )])
2246 .with_data(serde_json::json!({"stop_at": v, "available": CHECKPOINTS}))
2247 }
2248 ["clear"] => {
2249 self.stop_at.clear();
2250 CmdOut::ok(vec!["cleared stop-at checkpoints".into()])
2251 }
2252 [name] => match canon(name) {
2253 Some(c) => {
2254 self.stop_at.insert(c);
2255 CmdOut::ok(vec![format!("will stop at checkpoint `{c}`")])
2256 .with_data(serde_json::json!({"stop_at_added": c}))
2257 }
2258 None => CmdOut::err(format!(
2259 "unknown checkpoint `{name}` (one of {CHECKPOINTS:?})"
2260 )),
2261 },
2262 _ => CmdOut::err("usage: stop-at [<checkpoint> | clear]"),
2263 }
2264 }
2265
2266 fn cmd_set(&mut self, rest: &[&str], ctx: &mut dyn DebugState) -> CmdOut {
2267 match rest {
2268 ["mu", v] => match v.parse::<f64>() {
2269 Ok(mu) => match ctx.set_mu(mu) {
2270 Ok(()) => CmdOut::ok(vec![format!("mu := {mu:.6e}")]),
2271 Err(e) => CmdOut::err(e),
2272 },
2273 Err(_) => CmdOut::err("usage: set mu <value>"),
2274 },
2275 ["opt", name, value] => match as_nlp_mut(ctx) {
2276 Some(c) => self.cmd_set_opt(name, value, c),
2277 None => nlp_only("set opt"),
2278 },
2279 [target, value] => self.cmd_set_block(target, value, ctx),
2280 _ => CmdOut::err(
2281 "usage: set mu <v> | set <blk>[<i>] <v> | set <blk> <v0,v1,..> | set opt <name> <v>",
2282 ),
2283 }
2284 }
2285
2286 fn cmd_set_block(&mut self, target: &str, value: &str, ctx: &mut dyn DebugState) -> CmdOut {
2288 if let Some(open) = target.find('[') {
2290 if !target.ends_with(']') {
2291 return CmdOut::err("malformed component target (expected name[idx])");
2292 }
2293 let name = &target[..open];
2294 let idx_str = &target[open + 1..target.len() - 1];
2295 let Ok(idx) = idx_str.parse::<usize>() else {
2296 return CmdOut::err(format!("bad index `{idx_str}`"));
2297 };
2298 let Ok(val) = value.parse::<f64>() else {
2299 return CmdOut::err(format!("bad value `{value}`"));
2300 };
2301 return match ctx.set_component(name, idx, val) {
2302 Ok(()) => CmdOut::ok(vec![format!("{name}[{idx}] := {val:.6e}")]),
2303 Err(e) => CmdOut::err(e),
2304 };
2305 }
2306 let parsed: Result<Vec<f64>, _> =
2308 value.split(',').map(|s| s.trim().parse::<f64>()).collect();
2309 match parsed {
2310 Ok(vals) => match ctx.set_block(target, &vals) {
2311 Ok(()) => CmdOut::ok(vec![format!("{target} := {} value(s)", vals.len())]),
2312 Err(e) => CmdOut::err(e),
2313 },
2314 Err(_) => CmdOut::err("could not parse comma-separated values"),
2315 }
2316 }
2317
2318 fn cmd_set_opt(&mut self, name: &str, value: &str, ctx: &mut DebugCtx) -> CmdOut {
2319 let Some(reg) = self.reg.as_ref() else {
2320 return CmdOut::err("no options registry available");
2321 };
2322 let Some(opt) = reg.get_option(name) else {
2323 return CmdOut::err(format!("unknown option `{name}` (try `opt {name}`)"));
2324 };
2325 let valid = match opt.option_type {
2327 OptionType::OT_Number => value
2328 .parse::<f64>()
2329 .map(|v| opt.is_valid_number(v))
2330 .unwrap_or(false),
2331 OptionType::OT_Integer => value
2332 .parse::<i32>()
2333 .map(|v| opt.is_valid_integer(v))
2334 .unwrap_or(false),
2335 OptionType::OT_String => opt.is_valid_string(value),
2336 OptionType::OT_Unknown => true,
2337 };
2338 if !valid {
2339 return CmdOut::err(format!("`{value}` is not a valid value for `{name}`"));
2340 }
2341 self.staged.retain(|(k, _)| k != name);
2344 self.staged.push((name.to_string(), value.to_string()));
2345 if is_live_tolerance(name) {
2350 if let Ok(v) = value.parse::<f64>() {
2351 ctx.set_live_tolerance(name, v);
2352 return CmdOut::ok(vec![format!(
2353 "{name} = {value} (applied live — the next `step` uses it)"
2354 )])
2355 .with_data(serde_json::json!({
2356 "option": name, "value": value, "live": true
2357 }));
2358 }
2359 }
2360 CmdOut::ok(vec![format!(
2361 "staged {name} = {value} (validated; takes effect on `resolve` — built strategies don't re-read mid-solve)"
2362 )])
2363 .with_data(serde_json::json!({"option": name, "value": value, "staged": true}))
2364 }
2365
2366 fn cmd_get(&self, rest: &[&str]) -> CmdOut {
2373 let name = match rest {
2375 ["opt", n] => *n,
2376 [n] => *n,
2377 _ => return CmdOut::err("usage: get opt <name>"),
2378 };
2379 let Some(reg) = self.reg.as_ref() else {
2380 return CmdOut::err("no options registry available");
2381 };
2382 let Some(o) = reg.get_option(name) else {
2383 return CmdOut::err(format!("unknown option `{name}` (try `opt {name}`)"));
2384 };
2385 let def = default_str(&o.default);
2386 let staged = self
2387 .staged
2388 .iter()
2389 .find(|(k, _)| k == name)
2390 .map(|(_, v)| v.clone());
2391 let (value, source) = match &staged {
2392 Some(v) => (v.clone(), "staged"),
2393 None => (def.clone(), "default"),
2394 };
2395 CmdOut::ok(vec![format!("{name} = {value} ({source}; default={def})")]).with_data(
2396 serde_json::json!({
2397 "option": name, "value": value, "source": source,
2398 "default": def, "staged": staged,
2399 }),
2400 )
2401 }
2402
2403 fn cmd_opt(&self, rest: &[&str]) -> CmdOut {
2404 let Some(reg) = self.reg.as_ref() else {
2405 return CmdOut::err("no options registry available");
2406 };
2407 let filter = rest.first().copied().unwrap_or("");
2408 let mut lines = Vec::new();
2409 let mut data = Vec::new();
2410 for o in reg.registered_options_in_order() {
2411 if !filter.is_empty()
2412 && !o.name.contains(filter)
2413 && !o
2414 .category
2415 .to_ascii_lowercase()
2416 .contains(&filter.to_ascii_lowercase())
2417 {
2418 continue;
2419 }
2420 let ty = type_str(o.option_type);
2421 let def = default_str(&o.default);
2422 lines.push(format!(
2423 " {:<28} {:<7} default={:<12} {}",
2424 o.name, ty, def, o.short_description
2425 ));
2426 data.push(serde_json::json!({
2427 "name": o.name,
2428 "type": ty,
2429 "default": def,
2430 "category": o.category,
2431 "short": o.short_description,
2432 "valid": o.valid_strings.iter().map(|e| e.value.clone()).collect::<Vec<_>>(),
2433 }));
2434 }
2435 if lines.is_empty() {
2436 return CmdOut::ok(vec![format!("no options match `{filter}`")]);
2437 }
2438 if data.len() == 1 {
2440 if let Some(o) = reg.get_option(filter) {
2441 if !o.long_description.is_empty() {
2442 lines.push(String::new());
2443 lines.push(o.long_description.clone());
2444 }
2445 }
2446 }
2447 CmdOut::ok(lines).with_data(serde_json::json!({"options": data}))
2448 }
2449
2450 fn cmd_complete(&self, rest: &[&str]) -> CmdOut {
2455 let (before, word) = match rest.split_last() {
2456 Some((w, pre)) => (pre.join(" "), *w),
2457 None => (String::new(), ""),
2458 };
2459 let mut cands = completion_candidates(self.reg.as_deref(), &before, word);
2460 cands.sort();
2461 cands.dedup();
2462 CmdOut::ok(vec![cands.join(" ")]).with_data(serde_json::json!({"candidates": cands}))
2463 }
2464
2465 fn cmd_save(&self, rest: &[&str], ctx: &dyn DebugState) -> CmdOut {
2469 let iter = ctx.iter();
2470 let path = rest
2471 .first()
2472 .map(PathBuf::from)
2473 .unwrap_or_else(|| std::env::temp_dir().join(format!("pounce-dbg-iter{iter}.json")));
2474 let collect = |delta: bool| -> serde_json::Map<String, serde_json::Value> {
2475 let mut m = serde_json::Map::new();
2476 for b in block_names(ctx) {
2477 let v = if delta {
2478 ctx.delta_block(b)
2479 } else {
2480 ctx.block(b)
2481 };
2482 if let Some(v) = v {
2483 if !v.is_empty() {
2484 let key = if delta {
2485 format!("d{b}")
2486 } else {
2487 b.to_string()
2488 };
2489 m.insert(key, serde_json::json!(v));
2490 }
2491 }
2492 }
2493 m
2494 };
2495 let payload = serde_json::json!({
2496 "iter": iter,
2497 "mu": ctx.mu(),
2498 "objective": ctx.objective(),
2499 "inf_pr": ctx.inf_pr(),
2500 "inf_du": ctx.inf_du(),
2501 "nlp_error": ctx.nlp_error(),
2502 "iterate": collect(false),
2503 "delta": collect(true),
2504 });
2505 match std::fs::write(&path, format!("{payload}\n")) {
2506 Ok(()) => {
2507 let p = path.to_string_lossy().to_string();
2508 CmdOut::ok(vec![format!("saved iterate to {p}")])
2509 .with_data(serde_json::json!({"path": p}))
2510 }
2511 Err(e) => CmdOut::err(format!("save failed: {e}")),
2512 }
2513 }
2514
2515 fn cmd_load(&mut self, rest: &[&str], ctx: &mut DebugCtx) -> CmdOut {
2522 let Some(&path) = rest.first() else {
2523 return CmdOut::err("usage: load <file> [block] (inverse of `save`)");
2524 };
2525 let content = match std::fs::read_to_string(path) {
2526 Ok(c) => c,
2527 Err(e) => return CmdOut::err(format!("cannot read `{path}`: {e}")),
2528 };
2529 if let Ok(v) = serde_json::from_str::<serde_json::Value>(content.trim()) {
2533 let obj = v
2534 .get("iterate")
2535 .and_then(|o| o.as_object())
2536 .or_else(|| v.as_object());
2537 if let Some(obj) = obj {
2538 let mut loaded: Vec<(String, usize)> = Vec::new();
2539 let mut errs: Vec<String> = Vec::new();
2540 for &b in BLOCK_NAMES.iter() {
2541 let Some(arr) = obj.get(b).and_then(|a| a.as_array()) else {
2542 continue;
2543 };
2544 let vals: Option<Vec<f64>> = arr.iter().map(|x| x.as_f64()).collect();
2545 let Some(vals) = vals else {
2546 errs.push(format!("{b}: non-numeric entries"));
2547 continue;
2548 };
2549 match ctx.set_block(b, &vals) {
2550 Ok(()) => loaded.push((b.to_string(), vals.len())),
2551 Err(e) => errs.push(format!("{b}: {e}")),
2552 }
2553 }
2554 if loaded.is_empty() && errs.is_empty() {
2555 return CmdOut::err(
2556 "no recognizable blocks in JSON (expected `x`, `s`, … at top level or under `iterate`)",
2557 );
2558 }
2559 let mut lines: Vec<String> = loaded
2560 .iter()
2561 .map(|(b, n)| format!("loaded {b} ({n} values)"))
2562 .collect();
2563 lines.extend(errs.iter().map(|e| format!("skipped {e}")));
2564 return CmdOut::ok(lines).with_data(serde_json::json!({
2565 "loaded": loaded.iter().map(|(b, n)| serde_json::json!({"block": b, "n": n})).collect::<Vec<_>>(),
2566 "skipped": errs,
2567 }));
2568 }
2569 }
2570 let block = rest.get(1).copied().unwrap_or("x");
2572 let vals = match parse_floats(&content) {
2573 Ok(v) if !v.is_empty() => v,
2574 Ok(_) => return CmdOut::err("file held no numbers"),
2575 Err(e) => return CmdOut::err(e),
2576 };
2577 match ctx.set_block(block, &vals) {
2578 Ok(()) => CmdOut::ok(vec![format!("loaded {block} ({} values)", vals.len())])
2579 .with_data(serde_json::json!({"block": block, "n": vals.len()})),
2580 Err(e) => CmdOut::err(e),
2581 }
2582 }
2583
2584 fn cmd_sweep(&mut self, rest: &[&str], ctx: &mut DebugCtx) -> CmdOut {
2590 if self.restart.is_none() {
2591 return CmdOut::err("sweep needs re-solve, which is not available in this context");
2592 }
2593 let Some(&path) = rest.first() else {
2594 return CmdOut::err("usage: sweep <file> (one start per line, comma-separated)");
2595 };
2596 let content = match std::fs::read_to_string(path) {
2597 Ok(c) => c,
2598 Err(e) => return CmdOut::err(format!("cannot read `{path}`: {e}")),
2599 };
2600 let dim = ctx.block("x").map(|x| x.len()).unwrap_or(0);
2601 let mut seeds: Vec<Vec<f64>> = Vec::new();
2602 for (lineno, raw) in content.lines().enumerate() {
2603 let line = raw.trim();
2604 if line.is_empty() || line.starts_with('#') || line.starts_with("//") {
2605 continue;
2606 }
2607 match parse_floats(line) {
2608 Ok(v) if v.len() == dim => seeds.push(v),
2609 Ok(v) => {
2610 return CmdOut::err(format!(
2611 "line {}: got {} values, expected {dim} (= dim x)",
2612 lineno + 1,
2613 v.len()
2614 ));
2615 }
2616 Err(e) => return CmdOut::err(format!("line {}: {e}", lineno + 1)),
2617 }
2618 }
2619 self.start_sweep(seeds, &format!("sweep `{path}`"))
2620 }
2621
2622 fn cmd_multistart(&mut self, rest: &[&str], ctx: &mut DebugCtx) -> CmdOut {
2630 if self.restart.is_none() {
2631 return CmdOut::err(
2632 "multistart needs re-solve, which is not available in this context",
2633 );
2634 }
2635 let Some(n) = rest.first().and_then(|s| s.parse::<usize>().ok()) else {
2636 return CmdOut::err("usage: multistart <N> [rel] (N sampled restarts)");
2637 };
2638 if n == 0 {
2639 return CmdOut::err("N must be ≥ 1");
2640 }
2641 let rel = rest
2642 .get(1)
2643 .and_then(|s| s.parse::<f64>().ok())
2644 .unwrap_or(0.1);
2645 let Some(base) = ctx.block("x") else {
2646 return CmdOut::err("no current iterate to sample from");
2647 };
2648 let bounds = ctx
2650 .var_bounds()
2651 .filter(|(lo, hi)| lo.len() == base.len() && hi.len() == base.len());
2652 let n_box = bounds
2653 .as_ref()
2654 .map(|(lo, hi)| {
2655 lo.iter()
2656 .zip(hi)
2657 .filter(|(l, u)| l.is_finite() && u.is_finite() && u > l)
2658 .count()
2659 })
2660 .unwrap_or(0);
2661 let seeds: Vec<Vec<f64>> = (0..n)
2662 .map(|k| {
2663 let b = bounds
2664 .as_ref()
2665 .map(|(lo, hi)| (lo.as_slice(), hi.as_slice()));
2666 sample_start(&base, b, rel, k)
2667 })
2668 .collect();
2669 let n_var = base.len();
2670 let label = if n_box == n_var {
2671 format!("multistart {n} (box-sampled, {n_box}/{n_var} vars bounded)")
2672 } else if n_box > 0 {
2673 format!(
2674 "multistart {n} (box {n_box}/{n_var} vars; {} unbounded → jitter rel={rel})",
2675 n_var - n_box
2676 )
2677 } else {
2678 format!("multistart {n} (no finite boxes → jitter rel={rel})")
2679 };
2680 self.start_sweep(seeds, &label)
2681 }
2682
2683 fn start_sweep(&mut self, seeds: Vec<Vec<f64>>, label: &str) -> CmdOut {
2688 if seeds.is_empty() {
2689 return CmdOut::err("no start points");
2690 }
2691 let Some(cell) = self.restart.as_ref() else {
2692 return CmdOut::err("sweep needs re-solve, which is not available in this context");
2693 };
2694 let total = seeds.len();
2695 let mut queue: VecDeque<Vec<f64>> = seeds.into();
2696 let first = queue.pop_front().expect("non-empty");
2697 *cell.borrow_mut() = Some(RestartRequest {
2698 seed_x: first.clone(),
2699 options: self.staged.clone(),
2700 warm: None,
2701 });
2702 let saved_pause_iters = self.pause_iters;
2705 self.pause_iters = false;
2706 self.step = false;
2707 self.sub_step = false;
2708 self.run_to = None;
2709 self.sweep = Some(SweepState {
2710 queue,
2711 current: Some(first),
2712 records: Vec::new(),
2713 total,
2714 saved_pause_iters,
2715 });
2716 CmdOut::ok(vec![format!("{label}: running {total} start(s)…")])
2717 .with_data(serde_json::json!({"sweep": label, "starts": total}))
2718 .flow(Flow::Stop)
2719 }
2720
2721 fn drive_sweep(&mut self, ctx: &DebugCtx) -> Option<DebugAction> {
2728 let mut sweep = self.sweep.take()?;
2729 let rec = SweepRecord {
2730 idx: sweep.records.len(),
2731 seed: sweep.current.clone().unwrap_or_default(),
2732 status: ctx.status().unwrap_or("?").to_string(),
2733 objective: ctx.objective(),
2734 inf_pr: ctx.inf_pr(),
2735 iters: ctx.iter(),
2736 };
2737 self.emit_sweep_progress(&rec, sweep.total);
2738 sweep.records.push(rec);
2739 if let Some(next) = sweep.queue.pop_front() {
2740 sweep.current = Some(next.clone());
2741 if let Some(cell) = self.restart.as_ref() {
2742 *cell.borrow_mut() = Some(RestartRequest {
2743 seed_x: next,
2744 options: self.staged.clone(),
2745 warm: None,
2746 });
2747 }
2748 self.sweep = Some(sweep);
2749 return Some(DebugAction::Resume);
2750 }
2751 self.pause_iters = sweep.saved_pause_iters;
2753 self.emit_sweep_summary(&sweep);
2754 None
2755 }
2756
2757 fn emit_sweep_progress(&self, rec: &SweepRecord, total: usize) {
2760 match self.mode {
2761 DebugMode::Repl => eprintln!(
2762 " sweep {}/{}: {:<22} iters={:<4} obj={:.6e} inf_pr={:.2e}",
2763 rec.idx + 1,
2764 total,
2765 rec.status,
2766 rec.iters,
2767 rec.objective,
2768 rec.inf_pr,
2769 ),
2770 DebugMode::Json => emit_json(&serde_json::json!({
2771 "event": "sweep_result",
2772 "index": rec.idx,
2773 "total": total,
2774 "status": rec.status,
2775 "iters": rec.iters,
2776 "objective": rec.objective,
2777 "inf_pr": rec.inf_pr,
2778 "seed": rec.seed,
2779 })),
2780 }
2781 }
2782
2783 fn emit_sweep_summary(&self, sweep: &SweepState) {
2786 let succeeded: Vec<&SweepRecord> = sweep
2787 .records
2788 .iter()
2789 .filter(|r| is_success_status(&r.status))
2790 .collect();
2791 let mut distinct: Vec<f64> = Vec::new();
2793 for r in &succeeded {
2794 if !distinct
2795 .iter()
2796 .any(|&o| (o - r.objective).abs() <= 1e-6 * o.abs().max(1.0))
2797 {
2798 distinct.push(r.objective);
2799 }
2800 }
2801 let best = succeeded.iter().min_by(|a, b| {
2802 a.objective
2803 .partial_cmp(&b.objective)
2804 .unwrap_or(std::cmp::Ordering::Equal)
2805 });
2806 match self.mode {
2807 DebugMode::Repl => {
2808 eprintln!(
2809 "\n── sweep complete ── {} solves, {} succeeded, {} distinct end points (by objective; not curvature-classified)",
2810 sweep.records.len(),
2811 succeeded.len(),
2812 distinct.len()
2813 );
2814 eprintln!(
2815 " {:>3} {:<22} {:>5} {:>14} {:>9}",
2816 "#", "status", "iters", "objective", "inf_pr"
2817 );
2818 for r in &sweep.records {
2819 eprintln!(
2820 " {:>3} {:<22} {:>5} {:>14.6e} {:>9.2e}",
2821 r.idx, r.status, r.iters, r.objective, r.inf_pr
2822 );
2823 }
2824 if let Some(b) = best {
2825 eprintln!(" best: solve #{} obj={:.8e}", b.idx, b.objective);
2826 }
2827 }
2828 DebugMode::Json => emit_json(&serde_json::json!({
2829 "event": "sweep_summary",
2830 "solves": sweep.records.len(),
2831 "succeeded": succeeded.len(),
2832 "distinct_points": distinct.len(),
2836 "distinct_minima": distinct.len(),
2837 "best_index": best.map(|b| b.idx),
2838 "best_objective": best.map(|b| b.objective),
2839 "records": sweep.records.iter().map(|r| serde_json::json!({
2840 "index": r.idx, "status": r.status, "iters": r.iters,
2841 "objective": r.objective, "inf_pr": r.inf_pr,
2842 })).collect::<Vec<_>>(),
2843 })),
2844 }
2845 }
2846
2847 fn cmd_goto(&mut self, rest: &[&str], ctx: &mut dyn DebugState) -> CmdOut {
2849 match rest.first().and_then(|s| s.parse::<i32>().ok()) {
2850 Some(k) => self.restore_to(k, ctx),
2851 None => CmdOut::err("usage: goto <iteration>"),
2852 }
2853 }
2854
2855 fn restore_to(&mut self, k: i32, ctx: &mut dyn DebugState) -> CmdOut {
2859 match self.snapshots.get(&k) {
2860 Some(snap) => {
2861 if !ctx.restore(snap.as_ref()) {
2862 return CmdOut::err(format!(
2863 "this solver does not support rewinding to iter {k}"
2864 ));
2865 }
2866 CmdOut::ok(vec![format!(
2867 "rewound to iter {k} (primal-dual only; strategy history not restored). \
2868 `continue`/`step` to resume."
2869 )])
2870 .with_data(serde_json::json!({"restored_iter": k}))
2871 }
2872 None => {
2873 let have: Vec<i32> = self.snapshots.keys().copied().collect();
2874 CmdOut::err(format!("no snapshot for iter {k} (captured: {have:?})"))
2875 }
2876 }
2877 }
2878
2879 fn cmd_resolve(&mut self, ctx: &DebugCtx) -> CmdOut {
2887 let Some(cell) = self.restart.as_ref() else {
2888 return CmdOut::err("re-solve is not available in this context");
2889 };
2890 let Some(seed_x) = ctx.block("x") else {
2891 return CmdOut::err("no current iterate to seed from");
2892 };
2893 let warm = ctx.snapshot();
2894 let mu = warm.as_ref().map(|s| s.mu());
2895 let options = self.staged.clone();
2896 let n_opt = options.len();
2897 let warm_msg = match mu {
2898 Some(mu) => format!(
2899 "re-solving warm from the current primal-dual iterate (μ={mu:.3e}) \
2900 with {n_opt} staged option override(s)…"
2901 ),
2902 None => format!(
2903 "re-solving from current x (primal-only) with {n_opt} staged option override(s)…"
2904 ),
2905 };
2906 *cell.borrow_mut() = Some(RestartRequest {
2907 seed_x,
2908 options,
2909 warm,
2910 });
2911 CmdOut::ok(vec![warm_msg])
2912 .with_data(serde_json::json!({
2913 "resolve": true,
2914 "options": n_opt,
2915 "warm": mu.is_some(),
2916 "mu": mu,
2917 }))
2918 .flow(Flow::Stop)
2919 }
2920
2921 fn cmd_ask(&self, rest: &[&str], ctx: &dyn DebugState) -> CmdOut {
2927 let question = if rest.is_empty() {
2928 "Explain the current state of this interior-point solve and suggest what to try next."
2929 .to_string()
2930 } else {
2931 rest.join(" ")
2932 };
2933 let prompt = build_ask_prompt(ctx, &question);
2934 match run_llm(&prompt) {
2935 Ok(reply) => {
2936 let lines: Vec<String> = reply.lines().map(|l| l.to_string()).collect();
2937 CmdOut::ok(lines).with_data(serde_json::json!({
2938 "question": question,
2939 "reply": reply,
2940 }))
2941 }
2942 Err(e) => CmdOut::err(e),
2943 }
2944 }
2945
2946 fn cmd_watch(&mut self, rest: &[&str]) -> CmdOut {
2949 match rest {
2950 [] => CmdOut::ok(vec![format!("watches: {:?}", self.watches)])
2951 .with_data(serde_json::json!({"watches": self.watches})),
2952 ["clear"] => {
2953 self.watches.clear();
2954 CmdOut::ok(vec!["cleared watches".into()])
2955 }
2956 ["del", w] | ["delete", w] => {
2957 self.watches.retain(|x| x != w);
2958 CmdOut::ok(vec![format!("unwatched {w}")])
2959 }
2960 [w] => {
2961 let w = w.to_string();
2962 if !self.watches.contains(&w) {
2963 self.watches.push(w.clone());
2964 }
2965 CmdOut::ok(vec![format!("watching {w}")])
2966 }
2967 _ => CmdOut::err("usage: watch [<target> | clear | del <target>]"),
2968 }
2969 }
2970
2971 fn cmd_watchpoint(&mut self, rest: &[&str], ctx: &dyn DebugState) -> CmdOut {
2975 match rest {
2976 [] => {
2977 let v: Vec<&str> = self.watchpoints.iter().map(|w| w.raw.as_str()).collect();
2978 CmdOut::ok(vec![format!("watchpoints: {v:?}")])
2979 .with_data(serde_json::json!({"watchpoints": v}))
2980 }
2981 ["clear"] => {
2982 self.watchpoints.clear();
2983 CmdOut::ok(vec!["cleared watchpoints".into()])
2984 }
2985 ["del", spec] | ["delete", spec] => {
2986 self.watchpoints.retain(|w| w.raw != *spec);
2987 CmdOut::ok(vec![format!("removed watchpoint {spec}")])
2988 }
2989 [spec, rest @ ..] => {
2990 let threshold = rest
2991 .first()
2992 .and_then(|s| s.parse::<f64>().ok())
2993 .unwrap_or(0.0);
2994 let (block, idx) = match spec.find('[') {
2996 Some(open) if spec.ends_with(']') => {
2997 let b = &spec[..open];
2998 match spec[open + 1..spec.len() - 1].parse::<usize>() {
2999 Ok(i) => (b.to_string(), Some(i)),
3000 Err(_) => return CmdOut::err(format!("bad index in `{spec}`")),
3001 }
3002 }
3003 _ => (spec.to_string(), None),
3004 };
3005 if !is_block(ctx, block.as_str()) {
3006 return CmdOut::err(format!("unknown block `{block}`"));
3007 }
3008 let raw = spec.to_string();
3009 if !self.watchpoints.iter().any(|w| w.raw == raw) {
3010 self.watchpoints.push(WatchPoint {
3011 raw: raw.clone(),
3012 block,
3013 idx,
3014 threshold,
3015 last: None,
3016 });
3017 }
3018 CmdOut::ok(vec![format!("watchpoint on {raw} (Δ>{threshold:.3e})")])
3019 }
3020 }
3021 }
3022
3023 fn cmd_commands(&mut self, rest: &[&str]) -> CmdOut {
3028 let Some(iter) = rest.first().and_then(|s| s.parse::<i32>().ok()) else {
3029 if rest.is_empty() {
3030 let mut items: Vec<(i32, Vec<String>)> = self
3031 .bp_commands
3032 .iter()
3033 .map(|(k, v)| (*k, v.clone()))
3034 .collect();
3035 items.sort_by_key(|(k, _)| *k);
3036 let lines = if items.is_empty() {
3037 vec!["no breakpoint command lists".into()]
3038 } else {
3039 items
3040 .iter()
3041 .map(|(k, v)| format!("iter {k}: {}", v.join(" ; ")))
3042 .collect()
3043 };
3044 return CmdOut::ok(lines);
3045 }
3046 return CmdOut::err(
3047 "usage: commands <iter> <cmd> ; <cmd> … (or: commands <iter> clear)",
3048 );
3049 };
3050 let tail = rest[1..].join(" ");
3051 let tail = tail.trim();
3052 if tail.is_empty() || tail == "clear" {
3053 self.bp_commands.remove(&iter);
3054 return CmdOut::ok(vec![format!("cleared commands for iteration {iter}")]);
3055 }
3056 let cmds: Vec<String> = tail
3057 .split(';')
3058 .map(|s| s.trim().to_string())
3059 .filter(|s| !s.is_empty())
3060 .collect();
3061 self.bp_commands.insert(iter, cmds.clone());
3062 CmdOut::ok(vec![format!(
3063 "commands for iter {iter}: {}",
3064 cmds.join(" ; ")
3065 )])
3066 .with_data(serde_json::json!({"iter": iter, "commands": cmds}))
3067 }
3068
3069 fn cmd_diff(&self, ctx: &dyn DebugState) -> CmdOut {
3072 let iter = ctx.iter();
3073 let Some((&piter, prev)) = self.snapshots.range(..iter).next_back() else {
3074 return CmdOut::err("no previous iterate to diff against");
3075 };
3076 let mut lines = vec![format!("Δ since iter {piter}:")];
3077 let dmu = ctx.mu() - prev.mu();
3078 lines.push(format!(" mu = {:.6e} (Δ {:+.3e})", ctx.mu(), dmu));
3079 let mut blocks = serde_json::Map::new();
3080 for b in block_names(ctx) {
3081 let (Some(cur), Some(old)) = (ctx.block(b), prev.block(b)) else {
3082 continue;
3083 };
3084 if cur.is_empty() || cur.len() != old.len() {
3085 continue;
3086 }
3087 let mut amax = 0.0_f64;
3088 let mut imax = 0usize;
3089 for (i, (c, o)) in cur.iter().zip(&old).enumerate() {
3090 let d = (c - o).abs();
3091 if d > amax {
3092 amax = d;
3093 imax = i;
3094 }
3095 }
3096 if amax > 0.0 {
3097 lines.push(format!(
3098 " {b}: max|Δ|={amax:.3e} at [{imax}] ({:.4e} → {:.4e})",
3099 old[imax], cur[imax]
3100 ));
3101 blocks.insert(
3102 b.to_string(),
3103 serde_json::json!({"max_abs_change": amax, "argmax": imax}),
3104 );
3105 }
3106 }
3107 if lines.len() == 2 {
3108 lines.push(" (no change)".into());
3109 }
3110 CmdOut::ok(lines).with_data(
3111 serde_json::json!({"from_iter": piter, "to_iter": iter, "dmu": dmu, "blocks": blocks}),
3112 )
3113 }
3114
3115 fn cmd_source(&mut self, rest: &[&str], ctx: &mut dyn DebugState) -> CmdOut {
3119 let Some(&path) = rest.first() else {
3120 return CmdOut::err("usage: source <file>");
3121 };
3122 let content = match std::fs::read_to_string(path) {
3123 Ok(c) => c,
3124 Err(e) => return CmdOut::err(format!("cannot read `{path}`: {e}")),
3125 };
3126 let mut lines = Vec::new();
3127 let mut flow = Flow::Stay;
3128 for raw in content.lines() {
3129 let cmd = raw.trim();
3130 if cmd.is_empty() || cmd.starts_with('#') || cmd.starts_with("//") {
3131 continue;
3132 }
3133 lines.push(format!("[source] {cmd}"));
3134 let out = self.dispatch(cmd, ctx);
3135 lines.extend(out.lines);
3136 if !matches!(out.flow, Flow::Stay) {
3137 flow = out.flow;
3138 break;
3139 }
3140 }
3141 CmdOut {
3142 ok: true,
3143 lines,
3144 data: None,
3145 flow,
3146 }
3147 }
3148
3149 fn cmd_viz(&self, rest: &[&str], ctx: &mut dyn DebugState) -> CmdOut {
3150 let Some(&target) = rest.first() else {
3151 return CmdOut::err("usage: viz <x|s|y_c|...|dx|kkt|L>");
3152 };
3153 if target == "kkt" {
3156 if as_nlp(ctx).is_none() {
3158 return nlp_only("viz kkt");
3159 }
3160 let Some(k) = ctx.kkt() else {
3161 return CmdOut::err(
3162 "no KKT factorization captured yet — nothing has been factored (iter 0), \
3163 or the debugger is detached. `step` once to capture.",
3164 );
3165 };
3166 let Some((dim, irn, jcn, vals)) = ctx.kkt_matrix() else {
3171 return CmdOut::err(
3172 "KKT matrix not captured here — the debugger is detached \
3173 (running free). `step` once to capture and re-run `viz kkt`.",
3174 );
3175 };
3176 let kiter = k.iter;
3179 let matrix = serde_json::json!({"dim": dim, "irn": irn, "jcn": jcn, "vals": vals,
3180 "format": "triplet_1based_lower"});
3181 let payload = serde_json::json!({
3182 "label": "kkt", "iter": kiter,
3183 "dim": k.dim, "n_pos": k.n_pos, "n_neg": k.n_neg,
3184 "expected_neg": k.expected_neg, "inertia_correct": k.inertia_correct,
3185 "delta_w": k.delta_w, "delta_c": k.delta_c, "status": k.status,
3186 "matrix": matrix,
3187 });
3188 return match write_json_and_open("kkt", kiter, &payload) {
3189 Ok((path, viewer)) => CmdOut::ok(vec![format!(
3190 "wrote {path} (KKT system, iter {kiter}); opened with `{viewer}`"
3191 )])
3192 .with_data(serde_json::json!({"path": path, "viewer": viewer})),
3193 Err(e) => CmdOut::err(e),
3194 };
3195 }
3196 if target == "L" {
3201 if as_nlp(ctx).is_none() {
3202 return nlp_only("viz L");
3203 }
3204 match ctx.kkt_l_factor() {
3205 Some((n, perm, l_irn, l_jcn, l_vals)) => {
3206 let kiter = ctx.kkt_captured_iter().unwrap_or_else(|| ctx.iter());
3208 let payload = serde_json::json!({
3209 "label": "L", "iter": kiter, "n": n, "perm": perm,
3210 "l_irn": l_irn, "l_jcn": l_jcn, "l_vals": l_vals,
3211 "format": "strict_lower_1based_permuted",
3212 });
3213 return match write_json_and_open("L", kiter, &payload) {
3214 Ok((path, viewer)) => CmdOut::ok(vec![format!(
3215 "wrote {path} (L factor, iter {kiter}); opened with `{viewer}`"
3216 )])
3217 .with_data(serde_json::json!({"path": path, "viewer": viewer})),
3218 Err(e) => CmdOut::err(e),
3219 };
3220 }
3221 None => {
3222 return CmdOut::err(
3223 "L factor not captured here — nothing factored yet (iter 0), \
3224 or the debugger is detached. `step` once to capture.",
3225 );
3226 }
3227 }
3228 }
3229 let (label, vals) = if is_block(ctx, target) {
3231 match ctx.block(target) {
3232 Some(v) => (target.to_string(), v),
3233 None => return CmdOut::err(format!("no data for block `{target}`")),
3234 }
3235 } else if let Some(blk) = target.strip_prefix("d").filter(|b| is_block(ctx, b)) {
3236 match ctx.delta_block(blk) {
3237 Some(v) => (format!("d{blk}"), v),
3238 None => return CmdOut::err(format!("no search direction for `d{blk}`")),
3239 }
3240 } else {
3241 return CmdOut::err(format!("don't know how to visualize `{target}`"));
3242 };
3243 match write_and_open(&label, ctx.iter(), &vals) {
3244 Ok((path, viewer)) => CmdOut::ok(vec![format!(
3245 "wrote {} ({} values); opened with `{}`",
3246 path,
3247 vals.len(),
3248 viewer
3249 )])
3250 .with_data(serde_json::json!({"path": path, "viewer": viewer, "n": vals.len()})),
3251 Err(e) => CmdOut::err(e),
3252 }
3253 }
3254
3255 fn emit_pause(&self, ctx: &dyn DebugState, reason: Option<&str>) {
3259 let terminal = matches!(ctx.checkpoint(), Checkpoint::Terminated);
3260 match self.mode {
3261 DebugMode::Repl => {
3262 if terminal {
3263 let which = if self.in_restoration {
3266 "restoration inner solve"
3267 } else {
3268 "solve"
3269 };
3270 eprintln!(
3271 "\n── pounce-dbg ── TERMINATED {which} ({}) iter {} obj={:.6e} inf_pr={:.2e} inf_du={:.2e}",
3272 ctx.status().unwrap_or("?"),
3273 ctx.iter(),
3274 ctx.objective(),
3275 ctx.inf_pr(),
3276 ctx.inf_du(),
3277 );
3278 } else {
3279 let resto = if self.in_restoration {
3280 " [restoration]"
3281 } else {
3282 ""
3283 };
3284 eprintln!(
3285 "\n── pounce-dbg ── iter {} @{}{} mu={:.3e} obj={:.6e} inf_pr={:.2e} inf_du={:.2e}",
3286 ctx.iter(),
3287 ctx.checkpoint().as_str(),
3288 resto,
3289 ctx.mu(),
3290 ctx.objective(),
3291 ctx.inf_pr(),
3292 ctx.inf_du(),
3293 );
3294 }
3295 if let Some(r) = reason {
3296 eprintln!(" ↳ {r}");
3297 }
3298 for w in &self.watches {
3299 let out = self.cmd_print(&[w.as_str()], ctx);
3300 if out.ok {
3301 for l in &out.lines {
3302 eprintln!(" watch {l}");
3303 }
3304 } else {
3305 eprintln!(" watch {w}: (n/a)");
3309 }
3310 }
3311 }
3312 DebugMode::Json => {
3313 let watches: Vec<serde_json::Value> = self
3314 .watches
3315 .iter()
3316 .map(|w| {
3317 let out = self.cmd_print(&[w.as_str()], ctx);
3318 serde_json::json!({"expr": w, "ok": out.ok, "output": out.lines, "data": out.data})
3319 })
3320 .collect();
3321 let dims: serde_json::Map<String, serde_json::Value> = ctx
3322 .block_dims()
3323 .into_iter()
3324 .map(|(n, d)| (n.to_string(), serde_json::json!(d)))
3325 .collect();
3326 let conds: Vec<String> = self.conds.iter().map(|c| c.raw.clone()).collect();
3327 let mut ev = serde_json::json!({
3328 "event": "pause",
3329 "checkpoint": ctx.checkpoint().as_str(),
3330 "status": ctx.status(),
3331 "in_restoration": self.in_restoration,
3332 "phase": if self.in_restoration { "restoration" } else { "main" },
3337 "final": terminal && !self.in_restoration,
3338 "dims": dims,
3339 "breakpoints": self.breaks,
3340 "conditions": conds,
3341 "reason": reason,
3342 "watches": watches,
3343 });
3344 insert_metric_fields(&mut ev, ctx);
3347 emit_json(&ev);
3348 }
3349 }
3350 }
3351
3352 fn emit_progress_event(&self, ctx: &dyn DebugState) {
3357 let mut ev = serde_json::json!({ "event": "progress" });
3358 insert_metric_fields(&mut ev, ctx);
3360 emit_json(&ev);
3361 }
3362
3363 fn emit_result(&self, command: &str, out: &CmdOut, req_id: Option<&serde_json::Value>) {
3366 match self.mode {
3367 DebugMode::Repl => {
3368 let stderr = std::io::stderr();
3369 let mut h = stderr.lock();
3370 for l in &out.lines {
3371 let _ = writeln!(h, "{l}");
3372 }
3373 if !out.ok {
3374 let _ = writeln!(h, "(error)");
3375 }
3376 }
3377 DebugMode::Json => {
3378 let ev = serde_json::json!({
3379 "event": "result",
3380 "request_id": req_id,
3381 "command": command,
3382 "ok": out.ok,
3383 "output": out.lines,
3384 "data": out.data,
3385 });
3386 emit_json(&ev);
3387 }
3388 }
3389 }
3390
3391 fn emit_hello(&self, ctx: &dyn DebugState) {
3402 let nlp = as_nlp(ctx).is_some();
3403 let viz: &[&str] = if nlp {
3405 &["block", "delta", "kkt", "L"]
3406 } else {
3407 &["block", "delta"]
3408 };
3409 let ev = serde_json::json!({
3410 "event": "hello",
3411 "protocol": "pounce-dbg/1",
3412 "pounce_version": env!("CARGO_PKG_VERSION"),
3413 "capabilities": {
3414 "inspect": true,
3415 "mutate_iterate": true,
3416 "mutate_mu": nlp,
3419 "conditional_breakpoints": "compound",
3420 "request_ids": true,
3421 "viz": viz,
3422 "save": true,
3423 "load": nlp,
3424 "sweep": nlp && self.restart.is_some(),
3425 "kkt_inspect": nlp,
3426 "equations": self.equation_book.is_some(),
3429 "diagnose": nlp,
3431 "structural_diagnose": nlp && self.structure_book.is_some(),
3434 "llm_assist": true,
3435 "rewind": "primal_dual",
3436 "resolve": nlp && self.restart.is_some(),
3437 "terminal_checkpoint": true,
3438 "interruptible": self.interruptible,
3439 "progress_events": self.emit_progress,
3441 "async_pause": "checkpoint",
3442 "pause_command": true,
3445 },
3446 "checkpoints": CHECKPOINTS,
3447 "events": EVENTS,
3448 "commands": COMMANDS,
3449 "blocks": block_names(ctx),
3450 "metrics": METRICS,
3451 });
3452 emit_json(&ev);
3453 }
3454
3455 fn ensure_editor(&mut self) {
3459 if !matches!(self.mode, DebugMode::Repl)
3460 || self.editor.is_some()
3461 || !std::io::stdin().is_terminal()
3462 {
3463 return;
3464 }
3465 let mut ed: Editor<DbgHelper, FileHistory> = match Editor::new() {
3466 Ok(e) => e,
3467 Err(_) => return,
3468 };
3469 ed.set_helper(Some(DbgHelper {
3470 reg: self.reg.clone(),
3471 }));
3472 let path = std::env::var_os("HOME")
3473 .or_else(|| std::env::var_os("USERPROFILE"))
3474 .map(|h| PathBuf::from(h).join(".pounce_dbg_history"));
3475 if let Some(p) = &path {
3476 let _ = ed.load_history(p);
3477 }
3478 self.hist_path = path;
3479 self.editor = Some(ed);
3480 }
3481
3482 fn on_prompt_interrupt(&mut self) -> String {
3487 self.prompt_interrupts += 1;
3488 if self.prompt_interrupts >= 2 {
3489 self.prompt_interrupts = 0;
3490 eprintln!("(quitting — Ctrl-C)");
3491 "quit".to_string()
3492 } else {
3493 eprintln!("(Ctrl-C — press again, or `quit`/Ctrl-D, to stop the solve)");
3494 String::new()
3495 }
3496 }
3497
3498 fn next_command_line(&mut self) -> Option<String> {
3502 if let Some(q) = &self.script_queue {
3506 let cmd = q.borrow_mut().pop_front();
3507 if let Some(c) = &cmd {
3508 let _ = writeln!(std::io::stderr(), "pounce-dbg> {c}");
3509 }
3510 return cmd;
3511 }
3512 if let DebugMode::Repl = self.mode {
3513 if let Some(ed) = self.editor.as_mut() {
3514 return match ed.readline("pounce-dbg> ") {
3515 Ok(l) => {
3516 self.prompt_interrupts = 0;
3517 let _ = ed.add_history_entry(l.as_str());
3518 if let Some(p) = &self.hist_path {
3519 let _ = ed.save_history(p);
3520 }
3521 Some(l)
3522 }
3523 Err(ReadlineError::Interrupted) => Some(self.on_prompt_interrupt()),
3528 Err(ReadlineError::Eof) => None,
3530 Err(_) => None,
3531 };
3532 }
3533 if self.script_given && !std::io::stdin().is_terminal() {
3538 return None;
3539 }
3540 let _ = write!(std::io::stderr(), "pounce-dbg> ");
3541 let _ = std::io::stderr().flush();
3542 return read_stdin_line();
3543 }
3544 self.pump.get_or_insert_with(StdinPump::start).next()
3547 }
3548}
3549
3550fn read_stdin_line() -> Option<String> {
3552 let mut line = String::new();
3553 match std::io::stdin().read_line(&mut line) {
3554 Ok(0) => None,
3555 Ok(_) => Some(line),
3556 Err(_) => None,
3557 }
3558}
3559
3560fn rank_residuals(mut entries: Vec<Residual>, k: usize) -> Vec<Residual> {
3568 entries.sort_by(|a, b| {
3569 b.value
3570 .abs()
3571 .partial_cmp(&a.value.abs())
3572 .unwrap_or(std::cmp::Ordering::Equal)
3573 });
3574 entries.truncate(k);
3575 entries
3576}
3577
3578fn render_rank_report(
3589 rep: &RankReport,
3590 names: &Option<SplitNames>,
3591 equations: Option<&EquationBook>,
3592 iter: i32,
3593) -> (Vec<String>, serde_json::Value) {
3594 let m = rep.n_rows();
3595 let n = rep.n_cols;
3596 let mut lines = vec![
3597 format!("equality Jacobian J_c: {m} row(s) × {n} column(s)"),
3598 format!(
3599 "numerical rank = {} / {} (deficiency {})",
3600 rep.rank,
3601 m,
3602 rep.deficiency()
3603 ),
3604 format!(
3605 "σ_max = {:.3e} σ_min = {:.3e} cond = {} (rank tol τ = {:.3e})",
3606 rep.sigma_max(),
3607 rep.sigma_min(),
3608 fmt_cond(rep.cond),
3609 rep.tol
3610 ),
3611 ];
3612
3613 let shown: Vec<String> = rep
3615 .singular_values
3616 .iter()
3617 .take(MAX_SINGULAR_VALUES_SHOWN)
3618 .map(|s| format!("{s:.3e}"))
3619 .collect();
3620 let tail = if rep.singular_values.len() > MAX_SINGULAR_VALUES_SHOWN {
3621 " …"
3622 } else {
3623 ""
3624 };
3625 lines.push(format!("singular values: [{}{tail}]", shown.join(", ")));
3626
3627 if rep.is_rank_deficient() {
3628 lines.push(format!(
3629 "rank-deficient: {} equation(s) lie in the near-null space \
3630 (linearly dependent / redundant) — the source of δ_c regularization:",
3631 rep.deficiency()
3632 ));
3633 let mut shown_any_eq = false;
3634 for c in rep.culprits.iter().take(MAX_RANK_CULPRITS) {
3635 let row = &rep.rows[c.row];
3636 let label = rank_row_label(row, names);
3637 lines.push(format!(" {label} (participation {:.2})", c.weight));
3638 if let Some(eq) = culprit_equation(row, names, equations) {
3642 lines.push(format!(" {eq}"));
3643 shown_any_eq = true;
3644 }
3645 }
3646 if rep.culprits.len() > MAX_RANK_CULPRITS {
3647 lines.push(format!(
3648 " … and {} more",
3649 rep.culprits.len() - MAX_RANK_CULPRITS
3650 ));
3651 }
3652 if !shown_any_eq {
3655 lines.push("inspect a row with `print equation <name>` to see its terms".to_string());
3656 }
3657 } else {
3658 lines.push("J_c has full row rank at this iterate.".to_string());
3659 }
3660
3661 let culprits_json: Vec<serde_json::Value> = rep
3662 .culprits
3663 .iter()
3664 .map(|c| {
3665 let row = &rep.rows[c.row];
3666 serde_json::json!({
3667 "row": c.row,
3668 "kind": row.kind.tag(),
3669 "index": row.index,
3670 "name": rank_row_name(row, names),
3671 "label": rank_row_label(row, names),
3672 "weight": c.weight,
3673 "equation": culprit_equation(row, names, equations),
3674 })
3675 })
3676 .collect();
3677
3678 let data = serde_json::json!({
3679 "iter": iter,
3680 "n_rows": m,
3681 "n_cols": n,
3682 "rank": rep.rank,
3683 "deficiency": rep.deficiency(),
3684 "rank_deficient": rep.is_rank_deficient(),
3685 "sigma_max": rep.sigma_max(),
3686 "sigma_min": rep.sigma_min(),
3687 "cond": cond_json(rep.cond),
3688 "tol": rep.tol,
3689 "singular_values": rep.singular_values,
3690 "culprits": culprits_json,
3691 });
3692
3693 (lines, data)
3694}
3695
3696fn culprit_equation(
3703 row: &RankRow,
3704 names: &Option<SplitNames>,
3705 equations: Option<&EquationBook>,
3706) -> Option<String> {
3707 let book = equations?;
3708 let name = rank_row_name(row, names)?;
3709 let i = book.resolve(&name)?;
3710 Some(book.equations.get(i)?.clone())
3711}
3712
3713fn rank_row_name(row: &RankRow, names: &Option<SplitNames>) -> Option<String> {
3718 let r = Residual {
3719 kind: row.kind,
3720 index: row.index,
3721 value: 0.0,
3722 };
3723 resid_name(&r, names).map(|s| s.to_string())
3724}
3725
3726fn rank_row_label(row: &RankRow, names: &Option<SplitNames>) -> String {
3729 match rank_row_name(row, names) {
3730 Some(name) => format!("{}[{}]", row.kind.tag(), name),
3731 None => format!("{}[{}]", row.kind.tag(), row.index),
3732 }
3733}
3734
3735fn fmt_cond(cond: f64) -> String {
3738 if cond.is_finite() {
3739 format!("{cond:.3e}")
3740 } else {
3741 "inf (σ_min = 0)".to_string()
3742 }
3743}
3744
3745fn cond_json(cond: f64) -> serde_json::Value {
3748 if cond.is_finite() {
3749 serde_json::json!(cond)
3750 } else {
3751 serde_json::Value::Null
3752 }
3753}
3754
3755fn resid_name<'a>(r: &Residual, names: &'a Option<SplitNames>) -> Option<&'a str> {
3756 let n = names.as_ref()?;
3757 let pool = match r.kind {
3758 ResidKind::Eq => &n.eq,
3759 ResidKind::Ineq | ResidKind::DualS => &n.ineq,
3760 ResidKind::DualX => &n.x_var,
3761 };
3762 pool.get(r.index).and_then(|o| o.as_deref())
3763}
3764
3765fn worst_named(resids: Vec<Residual>, names: &Option<SplitNames>) -> Option<(String, f64)> {
3769 let top = rank_residuals(resids, 1);
3770 let r = top.first()?;
3771 let label = match resid_name(r, names) {
3772 Some(name) => format!("{}[{}]", r.kind.tag(), name),
3773 None => format!("{}[{}]", r.kind.tag(), r.index),
3774 };
3775 Some((label, r.value))
3776}
3777
3778pub fn print_open_banner(mode: DebugMode) {
3782 if !matches!(mode, DebugMode::Repl) {
3783 return;
3784 }
3785 let color = std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none();
3786 let paint = |r: u8, g: u8, b: u8, bold: bool, s: &str| -> String {
3787 if color {
3788 let w = if bold { "1;" } else { "" };
3789 format!("\x1b[{w}38;2;{r};{g};{b}m{s}\x1b[0m")
3790 } else {
3791 s.to_string()
3792 }
3793 };
3794 let orange = |s: &str| paint(0xE8, 0x7A, 0x1E, true, s);
3796 let gold = |s: &str| paint(0xFF, 0xB0, 0x00, true, s);
3797 let dim = |s: &str| paint(0x7A, 0x7E, 0x88, false, s);
3798 let item = |key: &str, gloss: &str| format!("{} {}", orange(key), dim(gloss));
3800
3801 let err = std::io::stderr();
3802 let mut h = err.lock();
3803 let _ = writeln!(h);
3804 for row in crate::print::logo_rows(color) {
3807 let _ = writeln!(h, " {row}");
3808 }
3809 let _ = writeln!(h);
3810 let _ = writeln!(
3811 h,
3812 " {} {}",
3813 gold("interior-point debugger"),
3814 dim(&format!(
3815 "· pounce {} · pdb for the IPM",
3816 env!("CARGO_PKG_VERSION")
3817 ))
3818 );
3819 let _ = writeln!(h);
3820 let _ = writeln!(
3822 h,
3823 " {} {} {} {} {}",
3824 item("s", "step"),
3825 item("c", "continue"),
3826 item("b", "N break"),
3827 item("r", "N run"),
3828 item("q", "quit"),
3829 );
3830 let _ = writeln!(
3831 h,
3832 " {} {} {} {} {}",
3833 item("p", "x print"),
3834 item("i", "info"),
3835 item("set", "x[i] v"),
3836 item("watch", "x"),
3837 item("viz", "kkt"),
3838 );
3839 let _ = writeln!(
3840 h,
3841 " {} {} {}",
3842 dim("type"),
3843 gold("help"),
3844 dim("for all commands · `ask` to consult Claude · Ctrl-C breaks in"),
3845 );
3846 let _ = writeln!(h);
3847}
3848
3849fn is_pause_command(line: &str) -> bool {
3852 parse_command(line, DebugMode::Json).command.trim() == "pause"
3853}
3854
3855struct StdinPump {
3860 inner: std::sync::Arc<(
3861 std::sync::Mutex<VecDeque<Option<String>>>,
3862 std::sync::Condvar,
3863 )>,
3864}
3865
3866impl StdinPump {
3867 fn start() -> Self {
3868 let inner = std::sync::Arc::new((
3869 std::sync::Mutex::new(VecDeque::new()),
3870 std::sync::Condvar::new(),
3871 ));
3872 let w = std::sync::Arc::clone(&inner);
3873 std::thread::spawn(move || {
3874 use std::io::BufRead;
3875 let stdin = std::io::stdin();
3876 let mut lock = stdin.lock();
3877 let (m, cv) = &*w;
3878 loop {
3879 let mut line = String::new();
3880 let item = match lock.read_line(&mut line) {
3881 Ok(0) | Err(_) => None, Ok(_) => Some(line),
3883 };
3884 let done = item.is_none();
3885 m.lock()
3886 .unwrap_or_else(std::sync::PoisonError::into_inner)
3887 .push_back(item);
3888 cv.notify_one();
3889 if done {
3890 break;
3891 }
3892 }
3893 });
3894 Self { inner }
3895 }
3896
3897 fn next(&self) -> Option<String> {
3899 let (m, cv) = &*self.inner;
3900 let mut q = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
3901 loop {
3902 match q.front() {
3903 None => {
3904 q = cv
3905 .wait(q)
3906 .unwrap_or_else(std::sync::PoisonError::into_inner)
3907 }
3908 Some(None) => return None, Some(Some(_)) => return q.pop_front().flatten(),
3910 }
3911 }
3912 }
3913
3914 fn try_take_pause(&self) -> bool {
3917 let (m, _) = &*self.inner;
3918 let mut q = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
3919 if let Some(Some(front)) = q.front() {
3920 if is_pause_command(front) {
3921 q.pop_front();
3922 return true;
3923 }
3924 }
3925 false
3926 }
3927}
3928
3929impl DebugHook for SolverDebugger {
3930 fn wants_kkt_capture(&self) -> bool {
3934 !self.detached
3935 }
3936
3937 fn arm(&mut self) {
3941 self.step = true;
3942 self.detached = false;
3943 self.pause_iters = true;
3944 self.pause_terminal = true;
3945 }
3946
3947 fn at_checkpoint(&mut self, ctx: &mut dyn DebugState) -> DebugAction {
3948 if matches!(self.mode, DebugMode::Json) && !self.hello_sent {
3951 self.emit_hello(&*ctx);
3952 self.hello_sent = true;
3953 }
3954 if let Checkpoint::Terminated = ctx.checkpoint() {
3958 if self.sweep.is_some() && !self.in_restoration {
3962 if let Some(c) = as_nlp(ctx) {
3965 if let Some(action) = self.drive_sweep(c) {
3966 return action;
3967 }
3968 }
3969 }
3970 let failed = ctx.status().map(|s| !is_success_status(s)).unwrap_or(false);
3971 let should =
3972 self.pause_terminal && !self.detached && (!self.terminal_only_on_error || failed);
3973 if !should {
3974 return DebugAction::Resume;
3975 }
3976 self.ensure_editor();
3977 self.emit_pause(ctx, None);
3978 return self.prompt_loop(ctx);
3979 }
3980
3981 let cp = ctx.checkpoint();
3982 match cp {
3984 Checkpoint::PreRestoration => self.in_restoration = true,
3985 Checkpoint::PostRestoration => self.in_restoration = false,
3986 _ => {}
3987 }
3988 let is_iter_start = matches!(cp, Checkpoint::IterStart);
3989
3990 if is_iter_start {
3994 if let Some(snap) = ctx.snapshot() {
3995 self.snapshots.insert(ctx.iter(), snap);
3996 while self.snapshots.len() > SNAPSHOT_CAP {
3997 let Some(&oldest) = self.snapshots.keys().next() else {
3998 break;
3999 };
4000 self.snapshots.remove(&oldest);
4001 }
4002 }
4003 self.update_mu_stall(ctx.mu());
4005 }
4006
4007 let mut reason: Option<String> = None;
4011 let mut pause = self.sub_step || self.stop_at.contains(cp.as_str());
4012
4013 if let Some(ev) = self.matched_event(ctx) {
4017 pause = true;
4018 reason = Some(format!("event: {ev}"));
4019 }
4020
4021 if is_iter_start {
4022 if self.interruptible && interrupt::take() {
4023 pause = true;
4024 reason = Some("interrupt (Ctrl-C)".into());
4025 }
4026 if let Some(p) = self.pump.as_ref() {
4029 if p.try_take_pause() {
4030 pause = true;
4031 reason = Some("pause (requested)".into());
4032 }
4033 }
4034 if self.pause_iters {
4035 if self.should_pause(ctx.iter()) {
4036 pause = true;
4037 }
4038 if let Some(c) = self.matched_condition(ctx) {
4039 pause = true;
4040 reason = Some(c);
4041 }
4042 }
4043 if let Some(w) = self.matched_watchpoint(ctx) {
4046 pause = true;
4047 reason = Some(format!("watchpoint: {w}"));
4048 }
4049 }
4050
4051 if !pause {
4052 if is_iter_start && self.emit_progress && matches!(self.mode, DebugMode::Json) {
4056 self.emit_progress_event(ctx);
4057 }
4058 return DebugAction::Resume;
4059 }
4060 self.step = false;
4062 self.sub_step = false;
4063 self.emit_pause(ctx, reason.as_deref());
4064
4065 if is_iter_start {
4069 if let Some(cmds) = self.bp_commands.get(&ctx.iter()).cloned() {
4070 for c in cmds {
4071 let out = self.dispatch(&c, ctx);
4072 self.emit_result(&c, &out, None);
4073 match out.flow {
4074 Flow::Resume => return DebugAction::Resume,
4075 Flow::Stop => return DebugAction::Stop,
4076 Flow::Stay => {}
4077 }
4078 }
4079 }
4080 }
4081
4082 self.ensure_editor();
4083 self.prompt_loop(ctx)
4084 }
4085}
4086
4087impl SolverDebugger {
4088 fn prompt_loop(&mut self, ctx: &mut dyn DebugState) -> DebugAction {
4090 if let Some(path) = self.pending_script.take() {
4093 let out = self.cmd_source(&[path.as_str()], ctx);
4094 self.emit_result("source", &out, None);
4095 match out.flow {
4096 Flow::Resume => return DebugAction::Resume,
4097 Flow::Stop => return DebugAction::Stop,
4098 Flow::Stay => {}
4099 }
4100 }
4101 loop {
4102 let line = match self.next_command_line() {
4103 Some(l) => l,
4104 None => {
4105 return match self.mode {
4110 DebugMode::Repl => {
4111 self.detached = true;
4112 DebugAction::Resume
4113 }
4114 DebugMode::Json => DebugAction::Stop,
4115 };
4116 }
4117 };
4118 let parsed = parse_command(&line, self.mode);
4119 let cmd = parsed.command.trim().to_string();
4120 if cmd.is_empty() {
4121 continue;
4122 }
4123 let out = self.dispatch(&cmd, ctx);
4124 self.emit_result(&cmd, &out, parsed.id.as_ref());
4125 match out.flow {
4126 Flow::Stay => continue,
4127 Flow::Resume => return DebugAction::Resume,
4128 Flow::Stop => return DebugAction::Stop,
4129 }
4130 }
4131 }
4132}
4133
4134struct ParsedCmd {
4138 command: String,
4139 id: Option<serde_json::Value>,
4140}
4141
4142fn tokenize_quoted(line: &str) -> Vec<String> {
4148 let mut out = Vec::new();
4149 let mut cur = String::new();
4150 let mut in_quote = false;
4151 let mut has_tok = false;
4152 for c in line.chars() {
4153 match c {
4154 '"' => {
4155 in_quote = !in_quote;
4156 has_tok = true; }
4158 c if c.is_whitespace() && !in_quote => {
4159 if has_tok {
4160 out.push(std::mem::take(&mut cur));
4161 has_tok = false;
4162 }
4163 }
4164 c => {
4165 cur.push(c);
4166 has_tok = true;
4167 }
4168 }
4169 }
4170 if has_tok {
4171 out.push(cur);
4172 }
4173 out
4174}
4175
4176fn parse_command(line: &str, mode: DebugMode) -> ParsedCmd {
4180 let trimmed = line.trim();
4181 if let DebugMode::Json = mode {
4182 if trimmed.starts_with('{') {
4183 if let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed) {
4184 let cmd = v.get("cmd").and_then(|c| c.as_str()).unwrap_or("");
4185 let mut s = cmd.to_string();
4186 if let Some(args) = v.get("args").and_then(|a| a.as_array()) {
4187 for a in args {
4188 s.push(' ');
4189 let tok = a
4190 .as_str()
4191 .map(str::to_string)
4192 .unwrap_or_else(|| a.to_string());
4193 if tok.contains(char::is_whitespace) {
4196 s.push('"');
4197 s.push_str(&tok);
4198 s.push('"');
4199 } else {
4200 s.push_str(&tok);
4201 }
4202 }
4203 }
4204 return ParsedCmd {
4205 command: s,
4206 id: v.get("id").cloned(),
4207 };
4208 }
4209 }
4210 }
4211 ParsedCmd {
4212 command: trimmed.to_string(),
4213 id: None,
4214 }
4215}
4216
4217fn emit_json(v: &serde_json::Value) {
4218 let stdout = std::io::stdout();
4219 let mut h = stdout.lock();
4220 let _ = writeln!(h, "{v}");
4221 let _ = h.flush();
4222}
4223
4224fn as_nlp<'a>(ctx: &'a dyn DebugState) -> Option<&'a DebugCtx> {
4229 ctx.as_any().and_then(|a| a.downcast_ref::<DebugCtx>())
4230}
4231
4232fn as_nlp_mut<'a>(ctx: &'a mut dyn DebugState) -> Option<&'a mut DebugCtx> {
4234 ctx.as_any_mut().and_then(|a| a.downcast_mut::<DebugCtx>())
4235}
4236
4237fn nlp_only(cmd: &str) -> CmdOut {
4239 CmdOut::err(format!(
4240 "`{cmd}` is only available for the NLP solver (not the convex/conic solver)"
4241 ))
4242}
4243
4244fn block_names(ctx: &dyn DebugState) -> Vec<&'static str> {
4249 ctx.block_dims().into_iter().map(|(n, _)| n).collect()
4250}
4251
4252fn is_block(ctx: &dyn DebugState, name: &str) -> bool {
4254 block_names(ctx).iter().any(|n| *n == name)
4255}
4256
4257fn fmt_vec(name: &str, v: &[f64]) -> String {
4258 const MAX: usize = 12;
4259 if v.len() <= MAX {
4260 format!(
4261 "{name} = [{}]",
4262 v.iter()
4263 .map(|x| format!("{x:.6e}"))
4264 .collect::<Vec<_>>()
4265 .join(", ")
4266 )
4267 } else {
4268 let head = v[..MAX]
4269 .iter()
4270 .map(|x| format!("{x:.6e}"))
4271 .collect::<Vec<_>>()
4272 .join(", ");
4273 format!("{name} = [{head}, … ({} total)]", v.len())
4274 }
4275}
4276
4277fn type_str(t: OptionType) -> &'static str {
4278 match t {
4279 OptionType::OT_Number => "Number",
4280 OptionType::OT_Integer => "Integer",
4281 OptionType::OT_String => "String",
4282 OptionType::OT_Unknown => "Unknown",
4283 }
4284}
4285
4286fn default_str(d: &DefaultValue) -> String {
4287 match d {
4288 DefaultValue::None => "-".into(),
4289 DefaultValue::Number(v) => format!("{v}"),
4290 DefaultValue::Integer(v) => format!("{v}"),
4291 DefaultValue::String(s) => s.clone(),
4292 }
4293}
4294
4295fn write_and_open(label: &str, iter: i32, vals: &[f64]) -> Result<(String, String), String> {
4300 let payload = serde_json::json!({"label": label, "iter": iter, "values": vals});
4301 write_json_and_open(label, iter, &payload)
4302}
4303
4304fn build_ask_prompt(ctx: &dyn DebugState, question: &str) -> String {
4307 use std::fmt::Write as _;
4308 let mut p = String::new();
4309 p.push_str(
4310 "You are helping debug a paused run of POUNCE, a pure-Rust interior-point \
4311 optimization solver whose NLP core is ported from Ipopt. The solve is \
4312 stopped at a debugger checkpoint. \
4313 Use the state below to answer concisely and suggest concrete next steps \
4314 (options to try, what to inspect). State:\n\n",
4315 );
4316 let _ = writeln!(p, "checkpoint = {}", ctx.checkpoint().as_str());
4317 if let Some(s) = ctx.status() {
4318 let _ = writeln!(p, "status = {s}");
4319 }
4320 let _ = writeln!(p, "iter = {}", ctx.iter());
4321 let _ = writeln!(p, "mu = {:.6e}", ctx.mu());
4322 let _ = writeln!(p, "objective = {:.8e}", ctx.objective());
4323 let _ = writeln!(p, "inf_pr = {:.6e}", ctx.inf_pr());
4324 let _ = writeln!(p, "inf_du = {:.6e}", ctx.inf_du());
4325 let _ = writeln!(p, "nlp_error = {:.6e}", ctx.nlp_error());
4326 let (ap, ad) = ctx.alpha();
4327 let _ = writeln!(p, "alpha_pr = {ap:.4e}, alpha_du = {ad:.4e}");
4328 let _ = writeln!(p, "ls_trials = {}", ctx.ls_count());
4329 let dims: Vec<String> = ctx
4330 .block_dims()
4331 .into_iter()
4332 .map(|(n, d)| format!("{n}:{d}"))
4333 .collect();
4334 let _ = writeln!(p, "dims = {}", dims.join(" "));
4335 if let Some(k) = ctx.kkt() {
4336 let _ = writeln!(
4337 p,
4338 "kkt = dim {} inertia n+={} n-={} (expected n-={}, {}) delta_w={:.3e} delta_c={:.3e} status={}",
4339 k.dim,
4340 k.n_pos,
4341 k.n_neg,
4342 k.expected_neg,
4343 if k.inertia_correct {
4344 "correct"
4345 } else {
4346 "WRONG"
4347 },
4348 k.delta_w,
4349 k.delta_c,
4350 k.status
4351 );
4352 }
4353 let _ = write!(p, "\nQuestion: {question}\n");
4354 p
4355}
4356
4357const LLM_PROVIDERS: &[&str] = &["claude", "codex", "gemini", "llm"];
4363
4364fn llm_preset(name: &str, prompt: &str) -> Option<(String, Vec<String>, bool)> {
4365 match name {
4366 "claude" => Some(("claude".to_string(), vec!["-p".to_string()], true)),
4368 "codex" => Some((
4370 "codex".to_string(),
4371 vec!["exec".to_string(), prompt.to_string()],
4372 false,
4373 )),
4374 "gemini" => Some((
4376 "gemini".to_string(),
4377 vec!["-p".to_string(), prompt.to_string()],
4378 false,
4379 )),
4380 "llm" => Some(("llm".to_string(), vec![prompt.to_string()], false)),
4382 _ => None,
4383 }
4384}
4385
4386fn llm_command(prompt: &str) -> (String, Vec<String>, bool) {
4392 let raw = std::env::var("POUNCE_DBG_LLM").unwrap_or_default();
4393 let tmpl = raw.trim();
4394 if tmpl.is_empty() {
4395 return llm_preset("claude", prompt).expect("claude is a known provider");
4397 }
4398 if !tmpl.contains(char::is_whitespace) {
4401 if let Some(preset) = llm_preset(tmpl, prompt) {
4402 return preset;
4403 }
4404 }
4405 let mut parts = tmpl
4407 .split_whitespace()
4408 .map(str::to_string)
4409 .collect::<Vec<_>>();
4410 let prog = parts.remove(0);
4411 let mut substituted = false;
4412 for a in parts.iter_mut() {
4413 if a.contains("{}") {
4414 *a = a.replace("{}", prompt);
4415 substituted = true;
4416 }
4417 }
4418 (prog, parts, !substituted)
4419}
4420
4421fn run_llm(prompt: &str) -> Result<String, String> {
4424 use std::io::Write as _;
4425 use std::process::{Command, Stdio};
4426 let (prog, args, on_stdin) = llm_command(prompt);
4427 let mut cmd = Command::new(&prog);
4428 cmd.args(&args)
4429 .stdout(Stdio::piped())
4430 .stderr(Stdio::piped());
4431 cmd.stdin(if on_stdin {
4432 Stdio::piped()
4433 } else {
4434 Stdio::null()
4435 });
4436 let mut child = cmd.spawn().map_err(|e| {
4437 if e.kind() == std::io::ErrorKind::NotFound {
4438 format!(
4442 "LLM CLI `{prog}` is not installed or not on PATH. Install it, \
4443 or set POUNCE_DBG_LLM to another provider \
4444 ({}) or a full command template (e.g. `my-llm --ask {{}}`).",
4445 LLM_PROVIDERS.join(" | ")
4446 )
4447 } else {
4448 format!("could not launch `{prog}`: {e}")
4449 }
4450 })?;
4451 if on_stdin {
4452 if let Some(mut si) = child.stdin.take() {
4454 let _ = si.write_all(prompt.as_bytes());
4455 }
4456 }
4457 let out = child
4458 .wait_with_output()
4459 .map_err(|e| format!("`{prog}` failed: {e}"))?;
4460 if !out.status.success() {
4461 let err = String::from_utf8_lossy(&out.stderr);
4462 return Err(format!(
4463 "`{prog}` exited with {}: {}",
4464 out.status,
4465 err.trim()
4466 ));
4467 }
4468 let reply = String::from_utf8_lossy(&out.stdout).trim().to_string();
4469 if reply.is_empty() {
4470 Err(format!("`{prog}` returned no output"))
4471 } else {
4472 Ok(reply)
4473 }
4474}
4475
4476fn write_json_and_open(
4479 label: &str,
4480 iter: i32,
4481 payload: &serde_json::Value,
4482) -> Result<(String, String), String> {
4483 let dir = std::env::temp_dir();
4484 let path = dir.join(format!("pounce-dbg-{label}-iter{iter}.json"));
4485 std::fs::write(&path, payload.to_string()).map_err(|e| format!("write failed: {e}"))?;
4486 let path_s = path.to_string_lossy().to_string();
4487
4488 let mut candidates: Vec<(String, Vec<String>, String)> = Vec::new();
4498 match std::env::var("POUNCE_DBG_VIEWER") {
4499 Ok(tmpl) if !tmpl.trim().is_empty() => {
4500 let mut parts = tmpl
4501 .split_whitespace()
4502 .map(String::from)
4503 .collect::<Vec<_>>();
4504 let prog = parts.remove(0);
4505 let mut replaced = false;
4506 for a in parts.iter_mut() {
4507 if a.contains("{}") {
4508 *a = a.replace("{}", &path_s);
4509 replaced = true;
4510 }
4511 }
4512 if !replaced {
4513 parts.push(path_s.clone());
4514 }
4515 candidates.push((prog, parts, path_s.clone()));
4516 }
4517 _ => {
4518 candidates.push((
4519 "pounce-dbg-viz".to_string(),
4520 vec![path_s.clone()],
4521 path_s.clone(),
4522 ));
4523 let opener = if cfg!(target_os = "macos") {
4524 "open"
4525 } else {
4526 "xdg-open"
4527 };
4528 let artifact = write_html_viz(label, iter, payload).unwrap_or_else(|_| path_s.clone());
4531 candidates.push((opener.to_string(), vec![artifact.clone()], artifact));
4532 }
4533 }
4534
4535 let mut last_err = String::new();
4536 for (program, args, artifact) in &candidates {
4537 match std::process::Command::new(program).args(args).spawn() {
4538 Ok(_) => return Ok((artifact.clone(), format!("{program} {}", args.join(" ")))),
4539 Err(e) => last_err = format!("`{program}`: {e}"),
4540 }
4541 }
4542 Err(format!(
4543 "wrote {path_s} but could not launch a viewer ({last_err}). \
4544 Install the interactive viewer (`pip install 'pounce-solver[viz]'`) \
4545 or set POUNCE_DBG_VIEWER, e.g. `python my_plot.py {{}}`."
4546 ))
4547}
4548
4549fn write_html_viz(label: &str, iter: i32, payload: &serde_json::Value) -> Result<String, String> {
4556 let dir = std::env::temp_dir();
4557 let path = dir.join(format!("pounce-dbg-{label}-iter{iter}.html"));
4558 let html = VIZ_HTML_TEMPLATE.replace("__PAYLOAD__", &payload.to_string());
4559 std::fs::write(&path, html).map_err(|e| format!("write failed: {e}"))?;
4560 Ok(path.to_string_lossy().to_string())
4561}
4562
4563const VIZ_HTML_TEMPLATE: &str = r##"<!doctype html>
4568<html lang="en"><head><meta charset="utf-8">
4569<title>pounce-dbg viz</title>
4570<style>
4571 html,body{margin:0;background:#0e1116;color:#d6dae0;
4572 font:13px/1.5 -apple-system,BlinkMacSystemFont,"SF Mono",Menlo,monospace}
4573 .wrap{padding:18px 20px;max-width:880px;margin:0 auto}
4574 h1{font-size:15px;margin:0 0 4px;font-weight:600}
4575 .sub{color:#7d8694;margin:0 0 12px}
4576 .stats{color:#9aa4b2;white-space:pre-wrap;margin:0 0 14px;
4577 background:#161b22;border:1px solid #21262d;border-radius:6px;padding:10px 12px}
4578 canvas{background:#161b22;border:1px solid #30363d;border-radius:6px;
4579 max-width:100%;height:auto;image-rendering:pixelated}
4580 .legend{margin-top:10px;color:#9aa4b2}
4581 .pos{color:#4ea1ff}.neg{color:#ff6b6b}.bad{color:#ff6b6b;font-weight:600}
4582 .ok{color:#56d364;font-weight:600}
4583</style></head><body><div class="wrap">
4584<h1 id="title">pounce-dbg</h1>
4585<div class="sub" id="sub"></div>
4586<div class="stats" id="stats"></div>
4587<canvas id="c" width="820" height="820"></canvas>
4588<div class="legend" id="legend"></div>
4589</div>
4590<script>
4591const D = __PAYLOAD__;
4592const cv = document.getElementById('c');
4593const ctx = cv.getContext('2d');
4594const $ = id => document.getElementById(id);
4595const fmt = x => (x===null||x===undefined) ? '—'
4596 : (Math.abs(x) >= 1e4 || (x!==0 && Math.abs(x) < 1e-3) ? x.toExponential(3) : (+x).toPrecision(6));
4597
4598function clearCanvas(){ ctx.fillStyle='#161b22'; ctx.fillRect(0,0,cv.width,cv.height); }
4599
4600function spy(irn, jcn, vals, dim, symmetric, title){
4601 $('sub').textContent = title;
4602 clearCanvas();
4603 const W=cv.width, H=cv.height, pad=42;
4604 const span=Math.max(1, dim);
4605 const cell=(Math.min(W,H)-2*pad)/span;
4606 const px=Math.max(0.7, cell);
4607 // frame + light grid ticks
4608 ctx.strokeStyle='#30363d'; ctx.lineWidth=1;
4609 ctx.strokeRect(pad-0.5, pad-0.5, span*cell+1, span*cell+1);
4610 ctx.fillStyle='#6e7681'; ctx.font='11px monospace';
4611 ctx.fillText('0', pad-12, pad+9);
4612 ctx.fillText(String(dim), pad+span*cell-8, pad-8);
4613 ctx.fillText('row', pad-34, pad+span*cell/2);
4614 ctx.fillText('col', pad+span*cell/2-8, pad-22);
4615 let nnz=0;
4616 for(let k=0;k<irn.length;k++){
4617 const i=irn[k]-1, j=jcn[k]-1, v=vals?vals[k]:1;
4618 ctx.fillStyle = v>=0 ? 'rgba(78,161,255,0.92)' : 'rgba(255,107,107,0.92)';
4619 ctx.fillRect(pad+j*cell, pad+i*cell, px, px); nnz++;
4620 if(symmetric && i!==j){ ctx.fillRect(pad+i*cell, pad+j*cell, px, px); nnz++; }
4621 }
4622 $('legend').innerHTML =
4623 `<span class="pos">■</span> positive <span class="neg">■</span> negative`
4624 + ` · ${dim}×${dim}, ${nnz} plotted nonzeros`
4625 + (symmetric ? ' (lower triangle mirrored)' : '');
4626}
4627
4628function bars(values, title){
4629 $('sub').textContent = title;
4630 clearCanvas();
4631 const W=cv.width, H=cv.height, pad=42;
4632 const n=values.length;
4633 const maxAbs=Math.max(1e-300, ...values.map(v=>Math.abs(v)));
4634 const x0=pad, y0=H-pad, plotW=W-2*pad, plotH=H-2*pad, mid=pad+plotH/2;
4635 const bw=Math.max(0.7, plotW/Math.max(1,n));
4636 // zero axis
4637 ctx.strokeStyle='#30363d'; ctx.beginPath();
4638 ctx.moveTo(pad, mid); ctx.lineTo(W-pad, mid); ctx.stroke();
4639 ctx.fillStyle='#6e7681'; ctx.font='11px monospace';
4640 ctx.fillText('+'+fmt(maxAbs), 4, pad+10);
4641 ctx.fillText('-'+fmt(maxAbs), 4, H-pad-2);
4642 ctx.fillText('0', 4, mid+4);
4643 for(let k=0;k<n;k++){
4644 const v=values[k], h=(Math.abs(v)/maxAbs)*(plotH/2);
4645 ctx.fillStyle = v>=0 ? 'rgba(78,161,255,0.92)' : 'rgba(255,107,107,0.92)';
4646 if(v>=0) ctx.fillRect(pad+k*bw, mid-h, bw, h);
4647 else ctx.fillRect(pad+k*bw, mid, bw, h);
4648 }
4649 $('legend').innerHTML = `${n} components · max |val| = ${fmt(maxAbs)}`;
4650}
4651
4652const lbl = D.label || 'viz';
4653const iter = (D.iter!==undefined) ? D.iter : '?';
4654$('title').textContent = `pounce-dbg · viz ${lbl} · iter ${iter}`;
4655
4656if(D.matrix && D.matrix.irn){
4657 const m=D.matrix;
4658 const inertia = (D.inertia_correct===false)
4659 ? `<span class="bad">WRONG</span>` : `<span class="ok">correct</span>`;
4660 $('stats').innerHTML =
4661 `KKT augmented system dim=${D.dim}\n`+
4662 `inertia n+=${D.n_pos} n-=${D.n_neg} (expected n-=${D.expected_neg}, ${inertia})\n`+
4663 `regularization delta_w=${fmt(D.delta_w)} delta_c=${fmt(D.delta_c)}\n`+
4664 `factorization status: ${D.status}`;
4665 spy(m.irn, m.jcn, m.vals, m.dim, true, 'sparsity pattern (sign-colored)');
4666} else if(D.l_irn){
4667 $('stats').textContent =
4668 `LDLᵀ factor n=${D.n} nnz(L)=${D.l_irn.length} format=${D.format||''}`;
4669 spy(D.l_irn, D.l_jcn, D.l_vals, D.n, false, 'L factor sparsity (permuted, strict lower)');
4670} else if(D.values){
4671 $('stats').textContent = `vector ${lbl} length=${D.values.length}`;
4672 bars(D.values, 'component magnitudes (zero-centered)');
4673} else {
4674 $('stats').textContent = 'unrecognized payload — raw JSON:\n'+JSON.stringify(D,null,2);
4675}
4676</script></body></html>
4677"##;
4678
4679#[cfg(test)]
4680mod tests {
4681 use super::*;
4682
4683 fn dbg(mode: DebugMode) -> SolverDebugger {
4684 SolverDebugger::new(mode, None)
4685 }
4686
4687 #[test]
4688 fn json_command_object_is_flattened() {
4689 assert_eq!(
4690 parse_command("{\"cmd\":\"print x\"}", DebugMode::Json).command,
4691 "print x"
4692 );
4693 let p = parse_command(
4694 "{\"cmd\":\"set\",\"args\":[\"x[0]\",\"1.5\"],\"id\":7}",
4695 DebugMode::Json,
4696 );
4697 assert_eq!(p.command, "set x[0] 1.5");
4698 assert_eq!(p.id, Some(serde_json::json!(7)));
4700 let s = parse_command("step\n", DebugMode::Json);
4702 assert_eq!(s.command, "step");
4703 assert!(s.id.is_none());
4704 assert_eq!(
4705 parse_command(" print x \n", DebugMode::Repl).command,
4706 "print x"
4707 );
4708 }
4709
4710 #[test]
4711 fn pauses_at_first_checkpoint_then_only_when_rearmed() {
4712 let mut d = dbg(DebugMode::Repl);
4713 assert!(d.should_pause(0));
4715 d.step = false;
4717 assert!(!d.should_pause(1));
4718 assert!(!d.should_pause(2));
4719 }
4720
4721 #[test]
4722 fn breakpoints_and_run_to_arm_pauses() {
4723 let mut d = dbg(DebugMode::Repl);
4724 d.step = false;
4725 d.breaks = vec![3, 7];
4726 assert!(!d.should_pause(2));
4727 assert!(d.should_pause(3));
4728 assert!(d.should_pause(7));
4729 d.run_to = Some(5);
4731 assert!(!d.should_pause(4));
4732 assert!(d.should_pause(5));
4733 assert_eq!(d.run_to, None);
4734 assert!(!d.should_pause(6));
4735 }
4736
4737 #[test]
4738 fn atom_parses_metric_op_threshold() {
4739 let a = Atom::parse("mu<1e-4").unwrap();
4740 assert_eq!(a.metric, Metric::Mu);
4741 assert_eq!(a.op, CmpOp::Lt);
4742 assert_eq!(a.rhs, 1e-4);
4743
4744 let a = Atom::parse("inf_pr<=1e-6").unwrap();
4746 assert_eq!(a.metric, Metric::InfPr);
4747 assert_eq!(a.op, CmpOp::Le);
4748
4749 let a = Atom::parse("iter==10").unwrap();
4750 assert_eq!(a.metric, Metric::Iter);
4751 assert_eq!(a.op, CmpOp::Eq);
4752 assert_eq!(a.rhs, 10.0);
4753 }
4754
4755 struct MinimalState;
4761 impl DebugState for MinimalState {
4762 fn checkpoint(&self) -> Checkpoint {
4763 Checkpoint::IterStart
4764 }
4765 fn iter(&self) -> i32 {
4766 7
4767 }
4768 fn mu(&self) -> f64 {
4769 1e-3
4770 }
4771 fn objective(&self) -> f64 {
4772 42.0
4773 }
4774 fn inf_pr(&self) -> f64 {
4775 1e-4
4776 }
4777 fn inf_du(&self) -> f64 {
4778 2e-4
4779 }
4780 fn complementarity(&self) -> f64 {
4781 5e-4
4782 }
4783 fn alpha(&self) -> (f64, f64) {
4784 (1.0, 1.0)
4785 }
4786 fn block_dims(&self) -> Vec<(&'static str, usize)> {
4787 vec![]
4788 }
4789 fn block(&self, _name: &str) -> Option<Vec<f64>> {
4790 None
4791 }
4792 fn delta_block(&self, _name: &str) -> Option<Vec<f64>> {
4793 None
4794 }
4795 }
4796
4797 #[test]
4803 fn metric_fields_match_advertised_vocabulary() {
4804 let fields = metric_fields(&MinimalState);
4805
4806 let names: Vec<&str> = fields.iter().map(|(n, _)| *n).collect();
4808 assert_eq!(names, METRICS);
4809
4810 for &name in METRICS {
4813 assert!(
4814 name == "iter" || Metric::parse(name).is_some(),
4815 "METRICS entry `{name}` has no matching Metric arm"
4816 );
4817 }
4818
4819 let map: std::collections::HashMap<_, _> = fields.into_iter().collect();
4820 assert_eq!(map["iter"], serde_json::json!(7));
4822 assert_eq!(map["objective"], serde_json::json!(42.0));
4823 assert_eq!(map["nlp_error"], serde_json::Value::Null);
4826 }
4827
4828 #[test]
4829 fn atom_parse_rejects_garbage() {
4830 assert!(Atom::parse("inf_pr 1e-6").is_err()); assert!(Atom::parse("bogus<1").is_err()); assert!(Atom::parse("mu<abc").is_err()); }
4834
4835 #[test]
4836 fn compound_condition_parses_and_evaluates_left_to_right() {
4837 let c = Condition::parse("mu<1e-4&&inf_pr>1e-3").unwrap();
4839 assert_eq!(c.rest.len(), 1);
4840 assert_eq!(c.rest[0].0, Join::And);
4841
4842 let c = Condition::parse("iter>10&&(inf_du>1e-2||obj<0)").unwrap();
4844 assert_eq!(c.rest.len(), 2);
4845 assert_eq!(c.rest[0].0, Join::And);
4846 assert_eq!(c.rest[1].0, Join::Or);
4847 assert_eq!(c.raw, "iter>10&&inf_du>1e-2||obj<0");
4848
4849 assert!(Condition::parse("mu<1e-4&&bogus>0").is_err());
4851 }
4852
4853 #[test]
4854 fn completion_is_context_sensitive() {
4855 let c = completion_candidates(None, "", "co");
4857 assert!(c.contains(&"continue".to_string()));
4858 assert!(c.contains(&"complete".to_string()));
4859 assert!(!c.contains(&"step".to_string()));
4860
4861 let c = completion_candidates(None, "set ", "");
4863 assert!(c.contains(&"mu".to_string()));
4864 assert!(c.contains(&"opt".to_string()));
4865 assert!(c.contains(&"x".to_string()));
4866
4867 let c = completion_candidates(None, "break if ", "inf");
4869 assert!(c.contains(&"inf_pr".to_string()));
4870 assert!(c.contains(&"inf_du".to_string()));
4871 assert!(!c.contains(&"mu".to_string()));
4872
4873 let c = completion_candidates(None, "print ", "");
4875 assert!(c.contains(&"x".to_string()));
4876 assert!(c.contains(&"obj".to_string()));
4877 }
4878
4879 #[test]
4880 fn cmp_op_truth_table() {
4881 assert!(CmpOp::Lt.eval(1.0, 2.0));
4882 assert!(!CmpOp::Lt.eval(2.0, 2.0));
4883 assert!(CmpOp::Le.eval(2.0, 2.0));
4884 assert!(CmpOp::Gt.eval(3.0, 2.0));
4885 assert!(CmpOp::Ge.eval(2.0, 2.0));
4886 assert!(CmpOp::Eq.eval(2.0, 2.0));
4887 assert!(!CmpOp::Eq.eval(2.0, 2.5));
4888 }
4889
4890 #[test]
4891 fn interrupt_is_consumed_once() {
4892 interrupt::set_pending_for_test();
4893 assert!(interrupt::take(), "first take sees the pending Ctrl-C");
4894 assert!(!interrupt::take(), "second take is clear (consumed once)");
4895 }
4896
4897 #[test]
4898 fn on_interrupt_constructor_runs_free_but_interruptible() {
4899 let d = SolverDebugger::on_interrupt(DebugMode::Repl, None);
4900 assert!(!d.pause_iters, "on-interrupt does not pause each iter");
4901 assert!(!d.pause_terminal, "on-interrupt does not pause at terminal");
4902 assert!(d.interruptible, "on-interrupt honors Ctrl-C");
4903 assert!(!d.step, "on-interrupt starts un-armed");
4904 }
4905
4906 #[test]
4907 fn coffee_easter_egg_prints_art_but_stays_hidden() {
4908 let d = SolverDebugger::new(DebugMode::Repl, None);
4909 let out = d.cmd_coffee();
4910 assert!(out.ok);
4911 assert!(out.lines.len() > 5, "multi-line art");
4912 assert!(
4913 out.lines.iter().any(|l| l.contains("COFFEE")),
4914 "the mug says COFFEE"
4915 );
4916 assert!(
4918 !COMMANDS.contains(&"coffee"),
4919 "hidden from help/complete/Tab"
4920 );
4921 assert!(
4923 out.lines.iter().all(|l| !l.contains('\x1b')),
4924 "no color when stderr isn't a TTY"
4925 );
4926 }
4927
4928 #[test]
4929 fn double_ctrl_c_at_prompt_quits_single_cancels_line() {
4930 let mut d = SolverDebugger::new(DebugMode::Repl, None);
4931 assert_eq!(d.on_prompt_interrupt(), "");
4933 assert_eq!(d.on_prompt_interrupt(), "quit");
4935 assert_eq!(d.on_prompt_interrupt(), "");
4937 d.prompt_interrupts = 0;
4940 assert_eq!(d.on_prompt_interrupt(), "", "fresh streak after a command");
4941 }
4942
4943 #[test]
4944 fn stop_at_accepts_names_and_aliases() {
4945 let mut d = SolverDebugger::new(DebugMode::Repl, None);
4946 assert!(d.cmd_stop_at(&["after_search_dir"]).ok);
4947 assert!(d.stop_at.contains("after_search_dir"));
4948 assert!(d.cmd_stop_at(&["mu"]).ok);
4950 assert!(d.stop_at.contains("after_mu"));
4951 assert!(d.cmd_stop_at(&["kkt"]).ok);
4952 assert!(d.stop_at.contains("after_search_dir"));
4953 assert!(!d.cmd_stop_at(&["bogus"]).ok);
4955 assert!(d.cmd_stop_at(&["clear"]).ok);
4957 assert!(d.stop_at.is_empty());
4958 }
4959
4960 #[test]
4961 fn llm_command_defaults_and_overrides() {
4962 unsafe { std::env::remove_var("POUNCE_DBG_LLM") };
4965 let (prog, args, on_stdin) = llm_command("hi");
4966 assert_eq!(prog, "claude");
4967 assert_eq!(args, vec!["-p".to_string()]);
4968 assert!(on_stdin);
4969
4970 unsafe { std::env::set_var("POUNCE_DBG_LLM", "mytool --ask {}") };
4973 let (prog, args, on_stdin) = llm_command("why");
4974 assert_eq!(prog, "mytool");
4975 assert_eq!(args, vec!["--ask".to_string(), "why".to_string()]);
4976 assert!(!on_stdin);
4977
4978 unsafe { std::env::set_var("POUNCE_DBG_LLM", "llm -m gpt") };
4981 let (_, _, on_stdin) = llm_command("q");
4982 assert!(on_stdin);
4983
4984 unsafe { std::env::set_var("POUNCE_DBG_LLM", "codex") };
4989 let (prog, args, on_stdin) = llm_command("why is mu stuck");
4990 assert_eq!(prog, "codex");
4991 assert_eq!(
4992 args,
4993 vec!["exec".to_string(), "why is mu stuck".to_string()]
4994 );
4995 assert!(!on_stdin); unsafe { std::env::set_var("POUNCE_DBG_LLM", "gemini") };
4999 let (prog, args, _) = llm_command("q");
5000 assert_eq!(prog, "gemini");
5001 assert_eq!(args, vec!["-p".to_string(), "q".to_string()]);
5002
5003 unsafe { std::env::set_var("POUNCE_DBG_LLM", "llm") };
5005 let (prog, args, _) = llm_command("q");
5006 assert_eq!(prog, "llm");
5007 assert_eq!(args, vec!["q".to_string()]);
5008
5009 unsafe { std::env::set_var("POUNCE_DBG_LLM", "claude") };
5013 let (prog, args, on_stdin) = llm_command("q");
5014 assert_eq!(prog, "claude");
5015 assert_eq!(args, vec!["-p".to_string()]);
5016 assert!(on_stdin);
5017
5018 unsafe { std::env::set_var("POUNCE_DBG_LLM", "mytool") };
5022 let (prog, args, on_stdin) = llm_command("q");
5023 assert_eq!(prog, "mytool");
5024 assert!(args.is_empty());
5025 assert!(on_stdin);
5026
5027 unsafe { std::env::set_var("POUNCE_DBG_LLM", "pounce-no-such-llm-xyz") };
5031 let err = run_llm("hello").unwrap_err();
5032 assert!(err.contains("not installed or not on PATH"), "{err}");
5033 assert!(err.contains("codex"), "{err}");
5034
5035 unsafe { std::env::remove_var("POUNCE_DBG_LLM") };
5037 }
5038
5039 #[test]
5040 fn detach_disables_all_pausing() {
5041 let mut d = dbg(DebugMode::Repl);
5042 d.detached = true;
5043 d.step = true;
5044 d.breaks = vec![1];
5045 assert!(!d.should_pause(0));
5046 assert!(!d.should_pause(1));
5047 }
5048
5049 #[test]
5050 fn kkt_capture_tracks_attached_state() {
5051 let mut d = dbg(DebugMode::Repl);
5054 assert!(d.wants_kkt_capture());
5055 d.detached = true;
5056 assert!(!d.wants_kkt_capture());
5057 }
5058
5059 fn resid(kind: ResidKind, index: usize, value: f64) -> Residual {
5060 Residual { kind, index, value }
5061 }
5062
5063 #[test]
5064 fn rank_residuals_sorts_by_magnitude_and_truncates() {
5065 use ResidKind::*;
5066 let entries = vec![
5067 resid(Eq, 0, -0.5),
5068 resid(Ineq, 1, 3.0),
5069 resid(DualX, 2, -7.0),
5070 resid(DualS, 3, 1.0),
5071 ];
5072 let top = rank_residuals(entries, 2);
5073 assert_eq!(top.len(), 2);
5074 assert_eq!(top[0].value, -7.0);
5076 assert_eq!(top[0].kind, DualX);
5077 assert_eq!(top[1].value, 3.0);
5078 assert_eq!(top[1].kind, Ineq);
5079 }
5080
5081 #[test]
5082 fn rank_residuals_k_zero_and_k_over_len() {
5083 use ResidKind::*;
5084 let entries = vec![resid(Eq, 0, 1.0), resid(Ineq, 1, 2.0)];
5085 assert!(rank_residuals(entries.clone(), 0).is_empty());
5086 let all = rank_residuals(entries, 99);
5088 assert_eq!(all.len(), 2);
5089 assert_eq!(all[0].value, 2.0);
5090 }
5091
5092 #[test]
5093 fn rank_residuals_is_stable_on_magnitude_ties() {
5094 use ResidKind::*;
5095 let entries = vec![
5097 resid(Ineq, 5, -2.0),
5098 resid(Eq, 1, 2.0),
5099 resid(DualX, 9, -2.0),
5100 ];
5101 let top = rank_residuals(entries, 3);
5102 assert_eq!(
5103 top.iter().map(|r| r.kind).collect::<Vec<_>>(),
5104 vec![Ineq, Eq, DualX]
5105 );
5106 }
5107
5108 fn split_names_fixture() -> SplitNames {
5109 SplitNames {
5110 x_var: vec![Some("T_reactor".into()), None],
5111 eq: vec![Some("mass_balance".into()), Some("energy_balance".into())],
5112 ineq: vec![Some("pressure_cap".into())],
5113 }
5114 }
5115
5116 #[test]
5117 fn resid_name_maps_each_kind_to_its_pool() {
5118 use ResidKind::*;
5119 let names = Some(split_names_fixture());
5120 assert_eq!(
5123 resid_name(&resid(Eq, 1, 0.0), &names),
5124 Some("energy_balance")
5125 );
5126 assert_eq!(
5127 resid_name(&resid(Ineq, 0, 0.0), &names),
5128 Some("pressure_cap")
5129 );
5130 assert_eq!(
5131 resid_name(&resid(DualS, 0, 0.0), &names),
5132 Some("pressure_cap")
5133 );
5134 assert_eq!(resid_name(&resid(DualX, 0, 0.0), &names), Some("T_reactor"));
5135 assert_eq!(resid_name(&resid(DualX, 1, 0.0), &names), None);
5137 assert_eq!(resid_name(&resid(Eq, 9, 0.0), &names), None);
5138 assert_eq!(resid_name(&resid(Eq, 0, 0.0), &None), None);
5140 }
5141
5142 #[test]
5143 fn worst_named_picks_largest_and_labels_it() {
5144 use ResidKind::*;
5145 let names = Some(split_names_fixture());
5146 let resids = vec![resid(Eq, 0, 0.5), resid(Eq, 1, -3.2), resid(Ineq, 0, 1.1)];
5148 assert_eq!(
5149 worst_named(resids, &names),
5150 Some(("c[energy_balance]".to_string(), -3.2))
5151 );
5152 let resids = vec![resid(DualX, 7, 9.0)];
5154 assert_eq!(
5155 worst_named(resids, &None),
5156 Some(("grad_x_L[7]".to_string(), 9.0))
5157 );
5158 assert_eq!(worst_named(vec![], &names), None);
5160 }
5161
5162 use pounce_algorithm::debug_rank::RankCulprit;
5163
5164 fn rank_report_fixture() -> RankReport {
5165 RankReport {
5168 rows: vec![
5169 RankRow {
5170 kind: ResidKind::Eq,
5171 index: 0,
5172 },
5173 RankRow {
5174 kind: ResidKind::Eq,
5175 index: 1,
5176 },
5177 ],
5178 n_cols: 3,
5179 singular_values: vec![2.0, 0.0],
5180 tol: 1e-15,
5181 rank: 1,
5182 cond: f64::INFINITY,
5183 culprits: vec![
5184 RankCulprit {
5185 row: 0,
5186 weight: 0.5,
5187 },
5188 RankCulprit {
5189 row: 1,
5190 weight: 0.5,
5191 },
5192 ],
5193 }
5194 }
5195
5196 #[test]
5197 fn render_rank_report_names_culprits_and_builds_json() {
5198 let names = Some(split_names_fixture());
5199 let rep = rank_report_fixture();
5200 let (lines, data) = render_rank_report(&rep, &names, None, 7);
5202
5203 let text = lines.join("\n");
5204 assert!(text.contains("2 row(s) × 3 column(s)"), "{text}");
5205 assert!(text.contains("numerical rank = 1 / 2"), "{text}");
5206 assert!(text.contains("inf (σ_min = 0)"), "{text}");
5208 assert!(text.contains("c[mass_balance]"), "{text}");
5210 assert!(text.contains("c[energy_balance]"), "{text}");
5211 assert!(text.contains("participation 0.50"), "{text}");
5212 assert!(text.contains("print equation"), "{text}");
5214
5215 assert_eq!(data["iter"], 7);
5218 assert_eq!(data["rank"], 1);
5219 assert_eq!(data["deficiency"], 1);
5220 assert_eq!(data["rank_deficient"], true);
5221 assert!(data["cond"].is_null(), "non-finite cond ⇒ null: {data}");
5222 assert_eq!(data["culprits"][0]["name"], "mass_balance");
5223 assert_eq!(data["culprits"][0]["label"], "c[mass_balance]");
5224 assert!(data["culprits"][0]["equation"].is_null());
5225 assert_eq!(data["culprits"][1]["name"], "energy_balance");
5226 }
5227
5228 #[test]
5229 fn render_rank_report_prints_culprit_equations_inline() {
5230 let names = Some(split_names_fixture());
5231 let rep = rank_report_fixture();
5232 let book = EquationBook::new(
5235 vec!["mass_balance".into(), "energy_balance".into()],
5236 vec![
5237 "x[0] + x[1] - 10 = 0".into(),
5238 "T_reactor*flow - Q = 0".into(),
5239 ],
5240 );
5241 let (lines, data) = render_rank_report(&rep, &names, Some(&book), 7);
5242
5243 let text = lines.join("\n");
5244 assert!(text.contains("x[0] + x[1] - 10 = 0"), "{text}");
5247 assert!(text.contains("T_reactor*flow - Q = 0"), "{text}");
5248 assert!(!text.contains("inspect a row with"), "{text}");
5250
5251 assert_eq!(data["culprits"][0]["equation"], "x[0] + x[1] - 10 = 0");
5253 assert_eq!(data["culprits"][1]["equation"], "T_reactor*flow - Q = 0");
5254 }
5255
5256 #[test]
5257 fn render_rank_report_full_rank_reports_positive_signal() {
5258 let rep = RankReport {
5259 rows: vec![
5260 RankRow {
5261 kind: ResidKind::Eq,
5262 index: 0,
5263 },
5264 RankRow {
5265 kind: ResidKind::Eq,
5266 index: 1,
5267 },
5268 ],
5269 n_cols: 3,
5270 singular_values: vec![2.0, 1.0],
5271 tol: 1e-15,
5272 rank: 2,
5273 cond: 2.0,
5274 culprits: vec![],
5275 };
5276 let (lines, data) = render_rank_report(&rep, &None, None, 3);
5277 let text = lines.join("\n");
5278 assert!(text.contains("full row rank"), "{text}");
5279 assert!(!text.contains("rank-deficient"), "{text}");
5280 assert_eq!(data["rank_deficient"], false);
5281 assert_eq!(data["cond"], 2.0);
5282 assert_eq!(data["culprits"].as_array().map(|a| a.len()), Some(0));
5283 }
5284
5285 #[test]
5286 fn print_equation_resolves_by_name_index_and_errors() {
5287 let mut d = dbg(DebugMode::Repl);
5288 let out = d.cmd_print_equation(&[]);
5290 assert!(!out.ok);
5291 assert!(out.lines[0].contains("needs an .nl model"));
5292
5293 d.set_equation_book(EquationBook::new(
5294 vec!["mass_balance".into(), String::new()],
5295 vec!["x[0] + x[1] = 10".into(), "x[0] - x[1] <= 2".into()],
5296 ));
5297
5298 let out = d.cmd_print_equation(&[]);
5300 assert!(out.ok);
5301 assert!(out.lines[0].contains("2 constraint equation"));
5302
5303 let out = d.cmd_print_equation(&["mass_balance"]);
5305 assert!(out.ok);
5306 assert_eq!(out.lines[0], "mass_balance: x[0] + x[1] = 10");
5307
5308 let out = d.cmd_print_equation(&["1"]);
5310 assert!(out.ok);
5311 assert_eq!(out.lines[0], "c[1]: x[0] - x[1] <= 2");
5312
5313 let out = d.cmd_print_equation(&["nope"]);
5315 assert!(!out.ok);
5316 assert!(out.lines[0].contains("no constraint named or indexed"));
5317 }
5318
5319 fn eq_inc(n_vars: usize, eq_row_inner_idx: Vec<usize>, rows: &[&[usize]]) -> EqualityIncidence {
5323 let mut adj_ptr = vec![0usize];
5324 let mut vars = Vec::new();
5325 for r in rows {
5326 let mut v = r.to_vec();
5327 v.sort_unstable();
5328 v.dedup();
5329 vars.extend_from_slice(&v);
5330 adj_ptr.push(vars.len());
5331 }
5332 EqualityIncidence {
5333 n_vars,
5334 eq_row_inner_idx,
5335 adj_ptr,
5336 vars,
5337 }
5338 }
5339
5340 #[test]
5341 fn structural_singularity_names_overdetermined_equations() {
5342 let inc = eq_inc(2, vec![0, 1, 2], &[&[0, 1], &[0, 1], &[0, 1]]);
5348 let book = StructureBook::new(
5349 inc,
5350 vec!["balance_a".into(), "balance_b".into(), "balance_c".into()],
5351 vec!["flow".into(), "temp".into()],
5352 );
5353 let f = book.findings();
5354 assert_eq!(f.len(), 1);
5355 let (sev, code, msg) = &f[0];
5356 assert_eq!(*sev, "warning");
5357 assert_eq!(*code, "structural_singularity");
5358 assert!(msg.contains("balance_a"), "msg: {msg}");
5359 assert!(msg.contains("balance_b"), "msg: {msg}");
5360 assert!(msg.contains("balance_c"), "msg: {msg}");
5361 assert!(msg.contains("flow") && msg.contains("temp"), "msg: {msg}");
5362 assert!(msg.contains("≥1"), "msg: {msg}");
5363 }
5364
5365 #[test]
5366 fn structural_findings_silent_when_well_posed_and_fall_back_to_indices() {
5367 let inc = eq_inc(2, vec![0, 1], &[&[0], &[1]]);
5371 let book = StructureBook::new(inc, vec![], vec![]);
5372 assert!(book.findings().is_empty());
5373
5374 let inc = eq_inc(1, vec![0, 1, 3], &[&[0], &[0], &[0]]);
5378 let book = StructureBook::new(inc, vec![], vec![]);
5379 let f = book.findings();
5380 assert_eq!(f.len(), 1);
5381 let msg = &f[0].2;
5382 assert!(
5383 msg.contains("c[0]") && msg.contains("c[1]") && msg.contains("c[3]"),
5384 "msg: {msg}"
5385 );
5386 }
5387
5388 #[test]
5389 fn structural_singularity_handles_empty_row_with_no_variables() {
5390 let inc = eq_inc(1, vec![0, 1], &[&[0], &[]]);
5393 let book = StructureBook::new(inc, vec!["real".into(), "ghost".into()], vec!["x".into()]);
5394 let f = book.findings();
5395 assert_eq!(f.len(), 1);
5396 let msg = &f[0].2;
5397 assert!(msg.contains("ghost"), "msg: {msg}");
5398 assert!(msg.contains("no variables"), "msg: {msg}");
5399 }
5400
5401 #[test]
5402 fn parse_floats_accepts_commas_whitespace_and_newlines() {
5403 assert_eq!(parse_floats("1, 2 ,3").unwrap(), vec![1.0, 2.0, 3.0]);
5404 assert_eq!(parse_floats("1\n2\n-3.5").unwrap(), vec![1.0, 2.0, -3.5]);
5405 assert_eq!(parse_floats(" 1.0 2e-1 ").unwrap(), vec![1.0, 0.2]);
5406 assert!(parse_floats("1, nope, 3").is_err());
5407 assert_eq!(parse_floats("").unwrap(), Vec::<f64>::new());
5408 }
5409
5410 #[test]
5411 fn jitter_start_zero_is_the_unperturbed_base_and_is_deterministic() {
5412 let base = vec![1.0, -2.0, 0.0];
5413 assert_eq!(jitter(&base, 0.1, 0), base);
5415 let a = jitter(&base, 0.1, 1);
5417 let b = jitter(&base, 0.1, 1);
5418 assert_eq!(a, b);
5419 assert_ne!(a, base);
5420 for (j, (&p, &x)) in a.iter().zip(&base).enumerate() {
5421 let bound = 0.1 * (x.abs() + 1.0);
5422 assert!(
5423 (p - x).abs() <= bound + 1e-12,
5424 "component {j} moved {} > bound {bound}",
5425 (p - x).abs()
5426 );
5427 }
5428 assert_ne!(jitter(&base, 0.1, 1), jitter(&base, 0.1, 2));
5430 }
5431
5432 #[test]
5433 fn sample_start_draws_inside_finite_boxes_and_jitters_unbounded() {
5434 let base = vec![1.0, 1.0, 0.5];
5435 let lo = vec![0.0, 0.0, -1.0];
5437 let hi = vec![2.0, f64::INFINITY, 1.0];
5438 let b = Some((lo.as_slice(), hi.as_slice()));
5439 assert_eq!(sample_start(&base, b, 0.1, 0), base);
5441 for k in 1..50 {
5442 let s = sample_start(&base, b, 0.1, k);
5443 assert!((0.0..=2.0).contains(&s[0]), "var0 {} out of [0,2]", s[0]);
5445 assert!((-1.0..=1.0).contains(&s[2]), "var2 {} out of [-1,1]", s[2]);
5446 let bound = 0.1 * (base[1].abs() + 1.0);
5448 assert!(
5449 (s[1] - base[1]).abs() <= bound + 1e-12,
5450 "var1 jitter exceeded"
5451 );
5452 }
5453 assert_eq!(
5455 sample_start(&base, b, 0.1, 7),
5456 sample_start(&base, b, 0.1, 7)
5457 );
5458 }
5459
5460 #[test]
5461 fn path_completion_lists_matching_files_with_dir_prefix() {
5462 let dir = std::env::temp_dir().join("pounce_dbg_complete_test");
5463 let _ = std::fs::remove_dir_all(&dir);
5464 std::fs::create_dir_all(&dir).unwrap();
5465 std::fs::write(dir.join("starts.txt"), "0,0\n").unwrap();
5466 std::fs::write(dir.join("start2.txt"), "1,1\n").unwrap();
5467 std::fs::write(dir.join("other.json"), "{}").unwrap();
5468 std::fs::create_dir_all(dir.join("subdir")).unwrap();
5469
5470 let p = dir.to_string_lossy().to_string();
5471 let mut got = path_candidates(&format!("{p}/start"));
5473 got.sort();
5474 assert_eq!(
5475 got,
5476 vec![format!("{p}/start2.txt"), format!("{p}/starts.txt")]
5477 );
5478 let got = path_candidates(&format!("{p}/sub"));
5480 assert_eq!(got, vec![format!("{p}/subdir/")]);
5481 assert_eq!(path_candidates(&format!("{p}/")).len(), 4);
5483 assert!(
5485 completion_candidates(None, "load", &format!("{p}/star"))
5486 .iter()
5487 .all(|c| c.contains("start"))
5488 );
5489
5490 let _ = std::fs::remove_dir_all(&dir);
5491 }
5492}