pounce_common/debug.rs
1//! Shared interior-point debugger abstraction.
2//!
3//! The interactive solver debugger (a "pdb for the interior-point loop")
4//! is driven by a [`DebugHook`] that the solver fires at well-defined
5//! [`Checkpoint`]s. The hook receives a `&mut dyn` [`DebugState`] — a
6//! live, possibly-mutable view of the solver's per-iteration state — and
7//! returns a [`DebugAction`] telling the loop whether to keep solving.
8//!
9//! These traits live in `pounce-common` so that *every* solver can be
10//! debugged by the *same* REPL: the NLP filter-IPM (`pounce-algorithm`)
11//! and the convex / conic IPM (`pounce-convex`) both implement
12//! [`DebugState`] over their own state, and the CLI's `SolverDebugger`
13//! implements [`DebugHook`] once against the trait.
14//!
15//! [`DebugState`] splits its surface in two:
16//!
17//! * **Generic** accessors every interior-point method has — iteration
18//! index, μ, objective, primal/dual infeasibility, complementarity,
19//! step lengths, and named iterate / search-direction blocks — are
20//! required methods.
21//! * **Solver-specific** extras (the NLP error metric, bound-slack
22//! active-set view, KKT inertia / matrix / factor capture, line-search
23//! trial count, snapshot/restore, mutation) have default impls that
24//! report "unsupported", so a solver overrides only what it actually
25//! has. The REPL turns an unsupported result into a friendly message.
26
27use crate::types::Number;
28use std::any::Any;
29
30/// Where in a solver's loop a checkpoint fired.
31///
32/// The variants cover the NLP filter-IPM's loop; other interior-point
33/// solvers fire the subset that applies to them (e.g. the convex IPM uses
34/// [`IterStart`](Checkpoint::IterStart),
35/// [`AfterSearchDirection`](Checkpoint::AfterSearchDirection),
36/// [`AfterStep`](Checkpoint::AfterStep), and
37/// [`Terminated`](Checkpoint::Terminated); it has no restoration phase or
38/// backtracking line search, so those variants simply never fire).
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40pub enum Checkpoint {
41 /// Top of an outer iteration — before this iteration's step is
42 /// computed. The iterate, multipliers, and μ reflect the *accepted*
43 /// point from the previous iteration.
44 IterStart,
45 /// After the barrier parameter μ was updated for this iteration
46 /// (before the search direction is computed).
47 AfterBarrierUpdate,
48 /// After the primal-dual Newton step was computed — the search
49 /// direction `δ`, the applied regularization, and the KKT
50 /// factorization are available.
51 AfterSearchDirection,
52 /// After a step length was chosen and the trial point accepted — the
53 /// step lengths α and the new iterate are in place.
54 AfterStep,
55 /// The line search *rejected* this iteration's step and the solver is
56 /// about to fall into restoration (NLP filter-IPM only).
57 StepRejected,
58 /// Just before the algorithm switches into the restoration phase
59 /// (NLP filter-IPM only).
60 PreRestoration,
61 /// Just after the restoration phase returns (NLP filter-IPM only).
62 PostRestoration,
63 /// The solve has finished: fired once before the solver returns, at
64 /// the final iterate, carrying the outcome via [`DebugState::status`].
65 /// The [`DebugAction`] returned here is **ignored** — the solve is
66 /// already over.
67 Terminated,
68}
69
70impl Checkpoint {
71 /// The stable wire/CLI protocol name for this checkpoint. These strings
72 /// are intentionally **not** the variant identifiers (`AfterBarrierUpdate`
73 /// → `"after_mu"`, `PreRestoration` → `"pre_restoration_entry"`) — they're
74 /// the names the JSON protocol and `stop-at` use, so match on the variant,
75 /// not the string.
76 pub fn as_str(self) -> &'static str {
77 match self {
78 Checkpoint::IterStart => "iter_start",
79 Checkpoint::AfterBarrierUpdate => "after_mu",
80 Checkpoint::AfterSearchDirection => "after_search_dir",
81 Checkpoint::AfterStep => "after_step",
82 Checkpoint::StepRejected => "step_rejected",
83 Checkpoint::PreRestoration => "pre_restoration_entry",
84 Checkpoint::PostRestoration => "post_restoration_exit",
85 Checkpoint::Terminated => "terminated",
86 }
87 }
88
89 /// Sub-iteration checkpoints (everything between `IterStart` and the
90 /// next `IterStart`).
91 pub fn is_sub_iteration(self) -> bool {
92 matches!(
93 self,
94 Checkpoint::AfterBarrierUpdate
95 | Checkpoint::AfterSearchDirection
96 | Checkpoint::AfterStep
97 | Checkpoint::StepRejected
98 | Checkpoint::PreRestoration
99 | Checkpoint::PostRestoration
100 )
101 }
102}
103
104/// What the solver should do after a [`DebugHook`] returns.
105#[derive(Clone, Copy, Debug, PartialEq, Eq)]
106pub enum DebugAction {
107 /// Keep solving.
108 Resume,
109 /// Stop the solve now. Surfaces to the caller as a
110 /// user-requested-stop outcome.
111 Stop,
112}
113
114/// KKT-factorization report (see [`DebugState::kkt`]). The inertia of a
115/// well-posed primal-dual system is `(n_pos = n, n_neg = m, n_zero = 0)`;
116/// a mismatch (or nonzero regularization) is the classic signal that the
117/// step is being stabilized.
118#[derive(Clone, Debug)]
119pub struct KktReport {
120 /// The outer iteration this factorization was assembled at — may be the
121 /// previous iteration when paused at `iter_start` (viz look-back).
122 pub iter: i32,
123 /// Augmented-system dimension (n + m).
124 pub dim: i32,
125 /// Negative eigenvalues reported (-1 if the backend has no inertia).
126 pub n_neg: i32,
127 /// Positive eigenvalues = `dim − n_neg` (-1 if unknown).
128 pub n_pos: i32,
129 /// Expected negatives = number of equality + inequality multipliers.
130 pub expected_neg: i32,
131 /// Whether the backend reports inertia.
132 pub provides_inertia: bool,
133 /// `true` when reported inertia matches the expected `(n, m, 0)`.
134 pub inertia_correct: bool,
135 /// Primal regularization δ_w applied to the (1,1) block.
136 pub delta_w: Number,
137 /// Dual regularization δ_c applied to the (3,3)/(4,4) blocks.
138 pub delta_c: Number,
139 /// Factorization status (debug string).
140 pub status: String,
141}
142
143/// Captured `LDLᵀ` factor for `viz L`:
144/// `(n, perm, l_irn, l_jcn, l_vals)`.
145pub type LFactor = (usize, Vec<usize>, Vec<i32>, Vec<i32>, Option<Vec<Number>>);
146
147/// Assembled KKT matrix triplets for `viz kkt`:
148/// `(dim, irn, jcn, vals)` (1-based lower triangle).
149pub type KktTriplets = (i32, Vec<i32>, Vec<i32>, Vec<Number>);
150
151/// Which residual space a [`Residual`] entry comes from.
152///
153/// Primal entries are the per-constraint violations whose max-norm is
154/// `inf_pr`; dual entries are the per-variable Lagrangian-gradient
155/// components whose max-norm is `inf_du`. (NLP-specific; the convex/conic
156/// and global solvers do not expose per-component residuals.)
157#[derive(Clone, Copy, Debug, PartialEq, Eq)]
158pub enum ResidKind {
159 /// Equality constraint residual `c_i(x)`.
160 Eq,
161 /// Inequality residual `d_i(x) − s_i` (the IPM slack reformulation).
162 Ineq,
163 /// `x`-space stationarity component `(∇_x L)_i`.
164 DualX,
165 /// `s`-space stationarity component `(∇_s L)_i`.
166 DualS,
167}
168
169impl ResidKind {
170 /// Short label used in the debugger's `print residuals` output and
171 /// the JSON `space` field. Stable — readers may match on it.
172 pub fn tag(self) -> &'static str {
173 match self {
174 ResidKind::Eq => "c",
175 ResidKind::Ineq => "d-s",
176 ResidKind::DualX => "grad_x_L",
177 ResidKind::DualS => "grad_s_L",
178 }
179 }
180
181 /// `true` for the primal (constraint) spaces, `false` for the dual
182 /// (stationarity) spaces.
183 pub fn is_primal(self) -> bool {
184 matches!(self, ResidKind::Eq | ResidKind::Ineq)
185 }
186}
187
188/// One signed residual component at the current iterate: its space, its
189/// index within that space, and its value. See
190/// [`DebugState::constraint_residuals`] / [`DebugState::dual_residuals`].
191#[derive(Clone, Copy, Debug)]
192pub struct Residual {
193 pub kind: ResidKind,
194 pub index: usize,
195 pub value: Number,
196}
197
198/// An opaque, readable snapshot of a solver's primal-dual state at one
199/// iteration, returned by [`DebugState::snapshot`] and replayed by
200/// [`DebugState::restore`].
201///
202/// The reader methods (`iter` / `mu` / `block`) let the REPL `diff` two
203/// captured points generically; [`as_any`](IterSnapshot::as_any) lets the
204/// originating solver downcast back to its concrete snapshot type to
205/// restore it.
206pub trait IterSnapshot: Any {
207 /// Iteration index this snapshot was taken at.
208 fn iter(&self) -> i32;
209 /// Barrier parameter μ at the snapshot.
210 fn mu(&self) -> Number;
211 /// A named iterate block at the snapshot, if present.
212 fn block(&self, name: &str) -> Option<Vec<Number>>;
213 /// Downcast handle for the originating solver's `restore`.
214 fn as_any(&self) -> &dyn Any;
215}
216
217/// A live view of solver state handed to a [`DebugHook`] at a checkpoint.
218///
219/// Required methods are the quantities every interior-point method has.
220/// The remaining methods carry solver-specific capabilities and default
221/// to "unsupported" (NaN / `None` / `-1` / `Err`), so a solver overrides
222/// only the ones it can answer. `set_*` mutators likewise default to a
223/// descriptive `Err` for solvers that don't support in-place edits.
224pub trait DebugState {
225 // ---- required: generic interior-point quantities -------------------
226
227 /// Downcast escape hatch for **solver-specific** REPL commands whose
228 /// payload can't live in this leaf crate (e.g. the NLP debugger's
229 /// rank diagnosis, model-name resolution, or full primal-dual warm
230 /// `resolve`). A solver that supports those returns `Some(self)` so the
231 /// REPL can downcast to its concrete state; the default `None` makes the
232 /// command report "not supported for this solver".
233 fn as_any(&self) -> Option<&dyn Any> {
234 None
235 }
236
237 /// Mutable form of [`as_any`](DebugState::as_any), for commands that
238 /// mutate solver-specific state (e.g. live-tolerance hot-swap).
239 fn as_any_mut(&mut self) -> Option<&mut dyn Any> {
240 None
241 }
242
243 /// Which checkpoint we are paused at.
244 fn checkpoint(&self) -> Checkpoint;
245
246 /// Current outer iteration counter.
247 fn iter(&self) -> i32;
248
249 /// Current barrier parameter μ.
250 fn mu(&self) -> Number;
251
252 /// Objective at the current iterate (in the user's original sense).
253 fn objective(&self) -> Number;
254
255 /// Max-norm primal infeasibility.
256 fn inf_pr(&self) -> Number;
257
258 /// Max-norm dual infeasibility.
259 fn inf_du(&self) -> Number;
260
261 /// Average complementarity — the IPM's "distance from the central
262 /// path" gauge; should track μ.
263 fn complementarity(&self) -> Number;
264
265 /// Accepted primal / dual step lengths (α_pr, α_du). A solver with a
266 /// single symmetric step (e.g. HSDE) reports it in both slots.
267 fn alpha(&self) -> (Number, Number);
268
269 /// Dimensions of every named iterate block, in display order.
270 fn block_dims(&self) -> Vec<(&'static str, usize)>;
271
272 /// Read a named block of the current iterate as a flat `f64` vec.
273 /// `None` for an unknown name or before the iterate is set.
274 fn block(&self, name: &str) -> Option<Vec<Number>>;
275
276 /// Read a named block of the most recent search direction.
277 fn delta_block(&self, name: &str) -> Option<Vec<Number>>;
278
279 // ---- optional: solver-specific extras (default = unsupported) ------
280
281 /// Solve outcome, present only at [`Checkpoint::Terminated`].
282 fn status(&self) -> Option<&str> {
283 None
284 }
285
286 /// A scalar convergence error driving termination (the NLP "nlp_error").
287 /// `NaN` when the solver has no single such metric.
288 fn nlp_error(&self) -> Number {
289 Number::NAN
290 }
291
292 /// Slacks to a bound category (`x_l` / `x_u` / `s_l` / `s_u`) for the
293 /// active-set view. `None` when the solver has no bound-slack notion.
294 fn bound_slack(&self, _which: &str) -> Option<Vec<Number>> {
295 None
296 }
297
298 /// Regularization applied to the KKT system this iteration. `NaN` when
299 /// the solver does not expose one.
300 fn regularization(&self) -> Number {
301 Number::NAN
302 }
303
304 /// Number of line-search trial points for the accepted step. `-1` for
305 /// solvers without a backtracking line search (e.g. the convex IPM,
306 /// which takes a fraction-to-boundary step).
307 fn ls_count(&self) -> i32 {
308 -1
309 }
310
311 /// KKT-factorization inertia / regularization report, if available.
312 fn kkt(&self) -> Option<KktReport> {
313 None
314 }
315
316 /// Assembled KKT matrix triplets for `viz kkt`, if captured.
317 fn kkt_matrix(&self) -> Option<KktTriplets> {
318 None
319 }
320
321 /// The `LDLᵀ` factor for `viz L`, if captured.
322 fn kkt_l_factor(&self) -> Option<LFactor> {
323 None
324 }
325
326 /// The iteration the currently-captured KKT matrix / factor came from
327 /// (may be the previous iteration when paused at `iter_start`, the viz
328 /// look-back). `None` when nothing is captured or unsupported.
329 fn kkt_captured_iter(&self) -> Option<i32> {
330 None
331 }
332
333 /// Ask the solver to capture the `LDLᵀ` factor on later solves.
334 /// Returns whether it is already available now.
335 fn request_l_factor(&mut self) -> bool {
336 false
337 }
338
339 /// Ask the solver to assemble the KKT triplets on later solves.
340 /// Returns whether they are already available now.
341 fn request_kkt_matrix(&mut self) -> bool {
342 false
343 }
344
345 /// Overwrite the barrier parameter μ.
346 fn set_mu(&mut self, _mu: Number) -> Result<(), String> {
347 Err("this solver does not support setting mu".into())
348 }
349
350 /// Overwrite an entire named block of the current iterate.
351 fn set_block(&mut self, _name: &str, _vals: &[Number]) -> Result<(), String> {
352 Err("this solver does not support editing the iterate".into())
353 }
354
355 /// Overwrite a single component of a named block. Defaults to a
356 /// read-modify-write through [`block`](DebugState::block) /
357 /// [`set_block`](DebugState::set_block).
358 fn set_component(&mut self, name: &str, idx: usize, val: Number) -> Result<(), String> {
359 let mut vals = self
360 .block(name)
361 .ok_or_else(|| format!("unknown block `{name}` or no iterate yet"))?;
362 if idx >= vals.len() {
363 return Err(format!(
364 "index {idx} out of range for block `{name}` (dimension {})",
365 vals.len()
366 ));
367 }
368 vals[idx] = val;
369 self.set_block(name, &vals)
370 }
371
372 /// Capture the current primal-dual state for a later [`restore`].
373 /// `None` when snapshots are unsupported or no iterate is set yet.
374 ///
375 /// [`restore`]: DebugState::restore
376 fn snapshot(&self) -> Option<Box<dyn IterSnapshot>> {
377 None
378 }
379
380 /// Restore a snapshot previously returned by [`snapshot`]. Returns
381 /// whether the restore succeeded (false on unsupported, or a snapshot
382 /// minted by a different solver).
383 ///
384 /// [`snapshot`]: DebugState::snapshot
385 fn restore(&mut self, _snap: &dyn IterSnapshot) -> bool {
386 false
387 }
388
389 /// Per-constraint signed primal residuals at the current iterate (the
390 /// components whose max-norm is `inf_pr`), for the `print residuals`
391 /// command. `None` when the solver does not expose per-component
392 /// residuals (the convex/conic and global solvers).
393 fn constraint_residuals(&self) -> Option<Vec<Residual>> {
394 None
395 }
396
397 /// Per-variable signed dual (Lagrangian-gradient) residuals at the
398 /// current iterate (the components whose max-norm is `inf_du`). `None`
399 /// when unsupported.
400 fn dual_residuals(&self) -> Option<Vec<Residual>> {
401 None
402 }
403}
404
405/// A consumer that a solver pauses at each [`Checkpoint`]. The CLI's
406/// REPL / agent driver is the production implementation; the same hook
407/// instance can drive any solver that exposes a [`DebugState`].
408pub trait DebugHook {
409 /// Called at every checkpoint. Inspect and/or mutate via `state`, then
410 /// return whether to keep solving.
411 fn at_checkpoint(&mut self, state: &mut dyn DebugState) -> DebugAction;
412
413 /// Whether the solver should capture the (heavier) KKT matrix triplets
414 /// and `LDLᵀ` factor this iteration, so `viz kkt` / `viz L` can look back
415 /// at the previous iteration's system. True while stepping interactively;
416 /// a detached (running-free) hook returns false so the O(nnz) assembly
417 /// isn't paid every iteration. The cheap inertia/status fields are
418 /// captured regardless.
419 fn wants_kkt_capture(&self) -> bool {
420 true
421 }
422
423 /// Arm the hook to pause at the next checkpoint. Used to debug a
424 /// sub-solve **on demand** — an outer driver can re-arm this
425 /// interior-point hook just before a particular solve, so the hook
426 /// stays quiet otherwise but drops in for that one solve. Default:
427 /// no-op (always-on hooks ignore it).
428 fn arm(&mut self) {}
429}