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