use crate::cli::DebugMode;
use pounce_algorithm::debug::{
is_live_tolerance, Checkpoint, DebugAction, DebugCtx, DebugHook, IterateSnapshot, ResidKind,
Residual, BLOCK_NAMES,
};
use pounce_algorithm::debug_rank::{RankReport, RankRow};
use pounce_common::reg_options::{DefaultValue, OptionType, RegisteredOptions};
use pounce_nlp::ipopt_nlp::SplitNames;
use pounce_presolve::dulmage_mendelsohn::DulmageMendelsohnPartition;
use pounce_presolve::incidence::EqualityIncidence;
use pounce_presolve::matching::hopcroft_karp;
use rustyline::completion::{Completer, Pair};
use rustyline::error::ReadlineError;
use rustyline::history::FileHistory;
use rustyline::{Context, Editor, Helper, Highlighter, Hinter, Validator};
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use std::io::{IsTerminal, Write};
use std::path::PathBuf;
use std::rc::Rc;
const COMMANDS: &[&str] = &[
"help",
"info",
"print",
"step",
"stepi",
"continue",
"run",
"break",
"tbreak",
"watchpoint",
"commands",
"stop-at",
"set",
"get",
"opt",
"complete",
"viz",
"save",
"load",
"sweep",
"multistart",
"goto",
"restart",
"resolve",
"ask",
"watch",
"diff",
"diagnose",
"source",
"progress",
"detach",
"quit",
];
const EVENTS: &[&str] = &[
"resto_entered",
"resto_exited",
"regularized",
"tiny_step",
"ls_rejected",
"mu_stalled",
"nan",
];
const MU_STALL_ITERS: u32 = 3;
#[derive(Clone)]
struct WatchPoint {
raw: String,
block: String,
idx: Option<usize>,
threshold: f64,
last: Option<Vec<f64>>,
}
const CHECKPOINTS: &[&str] = &[
"iter_start",
"after_mu",
"after_search_dir",
"after_step",
"step_rejected",
"pre_restoration_entry",
"post_restoration_exit",
"terminated",
];
pub struct RestartRequest {
pub seed_x: Vec<f64>,
pub options: Vec<(String, String)>,
pub warm: Option<IterateSnapshot>,
}
pub type RestartCell = Rc<std::cell::RefCell<Option<RestartRequest>>>;
#[derive(Clone)]
struct SweepRecord {
idx: usize,
seed: Vec<f64>,
status: String,
objective: f64,
inf_pr: f64,
iters: i32,
}
struct SweepState {
queue: VecDeque<Vec<f64>>,
current: Option<Vec<f64>>,
records: Vec<SweepRecord>,
total: usize,
saved_pause_iters: bool,
}
const SNAPSHOT_CAP: usize = 2000;
fn is_success_status(s: &str) -> bool {
matches!(s, "Success" | "StopAtAcceptablePoint")
}
fn parse_floats(s: &str) -> Result<Vec<f64>, String> {
s.split(|c: char| c == ',' || c.is_whitespace())
.filter(|t| !t.is_empty())
.map(|t| t.parse::<f64>().map_err(|_| format!("bad number `{t}`")))
.collect()
}
fn splitmix_unit(state: &mut u64) -> f64 {
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = *state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^= z >> 31;
((z >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0
}
fn seed_for(k: usize) -> u64 {
0x9E37_79B9_7F4A_7C15u64
^ (k as u64)
.wrapping_mul(0xD1B5_4A32_D192_ED03)
.wrapping_add(1)
}
fn sample_start(base: &[f64], bounds: Option<(&[f64], &[f64])>, rel: f64, k: usize) -> Vec<f64> {
if k == 0 {
return base.to_vec();
}
let mut state = seed_for(k);
base.iter()
.enumerate()
.map(|(i, &xi)| {
let unit = splitmix_unit(&mut state); if let Some((lo, hi)) = bounds {
let (l, u) = (lo[i], hi[i]);
if l.is_finite() && u.is_finite() && u > l {
return l + (u - l) * (unit * 0.5 + 0.5);
}
}
xi + rel * (xi.abs() + 1.0) * unit
})
.collect()
}
#[cfg(test)]
fn jitter(base: &[f64], rel: f64, k: usize) -> Vec<f64> {
sample_start(base, None, rel, k)
}
pub mod interrupt {
use std::sync::atomic::{AtomicBool, Ordering};
static PENDING: AtomicBool = AtomicBool::new(false);
#[cfg(unix)]
static INSTALLED: AtomicBool = AtomicBool::new(false);
#[cfg(unix)]
extern "C" fn handler(_sig: nix::libc::c_int) {
if PENDING.swap(true, Ordering::SeqCst) {
unsafe { nix::libc::_exit(130) };
}
}
#[cfg(unix)]
pub fn install() {
use nix::sys::signal::{self, SigHandler, Signal};
if INSTALLED.swap(true, Ordering::SeqCst) {
return;
}
unsafe {
let _ = signal::signal(Signal::SIGINT, SigHandler::Handler(handler));
}
}
#[cfg(not(unix))]
pub fn install() {}
pub fn take() -> bool {
PENDING.swap(false, Ordering::SeqCst)
}
#[cfg(test)]
pub fn set_pending_for_test() {
PENDING.store(true, Ordering::SeqCst);
}
}
#[derive(Clone, Copy)]
enum Flow {
Stay,
Resume,
Stop,
}
struct CmdOut {
ok: bool,
lines: Vec<String>,
data: Option<serde_json::Value>,
flow: Flow,
}
impl CmdOut {
fn ok(lines: Vec<String>) -> Self {
Self {
ok: true,
lines,
data: None,
flow: Flow::Stay,
}
}
fn err(msg: impl Into<String>) -> Self {
Self {
ok: false,
lines: vec![msg.into()],
data: None,
flow: Flow::Stay,
}
}
fn with_data(mut self, data: serde_json::Value) -> Self {
self.data = Some(data);
self
}
fn flow(mut self, flow: Flow) -> Self {
self.flow = flow;
self
}
}
const METRICS: &[&str] = &[
"iter",
"mu",
"objective",
"inf_pr",
"inf_du",
"nlp_error",
"complementarity",
];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Metric {
Mu,
InfPr,
InfDu,
Obj,
NlpError,
Compl,
Iter,
}
impl Metric {
fn parse(s: &str) -> Option<Metric> {
Some(match s {
"mu" => Metric::Mu,
"inf_pr" => Metric::InfPr,
"inf_du" => Metric::InfDu,
"obj" | "objective" => Metric::Obj,
"err" | "nlp_error" => Metric::NlpError,
"compl" | "complementarity" => Metric::Compl,
"iter" => Metric::Iter,
_ => return None,
})
}
fn eval(self, ctx: &DebugCtx) -> f64 {
match self {
Metric::Mu => ctx.mu(),
Metric::InfPr => ctx.inf_pr(),
Metric::InfDu => ctx.inf_du(),
Metric::Obj => ctx.objective(),
Metric::NlpError => ctx.nlp_error(),
Metric::Compl => ctx.complementarity(),
Metric::Iter => ctx.iter() as f64,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CmpOp {
Lt,
Le,
Gt,
Ge,
Eq,
}
impl CmpOp {
fn eval(self, lhs: f64, rhs: f64) -> bool {
match self {
CmpOp::Lt => lhs < rhs,
CmpOp::Le => lhs <= rhs,
CmpOp::Gt => lhs > rhs,
CmpOp::Ge => lhs >= rhs,
CmpOp::Eq => (lhs - rhs).abs() <= 1e-12 * rhs.abs().max(1.0),
}
}
}
#[derive(Clone, Debug)]
struct Atom {
metric: Metric,
op: CmpOp,
rhs: f64,
}
impl Atom {
fn parse(expr: &str) -> Result<Atom, String> {
let expr = expr.trim();
let mut found: Option<(&str, usize, usize)> = None;
for (i, _) in expr.char_indices() {
let rest = &expr[i..];
if rest.starts_with("<=") || rest.starts_with(">=") || rest.starts_with("==") {
found = Some((&expr[i..i + 2], i, 2));
break;
}
if rest.starts_with('<') || rest.starts_with('>') {
found = Some((&expr[i..i + 1], i, 1));
break;
}
}
let (op, pos, oplen) = found
.ok_or_else(|| format!("no comparison operator in `{expr}` (use < <= > >= ==)"))?;
let metric_s = expr[..pos].trim();
let rhs_s = expr[pos + oplen..].trim();
let metric = Metric::parse(metric_s)
.ok_or_else(|| format!("unknown metric `{metric_s}` (one of {METRICS:?})"))?;
let rhs = rhs_s
.parse::<f64>()
.map_err(|_| format!("bad threshold `{rhs_s}`"))?;
let cmp = match op {
"<" => CmpOp::Lt,
"<=" => CmpOp::Le,
">" => CmpOp::Gt,
">=" => CmpOp::Ge,
"==" => CmpOp::Eq,
_ => unreachable!(),
};
Ok(Atom {
metric,
op: cmp,
rhs,
})
}
fn holds(&self, ctx: &DebugCtx) -> bool {
self.op.eval(self.metric.eval(ctx), self.rhs)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Join {
And,
Or,
}
#[derive(Clone, Debug)]
struct Condition {
first: Atom,
rest: Vec<(Join, Atom)>,
raw: String,
}
impl Condition {
fn parse(expr: &str) -> Result<Condition, String> {
let cleaned: String = expr.chars().filter(|c| !matches!(c, '(' | ')')).collect();
let mut atoms: Vec<(Option<Join>, &str)> = Vec::new();
let bytes = cleaned.as_bytes();
let mut start = 0usize;
let mut i = 0usize;
let mut pending: Option<Join> = None;
while i + 1 < bytes.len() {
let two = &cleaned[i..i + 2];
let join = match two {
"&&" => Some(Join::And),
"||" => Some(Join::Or),
_ => None,
};
if let Some(j) = join {
atoms.push((pending, &cleaned[start..i]));
pending = Some(j);
i += 2;
start = i;
} else {
i += 1;
}
}
atoms.push((pending, &cleaned[start..]));
let mut iter = atoms.into_iter();
let Some((_, first_s)) = iter.next() else {
return Err("empty condition".into());
};
let first = Atom::parse(first_s)?;
let mut rest = Vec::new();
for (join, s) in iter {
let join = join.ok_or("malformed compound condition (dangling &&/||)")?;
rest.push((join, Atom::parse(s)?));
}
Ok(Condition {
first,
rest,
raw: cleaned,
})
}
fn holds(&self, ctx: &DebugCtx) -> bool {
let mut acc = self.first.holds(ctx);
for (join, atom) in &self.rest {
let v = atom.holds(ctx);
acc = match join {
Join::And => acc && v,
Join::Or => acc || v,
};
}
acc
}
}
fn path_candidates(word: &str) -> Vec<String> {
let (dir, prefix) = match word.rfind('/') {
Some(i) => (&word[..=i], &word[i + 1..]), None => ("", word),
};
let read_from = if dir.is_empty() { "." } else { dir };
let Ok(entries) = std::fs::read_dir(read_from) else {
return Vec::new();
};
let mut out: Vec<String> = Vec::new();
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
if !name.starts_with(prefix) {
continue;
}
if name.starts_with('.') && !prefix.starts_with('.') {
continue;
}
let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
let mut cand = format!("{dir}{name}");
if is_dir {
cand.push('/');
}
out.push(cand);
}
out.sort();
out
}
fn completion_candidates(reg: Option<&RegisteredOptions>, before: &str, word: &str) -> Vec<String> {
let toks: Vec<&str> = before.split_whitespace().collect();
let starts = |opts: &[&str]| -> Vec<String> {
opts.iter()
.filter(|c| c.starts_with(word))
.map(|c| c.to_string())
.collect()
};
let opt_names = || -> Vec<String> {
reg.map(|r| {
r.registered_options_in_order()
.iter()
.map(|o| o.name.clone())
.filter(|n| n.starts_with(word))
.collect()
})
.unwrap_or_default()
};
match toks.as_slice() {
[] => starts(COMMANDS),
["set"] => {
let mut v = starts(&["mu", "opt"]);
v.extend(starts(&BLOCK_NAMES));
v
}
["set", "opt"] | ["get", "opt"] | ["get"] | ["opt"] | ["options"] => opt_names(),
["set", "opt", name] => reg
.and_then(|r| r.get_option(name))
.map(|o| {
o.valid_strings
.iter()
.map(|e| e.value.clone())
.filter(|v| v.starts_with(word) && v != "*")
.collect()
})
.unwrap_or_default(),
["stop-at"] | ["stopat"] => starts(CHECKPOINTS),
["break", "if"] | ["b", "if"] => starts(METRICS),
["break", "on"] | ["b", "on"] => starts(EVENTS),
["break"] | ["b"] => starts(&["if", "on", "clear", "del"]),
["watchpoint"] | ["wp"] => starts(&BLOCK_NAMES),
["print"] | ["p"] | ["watch"] | ["display"] => {
let mut v = starts(&BLOCK_NAMES);
v.extend(starts(&[
"mu",
"obj",
"inf_pr",
"inf_du",
"err",
"compl",
"iter",
"kkt",
"active",
"inactive",
"residuals",
"equation",
"rank",
]));
v
}
["viz"] | ["plot"] => {
let mut v = starts(&BLOCK_NAMES);
v.extend(starts(&["kkt", "L"]));
v
}
["complete"] => starts(COMMANDS),
["save"] | ["load"] | ["sweep"] | ["source"] => path_candidates(word),
["load", _] => starts(&BLOCK_NAMES),
_ => Vec::new(),
}
}
#[derive(Helper, Hinter, Highlighter, Validator)]
struct DbgHelper {
reg: Option<Rc<RegisteredOptions>>,
}
impl Completer for DbgHelper {
type Candidate = Pair;
fn complete(
&self,
line: &str,
pos: usize,
_ctx: &Context<'_>,
) -> rustyline::Result<(usize, Vec<Pair>)> {
let before = &line[..pos];
let start = before
.rfind(char::is_whitespace)
.map(|i| i + 1)
.unwrap_or(0);
let word = &before[start..];
let cands = completion_candidates(self.reg.as_deref(), &before[..start], word);
let pairs = cands
.into_iter()
.map(|c| Pair {
display: c.clone(),
replacement: c,
})
.collect();
Ok((start, pairs))
}
}
pub struct EquationBook {
names: Vec<String>,
equations: Vec<String>,
}
impl EquationBook {
pub fn new(names: Vec<String>, equations: Vec<String>) -> Self {
Self { names, equations }
}
pub fn len(&self) -> usize {
self.equations.len()
}
pub fn is_empty(&self) -> bool {
self.equations.is_empty()
}
fn label(&self, i: usize) -> String {
match self.names.get(i) {
Some(n) if !n.is_empty() => n.clone(),
_ => format!("c[{i}]"),
}
}
fn resolve(&self, key: &str) -> Option<usize> {
if let Some(i) = self.names.iter().position(|n| n == key) {
return Some(i);
}
key.parse::<usize>()
.ok()
.filter(|&i| i < self.equations.len())
}
}
const MAX_STRUCT_NAMES: usize = 10;
const MAX_SINGULAR_VALUES_SHOWN: usize = 16;
const MAX_RANK_CULPRITS: usize = 12;
pub struct StructureBook {
inc: EqualityIncidence,
con_names: Vec<String>,
var_names: Vec<String>,
}
impl StructureBook {
pub fn new(inc: EqualityIncidence, con_names: Vec<String>, var_names: Vec<String>) -> Self {
Self {
inc,
con_names,
var_names,
}
}
fn con_label(&self, eq_row: usize) -> String {
let orig = self.inc.eq_row_inner_idx[eq_row];
match self.con_names.get(orig) {
Some(n) if !n.is_empty() => n.clone(),
_ => format!("c[{orig}]"),
}
}
fn var_label(&self, v: usize) -> String {
match self.var_names.get(v) {
Some(n) if !n.is_empty() => n.clone(),
_ => format!("x[{v}]"),
}
}
fn join_capped(labels: &[String]) -> String {
if labels.len() <= MAX_STRUCT_NAMES {
labels.join(", ")
} else {
let head = labels[..MAX_STRUCT_NAMES].join(", ");
let more = labels.len() - MAX_STRUCT_NAMES;
format!("{head}, … (+{more} more)")
}
}
fn findings(&self) -> Vec<(&'static str, &'static str, String)> {
let mut out = Vec::new();
if self.inc.n_eq_rows() == 0 {
return out;
}
let matching = hopcroft_karp(&self.inc);
let dm = DulmageMendelsohnPartition::from_matching(&self.inc, &matching);
if dm.over_rows.is_empty() {
return out;
}
let excess = dm.over_rows.len().saturating_sub(dm.over_cols.len());
let eq_labels: Vec<String> = dm.over_rows.iter().map(|&r| self.con_label(r)).collect();
let var_labels: Vec<String> = dm.over_cols.iter().map(|&v| self.var_label(v)).collect();
let eqs = Self::join_capped(&eq_labels);
let shared = if var_labels.is_empty() {
"no variables".to_string()
} else {
Self::join_capped(&var_labels)
};
out.push((
"warning",
"structural_singularity",
format!(
"Constraint Jacobian is structurally singular (Dulmage–Mendelsohn): {} equation(s) \
over-determine the {} variable(s) they jointly touch ({}), so ≥{} of them must be \
redundant or mutually inconsistent (LICQ fails on this block). Candidate \
dependent equations: {}. Inspect them with `print equation <name>`; this names \
the rows behind any δ_c dual-regularization / wrong-inertia signal.",
dm.over_rows.len(),
dm.over_cols.len(),
shared,
excess.max(1),
eqs
),
));
out
}
}
pub struct SolverDebugger {
mode: DebugMode,
reg: Option<Rc<RegisteredOptions>>,
step: bool,
run_to: Option<i32>,
breaks: Vec<i32>,
temp_breaks: Vec<i32>,
bp_commands: HashMap<i32, Vec<String>>,
conds: Vec<Condition>,
watchpoints: Vec<WatchPoint>,
last_mu: Option<f64>,
mu_stall: u32,
in_restoration: bool,
detached: bool,
hello_sent: bool,
pause_iters: bool,
pause_terminal: bool,
terminal_only_on_error: bool,
interruptible: bool,
emit_progress: bool,
sub_step: bool,
stop_at: HashSet<&'static str>,
break_events: HashSet<&'static str>,
snapshots: BTreeMap<i32, IterateSnapshot>,
restart: Option<RestartCell>,
editor: Option<Editor<DbgHelper, FileHistory>>,
hist_path: Option<PathBuf>,
pump: Option<StdinPump>,
watches: Vec<String>,
pending_script: Option<String>,
staged: Vec<(String, String)>,
sweep: Option<SweepState>,
prompt_interrupts: u8,
equation_book: Option<EquationBook>,
structure_book: Option<StructureBook>,
}
impl SolverDebugger {
pub fn new(mode: DebugMode, reg: Option<Rc<RegisteredOptions>>) -> Self {
Self {
mode,
reg,
step: true,
run_to: None,
breaks: Vec::new(),
temp_breaks: Vec::new(),
bp_commands: HashMap::new(),
conds: Vec::new(),
watchpoints: Vec::new(),
last_mu: None,
mu_stall: 0,
in_restoration: false,
detached: false,
hello_sent: false,
pause_iters: true,
pause_terminal: true,
terminal_only_on_error: false,
interruptible: true,
emit_progress: true,
sub_step: false,
stop_at: HashSet::new(),
break_events: HashSet::new(),
snapshots: BTreeMap::new(),
restart: None,
editor: None,
hist_path: None,
pump: None,
watches: Vec::new(),
pending_script: None,
staged: Vec::new(),
sweep: None,
prompt_interrupts: 0,
equation_book: None,
structure_book: None,
}
}
pub fn with_script(mut self, path: String) -> Self {
self.pending_script = Some(path);
self
}
pub fn set_equation_book(&mut self, book: EquationBook) {
self.equation_book = Some(book);
}
pub fn set_structure_book(&mut self, book: StructureBook) {
self.structure_book = Some(book);
}
pub fn with_restart(mut self, cell: RestartCell) -> Self {
self.restart = Some(cell);
self
}
pub fn on_error(mode: DebugMode, reg: Option<Rc<RegisteredOptions>>) -> Self {
Self {
step: false,
pause_iters: false,
terminal_only_on_error: true,
..Self::new(mode, reg)
}
}
pub fn on_interrupt(mode: DebugMode, reg: Option<Rc<RegisteredOptions>>) -> Self {
Self {
step: false,
pause_iters: false,
pause_terminal: false,
..Self::new(mode, reg)
}
}
pub fn staged_options(&self) -> &[(String, String)] {
&self.staged
}
fn should_pause(&mut self, iter: i32) -> bool {
if self.detached {
return false;
}
if self.step {
return true;
}
if let Some(t) = self.run_to {
if iter >= t {
self.run_to = None;
return true;
}
}
if self.breaks.contains(&iter) {
return true;
}
if let Some(pos) = self.temp_breaks.iter().position(|&b| b == iter) {
self.temp_breaks.remove(pos);
return true;
}
false
}
fn matched_condition(&self, ctx: &DebugCtx) -> Option<String> {
if self.detached {
return None;
}
self.conds
.iter()
.find(|c| c.holds(ctx))
.map(|c| c.raw.clone())
}
fn matched_event(&self, ctx: &DebugCtx) -> Option<&'static str> {
if self.detached || self.break_events.is_empty() {
return None;
}
let cp = ctx.checkpoint();
let tiny = 1e-10;
EVENTS.iter().copied().find(|&e| {
self.break_events.contains(e)
&& match e {
"resto_entered" => cp == Checkpoint::PreRestoration,
"resto_exited" => cp == Checkpoint::PostRestoration,
"regularized" => {
cp == Checkpoint::AfterSearchDirection && ctx.regularization() > 0.0
}
"tiny_step" => {
cp == Checkpoint::AfterSearchDirection
&& ctx
.delta_block("x")
.map(|v| v.iter().fold(0.0_f64, |m, &x| m.max(x.abs())) < tiny)
.unwrap_or(false)
}
"ls_rejected" => cp == Checkpoint::AfterStep && ctx.ls_count() > 1,
"mu_stalled" => cp == Checkpoint::IterStart && self.mu_stall >= MU_STALL_ITERS,
"nan" => !ctx.nlp_error().is_finite() || !ctx.objective().is_finite(),
_ => false,
}
})
}
fn update_mu_stall(&mut self, mu: f64) {
if let Some(last) = self.last_mu {
if (mu - last).abs() <= 1e-12 * last.abs().max(1.0) {
self.mu_stall += 1;
} else {
self.mu_stall = 0;
}
}
self.last_mu = Some(mu);
}
fn matched_watchpoint(&mut self, ctx: &DebugCtx) -> Option<String> {
if self.detached {
return None;
}
let mut hit = None;
for wp in self.watchpoints.iter_mut() {
let Some(full) = ctx.block(&wp.block) else {
continue;
};
let cur: Vec<f64> = match wp.idx {
Some(i) => match full.get(i) {
Some(&v) => vec![v],
None => continue,
},
None => full,
};
if let Some(prev) = &wp.last {
if prev.len() == cur.len() {
let changed = prev
.iter()
.zip(&cur)
.any(|(p, c)| (p - c).abs() > wp.threshold);
if changed && hit.is_none() {
hit = Some(wp.raw.clone());
}
}
}
wp.last = Some(cur);
}
hit
}
fn dispatch(&mut self, line: &str, ctx: &mut DebugCtx) -> CmdOut {
let owned = tokenize_quoted(line);
let toks: Vec<&str> = owned.iter().map(String::as_str).collect();
let Some(&verb) = toks.first() else {
return CmdOut::ok(vec![]); };
let rest = &toks[1..];
match verb {
"help" | "h" | "?" => self.cmd_help(),
"info" | "i" => self.cmd_info(ctx),
"print" | "p" => self.cmd_print(rest, ctx),
"step" | "s" | "n" | "next" if rest.first() == Some(&"sub") => {
self.sub_step = true;
CmdOut::ok(vec![
"stepping to the next checkpoint (sub-iteration)".into()
])
.flow(Flow::Resume)
}
"step" | "s" | "n" | "next" => {
self.step = true;
CmdOut::ok(vec!["stepping one iteration".into()]).flow(Flow::Resume)
}
"stepi" | "si" => {
self.sub_step = true;
CmdOut::ok(vec![
"stepping to the next checkpoint (sub-iteration)".into()
])
.flow(Flow::Resume)
}
"continue" | "c" | "cont" => {
self.step = false;
self.sub_step = false;
self.run_to = None;
CmdOut::ok(vec!["continuing".into()]).flow(Flow::Resume)
}
"run" | "r" => self.cmd_run(rest),
"break" | "b" => self.cmd_break(rest),
"tbreak" | "tb" => match rest.first().and_then(|s| s.parse::<i32>().ok()) {
Some(n) => {
if !self.temp_breaks.contains(&n) {
self.temp_breaks.push(n);
}
CmdOut::ok(vec![format!("temporary breakpoint at iteration {n}")])
}
None => CmdOut::err("usage: tbreak <iteration>"),
},
"watchpoint" | "wp" => self.cmd_watchpoint(rest),
"commands" => self.cmd_commands(rest),
"stop-at" | "stopat" => self.cmd_stop_at(rest),
"progress" => match rest.first().copied() {
Some("on") | None => {
self.emit_progress = true;
CmdOut::ok(vec!["progress events on".into()])
}
Some("off") => {
self.emit_progress = false;
CmdOut::ok(vec!["progress events off".into()])
}
_ => CmdOut::err("usage: progress [on|off]"),
},
"set" => self.cmd_set(rest, ctx),
"get" => self.cmd_get(rest),
"opt" | "options" => self.cmd_opt(rest),
"complete" => self.cmd_complete(rest),
"viz" | "plot" => self.cmd_viz(rest, ctx),
"save" => self.cmd_save(rest, ctx),
"load" => self.cmd_load(rest, ctx),
"sweep" => self.cmd_sweep(rest, ctx),
"multistart" => self.cmd_multistart(rest, ctx),
"goto" | "jump" => self.cmd_goto(rest, ctx),
"restart" => match self.snapshots.keys().next().copied() {
Some(k) => self.restore_to(k, ctx),
None => CmdOut::err("no snapshots captured yet"),
},
"resolve" | "re-solve" => self.cmd_resolve(ctx),
"ask" | "explain" | "claude" => self.cmd_ask(rest, ctx),
"watch" | "display" => self.cmd_watch(rest),
"diff" => self.cmd_diff(ctx),
"diagnose" | "diag" => self.cmd_diagnose(ctx),
"source" => self.cmd_source(rest, ctx),
"detach" => {
self.detached = true;
self.step = false;
self.run_to = None;
CmdOut::ok(vec!["detached — solving to completion".into()]).flow(Flow::Resume)
}
"pause" => CmdOut::ok(vec!["already paused".into()]),
"coffee" | "brew" | "espresso" => self.cmd_coffee(),
"quit" | "q" | "exit" => CmdOut::ok(vec!["stopping solve".into()]).flow(Flow::Stop),
other => CmdOut::err(format!("unknown command `{other}` (try `help`)")),
}
}
fn cmd_coffee(&self) -> CmdOut {
let color = matches!(self.mode, DebugMode::Repl)
&& std::io::stderr().is_terminal()
&& std::env::var_os("NO_COLOR").is_none();
let paint = |r: u8, g: u8, b: u8, s: &str| -> String {
if color {
format!("\x1b[38;2;{r};{g};{b}m{s}\x1b[0m")
} else {
s.to_string()
}
};
let cup = |s: &str| paint(0xEC, 0xEC, 0xEF, s);
let dark = |s: &str| paint(0x5A, 0x32, 0x1E, s);
let brew = |s: &str| paint(0x96, 0x5F, 0x37, s);
let steam = |s: &str| paint(0xB4, 0xB9, 0xC3, s);
let lines = vec![
String::new(),
format!(" {}", steam(") ) )")),
format!(" {}", steam("( ( (")),
format!(" {}", cup("._________.")),
format!(" {}{}{}", cup("|"), dark("~~~~~~~~"), cup("|_")),
format!(" {}{}{}", cup("| "), brew("COFFEE"), cup("| |")),
format!(" {}{}{}", cup("| "), dark("~~~~~~"), cup("| |")),
format!(" {}", cup("|________|_|")),
format!(" {}", cup("\\________/")),
format!(" {}", brew("a fresh cup for a stuck solve")),
String::new(),
];
CmdOut::ok(lines).with_data(serde_json::json!({"easter_egg": "coffee"}))
}
fn cmd_help(&self) -> CmdOut {
let lines = vec![
"commands:".into(),
" info | i summary of the current iterate".into(),
" print | p <what> x|s|y_c|y_d|z_l|z_u|v_l|v_u | dx (step) |".into(),
" mu|obj|inf_pr|inf_du|err|compl|iter | kkt | active | inactive".into(),
" print residuals [pr|du] [k] top-k largest-magnitude residuals (default k=10)".into(),
" print equation [name|row] source algebra of a constraint, by model name or row".into(),
" print rank SVD rank of the equality Jacobian; names dependent equations".into(),
" step | s | n run one iteration, pause again".into(),
" stepi | si | step sub run to the next checkpoint (into sub-iteration phases)".into(),
" progress [on|off] toggle per-iteration progress events (JSON mode)".into(),
" stop-at <cp> always pause at a checkpoint: after_mu|after_search_dir|after_step".into(),
" continue | c run to the next breakpoint".into(),
" run | r <N> run until iteration N".into(),
" break | b [N|clear|del N] set/list/clear breakpoints".into(),
" break if <m><op><v> conditional bp; m in mu|inf_pr|inf_du|obj|err|iter,".into(),
" op in < <= > >= == (e.g. break if inf_pr<1e-6)".into(),
" break on <event> event bp: resto_entered|resto_exited|regularized|".into(),
" tiny_step|ls_rejected|mu_stalled|nan".into(),
" tbreak <N> one-shot breakpoint (deletes after firing)".into(),
" watchpoint <blk>[<i>] [τ] pause when a value changes by > τ (alias wp)".into(),
" commands <N> <c>;<c>… auto-run commands when iter N's breakpoint hits".into(),
" set mu <v> overwrite the barrier parameter".into(),
" set <blk>[<i>] <v> overwrite one component (e.g. set x[2] 1.5)".into(),
" set <blk> <v0,v1,...> overwrite a whole block".into(),
" set opt <name> <value> stage a solver option (validated)".into(),
" get opt <name> show an option's effective value (staged or default)".into(),
" opt [filter] list solver options (name/type/default)".into(),
" complete <prefix> completion candidates (commands + options)".into(),
" viz <x|s|dx|...|kkt|L> open the artifact in an external viewer".into(),
" save [path] write the current iterate + residuals to JSON".into(),
" load <file> [block] read a block (default x) from a save artifact / numeric file".into(),
" sweep <file> one solve per start in <file>; tabulate outcomes".into(),
" multistart <N> [rel] N restarts (uniform in each finite box; jitter else)".into(),
" goto <k> | restart rewind to a captured iteration (primal-dual only)".into(),
" resolve re-solve from the current x with staged `set opt`s".into(),
" ask [question] ask an LLM about the state (default Claude Code; set".into(),
" POUNCE_DBG_LLM=claude|codex|gemini|llm or a command template)".into(),
" watch [target|clear|del] auto-print a `print` target at every pause".into(),
" diff what changed in the iterate since the last iteration".into(),
" diagnose | diag live health report: named culprit residuals, KKT inertia, stalls".into(),
" source <file> run debugger commands from a file".into(),
" detach stop pausing; solve to completion".into(),
" quit | q stop the solve now".into(),
];
CmdOut::ok(lines)
}
fn cmd_info(&self, ctx: &DebugCtx) -> CmdOut {
let dims: Vec<_> = ctx.block_dims();
let dims_json: serde_json::Map<String, serde_json::Value> = dims
.iter()
.map(|(n, d)| ((*n).to_string(), serde_json::json!(d)))
.collect();
let lines = vec![
format!("iter = {}", ctx.iter()),
format!("mu = {:.6e}", ctx.mu()),
format!("objective = {:.8e}", ctx.objective()),
format!("inf_pr = {:.6e}", ctx.inf_pr()),
format!("inf_du = {:.6e}", ctx.inf_du()),
format!("nlp_error = {:.6e}", ctx.nlp_error()),
format!(
"dims = {}",
dims.iter()
.map(|(n, d)| format!("{n}:{d}"))
.collect::<Vec<_>>()
.join(" ")
),
];
CmdOut::ok(lines).with_data(serde_json::json!({
"iter": ctx.iter(),
"mu": ctx.mu(),
"objective": ctx.objective(),
"inf_pr": ctx.inf_pr(),
"inf_du": ctx.inf_du(),
"nlp_error": ctx.nlp_error(),
"dims": dims_json,
}))
}
fn cmd_print(&self, rest: &[&str], ctx: &DebugCtx) -> CmdOut {
let Some(&what) = rest.first() else {
return self.cmd_info(ctx);
};
if what == "kkt" {
return self.cmd_print_kkt(ctx);
}
if what == "active" {
return self.cmd_print_bounds(ctx, true);
}
if what == "inactive" {
return self.cmd_print_bounds(ctx, false);
}
if what == "residuals" || what == "resid" {
return self.cmd_print_residuals(&rest[1..], ctx);
}
if what == "equation" || what == "eqn" || what == "eq" {
return self.cmd_print_equation(&rest[1..]);
}
if what == "rank" {
return self.cmd_print_rank(ctx);
}
let delta = what.strip_prefix("d").filter(|b| BLOCK_NAMES.contains(b));
if BLOCK_NAMES.contains(&what) {
match ctx.block(what) {
Some(v) => CmdOut::ok(vec![fmt_vec(what, &v)])
.with_data(serde_json::json!({"name": what, "values": v})),
None => CmdOut::err(format!("no iterate yet for block `{what}`")),
}
} else if let Some(blk) = delta {
match ctx.delta_block(blk) {
Some(v) => CmdOut::ok(vec![fmt_vec(&format!("d{blk}"), &v)])
.with_data(serde_json::json!({"name": format!("d{blk}"), "values": v})),
None => CmdOut::err(format!("no search direction available for `d{blk}` yet")),
}
} else {
let val = match what {
"mu" => ctx.mu(),
"obj" | "objective" => ctx.objective(),
"inf_pr" => ctx.inf_pr(),
"inf_du" => ctx.inf_du(),
"err" | "nlp_error" => ctx.nlp_error(),
"compl" | "complementarity" => ctx.complementarity(),
"iter" => ctx.iter() as f64,
_ => {
return CmdOut::err(format!(
"don't know how to print `{what}` (try a block name or mu|obj|inf_pr|inf_du|err|compl|iter)"
))
}
};
CmdOut::ok(vec![format!("{what} = {val:.10e}")])
.with_data(serde_json::json!({"name": what, "value": val}))
}
}
fn cmd_print_bounds(&self, ctx: &DebugCtx, active: bool) -> CmdOut {
let tol = 1e-6;
let mut lines = Vec::new();
let mut cats = serde_json::Map::new();
for cat in ["x_l", "x_u", "s_l", "s_u"] {
let Some(sl) = ctx.bound_slack(cat) else {
continue;
};
if sl.is_empty() {
continue;
}
let n = sl.len();
if active {
let min = sl.iter().copied().fold(f64::INFINITY, f64::min);
let near = sl.iter().filter(|&&s| s.abs() < tol).count();
lines.push(format!(
"{cat}: {n} bound(s), {near} near-active (slack<{tol:.0e}), min slack {min:.3e}"
));
cats.insert(
cat.to_string(),
serde_json::json!({"n": n, "near_active": near, "min_slack": min}),
);
} else {
let max = sl.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let far = sl.iter().filter(|&&s| s.abs() >= tol).count();
lines.push(format!(
"{cat}: {n} bound(s), {far} inactive (slack≥{tol:.0e}), max slack {max:.3e}"
));
cats.insert(
cat.to_string(),
serde_json::json!({"n": n, "inactive": far, "max_slack": max}),
);
}
}
if lines.is_empty() {
lines.push("no bounded variables or inequality slacks".into());
}
CmdOut::ok(lines).with_data(serde_json::json!({"tol": tol, "categories": cats}))
}
fn cmd_print_residuals(&self, rest: &[&str], ctx: &DebugCtx) -> CmdOut {
let mut k: Option<usize> = None;
let mut filter: Option<bool> = None; for &arg in rest {
if let Ok(n) = arg.parse::<usize>() {
k = Some(n);
} else {
match arg {
"primal" | "pr" => filter = Some(true),
"dual" | "du" => filter = Some(false),
other => {
return CmdOut::err(format!(
"usage: print residuals [primal|dual] [k] (got `{other}`)"
))
}
}
}
}
let k = k.unwrap_or(10);
let mut all = Vec::new();
if filter != Some(false) {
let Some(primal) = ctx.constraint_residuals() else {
return CmdOut::err("no iterate yet — residuals unavailable");
};
all.extend(primal);
}
if filter != Some(true) {
let Some(dual) = ctx.dual_residuals() else {
return CmdOut::err("no iterate yet — residuals unavailable");
};
all.extend(dual);
}
let total = all.len();
let top = rank_residuals(all, k);
if top.is_empty() {
return CmdOut::ok(vec!["no residuals at this iterate".into()])
.with_data(serde_json::json!({"k": k, "total": total, "top": []}));
}
let names = ctx.split_names();
let name_of = |r: &Residual| resid_name(r, &names);
let lines = top
.iter()
.map(|r| {
let label = match name_of(r) {
Some(name) => format!("{}[{}]", r.kind.tag(), name),
None => format!("{}[{}]", r.kind.tag(), r.index),
};
format!("{:>8} = {:+.6e} |{:.3e}|", label, r.value, r.value.abs())
})
.collect();
let data: Vec<_> = top
.iter()
.map(|r| {
serde_json::json!({
"space": r.kind.tag(),
"primal": r.kind.is_primal(),
"index": r.index,
"name": name_of(r),
"value": r.value,
})
})
.collect();
CmdOut::ok(lines).with_data(serde_json::json!({"k": k, "total": total, "top": data}))
}
fn cmd_print_equation(&self, rest: &[&str]) -> CmdOut {
let Some(book) = self.equation_book.as_ref() else {
return CmdOut::err(
"no equation source — `print equation` needs an .nl model (none was loaded)",
);
};
if book.is_empty() {
return CmdOut::err("the model has no constraint equations to print");
}
let Some(&key) = rest.first() else {
return CmdOut::ok(vec![format!(
"{} constraint equation(s) — `print equation <name|row>` to show one",
book.len()
)])
.with_data(serde_json::json!({"count": book.len()}));
};
let Some(i) = book.resolve(key) else {
return CmdOut::err(format!(
"no constraint named or indexed `{key}` (have {} equation(s); try a name or 0..{})",
book.len(),
book.len().saturating_sub(1)
));
};
let label = book.label(i);
let Some(eq) = book.equations.get(i) else {
return CmdOut::err(format!(
"constraint `{key}` has no source algebra (index {i} out of range)"
));
};
CmdOut::ok(vec![format!("{label}: {eq}")]).with_data(serde_json::json!({
"index": i,
"name": book.names.get(i).filter(|n| !n.is_empty()),
"equation": eq,
}))
}
fn cmd_diagnose(&self, ctx: &DebugCtx) -> CmdOut {
const TOL: f64 = 1e-6;
let names = ctx.split_names();
let mut f: Vec<(&'static str, &'static str, String)> = Vec::new();
let inf_pr = ctx.inf_pr();
if inf_pr > TOL {
if let Some(resids) = ctx.constraint_residuals() {
if let Some((label, val)) = worst_named(resids, &names) {
let sev = if inf_pr > 1e-2 { "error" } else { "warning" };
f.push((
sev,
"primal_infeasible",
format!(
"Primal infeasibility {inf_pr:.2e}; worst constraint residual is \
{label} = {val:+.3e}. Inspect this equation's feasibility and scaling \
at the current point (`print equation {label}`)."
),
));
}
}
}
let inf_du = ctx.inf_du();
if inf_du > TOL {
if let Some(resids) = ctx.dual_residuals() {
if let Some((label, val)) = worst_named(resids, &names) {
f.push((
"warning",
"dual_infeasible",
format!(
"Dual infeasibility {inf_du:.2e}; largest stationarity residual is \
{label} = {val:+.3e}."
),
));
}
}
}
if let Some(k) = ctx.kkt() {
if k.provides_inertia && !k.inertia_correct {
f.push((
"warning",
"inertia_wrong",
format!(
"KKT inertia is wrong (n-={} vs expected {}): the system was \
indefinite/singular and the step had to be stabilized. A persistent \
mismatch points at a rank-deficient Jacobian or an indefinite Hessian.",
k.n_neg, k.expected_neg
),
));
}
if k.delta_w > 1e-4 {
f.push((
"info",
"heavy_regularization",
format!(
"Primal regularization δ_w={:.2e} applied — the Hessian was indefinite at \
this step. Normal near saddle points; persistent large δ_w suggests a \
problematic Hessian.",
k.delta_w
),
));
}
if k.delta_c > 0.0 {
f.push((
"warning",
"dual_regularization",
format!(
"Dual regularization δ_c={:.2e} applied — the constraint Jacobian is (near) \
rank-deficient (linearly dependent or redundant equalities). Inspect the \
equality residuals by name (`print residuals primal`).",
k.delta_c
),
));
}
}
if let Some(book) = self.structure_book.as_ref() {
f.extend(book.findings());
}
if let Some(rep) = ctx.rank_report() {
if rep.is_rank_deficient() {
let culprits: Vec<String> = rep
.culprits
.iter()
.take(MAX_RANK_CULPRITS)
.map(|c| rank_row_label(&rep.rows[c.row], &names))
.collect();
let named = if culprits.is_empty() {
String::new()
} else {
format!(" Implicated equations: {}.", culprits.join(", "))
};
f.push((
"warning",
"rank_deficient_jacobian",
format!(
"Equality Jacobian J_c is numerically rank-deficient at this iterate: \
rank {}/{} (deficiency {}), σ_min={:.2e}, cond={}. Linearly dependent \
or redundant equality constraints — the root cause behind δ_c \
regularization / wrong inertia.{named}",
rep.rank,
rep.n_rows(),
rep.deficiency(),
rep.sigma_min(),
fmt_cond(rep.cond),
),
));
}
}
let mut max_mult = 0.0_f64;
for blk in ["y_c", "y_d", "z_l", "z_u", "v_l", "v_u"] {
if let Some(v) = ctx.block(blk) {
max_mult = v.iter().fold(max_mult, |m, &x| m.max(x.abs()));
}
}
if max_mult > 1e8 {
f.push((
"warning",
"large_multipliers",
format!(
"Largest multiplier magnitude is {max_mult:.2e}. Very large multipliers signal a \
constraint-qualification failure or poor scaling — consider rescaling the \
offending rows."
),
));
}
let mut pinned = 0usize;
for cat in ["x_l", "x_u"] {
if let Some(sl) = ctx.bound_slack(cat) {
pinned += sl.iter().filter(|&&s| s.abs() < TOL).count();
}
}
if pinned > 0 {
f.push((
"info",
"bounds_pinned",
format!(
"{pinned} variable bound(s) are active (slack < {TOL:.0e}). Active bounds are \
expected at a solution, but a large count early can throttle the line search."
),
));
}
let (alpha_pr, _) = ctx.alpha();
if ctx.iter() > 0 && alpha_pr > 0.0 && alpha_pr < 1e-6 {
f.push((
"warning",
"tiny_step",
format!(
"Accepted primal step α_pr={alpha_pr:.2e} is tiny — the line search is barely \
moving. Often a poor search direction or an ill-conditioned KKT system."
),
));
}
let ls = ctx.ls_count();
if ls >= 10 {
f.push((
"warning",
"heavy_line_search",
format!(
"Line search needed {ls} trial points for the accepted step — search-direction \
quality may be poor (check Hessian accuracy)."
),
));
}
if self.in_restoration {
f.push((
"warning",
"in_restoration",
"Currently inside feasibility restoration: the line search could not make \
progress on the original problem at the working point."
.to_string(),
));
}
if self.mu_stall >= MU_STALL_ITERS {
f.push((
"warning",
"mu_stalled",
format!(
"μ has not decreased for {} consecutive iterations — the barrier is stuck. \
Try mu_strategy=adaptive or a smaller mu_init.",
self.mu_stall
),
));
}
if f.is_empty() {
f.push((
"info",
"healthy",
format!(
"No issues detected at iter {}: inf_pr={:.2e}, inf_du={:.2e}, μ={:.2e}.",
ctx.iter(),
inf_pr,
inf_du,
ctx.mu()
),
));
}
let rank = |s: &str| match s {
"error" => 0,
"warning" => 1,
_ => 2,
};
f.sort_by_key(|(sev, _, _)| rank(sev));
let lines: Vec<String> = f
.iter()
.map(|(sev, code, msg)| format!("[{sev:>7}] {code}: {msg}"))
.collect();
let data: Vec<_> = f
.iter()
.map(|(sev, code, msg)| serde_json::json!({"severity": sev, "code": code, "message": msg}))
.collect();
let n = data.len();
CmdOut::ok(lines)
.with_data(serde_json::json!({"iter": ctx.iter(), "findings": data, "n_findings": n}))
}
fn cmd_print_kkt(&self, ctx: &DebugCtx) -> CmdOut {
let Some(k) = ctx.kkt() else {
return CmdOut::err(
"no KKT factorization yet — stop at `after_search_dir` (e.g. `stop-at kkt`)",
);
};
let inertia = if k.provides_inertia {
format!(
"n+={} n-={} (expected n-={}) → {}",
k.n_pos,
k.n_neg,
k.expected_neg,
if k.inertia_correct {
"correct"
} else {
"WRONG (step stabilized)"
}
)
} else {
"n/a (backend reports no inertia)".to_string()
};
let lines = vec![
format!("dim = {}", k.dim),
format!("inertia = {inertia}"),
format!("delta_w = {:.6e} (primal regularization)", k.delta_w),
format!("delta_c = {:.6e} (dual regularization)", k.delta_c),
format!("status = {}", k.status),
];
CmdOut::ok(lines).with_data(serde_json::json!({
"dim": k.dim,
"n_pos": k.n_pos,
"n_neg": k.n_neg,
"expected_neg": k.expected_neg,
"provides_inertia": k.provides_inertia,
"inertia_correct": k.inertia_correct,
"delta_w": k.delta_w,
"delta_c": k.delta_c,
"status": k.status,
}))
}
fn cmd_print_rank(&self, ctx: &DebugCtx) -> CmdOut {
let Some(rep) = ctx.rank_report() else {
return CmdOut::err(
"no equality-constraint Jacobian to analyze (the problem has no equality \
constraints, or there is no iterate yet)",
);
};
let names = ctx.split_names();
let (lines, data) =
render_rank_report(&rep, &names, self.equation_book.as_ref(), ctx.iter());
CmdOut::ok(lines).with_data(data)
}
fn cmd_run(&mut self, rest: &[&str]) -> CmdOut {
match rest.first().and_then(|s| s.parse::<i32>().ok()) {
Some(n) => {
self.run_to = Some(n);
self.step = false;
CmdOut::ok(vec![format!("running until iteration {n}")]).flow(Flow::Resume)
}
None => CmdOut::err("usage: run <iteration>"),
}
}
fn cmd_break(&mut self, rest: &[&str]) -> CmdOut {
if rest.first().copied() == Some("if") {
let expr: String = rest[1..].concat();
if expr.is_empty() {
return CmdOut::err(
"usage: break if <metric><op><value> (e.g. break if inf_pr<1e-6)",
);
}
return match Condition::parse(&expr) {
Ok(c) => {
let raw = c.raw.clone();
if !self.conds.iter().any(|e| e.raw == raw) {
self.conds.push(c);
}
CmdOut::ok(vec![format!("conditional breakpoint: {raw}")])
.with_data(serde_json::json!({"condition": raw}))
}
Err(e) => CmdOut::err(e),
};
}
if rest.first().copied() == Some("on") {
let Some(&name) = rest.get(1) else {
return CmdOut::err(format!("usage: break on <event> (one of {EVENTS:?})"));
};
let Some(&canon) = EVENTS.iter().find(|&&e| e == name) else {
return CmdOut::err(format!("unknown event `{name}` (one of {EVENTS:?})"));
};
self.break_events.insert(canon);
return CmdOut::ok(vec![format!("break on event `{canon}`")])
.with_data(serde_json::json!({"event": canon}));
}
match rest {
[] => {
let mut bs = self.breaks.clone();
bs.sort_unstable();
let conds: Vec<String> = self.conds.iter().map(|c| c.raw.clone()).collect();
let mut events: Vec<&str> = self.break_events.iter().copied().collect();
events.sort_unstable();
let mut lines = vec![format!("breakpoints: {bs:?}")];
if !conds.is_empty() {
lines.push(format!("conditions: {}", conds.join(", ")));
}
if !events.is_empty() {
lines.push(format!("events: {}", events.join(", ")));
}
CmdOut::ok(lines).with_data(
serde_json::json!({"breakpoints": bs, "conditions": conds, "events": events}),
)
}
["clear", "cond"] | ["clear", "conditions"] => {
self.conds.clear();
CmdOut::ok(vec!["cleared conditional breakpoints".into()])
}
["clear", "events"] => {
self.break_events.clear();
CmdOut::ok(vec!["cleared event breakpoints".into()])
}
["clear"] => {
self.breaks.clear();
self.conds.clear();
self.break_events.clear();
CmdOut::ok(vec!["cleared all breakpoints".into()])
}
["del", n] | ["delete", n] => match n.parse::<i32>() {
Ok(n) => {
self.breaks.retain(|&b| b != n);
CmdOut::ok(vec![format!("removed breakpoint {n}")])
}
Err(_) => CmdOut::err("usage: break del <iteration>"),
},
[n] => match n.parse::<i32>() {
Ok(n) => {
if !self.breaks.contains(&n) {
self.breaks.push(n);
}
CmdOut::ok(vec![format!("breakpoint at iteration {n}")])
}
Err(_) => CmdOut::err("usage: break <iteration>"),
},
_ => CmdOut::err("usage: break [N | if <m><op><v> | clear | clear cond | del N]"),
}
}
fn cmd_stop_at(&mut self, rest: &[&str]) -> CmdOut {
let canon = |s: &str| -> Option<&'static str> {
match s {
"mu" | "after_mu" => Some("after_mu"),
"kkt" | "search_dir" | "after_search_dir" => Some("after_search_dir"),
"step" | "after_step" => Some("after_step"),
"rejected" | "ls_rejected" | "step_rejected" => Some("step_rejected"),
"resto" | "restoration" | "pre_restoration_entry" => Some("pre_restoration_entry"),
"resto_exit" | "post_restoration_exit" => Some("post_restoration_exit"),
"iter" | "iter_start" => Some("iter_start"),
"terminated" => Some("terminated"),
_ => None,
}
};
match rest {
[] => {
let mut v: Vec<&str> = self.stop_at.iter().copied().collect();
v.sort_unstable();
CmdOut::ok(vec![format!(
"stop-at: {v:?} (available: {CHECKPOINTS:?})"
)])
.with_data(serde_json::json!({"stop_at": v, "available": CHECKPOINTS}))
}
["clear"] => {
self.stop_at.clear();
CmdOut::ok(vec!["cleared stop-at checkpoints".into()])
}
[name] => match canon(name) {
Some(c) => {
self.stop_at.insert(c);
CmdOut::ok(vec![format!("will stop at checkpoint `{c}`")])
.with_data(serde_json::json!({"stop_at_added": c}))
}
None => CmdOut::err(format!(
"unknown checkpoint `{name}` (one of {CHECKPOINTS:?})"
)),
},
_ => CmdOut::err("usage: stop-at [<checkpoint> | clear]"),
}
}
fn cmd_set(&mut self, rest: &[&str], ctx: &mut DebugCtx) -> CmdOut {
match rest {
["mu", v] => match v.parse::<f64>() {
Ok(mu) => match ctx.set_mu(mu) {
Ok(()) => CmdOut::ok(vec![format!("mu := {mu:.6e}")]),
Err(e) => CmdOut::err(e),
},
Err(_) => CmdOut::err("usage: set mu <value>"),
},
["opt", name, value] => self.cmd_set_opt(name, value, ctx),
[target, value] => self.cmd_set_block(target, value, ctx),
_ => CmdOut::err(
"usage: set mu <v> | set <blk>[<i>] <v> | set <blk> <v0,v1,..> | set opt <name> <v>",
),
}
}
fn cmd_set_block(&mut self, target: &str, value: &str, ctx: &mut DebugCtx) -> CmdOut {
if let Some(open) = target.find('[') {
if !target.ends_with(']') {
return CmdOut::err("malformed component target (expected name[idx])");
}
let name = &target[..open];
let idx_str = &target[open + 1..target.len() - 1];
let Ok(idx) = idx_str.parse::<usize>() else {
return CmdOut::err(format!("bad index `{idx_str}`"));
};
let Ok(val) = value.parse::<f64>() else {
return CmdOut::err(format!("bad value `{value}`"));
};
return match ctx.set_component(name, idx, val) {
Ok(()) => CmdOut::ok(vec![format!("{name}[{idx}] := {val:.6e}")]),
Err(e) => CmdOut::err(e),
};
}
let parsed: Result<Vec<f64>, _> =
value.split(',').map(|s| s.trim().parse::<f64>()).collect();
match parsed {
Ok(vals) => match ctx.set_block(target, &vals) {
Ok(()) => CmdOut::ok(vec![format!("{target} := {} value(s)", vals.len())]),
Err(e) => CmdOut::err(e),
},
Err(_) => CmdOut::err("could not parse comma-separated values"),
}
}
fn cmd_set_opt(&mut self, name: &str, value: &str, ctx: &mut DebugCtx) -> CmdOut {
let Some(reg) = self.reg.as_ref() else {
return CmdOut::err("no options registry available");
};
let Some(opt) = reg.get_option(name) else {
return CmdOut::err(format!("unknown option `{name}` (try `opt {name}`)"));
};
let valid = match opt.option_type {
OptionType::OT_Number => value
.parse::<f64>()
.map(|v| opt.is_valid_number(v))
.unwrap_or(false),
OptionType::OT_Integer => value
.parse::<i32>()
.map(|v| opt.is_valid_integer(v))
.unwrap_or(false),
OptionType::OT_String => opt.is_valid_string(value),
OptionType::OT_Unknown => true,
};
if !valid {
return CmdOut::err(format!("`{value}` is not a valid value for `{name}`"));
}
self.staged.retain(|(k, _)| k != name);
self.staged.push((name.to_string(), value.to_string()));
if is_live_tolerance(name) {
if let Ok(v) = value.parse::<f64>() {
ctx.set_live_tolerance(name, v);
return CmdOut::ok(vec![format!(
"{name} = {value} (applied live — the next `step` uses it)"
)])
.with_data(serde_json::json!({
"option": name, "value": value, "live": true
}));
}
}
CmdOut::ok(vec![format!(
"staged {name} = {value} (validated; takes effect on `resolve` — built strategies don't re-read mid-solve)"
)])
.with_data(serde_json::json!({"option": name, "value": value, "staged": true}))
}
fn cmd_get(&self, rest: &[&str]) -> CmdOut {
let name = match rest {
["opt", n] => *n,
[n] => *n,
_ => return CmdOut::err("usage: get opt <name>"),
};
let Some(reg) = self.reg.as_ref() else {
return CmdOut::err("no options registry available");
};
let Some(o) = reg.get_option(name) else {
return CmdOut::err(format!("unknown option `{name}` (try `opt {name}`)"));
};
let def = default_str(&o.default);
let staged = self
.staged
.iter()
.find(|(k, _)| k == name)
.map(|(_, v)| v.clone());
let (value, source) = match &staged {
Some(v) => (v.clone(), "staged"),
None => (def.clone(), "default"),
};
CmdOut::ok(vec![format!("{name} = {value} ({source}; default={def})")]).with_data(
serde_json::json!({
"option": name, "value": value, "source": source,
"default": def, "staged": staged,
}),
)
}
fn cmd_opt(&self, rest: &[&str]) -> CmdOut {
let Some(reg) = self.reg.as_ref() else {
return CmdOut::err("no options registry available");
};
let filter = rest.first().copied().unwrap_or("");
let mut lines = Vec::new();
let mut data = Vec::new();
for o in reg.registered_options_in_order() {
if !filter.is_empty()
&& !o.name.contains(filter)
&& !o
.category
.to_ascii_lowercase()
.contains(&filter.to_ascii_lowercase())
{
continue;
}
let ty = type_str(o.option_type);
let def = default_str(&o.default);
lines.push(format!(
" {:<28} {:<7} default={:<12} {}",
o.name, ty, def, o.short_description
));
data.push(serde_json::json!({
"name": o.name,
"type": ty,
"default": def,
"category": o.category,
"short": o.short_description,
"valid": o.valid_strings.iter().map(|e| e.value.clone()).collect::<Vec<_>>(),
}));
}
if lines.is_empty() {
return CmdOut::ok(vec![format!("no options match `{filter}`")]);
}
if data.len() == 1 {
if let Some(o) = reg.get_option(filter) {
if !o.long_description.is_empty() {
lines.push(String::new());
lines.push(o.long_description.clone());
}
}
}
CmdOut::ok(lines).with_data(serde_json::json!({"options": data}))
}
fn cmd_complete(&self, rest: &[&str]) -> CmdOut {
let (before, word) = match rest.split_last() {
Some((w, pre)) => (pre.join(" "), *w),
None => (String::new(), ""),
};
let mut cands = completion_candidates(self.reg.as_deref(), &before, word);
cands.sort();
cands.dedup();
CmdOut::ok(vec![cands.join(" ")]).with_data(serde_json::json!({"candidates": cands}))
}
fn cmd_save(&self, rest: &[&str], ctx: &DebugCtx) -> CmdOut {
let iter = ctx.iter();
let path = rest
.first()
.map(PathBuf::from)
.unwrap_or_else(|| std::env::temp_dir().join(format!("pounce-dbg-iter{iter}.json")));
let collect = |delta: bool| -> serde_json::Map<String, serde_json::Value> {
let mut m = serde_json::Map::new();
for &b in BLOCK_NAMES.iter() {
let v = if delta {
ctx.delta_block(b)
} else {
ctx.block(b)
};
if let Some(v) = v {
if !v.is_empty() {
let key = if delta {
format!("d{b}")
} else {
b.to_string()
};
m.insert(key, serde_json::json!(v));
}
}
}
m
};
let payload = serde_json::json!({
"iter": iter,
"mu": ctx.mu(),
"objective": ctx.objective(),
"inf_pr": ctx.inf_pr(),
"inf_du": ctx.inf_du(),
"nlp_error": ctx.nlp_error(),
"iterate": collect(false),
"delta": collect(true),
});
match std::fs::write(&path, format!("{payload}\n")) {
Ok(()) => {
let p = path.to_string_lossy().to_string();
CmdOut::ok(vec![format!("saved iterate to {p}")])
.with_data(serde_json::json!({"path": p}))
}
Err(e) => CmdOut::err(format!("save failed: {e}")),
}
}
fn cmd_load(&mut self, rest: &[&str], ctx: &mut DebugCtx) -> CmdOut {
let Some(&path) = rest.first() else {
return CmdOut::err("usage: load <file> [block] (inverse of `save`)");
};
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => return CmdOut::err(format!("cannot read `{path}`: {e}")),
};
if let Ok(v) = serde_json::from_str::<serde_json::Value>(content.trim()) {
let obj = v
.get("iterate")
.and_then(|o| o.as_object())
.or_else(|| v.as_object());
if let Some(obj) = obj {
let mut loaded: Vec<(String, usize)> = Vec::new();
let mut errs: Vec<String> = Vec::new();
for &b in BLOCK_NAMES.iter() {
let Some(arr) = obj.get(b).and_then(|a| a.as_array()) else {
continue;
};
let vals: Option<Vec<f64>> = arr.iter().map(|x| x.as_f64()).collect();
let Some(vals) = vals else {
errs.push(format!("{b}: non-numeric entries"));
continue;
};
match ctx.set_block(b, &vals) {
Ok(()) => loaded.push((b.to_string(), vals.len())),
Err(e) => errs.push(format!("{b}: {e}")),
}
}
if loaded.is_empty() && errs.is_empty() {
return CmdOut::err(
"no recognizable blocks in JSON (expected `x`, `s`, … at top level or under `iterate`)",
);
}
let mut lines: Vec<String> = loaded
.iter()
.map(|(b, n)| format!("loaded {b} ({n} values)"))
.collect();
lines.extend(errs.iter().map(|e| format!("skipped {e}")));
return CmdOut::ok(lines).with_data(serde_json::json!({
"loaded": loaded.iter().map(|(b, n)| serde_json::json!({"block": b, "n": n})).collect::<Vec<_>>(),
"skipped": errs,
}));
}
}
let block = rest.get(1).copied().unwrap_or("x");
let vals = match parse_floats(&content) {
Ok(v) if !v.is_empty() => v,
Ok(_) => return CmdOut::err("file held no numbers"),
Err(e) => return CmdOut::err(e),
};
match ctx.set_block(block, &vals) {
Ok(()) => CmdOut::ok(vec![format!("loaded {block} ({} values)", vals.len())])
.with_data(serde_json::json!({"block": block, "n": vals.len()})),
Err(e) => CmdOut::err(e),
}
}
fn cmd_sweep(&mut self, rest: &[&str], ctx: &mut DebugCtx) -> CmdOut {
if self.restart.is_none() {
return CmdOut::err("sweep needs re-solve, which is not available in this context");
}
let Some(&path) = rest.first() else {
return CmdOut::err("usage: sweep <file> (one start per line, comma-separated)");
};
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => return CmdOut::err(format!("cannot read `{path}`: {e}")),
};
let dim = ctx.block("x").map(|x| x.len()).unwrap_or(0);
let mut seeds: Vec<Vec<f64>> = Vec::new();
for (lineno, raw) in content.lines().enumerate() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with("//") {
continue;
}
match parse_floats(line) {
Ok(v) if v.len() == dim => seeds.push(v),
Ok(v) => {
return CmdOut::err(format!(
"line {}: got {} values, expected {dim} (= dim x)",
lineno + 1,
v.len()
));
}
Err(e) => return CmdOut::err(format!("line {}: {e}", lineno + 1)),
}
}
self.start_sweep(seeds, &format!("sweep `{path}`"))
}
fn cmd_multistart(&mut self, rest: &[&str], ctx: &mut DebugCtx) -> CmdOut {
if self.restart.is_none() {
return CmdOut::err(
"multistart needs re-solve, which is not available in this context",
);
}
let Some(n) = rest.first().and_then(|s| s.parse::<usize>().ok()) else {
return CmdOut::err("usage: multistart <N> [rel] (N sampled restarts)");
};
if n == 0 {
return CmdOut::err("N must be ≥ 1");
}
let rel = rest
.get(1)
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.1);
let Some(base) = ctx.block("x") else {
return CmdOut::err("no current iterate to sample from");
};
let bounds = ctx
.var_bounds()
.filter(|(lo, hi)| lo.len() == base.len() && hi.len() == base.len());
let n_box = bounds
.as_ref()
.map(|(lo, hi)| {
lo.iter()
.zip(hi)
.filter(|(l, u)| l.is_finite() && u.is_finite() && u > l)
.count()
})
.unwrap_or(0);
let seeds: Vec<Vec<f64>> = (0..n)
.map(|k| {
let b = bounds
.as_ref()
.map(|(lo, hi)| (lo.as_slice(), hi.as_slice()));
sample_start(&base, b, rel, k)
})
.collect();
let n_var = base.len();
let label = if n_box == n_var {
format!("multistart {n} (box-sampled, {n_box}/{n_var} vars bounded)")
} else if n_box > 0 {
format!(
"multistart {n} (box {n_box}/{n_var} vars; {} unbounded → jitter rel={rel})",
n_var - n_box
)
} else {
format!("multistart {n} (no finite boxes → jitter rel={rel})")
};
self.start_sweep(seeds, &label)
}
fn start_sweep(&mut self, seeds: Vec<Vec<f64>>, label: &str) -> CmdOut {
if seeds.is_empty() {
return CmdOut::err("no start points");
}
let Some(cell) = self.restart.as_ref() else {
return CmdOut::err("sweep needs re-solve, which is not available in this context");
};
let total = seeds.len();
let mut queue: VecDeque<Vec<f64>> = seeds.into();
let first = queue.pop_front().expect("non-empty");
*cell.borrow_mut() = Some(RestartRequest {
seed_x: first.clone(),
options: self.staged.clone(),
warm: None,
});
let saved_pause_iters = self.pause_iters;
self.pause_iters = false;
self.step = false;
self.sub_step = false;
self.run_to = None;
self.sweep = Some(SweepState {
queue,
current: Some(first),
records: Vec::new(),
total,
saved_pause_iters,
});
CmdOut::ok(vec![format!("{label}: running {total} start(s)…")])
.with_data(serde_json::json!({"sweep": label, "starts": total}))
.flow(Flow::Stop)
}
fn drive_sweep(&mut self, ctx: &DebugCtx) -> Option<DebugAction> {
let mut sweep = self.sweep.take()?;
let rec = SweepRecord {
idx: sweep.records.len(),
seed: sweep.current.clone().unwrap_or_default(),
status: ctx.status().unwrap_or("?").to_string(),
objective: ctx.objective(),
inf_pr: ctx.inf_pr(),
iters: ctx.iter(),
};
self.emit_sweep_progress(&rec, sweep.total);
sweep.records.push(rec);
if let Some(next) = sweep.queue.pop_front() {
sweep.current = Some(next.clone());
if let Some(cell) = self.restart.as_ref() {
*cell.borrow_mut() = Some(RestartRequest {
seed_x: next,
options: self.staged.clone(),
warm: None,
});
}
self.sweep = Some(sweep);
return Some(DebugAction::Resume);
}
self.pause_iters = sweep.saved_pause_iters;
self.emit_sweep_summary(&sweep);
None
}
fn emit_sweep_progress(&self, rec: &SweepRecord, total: usize) {
match self.mode {
DebugMode::Repl => eprintln!(
" sweep {}/{}: {:<22} iters={:<4} obj={:.6e} inf_pr={:.2e}",
rec.idx + 1,
total,
rec.status,
rec.iters,
rec.objective,
rec.inf_pr,
),
DebugMode::Json => emit_json(&serde_json::json!({
"event": "sweep_result",
"index": rec.idx,
"total": total,
"status": rec.status,
"iters": rec.iters,
"objective": rec.objective,
"inf_pr": rec.inf_pr,
"seed": rec.seed,
})),
}
}
fn emit_sweep_summary(&self, sweep: &SweepState) {
let succeeded: Vec<&SweepRecord> = sweep
.records
.iter()
.filter(|r| is_success_status(&r.status))
.collect();
let mut distinct: Vec<f64> = Vec::new();
for r in &succeeded {
if !distinct
.iter()
.any(|&o| (o - r.objective).abs() <= 1e-6 * o.abs().max(1.0))
{
distinct.push(r.objective);
}
}
let best = succeeded.iter().min_by(|a, b| {
a.objective
.partial_cmp(&b.objective)
.unwrap_or(std::cmp::Ordering::Equal)
});
match self.mode {
DebugMode::Repl => {
eprintln!(
"\n── sweep complete ── {} solves, {} succeeded, {} distinct minima",
sweep.records.len(),
succeeded.len(),
distinct.len()
);
eprintln!(
" {:>3} {:<22} {:>5} {:>14} {:>9}",
"#", "status", "iters", "objective", "inf_pr"
);
for r in &sweep.records {
eprintln!(
" {:>3} {:<22} {:>5} {:>14.6e} {:>9.2e}",
r.idx, r.status, r.iters, r.objective, r.inf_pr
);
}
if let Some(b) = best {
eprintln!(" best: solve #{} obj={:.8e}", b.idx, b.objective);
}
}
DebugMode::Json => emit_json(&serde_json::json!({
"event": "sweep_summary",
"solves": sweep.records.len(),
"succeeded": succeeded.len(),
"distinct_minima": distinct.len(),
"best_index": best.map(|b| b.idx),
"best_objective": best.map(|b| b.objective),
"records": sweep.records.iter().map(|r| serde_json::json!({
"index": r.idx, "status": r.status, "iters": r.iters,
"objective": r.objective, "inf_pr": r.inf_pr,
})).collect::<Vec<_>>(),
})),
}
}
fn cmd_goto(&mut self, rest: &[&str], ctx: &mut DebugCtx) -> CmdOut {
match rest.first().and_then(|s| s.parse::<i32>().ok()) {
Some(k) => self.restore_to(k, ctx),
None => CmdOut::err("usage: goto <iteration>"),
}
}
fn restore_to(&mut self, k: i32, ctx: &mut DebugCtx) -> CmdOut {
match self.snapshots.get(&k) {
Some(snap) => {
ctx.restore(snap);
CmdOut::ok(vec![format!(
"rewound to iter {k} (primal-dual only; strategy history not restored). \
`continue`/`step` to resume."
)])
.with_data(serde_json::json!({"restored_iter": k}))
}
None => {
let have: Vec<i32> = self.snapshots.keys().copied().collect();
CmdOut::err(format!("no snapshot for iter {k} (captured: {have:?})"))
}
}
}
fn cmd_resolve(&mut self, ctx: &DebugCtx) -> CmdOut {
let Some(cell) = self.restart.as_ref() else {
return CmdOut::err("re-solve is not available in this context");
};
let Some(seed_x) = ctx.block("x") else {
return CmdOut::err("no current iterate to seed from");
};
let warm = ctx.snapshot();
let mu = warm.as_ref().map(|s| s.mu());
let options = self.staged.clone();
let n_opt = options.len();
let warm_msg = match mu {
Some(mu) => format!(
"re-solving warm from the current primal-dual iterate (μ={mu:.3e}) \
with {n_opt} staged option override(s)…"
),
None => format!(
"re-solving from current x (primal-only) with {n_opt} staged option override(s)…"
),
};
*cell.borrow_mut() = Some(RestartRequest {
seed_x,
options,
warm,
});
CmdOut::ok(vec![warm_msg])
.with_data(serde_json::json!({
"resolve": true,
"options": n_opt,
"warm": mu.is_some(),
"mu": mu,
}))
.flow(Flow::Stop)
}
fn cmd_ask(&self, rest: &[&str], ctx: &DebugCtx) -> CmdOut {
let question = if rest.is_empty() {
"Explain the current state of this interior-point solve and suggest what to try next."
.to_string()
} else {
rest.join(" ")
};
let prompt = build_ask_prompt(ctx, &question);
match run_llm(&prompt) {
Ok(reply) => {
let lines: Vec<String> = reply.lines().map(|l| l.to_string()).collect();
CmdOut::ok(lines).with_data(serde_json::json!({
"question": question,
"reply": reply,
}))
}
Err(e) => CmdOut::err(e),
}
}
fn cmd_watch(&mut self, rest: &[&str]) -> CmdOut {
match rest {
[] => CmdOut::ok(vec![format!("watches: {:?}", self.watches)])
.with_data(serde_json::json!({"watches": self.watches})),
["clear"] => {
self.watches.clear();
CmdOut::ok(vec!["cleared watches".into()])
}
["del", w] | ["delete", w] => {
self.watches.retain(|x| x != w);
CmdOut::ok(vec![format!("unwatched {w}")])
}
[w] => {
let w = w.to_string();
if !self.watches.contains(&w) {
self.watches.push(w.clone());
}
CmdOut::ok(vec![format!("watching {w}")])
}
_ => CmdOut::err("usage: watch [<target> | clear | del <target>]"),
}
}
fn cmd_watchpoint(&mut self, rest: &[&str]) -> CmdOut {
match rest {
[] => {
let v: Vec<&str> = self.watchpoints.iter().map(|w| w.raw.as_str()).collect();
CmdOut::ok(vec![format!("watchpoints: {v:?}")])
.with_data(serde_json::json!({"watchpoints": v}))
}
["clear"] => {
self.watchpoints.clear();
CmdOut::ok(vec!["cleared watchpoints".into()])
}
["del", spec] | ["delete", spec] => {
self.watchpoints.retain(|w| w.raw != *spec);
CmdOut::ok(vec![format!("removed watchpoint {spec}")])
}
[spec, rest @ ..] => {
let threshold = rest
.first()
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0);
let (block, idx) = match spec.find('[') {
Some(open) if spec.ends_with(']') => {
let b = &spec[..open];
match spec[open + 1..spec.len() - 1].parse::<usize>() {
Ok(i) => (b.to_string(), Some(i)),
Err(_) => return CmdOut::err(format!("bad index in `{spec}`")),
}
}
_ => (spec.to_string(), None),
};
if !BLOCK_NAMES.contains(&block.as_str()) {
return CmdOut::err(format!("unknown block `{block}`"));
}
let raw = spec.to_string();
if !self.watchpoints.iter().any(|w| w.raw == raw) {
self.watchpoints.push(WatchPoint {
raw: raw.clone(),
block,
idx,
threshold,
last: None,
});
}
CmdOut::ok(vec![format!("watchpoint on {raw} (Δ>{threshold:.3e})")])
}
}
}
fn cmd_commands(&mut self, rest: &[&str]) -> CmdOut {
let Some(iter) = rest.first().and_then(|s| s.parse::<i32>().ok()) else {
if rest.is_empty() {
let mut items: Vec<(i32, Vec<String>)> = self
.bp_commands
.iter()
.map(|(k, v)| (*k, v.clone()))
.collect();
items.sort_by_key(|(k, _)| *k);
let lines = if items.is_empty() {
vec!["no breakpoint command lists".into()]
} else {
items
.iter()
.map(|(k, v)| format!("iter {k}: {}", v.join(" ; ")))
.collect()
};
return CmdOut::ok(lines);
}
return CmdOut::err(
"usage: commands <iter> <cmd> ; <cmd> … (or: commands <iter> clear)",
);
};
let tail = rest[1..].join(" ");
let tail = tail.trim();
if tail.is_empty() || tail == "clear" {
self.bp_commands.remove(&iter);
return CmdOut::ok(vec![format!("cleared commands for iteration {iter}")]);
}
let cmds: Vec<String> = tail
.split(';')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
self.bp_commands.insert(iter, cmds.clone());
CmdOut::ok(vec![format!(
"commands for iter {iter}: {}",
cmds.join(" ; ")
)])
.with_data(serde_json::json!({"iter": iter, "commands": cmds}))
}
fn cmd_diff(&self, ctx: &DebugCtx) -> CmdOut {
let iter = ctx.iter();
let Some((&piter, prev)) = self.snapshots.range(..iter).next_back() else {
return CmdOut::err("no previous iterate to diff against");
};
let mut lines = vec![format!("Δ since iter {piter}:")];
let dmu = ctx.mu() - prev.mu();
lines.push(format!(" mu = {:.6e} (Δ {:+.3e})", ctx.mu(), dmu));
let mut blocks = serde_json::Map::new();
for b in BLOCK_NAMES {
let (Some(cur), Some(old)) = (ctx.block(b), prev.block(b)) else {
continue;
};
if cur.is_empty() || cur.len() != old.len() {
continue;
}
let mut amax = 0.0_f64;
let mut imax = 0usize;
for (i, (c, o)) in cur.iter().zip(&old).enumerate() {
let d = (c - o).abs();
if d > amax {
amax = d;
imax = i;
}
}
if amax > 0.0 {
lines.push(format!(
" {b}: max|Δ|={amax:.3e} at [{imax}] ({:.4e} → {:.4e})",
old[imax], cur[imax]
));
blocks.insert(
b.to_string(),
serde_json::json!({"max_abs_change": amax, "argmax": imax}),
);
}
}
if lines.len() == 2 {
lines.push(" (no change)".into());
}
CmdOut::ok(lines).with_data(
serde_json::json!({"from_iter": piter, "to_iter": iter, "dmu": dmu, "blocks": blocks}),
)
}
fn cmd_source(&mut self, rest: &[&str], ctx: &mut DebugCtx) -> CmdOut {
let Some(&path) = rest.first() else {
return CmdOut::err("usage: source <file>");
};
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => return CmdOut::err(format!("cannot read `{path}`: {e}")),
};
let mut lines = Vec::new();
let mut flow = Flow::Stay;
for raw in content.lines() {
let cmd = raw.trim();
if cmd.is_empty() || cmd.starts_with('#') || cmd.starts_with("//") {
continue;
}
lines.push(format!("[source] {cmd}"));
let out = self.dispatch(cmd, ctx);
lines.extend(out.lines);
if !matches!(out.flow, Flow::Stay) {
flow = out.flow;
break;
}
}
CmdOut {
ok: true,
lines,
data: None,
flow,
}
}
fn cmd_viz(&self, rest: &[&str], ctx: &mut DebugCtx) -> CmdOut {
let Some(&target) = rest.first() else {
return CmdOut::err("usage: viz <x|s|y_c|...|dx|kkt|L>");
};
if target == "kkt" {
let Some(k) = ctx.kkt() else {
return CmdOut::err(
"no KKT factorization captured yet — nothing has been factored (iter 0), \
or the debugger is detached. `step` once to capture.",
);
};
let Some((dim, irn, jcn, vals)) = ctx.kkt_matrix() else {
return CmdOut::err(
"KKT matrix not captured here — the debugger is detached \
(running free). `step` once to capture and re-run `viz kkt`.",
);
};
let kiter = k.iter;
let matrix = serde_json::json!({"dim": dim, "irn": irn, "jcn": jcn, "vals": vals,
"format": "triplet_1based_lower"});
let payload = serde_json::json!({
"label": "kkt", "iter": kiter,
"dim": k.dim, "n_pos": k.n_pos, "n_neg": k.n_neg,
"expected_neg": k.expected_neg, "inertia_correct": k.inertia_correct,
"delta_w": k.delta_w, "delta_c": k.delta_c, "status": k.status,
"matrix": matrix,
});
return match write_json_and_open("kkt", kiter, &payload) {
Ok((path, viewer)) => CmdOut::ok(vec![format!(
"wrote {path} (KKT system, iter {kiter}); opened with `{viewer}`"
)])
.with_data(serde_json::json!({"path": path, "viewer": viewer})),
Err(e) => CmdOut::err(e),
};
}
if target == "L" {
match ctx.kkt_l_factor() {
Some((n, perm, l_irn, l_jcn, l_vals)) => {
let kiter = ctx.kkt_captured_iter().unwrap_or_else(|| ctx.iter());
let payload = serde_json::json!({
"label": "L", "iter": kiter, "n": n, "perm": perm,
"l_irn": l_irn, "l_jcn": l_jcn, "l_vals": l_vals,
"format": "strict_lower_1based_permuted",
});
return match write_json_and_open("L", kiter, &payload) {
Ok((path, viewer)) => CmdOut::ok(vec![format!(
"wrote {path} (L factor, iter {kiter}); opened with `{viewer}`"
)])
.with_data(serde_json::json!({"path": path, "viewer": viewer})),
Err(e) => CmdOut::err(e),
};
}
None => {
return CmdOut::err(
"L factor not captured here — nothing factored yet (iter 0), \
or the debugger is detached. `step` once to capture.",
);
}
}
}
let (label, vals) = if BLOCK_NAMES.contains(&target) {
match ctx.block(target) {
Some(v) => (target.to_string(), v),
None => return CmdOut::err(format!("no data for block `{target}`")),
}
} else if let Some(blk) = target.strip_prefix("d").filter(|b| BLOCK_NAMES.contains(b)) {
match ctx.delta_block(blk) {
Some(v) => (format!("d{blk}"), v),
None => return CmdOut::err(format!("no search direction for `d{blk}`")),
}
} else {
return CmdOut::err(format!("don't know how to visualize `{target}`"));
};
match write_and_open(&label, ctx.iter(), &vals) {
Ok((path, viewer)) => CmdOut::ok(vec![format!(
"wrote {} ({} values); opened with `{}`",
path,
vals.len(),
viewer
)])
.with_data(serde_json::json!({"path": path, "viewer": viewer, "n": vals.len()})),
Err(e) => CmdOut::err(e),
}
}
fn emit_pause(&self, ctx: &DebugCtx, reason: Option<&str>) {
let terminal = matches!(ctx.checkpoint(), Checkpoint::Terminated);
match self.mode {
DebugMode::Repl => {
if terminal {
eprintln!(
"\n── pounce-dbg ── TERMINATED ({}) iter {} obj={:.6e} inf_pr={:.2e} inf_du={:.2e}",
ctx.status().unwrap_or("?"),
ctx.iter(),
ctx.objective(),
ctx.inf_pr(),
ctx.inf_du(),
);
} else {
let resto = if self.in_restoration {
" [restoration]"
} else {
""
};
eprintln!(
"\n── pounce-dbg ── iter {} @{}{} mu={:.3e} obj={:.6e} inf_pr={:.2e} inf_du={:.2e}",
ctx.iter(),
ctx.checkpoint().as_str(),
resto,
ctx.mu(),
ctx.objective(),
ctx.inf_pr(),
ctx.inf_du(),
);
}
if let Some(r) = reason {
eprintln!(" ↳ {r}");
}
for w in &self.watches {
let out = self.cmd_print(&[w.as_str()], ctx);
if out.ok {
for l in &out.lines {
eprintln!(" watch {l}");
}
} else {
eprintln!(" watch {w}: (n/a)");
}
}
}
DebugMode::Json => {
let watches: Vec<serde_json::Value> = self
.watches
.iter()
.map(|w| {
let out = self.cmd_print(&[w.as_str()], ctx);
serde_json::json!({"expr": w, "ok": out.ok, "output": out.lines, "data": out.data})
})
.collect();
let dims: serde_json::Map<String, serde_json::Value> = ctx
.block_dims()
.into_iter()
.map(|(n, d)| (n.to_string(), serde_json::json!(d)))
.collect();
let conds: Vec<String> = self.conds.iter().map(|c| c.raw.clone()).collect();
let ev = serde_json::json!({
"event": "pause",
"checkpoint": ctx.checkpoint().as_str(),
"status": ctx.status(),
"in_restoration": self.in_restoration,
"iter": ctx.iter(),
"mu": ctx.mu(),
"objective": ctx.objective(),
"inf_pr": ctx.inf_pr(),
"inf_du": ctx.inf_du(),
"nlp_error": ctx.nlp_error(),
"complementarity": ctx.complementarity(),
"dims": dims,
"breakpoints": self.breaks,
"conditions": conds,
"reason": reason,
"watches": watches,
});
emit_json(&ev);
}
}
}
fn emit_progress_event(&self, ctx: &DebugCtx) {
let ev = serde_json::json!({
"event": "progress",
"iter": ctx.iter(),
"mu": ctx.mu(),
"inf_pr": ctx.inf_pr(),
"inf_du": ctx.inf_du(),
"objective": ctx.objective(),
"nlp_error": ctx.nlp_error(),
"complementarity": ctx.complementarity(),
});
emit_json(&ev);
}
fn emit_result(&self, command: &str, out: &CmdOut, req_id: Option<&serde_json::Value>) {
match self.mode {
DebugMode::Repl => {
let stderr = std::io::stderr();
let mut h = stderr.lock();
for l in &out.lines {
let _ = writeln!(h, "{l}");
}
if !out.ok {
let _ = writeln!(h, "(error)");
}
}
DebugMode::Json => {
let ev = serde_json::json!({
"event": "result",
"request_id": req_id,
"command": command,
"ok": out.ok,
"output": out.lines,
"data": out.data,
});
emit_json(&ev);
}
}
}
fn emit_hello(&self) {
let ev = serde_json::json!({
"event": "hello",
"protocol": "pounce-dbg/1",
"pounce_version": env!("CARGO_PKG_VERSION"),
"capabilities": {
"inspect": true,
"mutate_iterate": true,
"mutate_mu": true,
"conditional_breakpoints": "compound",
"request_ids": true,
"viz": ["block", "delta", "kkt", "L"],
"save": true,
"load": true,
"sweep": self.restart.is_some(),
"kkt_inspect": true,
"equations": self.equation_book.is_some(),
"diagnose": true,
"structural_diagnose": self.structure_book.is_some(),
"llm_assist": true,
"rewind": "primal_dual",
"resolve": self.restart.is_some(),
"terminal_checkpoint": true,
"interruptible": self.interruptible,
"progress_events": self.emit_progress,
"async_pause": "checkpoint",
"pause_command": true,
},
"checkpoints": CHECKPOINTS,
"events": EVENTS,
"commands": COMMANDS,
"blocks": BLOCK_NAMES,
"metrics": METRICS,
});
emit_json(&ev);
}
fn ensure_editor(&mut self) {
if !matches!(self.mode, DebugMode::Repl)
|| self.editor.is_some()
|| !std::io::stdin().is_terminal()
{
return;
}
let mut ed: Editor<DbgHelper, FileHistory> = match Editor::new() {
Ok(e) => e,
Err(_) => return,
};
ed.set_helper(Some(DbgHelper {
reg: self.reg.clone(),
}));
let path = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(|h| PathBuf::from(h).join(".pounce_dbg_history"));
if let Some(p) = &path {
let _ = ed.load_history(p);
}
self.hist_path = path;
self.editor = Some(ed);
}
fn on_prompt_interrupt(&mut self) -> String {
self.prompt_interrupts += 1;
if self.prompt_interrupts >= 2 {
self.prompt_interrupts = 0;
eprintln!("(quitting — Ctrl-C)");
"quit".to_string()
} else {
eprintln!("(Ctrl-C — press again, or `quit`/Ctrl-D, to stop the solve)");
String::new()
}
}
fn next_command_line(&mut self) -> Option<String> {
if let DebugMode::Repl = self.mode {
if let Some(ed) = self.editor.as_mut() {
return match ed.readline("pounce-dbg> ") {
Ok(l) => {
self.prompt_interrupts = 0;
let _ = ed.add_history_entry(l.as_str());
if let Some(p) = &self.hist_path {
let _ = ed.save_history(p);
}
Some(l)
}
Err(ReadlineError::Interrupted) => Some(self.on_prompt_interrupt()),
Err(ReadlineError::Eof) => None,
Err(_) => None,
};
}
let _ = write!(std::io::stderr(), "pounce-dbg> ");
let _ = std::io::stderr().flush();
return read_stdin_line();
}
self.pump.get_or_insert_with(StdinPump::start).next()
}
}
fn read_stdin_line() -> Option<String> {
let mut line = String::new();
match std::io::stdin().read_line(&mut line) {
Ok(0) => None,
Ok(_) => Some(line),
Err(_) => None,
}
}
fn rank_residuals(mut entries: Vec<Residual>, k: usize) -> Vec<Residual> {
entries.sort_by(|a, b| {
b.value
.abs()
.partial_cmp(&a.value.abs())
.unwrap_or(std::cmp::Ordering::Equal)
});
entries.truncate(k);
entries
}
fn render_rank_report(
rep: &RankReport,
names: &Option<SplitNames>,
equations: Option<&EquationBook>,
iter: i32,
) -> (Vec<String>, serde_json::Value) {
let m = rep.n_rows();
let n = rep.n_cols;
let mut lines = vec![
format!("equality Jacobian J_c: {m} row(s) × {n} column(s)"),
format!(
"numerical rank = {} / {} (deficiency {})",
rep.rank,
m,
rep.deficiency()
),
format!(
"σ_max = {:.3e} σ_min = {:.3e} cond = {} (rank tol τ = {:.3e})",
rep.sigma_max(),
rep.sigma_min(),
fmt_cond(rep.cond),
rep.tol
),
];
let shown: Vec<String> = rep
.singular_values
.iter()
.take(MAX_SINGULAR_VALUES_SHOWN)
.map(|s| format!("{s:.3e}"))
.collect();
let tail = if rep.singular_values.len() > MAX_SINGULAR_VALUES_SHOWN {
" …"
} else {
""
};
lines.push(format!("singular values: [{}{tail}]", shown.join(", ")));
if rep.is_rank_deficient() {
lines.push(format!(
"rank-deficient: {} equation(s) lie in the near-null space \
(linearly dependent / redundant) — the source of δ_c regularization:",
rep.deficiency()
));
let mut shown_any_eq = false;
for c in rep.culprits.iter().take(MAX_RANK_CULPRITS) {
let row = &rep.rows[c.row];
let label = rank_row_label(row, names);
lines.push(format!(" {label} (participation {:.2})", c.weight));
if let Some(eq) = culprit_equation(row, names, equations) {
lines.push(format!(" {eq}"));
shown_any_eq = true;
}
}
if rep.culprits.len() > MAX_RANK_CULPRITS {
lines.push(format!(
" … and {} more",
rep.culprits.len() - MAX_RANK_CULPRITS
));
}
if !shown_any_eq {
lines.push("inspect a row with `print equation <name>` to see its terms".to_string());
}
} else {
lines.push("J_c has full row rank at this iterate.".to_string());
}
let culprits_json: Vec<serde_json::Value> = rep
.culprits
.iter()
.map(|c| {
let row = &rep.rows[c.row];
serde_json::json!({
"row": c.row,
"kind": row.kind.tag(),
"index": row.index,
"name": rank_row_name(row, names),
"label": rank_row_label(row, names),
"weight": c.weight,
"equation": culprit_equation(row, names, equations),
})
})
.collect();
let data = serde_json::json!({
"iter": iter,
"n_rows": m,
"n_cols": n,
"rank": rep.rank,
"deficiency": rep.deficiency(),
"rank_deficient": rep.is_rank_deficient(),
"sigma_max": rep.sigma_max(),
"sigma_min": rep.sigma_min(),
"cond": cond_json(rep.cond),
"tol": rep.tol,
"singular_values": rep.singular_values,
"culprits": culprits_json,
});
(lines, data)
}
fn culprit_equation(
row: &RankRow,
names: &Option<SplitNames>,
equations: Option<&EquationBook>,
) -> Option<String> {
let book = equations?;
let name = rank_row_name(row, names)?;
let i = book.resolve(&name)?;
Some(book.equations.get(i)?.clone())
}
fn rank_row_name(row: &RankRow, names: &Option<SplitNames>) -> Option<String> {
let r = Residual {
kind: row.kind,
index: row.index,
value: 0.0,
};
resid_name(&r, names).map(|s| s.to_string())
}
fn rank_row_label(row: &RankRow, names: &Option<SplitNames>) -> String {
match rank_row_name(row, names) {
Some(name) => format!("{}[{}]", row.kind.tag(), name),
None => format!("{}[{}]", row.kind.tag(), row.index),
}
}
fn fmt_cond(cond: f64) -> String {
if cond.is_finite() {
format!("{cond:.3e}")
} else {
"inf (σ_min = 0)".to_string()
}
}
fn cond_json(cond: f64) -> serde_json::Value {
if cond.is_finite() {
serde_json::json!(cond)
} else {
serde_json::Value::Null
}
}
fn resid_name<'a>(r: &Residual, names: &'a Option<SplitNames>) -> Option<&'a str> {
let n = names.as_ref()?;
let pool = match r.kind {
ResidKind::Eq => &n.eq,
ResidKind::Ineq | ResidKind::DualS => &n.ineq,
ResidKind::DualX => &n.x_var,
};
pool.get(r.index).and_then(|o| o.as_deref())
}
fn worst_named(resids: Vec<Residual>, names: &Option<SplitNames>) -> Option<(String, f64)> {
let top = rank_residuals(resids, 1);
let r = top.first()?;
let label = match resid_name(r, names) {
Some(name) => format!("{}[{}]", r.kind.tag(), name),
None => format!("{}[{}]", r.kind.tag(), r.index),
};
Some((label, r.value))
}
pub fn print_open_banner(mode: DebugMode) {
if !matches!(mode, DebugMode::Repl) {
return;
}
let color = std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none();
let paint = |r: u8, g: u8, b: u8, bold: bool, s: &str| -> String {
if color {
let w = if bold { "1;" } else { "" };
format!("\x1b[{w}38;2;{r};{g};{b}m{s}\x1b[0m")
} else {
s.to_string()
}
};
let orange = |s: &str| paint(0xE8, 0x7A, 0x1E, true, s);
let gold = |s: &str| paint(0xFF, 0xB0, 0x00, true, s);
let dim = |s: &str| paint(0x7A, 0x7E, 0x88, false, s);
let item = |key: &str, gloss: &str| format!("{} {}", orange(key), dim(gloss));
let err = std::io::stderr();
let mut h = err.lock();
let _ = writeln!(h);
for row in crate::print::logo_rows(color) {
let _ = writeln!(h, " {row}");
}
let _ = writeln!(h);
let _ = writeln!(
h,
" {} {}",
gold("interior-point debugger"),
dim(&format!(
"· pounce {} · pdb for the IPM",
env!("CARGO_PKG_VERSION")
))
);
let _ = writeln!(h);
let _ = writeln!(
h,
" {} {} {} {} {}",
item("s", "step"),
item("c", "continue"),
item("b", "N break"),
item("r", "N run"),
item("q", "quit"),
);
let _ = writeln!(
h,
" {} {} {} {} {}",
item("p", "x print"),
item("i", "info"),
item("set", "x[i] v"),
item("watch", "x"),
item("viz", "kkt"),
);
let _ = writeln!(
h,
" {} {} {}",
dim("type"),
gold("help"),
dim("for all commands · `ask` to consult Claude · Ctrl-C breaks in"),
);
let _ = writeln!(h);
}
fn is_pause_command(line: &str) -> bool {
parse_command(line, DebugMode::Json).command.trim() == "pause"
}
struct StdinPump {
inner: std::sync::Arc<(
std::sync::Mutex<VecDeque<Option<String>>>,
std::sync::Condvar,
)>,
}
impl StdinPump {
fn start() -> Self {
let inner = std::sync::Arc::new((
std::sync::Mutex::new(VecDeque::new()),
std::sync::Condvar::new(),
));
let w = std::sync::Arc::clone(&inner);
std::thread::spawn(move || {
use std::io::BufRead;
let stdin = std::io::stdin();
let mut lock = stdin.lock();
let (m, cv) = &*w;
loop {
let mut line = String::new();
let item = match lock.read_line(&mut line) {
Ok(0) | Err(_) => None, Ok(_) => Some(line),
};
let done = item.is_none();
m.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push_back(item);
cv.notify_one();
if done {
break;
}
}
});
Self { inner }
}
fn next(&self) -> Option<String> {
let (m, cv) = &*self.inner;
let mut q = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
loop {
match q.front() {
None => {
q = cv
.wait(q)
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
Some(None) => return None, Some(Some(_)) => return q.pop_front().flatten(),
}
}
}
fn try_take_pause(&self) -> bool {
let (m, _) = &*self.inner;
let mut q = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(Some(front)) = q.front() {
if is_pause_command(front) {
q.pop_front();
return true;
}
}
false
}
}
impl DebugHook for SolverDebugger {
fn wants_kkt_capture(&self) -> bool {
!self.detached
}
fn at_checkpoint(&mut self, ctx: &mut DebugCtx) -> DebugAction {
if matches!(self.mode, DebugMode::Json) && !self.hello_sent {
self.emit_hello();
self.hello_sent = true;
}
if let Checkpoint::Terminated = ctx.checkpoint() {
if self.sweep.is_some() {
if let Some(action) = self.drive_sweep(ctx) {
return action;
}
}
let failed = ctx.status().map(|s| !is_success_status(s)).unwrap_or(false);
let should =
self.pause_terminal && !self.detached && (!self.terminal_only_on_error || failed);
if !should {
return DebugAction::Resume;
}
self.ensure_editor();
self.emit_pause(ctx, None);
return self.prompt_loop(ctx);
}
let cp = ctx.checkpoint();
match cp {
Checkpoint::PreRestoration => self.in_restoration = true,
Checkpoint::PostRestoration => self.in_restoration = false,
_ => {}
}
let is_iter_start = matches!(cp, Checkpoint::IterStart);
if is_iter_start {
if let Some(snap) = ctx.snapshot() {
self.snapshots.insert(snap.iter(), snap);
while self.snapshots.len() > SNAPSHOT_CAP {
let Some(&oldest) = self.snapshots.keys().next() else {
break;
};
self.snapshots.remove(&oldest);
}
}
self.update_mu_stall(ctx.mu());
}
let mut reason: Option<String> = None;
let mut pause = self.sub_step || self.stop_at.contains(cp.as_str());
if let Some(ev) = self.matched_event(ctx) {
pause = true;
reason = Some(format!("event: {ev}"));
}
if is_iter_start {
if self.interruptible && interrupt::take() {
pause = true;
reason = Some("interrupt (Ctrl-C)".into());
}
if let Some(p) = self.pump.as_ref() {
if p.try_take_pause() {
pause = true;
reason = Some("pause (requested)".into());
}
}
if self.pause_iters {
if self.should_pause(ctx.iter()) {
pause = true;
}
if let Some(c) = self.matched_condition(ctx) {
pause = true;
reason = Some(c);
}
}
if let Some(w) = self.matched_watchpoint(ctx) {
pause = true;
reason = Some(format!("watchpoint: {w}"));
}
}
if !pause {
if is_iter_start && self.emit_progress && matches!(self.mode, DebugMode::Json) {
self.emit_progress_event(ctx);
}
return DebugAction::Resume;
}
self.step = false;
self.sub_step = false;
self.emit_pause(ctx, reason.as_deref());
if is_iter_start {
if let Some(cmds) = self.bp_commands.get(&ctx.iter()).cloned() {
for c in cmds {
let out = self.dispatch(&c, ctx);
self.emit_result(&c, &out, None);
match out.flow {
Flow::Resume => return DebugAction::Resume,
Flow::Stop => return DebugAction::Stop,
Flow::Stay => {}
}
}
}
}
self.ensure_editor();
self.prompt_loop(ctx)
}
}
impl SolverDebugger {
fn prompt_loop(&mut self, ctx: &mut DebugCtx) -> DebugAction {
if let Some(path) = self.pending_script.take() {
let out = self.cmd_source(&[path.as_str()], ctx);
self.emit_result("source", &out, None);
match out.flow {
Flow::Resume => return DebugAction::Resume,
Flow::Stop => return DebugAction::Stop,
Flow::Stay => {}
}
}
loop {
let line = match self.next_command_line() {
Some(l) => l,
None => {
return match self.mode {
DebugMode::Repl => {
self.detached = true;
DebugAction::Resume
}
DebugMode::Json => DebugAction::Stop,
};
}
};
let parsed = parse_command(&line, self.mode);
let cmd = parsed.command.trim().to_string();
if cmd.is_empty() {
continue;
}
let out = self.dispatch(&cmd, ctx);
self.emit_result(&cmd, &out, parsed.id.as_ref());
match out.flow {
Flow::Stay => continue,
Flow::Resume => return DebugAction::Resume,
Flow::Stop => return DebugAction::Stop,
}
}
}
}
struct ParsedCmd {
command: String,
id: Option<serde_json::Value>,
}
fn tokenize_quoted(line: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut in_quote = false;
let mut has_tok = false;
for c in line.chars() {
match c {
'"' => {
in_quote = !in_quote;
has_tok = true; }
c if c.is_whitespace() && !in_quote => {
if has_tok {
out.push(std::mem::take(&mut cur));
has_tok = false;
}
}
c => {
cur.push(c);
has_tok = true;
}
}
}
if has_tok {
out.push(cur);
}
out
}
fn parse_command(line: &str, mode: DebugMode) -> ParsedCmd {
let trimmed = line.trim();
if let DebugMode::Json = mode {
if trimmed.starts_with('{') {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed) {
let cmd = v.get("cmd").and_then(|c| c.as_str()).unwrap_or("");
let mut s = cmd.to_string();
if let Some(args) = v.get("args").and_then(|a| a.as_array()) {
for a in args {
s.push(' ');
let tok = a
.as_str()
.map(str::to_string)
.unwrap_or_else(|| a.to_string());
if tok.contains(char::is_whitespace) {
s.push('"');
s.push_str(&tok);
s.push('"');
} else {
s.push_str(&tok);
}
}
}
return ParsedCmd {
command: s,
id: v.get("id").cloned(),
};
}
}
}
ParsedCmd {
command: trimmed.to_string(),
id: None,
}
}
fn emit_json(v: &serde_json::Value) {
let stdout = std::io::stdout();
let mut h = stdout.lock();
let _ = writeln!(h, "{v}");
let _ = h.flush();
}
fn fmt_vec(name: &str, v: &[f64]) -> String {
const MAX: usize = 12;
if v.len() <= MAX {
format!(
"{name} = [{}]",
v.iter()
.map(|x| format!("{x:.6e}"))
.collect::<Vec<_>>()
.join(", ")
)
} else {
let head = v[..MAX]
.iter()
.map(|x| format!("{x:.6e}"))
.collect::<Vec<_>>()
.join(", ");
format!("{name} = [{head}, … ({} total)]", v.len())
}
}
fn type_str(t: OptionType) -> &'static str {
match t {
OptionType::OT_Number => "Number",
OptionType::OT_Integer => "Integer",
OptionType::OT_String => "String",
OptionType::OT_Unknown => "Unknown",
}
}
fn default_str(d: &DefaultValue) -> String {
match d {
DefaultValue::None => "-".into(),
DefaultValue::Number(v) => format!("{v}"),
DefaultValue::Integer(v) => format!("{v}"),
DefaultValue::String(s) => s.clone(),
}
}
fn write_and_open(label: &str, iter: i32, vals: &[f64]) -> Result<(String, String), String> {
let payload = serde_json::json!({"label": label, "iter": iter, "values": vals});
write_json_and_open(label, iter, &payload)
}
fn build_ask_prompt(ctx: &DebugCtx, question: &str) -> String {
use std::fmt::Write as _;
let mut p = String::new();
p.push_str(
"You are helping debug a paused run of POUNCE, a pure-Rust port of the Ipopt \
interior-point NLP solver. The solve is stopped at a debugger checkpoint. \
Use the state below to answer concisely and suggest concrete next steps \
(options to try, what to inspect). State:\n\n",
);
let _ = writeln!(p, "checkpoint = {}", ctx.checkpoint().as_str());
if let Some(s) = ctx.status() {
let _ = writeln!(p, "status = {s}");
}
let _ = writeln!(p, "iter = {}", ctx.iter());
let _ = writeln!(p, "mu = {:.6e}", ctx.mu());
let _ = writeln!(p, "objective = {:.8e}", ctx.objective());
let _ = writeln!(p, "inf_pr = {:.6e}", ctx.inf_pr());
let _ = writeln!(p, "inf_du = {:.6e}", ctx.inf_du());
let _ = writeln!(p, "nlp_error = {:.6e}", ctx.nlp_error());
let (ap, ad) = ctx.alpha();
let _ = writeln!(p, "alpha_pr = {ap:.4e}, alpha_du = {ad:.4e}");
let _ = writeln!(p, "ls_trials = {}", ctx.ls_count());
let dims: Vec<String> = ctx
.block_dims()
.into_iter()
.map(|(n, d)| format!("{n}:{d}"))
.collect();
let _ = writeln!(p, "dims = {}", dims.join(" "));
if let Some(k) = ctx.kkt() {
let _ = writeln!(
p,
"kkt = dim {} inertia n+={} n-={} (expected n-={}, {}) delta_w={:.3e} delta_c={:.3e} status={}",
k.dim,
k.n_pos,
k.n_neg,
k.expected_neg,
if k.inertia_correct { "correct" } else { "WRONG" },
k.delta_w,
k.delta_c,
k.status
);
}
let _ = write!(p, "\nQuestion: {question}\n");
p
}
const LLM_PROVIDERS: &[&str] = &["claude", "codex", "gemini", "llm"];
fn llm_preset(name: &str, prompt: &str) -> Option<(String, Vec<String>, bool)> {
match name {
"claude" => Some(("claude".to_string(), vec!["-p".to_string()], true)),
"codex" => Some((
"codex".to_string(),
vec!["exec".to_string(), prompt.to_string()],
false,
)),
"gemini" => Some((
"gemini".to_string(),
vec!["-p".to_string(), prompt.to_string()],
false,
)),
"llm" => Some(("llm".to_string(), vec![prompt.to_string()], false)),
_ => None,
}
}
fn llm_command(prompt: &str) -> (String, Vec<String>, bool) {
let raw = std::env::var("POUNCE_DBG_LLM").unwrap_or_default();
let tmpl = raw.trim();
if tmpl.is_empty() {
return llm_preset("claude", prompt).expect("claude is a known provider");
}
if !tmpl.contains(char::is_whitespace) {
if let Some(preset) = llm_preset(tmpl, prompt) {
return preset;
}
}
let mut parts = tmpl
.split_whitespace()
.map(str::to_string)
.collect::<Vec<_>>();
let prog = parts.remove(0);
let mut substituted = false;
for a in parts.iter_mut() {
if a.contains("{}") {
*a = a.replace("{}", prompt);
substituted = true;
}
}
(prog, parts, !substituted)
}
fn run_llm(prompt: &str) -> Result<String, String> {
use std::io::Write as _;
use std::process::{Command, Stdio};
let (prog, args, on_stdin) = llm_command(prompt);
let mut cmd = Command::new(&prog);
cmd.args(&args)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
cmd.stdin(if on_stdin {
Stdio::piped()
} else {
Stdio::null()
});
let mut child = cmd.spawn().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
format!(
"LLM CLI `{prog}` is not installed or not on PATH. Install it, \
or set POUNCE_DBG_LLM to another provider \
({}) or a full command template (e.g. `my-llm --ask {{}}`).",
LLM_PROVIDERS.join(" | ")
)
} else {
format!("could not launch `{prog}`: {e}")
}
})?;
if on_stdin {
if let Some(mut si) = child.stdin.take() {
let _ = si.write_all(prompt.as_bytes());
}
}
let out = child
.wait_with_output()
.map_err(|e| format!("`{prog}` failed: {e}"))?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
return Err(format!(
"`{prog}` exited with {}: {}",
out.status,
err.trim()
));
}
let reply = String::from_utf8_lossy(&out.stdout).trim().to_string();
if reply.is_empty() {
Err(format!("`{prog}` returned no output"))
} else {
Ok(reply)
}
}
fn write_json_and_open(
label: &str,
iter: i32,
payload: &serde_json::Value,
) -> Result<(String, String), String> {
let dir = std::env::temp_dir();
let path = dir.join(format!("pounce-dbg-{label}-iter{iter}.json"));
std::fs::write(&path, payload.to_string()).map_err(|e| format!("write failed: {e}"))?;
let path_s = path.to_string_lossy().to_string();
let mut candidates: Vec<(String, Vec<String>, String)> = Vec::new();
match std::env::var("POUNCE_DBG_VIEWER") {
Ok(tmpl) if !tmpl.trim().is_empty() => {
let mut parts = tmpl
.split_whitespace()
.map(String::from)
.collect::<Vec<_>>();
let prog = parts.remove(0);
let mut replaced = false;
for a in parts.iter_mut() {
if a.contains("{}") {
*a = a.replace("{}", &path_s);
replaced = true;
}
}
if !replaced {
parts.push(path_s.clone());
}
candidates.push((prog, parts, path_s.clone()));
}
_ => {
candidates.push((
"pounce-dbg-viz".to_string(),
vec![path_s.clone()],
path_s.clone(),
));
let opener = if cfg!(target_os = "macos") {
"open"
} else {
"xdg-open"
};
let artifact = write_html_viz(label, iter, payload).unwrap_or_else(|_| path_s.clone());
candidates.push((opener.to_string(), vec![artifact.clone()], artifact));
}
}
let mut last_err = String::new();
for (program, args, artifact) in &candidates {
match std::process::Command::new(program).args(args).spawn() {
Ok(_) => return Ok((artifact.clone(), format!("{program} {}", args.join(" ")))),
Err(e) => last_err = format!("`{program}`: {e}"),
}
}
Err(format!(
"wrote {path_s} but could not launch a viewer ({last_err}). \
Install the interactive viewer (`pip install 'pounce-solver[viz]'`) \
or set POUNCE_DBG_VIEWER, e.g. `python my_plot.py {{}}`."
))
}
fn write_html_viz(label: &str, iter: i32, payload: &serde_json::Value) -> Result<String, String> {
let dir = std::env::temp_dir();
let path = dir.join(format!("pounce-dbg-{label}-iter{iter}.html"));
let html = VIZ_HTML_TEMPLATE.replace("__PAYLOAD__", &payload.to_string());
std::fs::write(&path, html).map_err(|e| format!("write failed: {e}"))?;
Ok(path.to_string_lossy().to_string())
}
const VIZ_HTML_TEMPLATE: &str = r##"<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<title>pounce-dbg viz</title>
<style>
html,body{margin:0;background:#0e1116;color:#d6dae0;
font:13px/1.5 -apple-system,BlinkMacSystemFont,"SF Mono",Menlo,monospace}
.wrap{padding:18px 20px;max-width:880px;margin:0 auto}
h1{font-size:15px;margin:0 0 4px;font-weight:600}
.sub{color:#7d8694;margin:0 0 12px}
.stats{color:#9aa4b2;white-space:pre-wrap;margin:0 0 14px;
background:#161b22;border:1px solid #21262d;border-radius:6px;padding:10px 12px}
canvas{background:#161b22;border:1px solid #30363d;border-radius:6px;
max-width:100%;height:auto;image-rendering:pixelated}
.legend{margin-top:10px;color:#9aa4b2}
.pos{color:#4ea1ff}.neg{color:#ff6b6b}.bad{color:#ff6b6b;font-weight:600}
.ok{color:#56d364;font-weight:600}
</style></head><body><div class="wrap">
<h1 id="title">pounce-dbg</h1>
<div class="sub" id="sub"></div>
<div class="stats" id="stats"></div>
<canvas id="c" width="820" height="820"></canvas>
<div class="legend" id="legend"></div>
</div>
<script>
const D = __PAYLOAD__;
const cv = document.getElementById('c');
const ctx = cv.getContext('2d');
const $ = id => document.getElementById(id);
const fmt = x => (x===null||x===undefined) ? '—'
: (Math.abs(x) >= 1e4 || (x!==0 && Math.abs(x) < 1e-3) ? x.toExponential(3) : (+x).toPrecision(6));
function clearCanvas(){ ctx.fillStyle='#161b22'; ctx.fillRect(0,0,cv.width,cv.height); }
function spy(irn, jcn, vals, dim, symmetric, title){
$('sub').textContent = title;
clearCanvas();
const W=cv.width, H=cv.height, pad=42;
const span=Math.max(1, dim);
const cell=(Math.min(W,H)-2*pad)/span;
const px=Math.max(0.7, cell);
// frame + light grid ticks
ctx.strokeStyle='#30363d'; ctx.lineWidth=1;
ctx.strokeRect(pad-0.5, pad-0.5, span*cell+1, span*cell+1);
ctx.fillStyle='#6e7681'; ctx.font='11px monospace';
ctx.fillText('0', pad-12, pad+9);
ctx.fillText(String(dim), pad+span*cell-8, pad-8);
ctx.fillText('row', pad-34, pad+span*cell/2);
ctx.fillText('col', pad+span*cell/2-8, pad-22);
let nnz=0;
for(let k=0;k<irn.length;k++){
const i=irn[k]-1, j=jcn[k]-1, v=vals?vals[k]:1;
ctx.fillStyle = v>=0 ? 'rgba(78,161,255,0.92)' : 'rgba(255,107,107,0.92)';
ctx.fillRect(pad+j*cell, pad+i*cell, px, px); nnz++;
if(symmetric && i!==j){ ctx.fillRect(pad+i*cell, pad+j*cell, px, px); nnz++; }
}
$('legend').innerHTML =
`<span class="pos">■</span> positive <span class="neg">■</span> negative`
+ ` · ${dim}×${dim}, ${nnz} plotted nonzeros`
+ (symmetric ? ' (lower triangle mirrored)' : '');
}
function bars(values, title){
$('sub').textContent = title;
clearCanvas();
const W=cv.width, H=cv.height, pad=42;
const n=values.length;
const maxAbs=Math.max(1e-300, ...values.map(v=>Math.abs(v)));
const x0=pad, y0=H-pad, plotW=W-2*pad, plotH=H-2*pad, mid=pad+plotH/2;
const bw=Math.max(0.7, plotW/Math.max(1,n));
// zero axis
ctx.strokeStyle='#30363d'; ctx.beginPath();
ctx.moveTo(pad, mid); ctx.lineTo(W-pad, mid); ctx.stroke();
ctx.fillStyle='#6e7681'; ctx.font='11px monospace';
ctx.fillText('+'+fmt(maxAbs), 4, pad+10);
ctx.fillText('-'+fmt(maxAbs), 4, H-pad-2);
ctx.fillText('0', 4, mid+4);
for(let k=0;k<n;k++){
const v=values[k], h=(Math.abs(v)/maxAbs)*(plotH/2);
ctx.fillStyle = v>=0 ? 'rgba(78,161,255,0.92)' : 'rgba(255,107,107,0.92)';
if(v>=0) ctx.fillRect(pad+k*bw, mid-h, bw, h);
else ctx.fillRect(pad+k*bw, mid, bw, h);
}
$('legend').innerHTML = `${n} components · max |val| = ${fmt(maxAbs)}`;
}
const lbl = D.label || 'viz';
const iter = (D.iter!==undefined) ? D.iter : '?';
$('title').textContent = `pounce-dbg · viz ${lbl} · iter ${iter}`;
if(D.matrix && D.matrix.irn){
const m=D.matrix;
const inertia = (D.inertia_correct===false)
? `<span class="bad">WRONG</span>` : `<span class="ok">correct</span>`;
$('stats').innerHTML =
`KKT augmented system dim=${D.dim}\n`+
`inertia n+=${D.n_pos} n-=${D.n_neg} (expected n-=${D.expected_neg}, ${inertia})\n`+
`regularization delta_w=${fmt(D.delta_w)} delta_c=${fmt(D.delta_c)}\n`+
`factorization status: ${D.status}`;
spy(m.irn, m.jcn, m.vals, m.dim, true, 'sparsity pattern (sign-colored)');
} else if(D.l_irn){
$('stats').textContent =
`LDLᵀ factor n=${D.n} nnz(L)=${D.l_irn.length} format=${D.format||''}`;
spy(D.l_irn, D.l_jcn, D.l_vals, D.n, false, 'L factor sparsity (permuted, strict lower)');
} else if(D.values){
$('stats').textContent = `vector ${lbl} length=${D.values.length}`;
bars(D.values, 'component magnitudes (zero-centered)');
} else {
$('stats').textContent = 'unrecognized payload — raw JSON:\n'+JSON.stringify(D,null,2);
}
</script></body></html>
"##;
#[cfg(test)]
mod tests {
use super::*;
fn dbg(mode: DebugMode) -> SolverDebugger {
SolverDebugger::new(mode, None)
}
#[test]
fn json_command_object_is_flattened() {
assert_eq!(
parse_command("{\"cmd\":\"print x\"}", DebugMode::Json).command,
"print x"
);
let p = parse_command(
"{\"cmd\":\"set\",\"args\":[\"x[0]\",\"1.5\"],\"id\":7}",
DebugMode::Json,
);
assert_eq!(p.command, "set x[0] 1.5");
assert_eq!(p.id, Some(serde_json::json!(7)));
let s = parse_command("step\n", DebugMode::Json);
assert_eq!(s.command, "step");
assert!(s.id.is_none());
assert_eq!(
parse_command(" print x \n", DebugMode::Repl).command,
"print x"
);
}
#[test]
fn pauses_at_first_checkpoint_then_only_when_rearmed() {
let mut d = dbg(DebugMode::Repl);
assert!(d.should_pause(0));
d.step = false;
assert!(!d.should_pause(1));
assert!(!d.should_pause(2));
}
#[test]
fn breakpoints_and_run_to_arm_pauses() {
let mut d = dbg(DebugMode::Repl);
d.step = false;
d.breaks = vec![3, 7];
assert!(!d.should_pause(2));
assert!(d.should_pause(3));
assert!(d.should_pause(7));
d.run_to = Some(5);
assert!(!d.should_pause(4));
assert!(d.should_pause(5));
assert_eq!(d.run_to, None);
assert!(!d.should_pause(6));
}
#[test]
fn atom_parses_metric_op_threshold() {
let a = Atom::parse("mu<1e-4").unwrap();
assert_eq!(a.metric, Metric::Mu);
assert_eq!(a.op, CmpOp::Lt);
assert_eq!(a.rhs, 1e-4);
let a = Atom::parse("inf_pr<=1e-6").unwrap();
assert_eq!(a.metric, Metric::InfPr);
assert_eq!(a.op, CmpOp::Le);
let a = Atom::parse("iter==10").unwrap();
assert_eq!(a.metric, Metric::Iter);
assert_eq!(a.op, CmpOp::Eq);
assert_eq!(a.rhs, 10.0);
}
#[test]
fn atom_parse_rejects_garbage() {
assert!(Atom::parse("inf_pr 1e-6").is_err()); assert!(Atom::parse("bogus<1").is_err()); assert!(Atom::parse("mu<abc").is_err()); }
#[test]
fn compound_condition_parses_and_evaluates_left_to_right() {
let c = Condition::parse("mu<1e-4&&inf_pr>1e-3").unwrap();
assert_eq!(c.rest.len(), 1);
assert_eq!(c.rest[0].0, Join::And);
let c = Condition::parse("iter>10&&(inf_du>1e-2||obj<0)").unwrap();
assert_eq!(c.rest.len(), 2);
assert_eq!(c.rest[0].0, Join::And);
assert_eq!(c.rest[1].0, Join::Or);
assert_eq!(c.raw, "iter>10&&inf_du>1e-2||obj<0");
assert!(Condition::parse("mu<1e-4&&bogus>0").is_err());
}
#[test]
fn completion_is_context_sensitive() {
let c = completion_candidates(None, "", "co");
assert!(c.contains(&"continue".to_string()));
assert!(c.contains(&"complete".to_string()));
assert!(!c.contains(&"step".to_string()));
let c = completion_candidates(None, "set ", "");
assert!(c.contains(&"mu".to_string()));
assert!(c.contains(&"opt".to_string()));
assert!(c.contains(&"x".to_string()));
let c = completion_candidates(None, "break if ", "inf");
assert!(c.contains(&"inf_pr".to_string()));
assert!(c.contains(&"inf_du".to_string()));
assert!(!c.contains(&"mu".to_string()));
let c = completion_candidates(None, "print ", "");
assert!(c.contains(&"x".to_string()));
assert!(c.contains(&"obj".to_string()));
}
#[test]
fn cmp_op_truth_table() {
assert!(CmpOp::Lt.eval(1.0, 2.0));
assert!(!CmpOp::Lt.eval(2.0, 2.0));
assert!(CmpOp::Le.eval(2.0, 2.0));
assert!(CmpOp::Gt.eval(3.0, 2.0));
assert!(CmpOp::Ge.eval(2.0, 2.0));
assert!(CmpOp::Eq.eval(2.0, 2.0));
assert!(!CmpOp::Eq.eval(2.0, 2.5));
}
#[test]
fn interrupt_is_consumed_once() {
interrupt::set_pending_for_test();
assert!(interrupt::take(), "first take sees the pending Ctrl-C");
assert!(!interrupt::take(), "second take is clear (consumed once)");
}
#[test]
fn on_interrupt_constructor_runs_free_but_interruptible() {
let d = SolverDebugger::on_interrupt(DebugMode::Repl, None);
assert!(!d.pause_iters, "on-interrupt does not pause each iter");
assert!(!d.pause_terminal, "on-interrupt does not pause at terminal");
assert!(d.interruptible, "on-interrupt honors Ctrl-C");
assert!(!d.step, "on-interrupt starts un-armed");
}
#[test]
fn coffee_easter_egg_prints_art_but_stays_hidden() {
let d = SolverDebugger::new(DebugMode::Repl, None);
let out = d.cmd_coffee();
assert!(out.ok);
assert!(out.lines.len() > 5, "multi-line art");
assert!(
out.lines.iter().any(|l| l.contains("COFFEE")),
"the mug says COFFEE"
);
assert!(
!COMMANDS.contains(&"coffee"),
"hidden from help/complete/Tab"
);
assert!(
out.lines.iter().all(|l| !l.contains('\x1b')),
"no color when stderr isn't a TTY"
);
}
#[test]
fn double_ctrl_c_at_prompt_quits_single_cancels_line() {
let mut d = SolverDebugger::new(DebugMode::Repl, None);
assert_eq!(d.on_prompt_interrupt(), "");
assert_eq!(d.on_prompt_interrupt(), "quit");
assert_eq!(d.on_prompt_interrupt(), "");
d.prompt_interrupts = 0;
assert_eq!(d.on_prompt_interrupt(), "", "fresh streak after a command");
}
#[test]
fn stop_at_accepts_names_and_aliases() {
let mut d = SolverDebugger::new(DebugMode::Repl, None);
assert!(d.cmd_stop_at(&["after_search_dir"]).ok);
assert!(d.stop_at.contains("after_search_dir"));
assert!(d.cmd_stop_at(&["mu"]).ok);
assert!(d.stop_at.contains("after_mu"));
assert!(d.cmd_stop_at(&["kkt"]).ok);
assert!(d.stop_at.contains("after_search_dir"));
assert!(!d.cmd_stop_at(&["bogus"]).ok);
assert!(d.cmd_stop_at(&["clear"]).ok);
assert!(d.stop_at.is_empty());
}
#[test]
fn llm_command_defaults_and_overrides() {
std::env::remove_var("POUNCE_DBG_LLM");
let (prog, args, on_stdin) = llm_command("hi");
assert_eq!(prog, "claude");
assert_eq!(args, vec!["-p".to_string()]);
assert!(on_stdin);
std::env::set_var("POUNCE_DBG_LLM", "mytool --ask {}");
let (prog, args, on_stdin) = llm_command("why");
assert_eq!(prog, "mytool");
assert_eq!(args, vec!["--ask".to_string(), "why".to_string()]);
assert!(!on_stdin);
std::env::set_var("POUNCE_DBG_LLM", "llm -m gpt");
let (_, _, on_stdin) = llm_command("q");
assert!(on_stdin);
std::env::set_var("POUNCE_DBG_LLM", "codex");
let (prog, args, on_stdin) = llm_command("why is mu stuck");
assert_eq!(prog, "codex");
assert_eq!(
args,
vec!["exec".to_string(), "why is mu stuck".to_string()]
);
assert!(!on_stdin);
std::env::set_var("POUNCE_DBG_LLM", "gemini");
let (prog, args, _) = llm_command("q");
assert_eq!(prog, "gemini");
assert_eq!(args, vec!["-p".to_string(), "q".to_string()]);
std::env::set_var("POUNCE_DBG_LLM", "llm");
let (prog, args, _) = llm_command("q");
assert_eq!(prog, "llm");
assert_eq!(args, vec!["q".to_string()]);
std::env::set_var("POUNCE_DBG_LLM", "claude");
let (prog, args, on_stdin) = llm_command("q");
assert_eq!(prog, "claude");
assert_eq!(args, vec!["-p".to_string()]);
assert!(on_stdin);
std::env::set_var("POUNCE_DBG_LLM", "mytool");
let (prog, args, on_stdin) = llm_command("q");
assert_eq!(prog, "mytool");
assert!(args.is_empty());
assert!(on_stdin);
std::env::set_var("POUNCE_DBG_LLM", "pounce-no-such-llm-xyz");
let err = run_llm("hello").unwrap_err();
assert!(err.contains("not installed or not on PATH"), "{err}");
assert!(err.contains("codex"), "{err}");
std::env::remove_var("POUNCE_DBG_LLM");
}
#[test]
fn detach_disables_all_pausing() {
let mut d = dbg(DebugMode::Repl);
d.detached = true;
d.step = true;
d.breaks = vec![1];
assert!(!d.should_pause(0));
assert!(!d.should_pause(1));
}
#[test]
fn kkt_capture_tracks_attached_state() {
let mut d = dbg(DebugMode::Repl);
assert!(d.wants_kkt_capture());
d.detached = true;
assert!(!d.wants_kkt_capture());
}
fn resid(kind: ResidKind, index: usize, value: f64) -> Residual {
Residual { kind, index, value }
}
#[test]
fn rank_residuals_sorts_by_magnitude_and_truncates() {
use ResidKind::*;
let entries = vec![
resid(Eq, 0, -0.5),
resid(Ineq, 1, 3.0),
resid(DualX, 2, -7.0),
resid(DualS, 3, 1.0),
];
let top = rank_residuals(entries, 2);
assert_eq!(top.len(), 2);
assert_eq!(top[0].value, -7.0);
assert_eq!(top[0].kind, DualX);
assert_eq!(top[1].value, 3.0);
assert_eq!(top[1].kind, Ineq);
}
#[test]
fn rank_residuals_k_zero_and_k_over_len() {
use ResidKind::*;
let entries = vec![resid(Eq, 0, 1.0), resid(Ineq, 1, 2.0)];
assert!(rank_residuals(entries.clone(), 0).is_empty());
let all = rank_residuals(entries, 99);
assert_eq!(all.len(), 2);
assert_eq!(all[0].value, 2.0);
}
#[test]
fn rank_residuals_is_stable_on_magnitude_ties() {
use ResidKind::*;
let entries = vec![
resid(Ineq, 5, -2.0),
resid(Eq, 1, 2.0),
resid(DualX, 9, -2.0),
];
let top = rank_residuals(entries, 3);
assert_eq!(
top.iter().map(|r| r.kind).collect::<Vec<_>>(),
vec![Ineq, Eq, DualX]
);
}
fn split_names_fixture() -> SplitNames {
SplitNames {
x_var: vec![Some("T_reactor".into()), None],
eq: vec![Some("mass_balance".into()), Some("energy_balance".into())],
ineq: vec![Some("pressure_cap".into())],
}
}
#[test]
fn resid_name_maps_each_kind_to_its_pool() {
use ResidKind::*;
let names = Some(split_names_fixture());
assert_eq!(
resid_name(&resid(Eq, 1, 0.0), &names),
Some("energy_balance")
);
assert_eq!(
resid_name(&resid(Ineq, 0, 0.0), &names),
Some("pressure_cap")
);
assert_eq!(
resid_name(&resid(DualS, 0, 0.0), &names),
Some("pressure_cap")
);
assert_eq!(resid_name(&resid(DualX, 0, 0.0), &names), Some("T_reactor"));
assert_eq!(resid_name(&resid(DualX, 1, 0.0), &names), None);
assert_eq!(resid_name(&resid(Eq, 9, 0.0), &names), None);
assert_eq!(resid_name(&resid(Eq, 0, 0.0), &None), None);
}
#[test]
fn worst_named_picks_largest_and_labels_it() {
use ResidKind::*;
let names = Some(split_names_fixture());
let resids = vec![resid(Eq, 0, 0.5), resid(Eq, 1, -3.2), resid(Ineq, 0, 1.1)];
assert_eq!(
worst_named(resids, &names),
Some(("c[energy_balance]".to_string(), -3.2))
);
let resids = vec![resid(DualX, 7, 9.0)];
assert_eq!(
worst_named(resids, &None),
Some(("grad_x_L[7]".to_string(), 9.0))
);
assert_eq!(worst_named(vec![], &names), None);
}
use pounce_algorithm::debug_rank::RankCulprit;
fn rank_report_fixture() -> RankReport {
RankReport {
rows: vec![
RankRow {
kind: ResidKind::Eq,
index: 0,
},
RankRow {
kind: ResidKind::Eq,
index: 1,
},
],
n_cols: 3,
singular_values: vec![2.0, 0.0],
tol: 1e-15,
rank: 1,
cond: f64::INFINITY,
culprits: vec![
RankCulprit {
row: 0,
weight: 0.5,
},
RankCulprit {
row: 1,
weight: 0.5,
},
],
}
}
#[test]
fn render_rank_report_names_culprits_and_builds_json() {
let names = Some(split_names_fixture());
let rep = rank_report_fixture();
let (lines, data) = render_rank_report(&rep, &names, None, 7);
let text = lines.join("\n");
assert!(text.contains("2 row(s) × 3 column(s)"), "{text}");
assert!(text.contains("numerical rank = 1 / 2"), "{text}");
assert!(text.contains("inf (σ_min = 0)"), "{text}");
assert!(text.contains("c[mass_balance]"), "{text}");
assert!(text.contains("c[energy_balance]"), "{text}");
assert!(text.contains("participation 0.50"), "{text}");
assert!(text.contains("print equation"), "{text}");
assert_eq!(data["iter"], 7);
assert_eq!(data["rank"], 1);
assert_eq!(data["deficiency"], 1);
assert_eq!(data["rank_deficient"], true);
assert!(data["cond"].is_null(), "non-finite cond ⇒ null: {data}");
assert_eq!(data["culprits"][0]["name"], "mass_balance");
assert_eq!(data["culprits"][0]["label"], "c[mass_balance]");
assert!(data["culprits"][0]["equation"].is_null());
assert_eq!(data["culprits"][1]["name"], "energy_balance");
}
#[test]
fn render_rank_report_prints_culprit_equations_inline() {
let names = Some(split_names_fixture());
let rep = rank_report_fixture();
let book = EquationBook::new(
vec!["mass_balance".into(), "energy_balance".into()],
vec![
"x[0] + x[1] - 10 = 0".into(),
"T_reactor*flow - Q = 0".into(),
],
);
let (lines, data) = render_rank_report(&rep, &names, Some(&book), 7);
let text = lines.join("\n");
assert!(text.contains("x[0] + x[1] - 10 = 0"), "{text}");
assert!(text.contains("T_reactor*flow - Q = 0"), "{text}");
assert!(!text.contains("inspect a row with"), "{text}");
assert_eq!(data["culprits"][0]["equation"], "x[0] + x[1] - 10 = 0");
assert_eq!(data["culprits"][1]["equation"], "T_reactor*flow - Q = 0");
}
#[test]
fn render_rank_report_full_rank_reports_positive_signal() {
let rep = RankReport {
rows: vec![
RankRow {
kind: ResidKind::Eq,
index: 0,
},
RankRow {
kind: ResidKind::Eq,
index: 1,
},
],
n_cols: 3,
singular_values: vec![2.0, 1.0],
tol: 1e-15,
rank: 2,
cond: 2.0,
culprits: vec![],
};
let (lines, data) = render_rank_report(&rep, &None, None, 3);
let text = lines.join("\n");
assert!(text.contains("full row rank"), "{text}");
assert!(!text.contains("rank-deficient"), "{text}");
assert_eq!(data["rank_deficient"], false);
assert_eq!(data["cond"], 2.0);
assert_eq!(data["culprits"].as_array().map(|a| a.len()), Some(0));
}
#[test]
fn print_equation_resolves_by_name_index_and_errors() {
let mut d = dbg(DebugMode::Repl);
let out = d.cmd_print_equation(&[]);
assert!(!out.ok);
assert!(out.lines[0].contains("needs an .nl model"));
d.set_equation_book(EquationBook::new(
vec!["mass_balance".into(), String::new()],
vec!["x[0] + x[1] = 10".into(), "x[0] - x[1] <= 2".into()],
));
let out = d.cmd_print_equation(&[]);
assert!(out.ok);
assert!(out.lines[0].contains("2 constraint equation"));
let out = d.cmd_print_equation(&["mass_balance"]);
assert!(out.ok);
assert_eq!(out.lines[0], "mass_balance: x[0] + x[1] = 10");
let out = d.cmd_print_equation(&["1"]);
assert!(out.ok);
assert_eq!(out.lines[0], "c[1]: x[0] - x[1] <= 2");
let out = d.cmd_print_equation(&["nope"]);
assert!(!out.ok);
assert!(out.lines[0].contains("no constraint named or indexed"));
}
fn eq_inc(n_vars: usize, eq_row_inner_idx: Vec<usize>, rows: &[&[usize]]) -> EqualityIncidence {
let mut adj_ptr = vec![0usize];
let mut vars = Vec::new();
for r in rows {
let mut v = r.to_vec();
v.sort_unstable();
v.dedup();
vars.extend_from_slice(&v);
adj_ptr.push(vars.len());
}
EqualityIncidence {
n_vars,
eq_row_inner_idx,
adj_ptr,
vars,
}
}
#[test]
fn structural_singularity_names_overdetermined_equations() {
let inc = eq_inc(2, vec![0, 1, 2], &[&[0, 1], &[0, 1], &[0, 1]]);
let book = StructureBook::new(
inc,
vec!["balance_a".into(), "balance_b".into(), "balance_c".into()],
vec!["flow".into(), "temp".into()],
);
let f = book.findings();
assert_eq!(f.len(), 1);
let (sev, code, msg) = &f[0];
assert_eq!(*sev, "warning");
assert_eq!(*code, "structural_singularity");
assert!(msg.contains("balance_a"), "msg: {msg}");
assert!(msg.contains("balance_b"), "msg: {msg}");
assert!(msg.contains("balance_c"), "msg: {msg}");
assert!(msg.contains("flow") && msg.contains("temp"), "msg: {msg}");
assert!(msg.contains("≥1"), "msg: {msg}");
}
#[test]
fn structural_findings_silent_when_well_posed_and_fall_back_to_indices() {
let inc = eq_inc(2, vec![0, 1], &[&[0], &[1]]);
let book = StructureBook::new(inc, vec![], vec![]);
assert!(book.findings().is_empty());
let inc = eq_inc(1, vec![0, 1, 3], &[&[0], &[0], &[0]]);
let book = StructureBook::new(inc, vec![], vec![]);
let f = book.findings();
assert_eq!(f.len(), 1);
let msg = &f[0].2;
assert!(
msg.contains("c[0]") && msg.contains("c[1]") && msg.contains("c[3]"),
"msg: {msg}"
);
}
#[test]
fn structural_singularity_handles_empty_row_with_no_variables() {
let inc = eq_inc(1, vec![0, 1], &[&[0], &[]]);
let book = StructureBook::new(inc, vec!["real".into(), "ghost".into()], vec!["x".into()]);
let f = book.findings();
assert_eq!(f.len(), 1);
let msg = &f[0].2;
assert!(msg.contains("ghost"), "msg: {msg}");
assert!(msg.contains("no variables"), "msg: {msg}");
}
#[test]
fn parse_floats_accepts_commas_whitespace_and_newlines() {
assert_eq!(parse_floats("1, 2 ,3").unwrap(), vec![1.0, 2.0, 3.0]);
assert_eq!(parse_floats("1\n2\n-3.5").unwrap(), vec![1.0, 2.0, -3.5]);
assert_eq!(parse_floats(" 1.0 2e-1 ").unwrap(), vec![1.0, 0.2]);
assert!(parse_floats("1, nope, 3").is_err());
assert_eq!(parse_floats("").unwrap(), Vec::<f64>::new());
}
#[test]
fn jitter_start_zero_is_the_unperturbed_base_and_is_deterministic() {
let base = vec![1.0, -2.0, 0.0];
assert_eq!(jitter(&base, 0.1, 0), base);
let a = jitter(&base, 0.1, 1);
let b = jitter(&base, 0.1, 1);
assert_eq!(a, b);
assert_ne!(a, base);
for (j, (&p, &x)) in a.iter().zip(&base).enumerate() {
let bound = 0.1 * (x.abs() + 1.0);
assert!(
(p - x).abs() <= bound + 1e-12,
"component {j} moved {} > bound {bound}",
(p - x).abs()
);
}
assert_ne!(jitter(&base, 0.1, 1), jitter(&base, 0.1, 2));
}
#[test]
fn sample_start_draws_inside_finite_boxes_and_jitters_unbounded() {
let base = vec![1.0, 1.0, 0.5];
let lo = vec![0.0, 0.0, -1.0];
let hi = vec![2.0, f64::INFINITY, 1.0];
let b = Some((lo.as_slice(), hi.as_slice()));
assert_eq!(sample_start(&base, b, 0.1, 0), base);
for k in 1..50 {
let s = sample_start(&base, b, 0.1, k);
assert!((0.0..=2.0).contains(&s[0]), "var0 {} out of [0,2]", s[0]);
assert!((-1.0..=1.0).contains(&s[2]), "var2 {} out of [-1,1]", s[2]);
let bound = 0.1 * (base[1].abs() + 1.0);
assert!(
(s[1] - base[1]).abs() <= bound + 1e-12,
"var1 jitter exceeded"
);
}
assert_eq!(
sample_start(&base, b, 0.1, 7),
sample_start(&base, b, 0.1, 7)
);
}
#[test]
fn path_completion_lists_matching_files_with_dir_prefix() {
let dir = std::env::temp_dir().join("pounce_dbg_complete_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("starts.txt"), "0,0\n").unwrap();
std::fs::write(dir.join("start2.txt"), "1,1\n").unwrap();
std::fs::write(dir.join("other.json"), "{}").unwrap();
std::fs::create_dir_all(dir.join("subdir")).unwrap();
let p = dir.to_string_lossy().to_string();
let mut got = path_candidates(&format!("{p}/start"));
got.sort();
assert_eq!(
got,
vec![format!("{p}/start2.txt"), format!("{p}/starts.txt")]
);
let got = path_candidates(&format!("{p}/sub"));
assert_eq!(got, vec![format!("{p}/subdir/")]);
assert_eq!(path_candidates(&format!("{p}/")).len(), 4);
assert!(completion_candidates(None, "load", &format!("{p}/star"))
.iter()
.all(|c| c.contains("start")));
let _ = std::fs::remove_dir_all(&dir);
}
}