Skip to main content

pounce_algorithm/
debug.rs

1//! Interactive solver debugger — a "pdb for the interior-point loop".
2//!
3//! The main loop ([`crate::ipopt_alg::IpoptAlgorithm::optimize`]) fires
4//! a [`DebugHook`] at well-defined checkpoints. A hook receives a
5//! [`DebugCtx`] — a live, *mutable* view of the algorithm state — and
6//! returns a [`DebugAction`] telling the loop whether to keep solving
7//! or stop. This is the engine; the user-facing REPL / agent protocol
8//! lives in the CLI (`pounce --debug`), which implements [`DebugHook`].
9//!
10//! Two design points make mutation safe:
11//!
12//!   * [`DebugCtx`] holds cheap `Rc` clones of the same `IpoptData` /
13//!     `IpoptCq` handles the loop uses, so reads and writes go through
14//!     the identical `RefCell` path — there is no shadow copy to drift.
15//!   * Overwriting the iterate rebuilds a *fresh* [`IteratesVector`]
16//!     (via `deep_copy().freeze()`), which mints a new vector tag. The
17//!     CQ caches are tag-keyed (see `ipopt_cq.rs`), so a mutated iterate
18//!     transparently invalidates every derived quantity — exactly as if
19//!     the line search had produced the new point.
20//!
21//! Checkpoints fire at the iteration top, the sub-iteration phases
22//! (`after_mu` / `after_search_dir` / `after_step`), around restoration
23//! entry/exit, and at termination. The same hook is shared
24//! (`Rc<RefCell<…>>`) with the restoration inner IPM, so one debugger
25//! steps both the outer and inner solves.
26
27use crate::ipopt_cq::IpoptCqHandle;
28use crate::ipopt_data::IpoptDataHandle;
29use pounce_common::types::Number;
30use pounce_linalg::{Matrix, Vector};
31use pounce_nlp::ipopt_nlp::SplitNames;
32
33/// Where in the main loop a checkpoint fired.
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub enum Checkpoint {
36    /// Top of an outer iteration — after the intermediate callback,
37    /// before this iteration's Newton step is computed. The iterate,
38    /// multipliers, and μ all reflect the *accepted* point from the
39    /// previous iteration.
40    IterStart,
41    /// After the barrier parameter μ was updated for this iteration
42    /// (before the search direction is computed).
43    AfterBarrierUpdate,
44    /// After the primal-dual Newton step was computed — the search
45    /// direction `δ` (`data.delta`), the applied regularization, and the
46    /// KKT factorization are available.
47    AfterSearchDirection,
48    /// After the line search chose a step length and the trial point was
49    /// accepted — α (`info_alpha_*`) and the new iterate are in place.
50    AfterStep,
51    /// The line search *rejected* this iteration's step — it hit the tiny-step
52    /// floor or exhausted its backtracks without an acceptable point, and the
53    /// solver is about to fall into restoration. The search direction `δ` and
54    /// the un-accepted current iterate are intact for inspection. The "why did
55    /// the line search give up here?" stop, distinct from the restoration entry
56    /// that follows.
57    StepRejected,
58    /// Just before the algorithm switches into the restoration phase —
59    /// the iterate that tripped restoration is intact. The most-requested
60    /// "why did this go to restoration?" stop.
61    PreRestoration,
62    /// Just after the restoration phase returns, so its effect on the
63    /// iterate can be inspected.
64    PostRestoration,
65    /// The solve has finished (or is about to): fired once before
66    /// `optimize` returns, at the final iterate, carrying the outcome
67    /// via [`DebugCtx::status`]. Lets a debugger drop in for a
68    /// post-mortem at the failing (or final) point. The [`DebugAction`]
69    /// returned at this checkpoint is **ignored** — the solve is already
70    /// over, so there is nothing left to resume or stop.
71    Terminated,
72}
73
74impl Checkpoint {
75    /// The stable wire/CLI protocol name for this checkpoint. These strings
76    /// are intentionally **not** the variant identifiers (`AfterBarrierUpdate`
77    /// → `"after_mu"`, `PreRestoration` → `"pre_restoration_entry"`) — they're
78    /// the names the JSON protocol and `stop-at` use, so match on the variant,
79    /// not the string. Locked by the `checkpoint_as_str_is_stable` test.
80    pub fn as_str(self) -> &'static str {
81        match self {
82            Checkpoint::IterStart => "iter_start",
83            Checkpoint::AfterBarrierUpdate => "after_mu",
84            Checkpoint::AfterSearchDirection => "after_search_dir",
85            Checkpoint::AfterStep => "after_step",
86            Checkpoint::StepRejected => "step_rejected",
87            Checkpoint::PreRestoration => "pre_restoration_entry",
88            Checkpoint::PostRestoration => "post_restoration_exit",
89            Checkpoint::Terminated => "terminated",
90        }
91    }
92
93    /// Sub-iteration checkpoints (everything between `IterStart` and the
94    /// next `IterStart`).
95    pub fn is_sub_iteration(self) -> bool {
96        matches!(
97            self,
98            Checkpoint::AfterBarrierUpdate
99                | Checkpoint::AfterSearchDirection
100                | Checkpoint::AfterStep
101                | Checkpoint::StepRejected
102                | Checkpoint::PreRestoration
103                | Checkpoint::PostRestoration
104        )
105    }
106}
107
108/// What the algorithm should do after a [`DebugHook`] returns.
109#[derive(Clone, Copy, Debug, PartialEq, Eq)]
110pub enum DebugAction {
111    /// Keep solving.
112    Resume,
113    /// Stop the solve now. Surfaces to the caller as
114    /// `SolverReturn::UserRequestedStop`.
115    Stop,
116}
117
118/// The eight primal/dual blocks of an iterate, addressable by name.
119pub const BLOCK_NAMES: [&str; 8] = ["x", "s", "y_c", "y_d", "z_l", "z_u", "v_l", "v_u"];
120
121/// KKT-factorization report (see [`DebugCtx::kkt`]). The inertia of a
122/// well-posed primal-dual system is `(n_pos = n, n_neg = m, n_zero = 0)`;
123/// a mismatch (or nonzero regularization) is the classic signal that the
124/// step is being stabilized.
125#[derive(Clone, Debug)]
126pub struct KktReport {
127    /// The outer iteration this factorization was assembled at — may be the
128    /// previous iteration when paused at `iter_start` (look-back).
129    pub iter: i32,
130    /// Augmented-system dimension (n + m).
131    pub dim: i32,
132    /// Negative eigenvalues reported (-1 if the backend has no inertia).
133    pub n_neg: i32,
134    /// Positive eigenvalues = `dim − n_neg` (-1 if unknown).
135    pub n_pos: i32,
136    /// Expected negatives = number of equality + inequality multipliers.
137    pub expected_neg: i32,
138    /// Whether the backend reports inertia.
139    pub provides_inertia: bool,
140    /// `true` when reported inertia matches the expected `(n, m, 0)`.
141    pub inertia_correct: bool,
142    /// Primal regularization δ_w applied to the (1,1) block.
143    pub delta_w: Number,
144    /// Dual regularization δ_c applied to the (3,3)/(4,4) blocks.
145    pub delta_c: Number,
146    /// Factorization status (debug string).
147    pub status: String,
148}
149
150/// Which residual space a [`Residual`] entry comes from.
151///
152/// Primal entries are the per-constraint violations whose max-norm is
153/// `inf_pr`; dual entries are the per-variable Lagrangian-gradient
154/// components whose max-norm is `inf_du`.
155#[derive(Clone, Copy, Debug, PartialEq, Eq)]
156pub enum ResidKind {
157    /// Equality constraint residual `c_i(x)`.
158    Eq,
159    /// Inequality residual `d_i(x) − s_i` (the IPM slack reformulation).
160    Ineq,
161    /// `x`-space stationarity component `(∇_x L)_i`.
162    DualX,
163    /// `s`-space stationarity component `(∇_s L)_i`.
164    DualS,
165}
166
167impl ResidKind {
168    /// Short label used in the debugger's `print residuals` output and
169    /// the JSON `space` field. Stable — readers may match on it.
170    pub fn tag(self) -> &'static str {
171        match self {
172            ResidKind::Eq => "c",
173            ResidKind::Ineq => "d-s",
174            ResidKind::DualX => "grad_x_L",
175            ResidKind::DualS => "grad_s_L",
176        }
177    }
178
179    /// `true` for the primal (constraint) spaces, `false` for the dual
180    /// (stationarity) spaces.
181    pub fn is_primal(self) -> bool {
182        matches!(self, ResidKind::Eq | ResidKind::Ineq)
183    }
184}
185
186/// One signed residual component at the current iterate: its space, its
187/// index within that space, and its value. See
188/// [`DebugCtx::constraint_residuals`] / [`DebugCtx::dual_residuals`].
189#[derive(Clone, Copy, Debug)]
190pub struct Residual {
191    pub kind: ResidKind,
192    pub index: usize,
193    pub value: Number,
194}
195
196/// Live, mutable view of solver state handed to a [`DebugHook`].
197///
198/// Cheap to construct (two `Rc` clones); every accessor borrows the
199/// shared `RefCell`s on demand.
200pub struct DebugCtx {
201    data: IpoptDataHandle,
202    /// Always `Some` in production (the main loop has a live CQ). Left
203    /// `None` only by the data-only unit-test constructor, in which case
204    /// the CQ-derived scalar accessors report `NaN`.
205    cq: Option<IpoptCqHandle>,
206    cp: Checkpoint,
207    /// Solve outcome, set only for the [`Checkpoint::Terminated`] fire.
208    status: Option<String>,
209    /// Convergence-tolerance changes the debugger asked to apply *in
210    /// place* (so the next `step` honors them). The main loop drains
211    /// these after the hook returns and writes them into the live
212    /// convergence-check policy — see [`Self::take_live_tolerances`].
213    pending_tol: Vec<(String, Number)>,
214}
215
216/// Solver options the debugger can apply in place at the next checkpoint:
217/// the convergence-check tolerances [`crate::conv_check`]'s policy
218/// re-reads each iteration. Anything not listed here is baked into a
219/// strategy at build time and needs a `resolve` to take effect.
220pub const LIVE_TOLERANCE_OPTS: &[&str] = &[
221    "tol",
222    "dual_inf_tol",
223    "constr_viol_tol",
224    "compl_inf_tol",
225    "acceptable_tol",
226    "acceptable_dual_inf_tol",
227    "acceptable_constr_viol_tol",
228    "acceptable_compl_inf_tol",
229    "acceptable_obj_change_tol",
230];
231
232/// Whether `name` is a tolerance the debugger can hot-swap live (next
233/// `step`), as opposed to a structural option that needs `resolve`.
234pub fn is_live_tolerance(name: &str) -> bool {
235    LIVE_TOLERANCE_OPTS.contains(&name)
236}
237
238/// A cheap, correct snapshot of the primal-dual state at one step.
239///
240/// Accepted iterates are immutable frozen [`IteratesVector`]s, so this is
241/// just an `Rc` clone plus a few scalars. It captures the iterate, μ, τ,
242/// and the iteration index — **not** strategy history (filter, adaptive-μ
243/// oracle, quasi-Newton memory), so restoring and continuing is an
244/// approximate "resume from here", not a bit-exact rewind.
245#[derive(Clone)]
246pub struct IterateSnapshot {
247    pub iter: i32,
248    pub mu: Number,
249    pub tau: Number,
250    curr: crate::iterates_vector::IteratesVector,
251}
252
253impl IterateSnapshot {
254    pub fn iter(&self) -> i32 {
255        self.iter
256    }
257
258    pub fn mu(&self) -> Number {
259        self.mu
260    }
261
262    pub fn tau(&self) -> Number {
263        self.tau
264    }
265
266    /// The captured full primal-dual iterate (algorithm space), for the
267    /// debugger's `resolve` warm restart. Cloning is `Rc`-shallow.
268    pub(crate) fn iterates(&self) -> &crate::iterates_vector::IteratesVector {
269        &self.curr
270    }
271
272    /// Read a named block of the snapshotted iterate as a flat `f64` vec.
273    pub fn block(&self, name: &str) -> Option<Vec<Number>> {
274        let v = block_ref(&self.curr, name)?;
275        Some(crate::ipopt_alg::flat_read_owned(v.as_ref()))
276    }
277}
278
279impl DebugCtx {
280    pub fn new(data: IpoptDataHandle, cq: IpoptCqHandle, cp: Checkpoint) -> Self {
281        Self {
282            data,
283            cq: Some(cq),
284            cp,
285            status: None,
286            pending_tol: Vec::new(),
287        }
288    }
289
290    /// Stage a live convergence-tolerance change (e.g. `tol`,
291    /// `acceptable_tol`). Accumulated across all commands at one pause and
292    /// applied by the main loop after the hook returns, so the next
293    /// iteration's convergence test uses the new value. No effect for
294    /// names outside [`LIVE_TOLERANCE_OPTS`].
295    pub fn set_live_tolerance(&mut self, name: &str, value: Number) {
296        self.pending_tol.push((name.to_string(), value));
297    }
298
299    /// Drain the staged live-tolerance changes (main loop only).
300    pub fn take_live_tolerances(&mut self) -> Vec<(String, Number)> {
301        std::mem::take(&mut self.pending_tol)
302    }
303
304    /// Attach a solve-outcome string (used for the terminal checkpoint).
305    pub fn with_status(mut self, status: String) -> Self {
306        self.status = Some(status);
307        self
308    }
309
310    /// Solve outcome, present only at [`Checkpoint::Terminated`].
311    pub fn status(&self) -> Option<&str> {
312        self.status.as_deref()
313    }
314
315    /// Test-only constructor without a CQ. CQ-derived scalars are `NaN`.
316    #[cfg(test)]
317    fn new_data_only(data: IpoptDataHandle, cp: Checkpoint) -> Self {
318        Self {
319            data,
320            cq: None,
321            cp,
322            status: None,
323            pending_tol: Vec::new(),
324        }
325    }
326
327    /// Capture the current primal-dual state for later [`Self::restore`].
328    /// `None` before the iterate is set.
329    pub fn snapshot(&self) -> Option<IterateSnapshot> {
330        let d = self.data.borrow();
331        let curr = d.curr.as_ref()?.clone();
332        Some(IterateSnapshot {
333            iter: d.iter_count,
334            mu: d.curr_mu,
335            tau: d.curr_tau,
336            curr,
337        })
338    }
339
340    /// Restore a previously captured snapshot: rewinds the iterate, μ, τ,
341    /// and iteration index so the next `iterate()` resumes from that
342    /// point. Strategy history is not rewound (see [`IterateSnapshot`]).
343    pub fn restore(&mut self, snap: &IterateSnapshot) {
344        let mut d = self.data.borrow_mut();
345        d.set_curr(snap.curr.clone());
346        d.curr_mu = snap.mu;
347        d.curr_tau = snap.tau;
348        d.iter_count = snap.iter;
349    }
350
351    fn cq_scalar(
352        &self,
353        f: impl FnOnce(&crate::ipopt_cq::IpoptCalculatedQuantities) -> Number,
354    ) -> Number {
355        match self.cq.as_ref() {
356            Some(cq) => f(&cq.borrow()),
357            None => Number::NAN,
358        }
359    }
360
361    /// Which checkpoint we are paused at.
362    pub fn checkpoint(&self) -> Checkpoint {
363        self.cp
364    }
365
366    // ---- scalar reads --------------------------------------------------
367
368    /// Current outer iteration counter.
369    pub fn iter(&self) -> i32 {
370        self.data.borrow().iter_count
371    }
372
373    /// Current barrier parameter μ.
374    pub fn mu(&self) -> Number {
375        self.data.borrow().curr_mu
376    }
377
378    /// Unscaled objective at the current iterate.
379    pub fn objective(&self) -> Number {
380        self.cq_scalar(|c| c.unscaled_curr_f())
381    }
382
383    /// Max-norm primal infeasibility.
384    pub fn inf_pr(&self) -> Number {
385        self.cq_scalar(|c| c.curr_primal_infeasibility_max())
386    }
387
388    /// Max-norm dual infeasibility.
389    pub fn inf_du(&self) -> Number {
390        self.cq_scalar(|c| c.curr_dual_infeasibility_max())
391    }
392
393    /// Scaled overall NLP error driving convergence.
394    pub fn nlp_error(&self) -> Number {
395        self.cq_scalar(|c| c.curr_nlp_error())
396    }
397
398    /// Average complementarity (mean slack·multiplier over all bounds) —
399    /// the IPM's "distance from the central path" gauge; should track μ.
400    pub fn complementarity(&self) -> Number {
401        self.cq_scalar(|c| c.curr_avrg_compl())
402    }
403
404    /// Slacks to a bound category — `x_l` / `x_u` / `s_l` / `s_u` — i.e.
405    /// the distance of each (lower/upper-bounded) variable or inequality
406    /// slack from its bound. A small entry ⇒ that bound is near-active.
407    pub fn bound_slack(&self, which: &str) -> Option<Vec<Number>> {
408        let c = self.cq.as_ref()?.borrow();
409        let v = match which {
410            "x_l" => c.curr_slack_x_l(),
411            "x_u" => c.curr_slack_x_u(),
412            "s_l" => c.curr_slack_s_l(),
413            "s_u" => c.curr_slack_s_u(),
414            _ => return None,
415        };
416        Some(crate::ipopt_alg::flat_read_owned(v.as_ref()))
417    }
418
419    /// Full-length variable bounds in algorithm space — `(x_L, x_U)`, each
420    /// of length `n`, with `-∞` / `+∞` in slots that have no lower / upper
421    /// bound. Reconstructed from the NLP's *reduced* bound vectors
422    /// (`x_l`/`x_u`, indexed over only the bounded variables) and their
423    /// expansion matrices, so the result lines up with the `x` block and
424    /// with a `set x` / `resolve` seed. `None` in the CQ-less test context
425    /// or before the iterate exists.
426    ///
427    /// These are the bounds the *algorithm* sees — post-scaling and after
428    /// any `bound_relax_factor` — which is exactly the space a box-sampled
429    /// start must live in to be a valid seed.
430    pub fn var_bounds(&self) -> Option<(Vec<Number>, Vec<Number>)> {
431        let cq = self.cq.as_ref()?.borrow();
432        let nlp = cq.nlp().borrow();
433        let d = self.data.borrow();
434        let x = &d.curr.as_ref()?.x; // full x-space template
435        let lower = expand_bound(&*nlp.px_l(), nlp.x_l(), &**x, Number::NEG_INFINITY);
436        let upper = expand_bound(&*nlp.px_u(), nlp.x_u(), &**x, Number::INFINITY);
437        Some((lower, upper))
438    }
439
440    /// Per-constraint signed primal residuals at the current iterate,
441    /// equality constraints ([`ResidKind::Eq`], `c_i(x)`) then inequality
442    /// constraints ([`ResidKind::Ineq`], `d_i(x) − s_i`). These are the
443    /// same quantities the studio iterate-dump emits as `slack`, and the
444    /// largest `|value|` over the returned vector equals [`Self::inf_pr`].
445    /// `None` only in the CQ-less test context.
446    pub fn constraint_residuals(&self) -> Option<Vec<Residual>> {
447        let cq = self.cq.as_ref()?.borrow();
448        let c = crate::ipopt_alg::flat_read_owned(cq.curr_c().as_ref());
449        let dms = crate::ipopt_alg::flat_read_owned(cq.curr_d_minus_s().as_ref());
450        let mut out = Vec::with_capacity(c.len() + dms.len());
451        out.extend(c.iter().enumerate().map(|(index, &value)| Residual {
452            kind: ResidKind::Eq,
453            index,
454            value,
455        }));
456        out.extend(dms.iter().enumerate().map(|(index, &value)| Residual {
457            kind: ResidKind::Ineq,
458            index,
459            value,
460        }));
461        Some(out)
462    }
463
464    /// Per-variable signed dual residuals (Lagrangian-gradient
465    /// components) at the current iterate, `x`-space
466    /// ([`ResidKind::DualX`], `(∇_x L)_i`) then `s`-space
467    /// ([`ResidKind::DualS`], `(∇_s L)_i`). The largest `|value|` over
468    /// the returned vector equals [`Self::inf_du`]. `None` only in the
469    /// CQ-less test context.
470    pub fn dual_residuals(&self) -> Option<Vec<Residual>> {
471        let cq = self.cq.as_ref()?.borrow();
472        let gx = crate::ipopt_alg::flat_read_owned(cq.curr_grad_lag_x().as_ref());
473        let gs = crate::ipopt_alg::flat_read_owned(cq.curr_grad_lag_s().as_ref());
474        let mut out = Vec::with_capacity(gx.len() + gs.len());
475        out.extend(gx.iter().enumerate().map(|(index, &value)| Residual {
476            kind: ResidKind::DualX,
477            index,
478            value,
479        }));
480        out.extend(gs.iter().enumerate().map(|(index, &value)| Residual {
481            kind: ResidKind::DualS,
482            index,
483            value,
484        }));
485        Some(out)
486    }
487
488    /// Model names for the residual index spaces, projected into the
489    /// solver's split space (free variables, equalities, inequalities), or
490    /// `None` when the problem carries no names (or in the CQ-less test
491    /// context). The REPL pairs these with [`Residual::kind`] /
492    /// [`Residual::index`] to print `mass_balance` instead of `c[3]` —
493    /// closing the model-vs-index reporting gap Lee et al. (2024,
494    /// <https://doi.org/10.69997/sct.147875>) identify for equation-oriented
495    /// model debugging.
496    pub fn split_names(&self) -> Option<SplitNames> {
497        let cq = self.cq.as_ref()?.borrow();
498        let names = cq.nlp().borrow().split_space_names();
499        names
500    }
501
502    /// Primal regularization δ_w **as recorded for this iteration's info**
503    /// (`info_regu_x`) — the value reported in the iteration table's `lg(rg)`
504    /// column, reset to 0 at the start of each iteration. This is distinct
505    /// from [`Self::kkt`]'s `delta_w`, which reads the *live* perturbation
506    /// (`perturbations.delta_x`) applied during the inertia-correction loop;
507    /// the two can differ by timing at a given checkpoint.
508    pub fn regularization(&self) -> Number {
509        self.data.borrow().info_regu_x
510    }
511
512    /// Number of line-search trial points tried for the accepted step
513    /// (1 ⇒ full step accepted first try).
514    pub fn ls_count(&self) -> i32 {
515        self.data.borrow().info_ls_count
516    }
517
518    /// Accepted primal / dual step lengths (α_pr, α_du).
519    pub fn alpha(&self) -> (Number, Number) {
520        let d = self.data.borrow();
521        (d.info_alpha_primal, d.info_alpha_dual)
522    }
523
524    /// KKT-factorization report for the current iteration, if a search
525    /// direction has been computed this iteration (i.e. at/after the
526    /// `after_search_dir` checkpoint). Combines the captured inertia with
527    /// the applied regularization and the *expected* inertia derived from
528    /// the multiplier dimensions. `delta_w`/`delta_c` are the **live**
529    /// primal/dual perturbations (`perturbations.delta_x/delta_c`) applied
530    /// during inertia correction — distinct from [`Self::regularization`]'s
531    /// recorded per-iteration info value (see its note).
532    pub fn kkt(&self) -> Option<KktReport> {
533        let d = self.data.borrow();
534        let k = d.kkt_debug.as_ref()?;
535        let curr = d.curr.as_ref();
536        let expected_neg = curr.map(|c| c.y_c.dim() + c.y_d.dim()).unwrap_or(0);
537        // n+ = dim − n− (assuming a non-singular KKT, n0 = 0).
538        let n_pos = if k.n_neg >= 0 { k.dim - k.n_neg } else { -1 };
539        let inertia_correct = k.provides_inertia && k.n_neg == expected_neg;
540        Some(KktReport {
541            iter: k.iter,
542            dim: k.dim,
543            n_neg: k.n_neg,
544            n_pos,
545            expected_neg,
546            provides_inertia: k.provides_inertia,
547            inertia_correct,
548            delta_w: d.perturbations.delta_x,
549            delta_c: d.perturbations.delta_c,
550            status: k.status.clone(),
551        })
552    }
553
554    /// The assembled KKT matrix triplets `(dim, irn, jcn, vals)` (1-based
555    /// lower triangle) for `viz kkt`, if captured this iteration.
556    pub fn kkt_matrix(&self) -> Option<(i32, Vec<i32>, Vec<i32>, Vec<Number>)> {
557        self.data.borrow().kkt_debug.as_ref()?.matrix.clone()
558    }
559
560    /// Numerical rank diagnosis of the equality-constraint Jacobian `J_c`
561    /// at the current iterate. `J_c` is the block whose (near) rank
562    /// deficiency drives the `δ_c` dual regularization and wrong-inertia
563    /// signals; this assembles it densely (with the constraint scaling the
564    /// solver factorizes), runs a rank-revealing SVD, and localizes any
565    /// dependency to specific equation rows by their model name (see
566    /// [`crate::debug_rank`]). `None` in the CQ-less test context, when the
567    /// problem has no equality constraints, or if the SVD fails.
568    ///
569    /// Unlike the structural Dulmage–Mendelsohn pass (which works on the
570    /// sparsity pattern alone), this catches dependencies that are
571    /// *numerical* — values that cancel over a structurally full-rank
572    /// pattern at this point.
573    pub fn rank_report(&self) -> Option<crate::debug_rank::RankReport> {
574        use crate::debug_rank::RankRow;
575        use pounce_linalg::triplet::GenTMatrix;
576        let cq = self.cq.as_ref()?.borrow();
577        let jac = cq.curr_jac_c();
578        let g = jac.as_any().downcast_ref::<GenTMatrix>()?;
579        let m = g.n_rows() as usize;
580        let n = g.n_cols() as usize;
581        if m == 0 || n == 0 {
582            return None;
583        }
584        // Dense row-major from the 1-based triplets. Values already carry
585        // the constraint scaling, so this is the matrix the linear solver
586        // actually factorizes.
587        let mut dense = vec![0.0; m * n];
588        for ((&ir, &jc), &v) in g.irows().iter().zip(g.jcols()).zip(g.values()) {
589            dense[(ir - 1) as usize * n + (jc - 1) as usize] += v;
590        }
591        let rows: Vec<RankRow> = (0..m)
592            .map(|index| RankRow {
593                kind: ResidKind::Eq,
594                index,
595            })
596            .collect();
597        crate::debug_rank::svd_rank(m, n, &dense, rows)
598    }
599
600    /// The outer iteration the captured KKT system / factor came from —
601    /// the previous iteration at an `iter_start` pause (look-back). For
602    /// labeling `viz kkt` / `viz L` with the right iteration.
603    pub fn kkt_captured_iter(&self) -> Option<i32> {
604        Some(self.data.borrow().kkt_debug.as_ref()?.iter)
605    }
606
607    /// The `LDLᵀ` factor (`n`, `perm`, strict-lower `l_irn`/`l_jcn` and
608    /// optional `l_vals`) for `viz L`, if captured this iteration (i.e.
609    /// the debugger was stepping — see `DebugHook::wants_kkt_capture`).
610    #[allow(clippy::type_complexity)]
611    pub fn kkt_l_factor(
612        &self,
613    ) -> Option<(usize, Vec<usize>, Vec<i32>, Vec<i32>, Option<Vec<Number>>)> {
614        let d = self.data.borrow();
615        let f = d.kkt_debug.as_ref()?.l_factor.as_ref()?;
616        Some((
617            f.n,
618            f.perm.clone(),
619            f.l_irn.clone(),
620            f.l_jcn.clone(),
621            f.l_vals.clone(),
622        ))
623    }
624
625    // ---- vector reads --------------------------------------------------
626
627    /// Dimensions of every named block, in [`BLOCK_NAMES`] order.
628    pub fn block_dims(&self) -> Vec<(&'static str, usize)> {
629        let d = self.data.borrow();
630        let Some(curr) = d.curr.as_ref() else {
631            return BLOCK_NAMES.iter().map(|&n| (n, 0)).collect();
632        };
633        BLOCK_NAMES
634            .iter()
635            .map(|&n| (n, block_ref(curr, n).map(|v| v.dim() as usize).unwrap_or(0)))
636            .collect()
637    }
638
639    /// Read a named block of the current iterate as a flat `f64` vec.
640    /// Returns `None` for an unknown name or before the iterate is set.
641    pub fn block(&self, name: &str) -> Option<Vec<Number>> {
642        let d = self.data.borrow();
643        let curr = d.curr.as_ref()?;
644        let v = block_ref(curr, name)?;
645        Some(crate::ipopt_alg::flat_read_owned(v.as_ref()))
646    }
647
648    /// Read a named block of the most recent search direction δ.
649    pub fn delta_block(&self, name: &str) -> Option<Vec<Number>> {
650        let d = self.data.borrow();
651        let delta = d.delta.as_ref()?;
652        let v = block_ref(delta, name)?;
653        Some(crate::ipopt_alg::flat_read_owned(v.as_ref()))
654    }
655
656    // ---- mutation ------------------------------------------------------
657
658    /// Overwrite the barrier parameter μ. Takes effect on the next
659    /// `update_barrier_parameter` consult (the monotone updater treats
660    /// it as the current value; adaptive updaters re-derive from it).
661    pub fn set_mu(&mut self, mu: Number) -> Result<(), String> {
662        if !mu.is_finite() || mu <= 0.0 {
663            return Err(format!("mu must be finite and positive, got {mu}"));
664        }
665        self.data.borrow_mut().curr_mu = mu;
666        Ok(())
667    }
668
669    /// Overwrite an entire named block of the current iterate.
670    ///
671    /// Rebuilds `curr` from a deep copy with a fresh vector tag, so all
672    /// tag-keyed CQ caches invalidate and downstream quantities recompute
673    /// from the new point.
674    ///
675    /// **No invariant enforcement.** Only the dimension is checked. Mutating
676    /// the slacks (`s`) to ≤ 0, or the bound/inequality multipliers
677    /// (`z_l`/`z_u`/`v_l`/`v_u`) to ≤ 0, or pushing `x` past a bound, breaks
678    /// the interior-point feasibility invariant (`s > 0, z > 0`) — the next
679    /// step's σ = z/s and fraction-to-the-boundary rule can then produce
680    /// `NaN`/`Inf` or a non-descent direction rather than erroring here. The
681    /// solver's strategy history (filter, adaptive-μ oracle, quasi-Newton
682    /// memory) is **not** rewound to the mutated point either, so a resumed
683    /// solve may behave inconsistently. This is a debugging tool: "wrong"
684    /// states are allowed on purpose — it's on the caller to keep the point
685    /// sane if they intend to continue the solve.
686    pub fn set_block(&mut self, name: &str, vals: &[Number]) -> Result<(), String> {
687        if !BLOCK_NAMES.contains(&name) {
688            return Err(format!(
689                "unknown block `{name}` (expected one of {BLOCK_NAMES:?})"
690            ));
691        }
692        let mut d = self.data.borrow_mut();
693        let curr = d.curr.as_ref().ok_or("no current iterate yet")?;
694        let mut m = curr.deep_copy();
695        let blk = block_ref_mut(&mut m, name).expect("name checked above");
696        let dim = blk.dim() as usize;
697        if vals.len() != dim {
698            return Err(format!(
699                "block `{name}` has dimension {dim}, got {} value(s)",
700                vals.len()
701            ));
702        }
703        crate::ipopt_alg::flat_write_into(blk.as_mut(), vals);
704        let frozen = m.freeze();
705        d.set_curr(frozen);
706        Ok(())
707    }
708
709    /// Overwrite a single component of a named block.
710    pub fn set_component(&mut self, name: &str, idx: usize, val: Number) -> Result<(), String> {
711        let mut vals = self
712            .block(name)
713            .ok_or_else(|| format!("unknown block `{name}` or no iterate yet"))?;
714        if idx >= vals.len() {
715            return Err(format!(
716                "index {idx} out of range for block `{name}` (dimension {})",
717                vals.len()
718            ));
719        }
720        vals[idx] = val;
721        self.set_block(name, &vals)
722    }
723}
724
725/// Expand a *reduced* bound vector (one entry per bounded variable) into a
726/// full-length `Vec<Number>`, placing `absent` in slots that variable has
727/// no such bound. `p` is the bound's expansion matrix (full × reduced),
728/// `template` any full x-space vector (for the right dimension/space).
729///
730/// `P · 1` marks which full slots are bounded; `P · bound` scatters the
731/// bound values into them. Anything the mask leaves untouched gets `absent`
732/// (`±∞`).
733fn expand_bound(
734    p: &dyn Matrix,
735    reduced: &dyn Vector,
736    template: &dyn Vector,
737    absent: Number,
738) -> Vec<Number> {
739    let mut ones = reduced.make_new();
740    ones.set(1.0);
741    let mut mask = template.make_new();
742    p.mult_vector(1.0, &*ones, 0.0, &mut *mask);
743    let mut vals = template.make_new();
744    p.mult_vector(1.0, reduced, 0.0, &mut *vals);
745    let mask = crate::ipopt_alg::flat_read_owned(&*mask);
746    let vals = crate::ipopt_alg::flat_read_owned(&*vals);
747    mask.iter()
748        .zip(vals)
749        .map(|(&m, v)| if m > 0.5 { v } else { absent })
750        .collect()
751}
752
753/// Borrow a named block of an [`IteratesVector`].
754fn block_ref<'a>(
755    iv: &'a crate::iterates_vector::IteratesVector,
756    name: &str,
757) -> Option<&'a std::rc::Rc<dyn pounce_linalg::Vector>> {
758    Some(match name {
759        "x" => &iv.x,
760        "s" => &iv.s,
761        "y_c" => &iv.y_c,
762        "y_d" => &iv.y_d,
763        "z_l" => &iv.z_l,
764        "z_u" => &iv.z_u,
765        "v_l" => &iv.v_l,
766        "v_u" => &iv.v_u,
767        _ => return None,
768    })
769}
770
771/// Borrow a named block of a mutable [`IteratesVectorMut`].
772fn block_ref_mut<'a>(
773    iv: &'a mut crate::iterates_vector::IteratesVectorMut,
774    name: &str,
775) -> Option<&'a mut Box<dyn pounce_linalg::Vector>> {
776    Some(match name {
777        "x" => &mut iv.x,
778        "s" => &mut iv.s,
779        "y_c" => &mut iv.y_c,
780        "y_d" => &mut iv.y_d,
781        "z_l" => &mut iv.z_l,
782        "z_u" => &mut iv.z_u,
783        "v_l" => &mut iv.v_l,
784        "v_u" => &mut iv.v_u,
785        _ => return None,
786    })
787}
788
789/// A consumer that the main loop pauses at each checkpoint. The CLI's
790/// REPL / agent driver is the production implementation.
791pub trait DebugHook {
792    /// Called at every [`Checkpoint`]. Inspect and/or mutate via `ctx`,
793    /// then return whether to keep solving.
794    fn at_checkpoint(&mut self, ctx: &mut DebugCtx) -> DebugAction;
795
796    /// Whether the main loop should capture the (heavier) KKT matrix
797    /// triplets and `LDLᵀ` factor into `kkt_debug` this iteration, so
798    /// `viz kkt` / `viz L` can look back at the previous iteration's
799    /// system. True while the debugger is stepping interactively; an
800    /// implementation that has detached (running free) returns false so
801    /// the O(nnz) assembly isn't paid every iteration. Defaults to true
802    /// — the cheap inertia/status fields are captured regardless.
803    fn wants_kkt_capture(&self) -> bool {
804        true
805    }
806}
807
808#[cfg(test)]
809mod tests {
810    use super::*;
811    use crate::ipopt_data::IpoptData;
812    use crate::iterates_vector::IteratesVector;
813    use pounce_linalg::dense_vector::DenseVectorSpace;
814    use pounce_linalg::Vector;
815    use std::cell::RefCell;
816    use std::rc::Rc;
817
818    fn iv(xvals: &[f64]) -> IteratesVector {
819        let dense = |vals: &[f64]| {
820            let mut v = DenseVectorSpace::new(vals.len() as i32).make_new_dense();
821            v.set_values(vals);
822            Rc::new(v) as Rc<dyn Vector>
823        };
824        let z = |n| dense(&vec![0.0; n]);
825        IteratesVector::new(dense(xvals), z(1), z(1), z(1), z(2), z(2), z(1), z(1))
826    }
827
828    fn ctx_with(xvals: &[f64]) -> DebugCtx {
829        let mut data = IpoptData::new();
830        data.set_curr(iv(xvals));
831        data.curr_mu = 0.1;
832        let data = Rc::new(RefCell::new(data));
833        DebugCtx::new_data_only(data, Checkpoint::IterStart)
834    }
835
836    #[test]
837    fn reads_block_and_mu() {
838        let ctx = ctx_with(&[1.0, 2.0]);
839        assert_eq!(ctx.mu(), 0.1);
840        assert_eq!(ctx.block("x"), Some(vec![1.0, 2.0]));
841        assert_eq!(ctx.block("nope"), None);
842    }
843
844    #[test]
845    fn set_component_rebuilds_iterate_with_fresh_tag() {
846        let mut ctx = ctx_with(&[1.0, 2.0]);
847        let before = ctx
848            .data
849            .borrow()
850            .curr
851            .as_ref()
852            .unwrap()
853            .x
854            .as_tagged()
855            .get_tag();
856        ctx.set_component("x", 1, 9.0).unwrap();
857        let after = ctx
858            .data
859            .borrow()
860            .curr
861            .as_ref()
862            .unwrap()
863            .x
864            .as_tagged()
865            .get_tag();
866        assert_eq!(ctx.block("x"), Some(vec![1.0, 9.0]));
867        assert_ne!(before, after, "mutating the iterate must mint a new tag");
868    }
869
870    #[test]
871    fn set_block_dim_mismatch_is_rejected() {
872        let mut ctx = ctx_with(&[1.0, 2.0]);
873        assert!(ctx.set_block("x", &[1.0]).is_err());
874        assert!(ctx.set_block("x", &[1.0, 2.0, 3.0]).is_err());
875        assert!(ctx.set_block("x", &[3.0, 4.0]).is_ok());
876        assert_eq!(ctx.block("x"), Some(vec![3.0, 4.0]));
877    }
878
879    #[test]
880    fn block_names_all_resolve_in_block_ref() {
881        // Locks `BLOCK_NAMES` to the `block_ref` / `block_ref_mut` match arms:
882        // every name must resolve in both, or `set_block`'s
883        // `expect("name checked above")` could panic on a name that's in the
884        // array but missing from a match arm.
885        let mut ctx = ctx_with(&[1.0, 2.0]);
886        for name in BLOCK_NAMES {
887            let cur = ctx
888                .block(name)
889                .unwrap_or_else(|| panic!("block_ref does not resolve `{name}`"));
890            // Round-trips through `block_ref_mut` (dimension-correct values).
891            ctx.set_block(name, &cur)
892                .unwrap_or_else(|e| panic!("block_ref_mut does not resolve `{name}`: {e}"));
893        }
894    }
895
896    #[test]
897    fn residuals_are_none_without_cq() {
898        // The data-only test ctx has no CQ, so both residual accessors
899        // report `None` (mirrors the documented NaN-scalar contract).
900        let ctx = ctx_with(&[1.0, 2.0]);
901        assert!(ctx.constraint_residuals().is_none());
902        assert!(ctx.dual_residuals().is_none());
903    }
904
905    #[test]
906    fn resid_kind_tags_and_primal_classification_are_stable() {
907        assert_eq!(ResidKind::Eq.tag(), "c");
908        assert_eq!(ResidKind::Ineq.tag(), "d-s");
909        assert_eq!(ResidKind::DualX.tag(), "grad_x_L");
910        assert_eq!(ResidKind::DualS.tag(), "grad_s_L");
911        assert!(ResidKind::Eq.is_primal());
912        assert!(ResidKind::Ineq.is_primal());
913        assert!(!ResidKind::DualX.is_primal());
914        assert!(!ResidKind::DualS.is_primal());
915    }
916
917    #[test]
918    fn checkpoint_as_str_is_stable() {
919        // These strings are the wire/CLI protocol names — intentionally
920        // distinct from the variant identifiers (e.g. `AfterBarrierUpdate` →
921        // `"after_mu"`). Locked here so a rename is a deliberate,
922        // protocol-breaking change rather than a silent one.
923        assert_eq!(Checkpoint::IterStart.as_str(), "iter_start");
924        assert_eq!(Checkpoint::AfterBarrierUpdate.as_str(), "after_mu");
925        assert_eq!(
926            Checkpoint::AfterSearchDirection.as_str(),
927            "after_search_dir"
928        );
929        assert_eq!(Checkpoint::AfterStep.as_str(), "after_step");
930        assert_eq!(Checkpoint::StepRejected.as_str(), "step_rejected");
931        assert_eq!(Checkpoint::PreRestoration.as_str(), "pre_restoration_entry");
932        assert_eq!(
933            Checkpoint::PostRestoration.as_str(),
934            "post_restoration_exit"
935        );
936        assert_eq!(Checkpoint::Terminated.as_str(), "terminated");
937    }
938
939    #[test]
940    fn snapshot_then_restore_round_trips_iterate_and_mu() {
941        let mut ctx = ctx_with(&[1.0, 2.0]);
942        let snap = ctx.snapshot().expect("snapshot");
943        assert_eq!(snap.iter(), 0);
944        // Mutate away from the snapshot.
945        ctx.set_component("x", 0, 99.0).unwrap();
946        ctx.set_mu(0.5).unwrap();
947        assert_eq!(ctx.block("x"), Some(vec![99.0, 2.0]));
948        assert_eq!(ctx.mu(), 0.5);
949        // Restore brings back the captured state.
950        ctx.restore(&snap);
951        assert_eq!(ctx.block("x"), Some(vec![1.0, 2.0]));
952        assert_eq!(ctx.mu(), 0.1);
953        assert_eq!(ctx.iter(), 0);
954    }
955
956    #[test]
957    fn set_mu_rejects_nonpositive() {
958        let mut ctx = ctx_with(&[1.0]);
959        assert!(ctx.set_mu(-1.0).is_err());
960        assert!(ctx.set_mu(0.0).is_err());
961        assert!(ctx.set_mu(1e-3).is_ok());
962        assert_eq!(ctx.mu(), 1e-3);
963    }
964}