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}