Skip to main content

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}