Skip to main content

pounce_algorithm/
ipopt_data.rs

1//! Mutable algorithm state — port of `Algorithm/IpIpoptData.{hpp,cpp}`.
2//!
3//! Holds the iterate trio (`curr` / `trial` / `delta`), the affine
4//! step `delta_aff`, current barrier parameter `mu`, fraction-to-the-
5//! boundary `tau`, iteration count, tolerances, and the four PD
6//! perturbations. No additional-data plug in v1.0 (CG penalty is
7//! deferred to Phase 10).
8//!
9//! Phase 5 ships the full state holder; concrete strategies (line
10//! search, mu update, etc.) read/write fields here as their inputs
11//! and outputs.
12
13use crate::iterates_vector::IteratesVector;
14use pounce_common::timing::{Deadline, TimingStatistics};
15use pounce_common::types::{Index, Number};
16use pounce_linalg::SymMatrix;
17use std::cell::RefCell;
18use std::rc::Rc;
19
20/// Primal-dual perturbation triple (`delta_x`, `delta_s`, `delta_c`,
21/// `delta_d`) — port of the four `current_perturbation` fields in
22/// `IpIpoptData.hpp`.
23#[derive(Debug, Default, Clone, Copy)]
24pub struct PdPerturbations {
25    pub delta_x: Number,
26    pub delta_s: Number,
27    pub delta_c: Number,
28    pub delta_d: Number,
29}
30
31/// KKT-factorization diagnostics captured for the interactive debugger
32/// after a search-direction solve. Only populated when a debugger is
33/// installed (see `IpoptAlgorithm`); inspected via `DebugCtx::kkt`.
34#[derive(Clone, Debug, Default)]
35pub struct KktDebug {
36    /// The outer iteration this factorization was assembled at. Lets the
37    /// debugger label `viz kkt` / `viz L` with the iteration the system
38    /// actually came from — at an `iter_start` pause that's the *previous*
39    /// iteration (the step that produced the current point), not the
40    /// iterate you're standing on.
41    pub iter: i32,
42    /// Dimension of the augmented system (n + m).
43    pub dim: i32,
44    /// Negative eigenvalues reported by the factorization (-1 if the
45    /// backend doesn't provide inertia).
46    pub n_neg: i32,
47    /// Whether the backend reports inertia at all.
48    pub provides_inertia: bool,
49    /// Debug string of the last factorization status.
50    pub status: String,
51    /// Assembled KKT triplets `(dim, irn, jcn, vals)`, 1-based lower
52    /// triangle — for `viz kkt`. Captured when a debugger is attached.
53    pub matrix: Option<(i32, Vec<i32>, Vec<i32>, Vec<f64>)>,
54    /// `LDLᵀ` factor pattern (+ values) — for `viz L`. Captured only
55    /// after the debugger opts in (it's the expensive piece).
56    pub l_factor: Option<pounce_linsol::FactorPattern>,
57}
58
59/// Mutable state passed down through the algorithm. Owned by
60/// `IpoptAlgorithm`; strategies access via `Rc<RefCell<IpoptData>>`.
61pub struct IpoptData {
62    pub curr: Option<IteratesVector>,
63    pub trial: Option<IteratesVector>,
64    pub delta: Option<IteratesVector>,
65    pub delta_aff: Option<IteratesVector>,
66    /// Pure centering step — solution of the primal-dual system with
67    /// RHS `(0, 0, 0, 0, μ̄·1, μ̄·1, μ̄·1, μ̄·1)` (where μ̄ = avrg_compl)
68    /// per upstream `IpQualityFunctionMuOracle.cpp:227-247`. Used by
69    /// the quality-function oracle to assemble σ-step trial points
70    /// without re-factorising for each candidate σ.
71    pub delta_cen: Option<IteratesVector>,
72
73    /// Hessian of the Lagrangian for the *current* iterate. Set by
74    /// `HessianUpdater` (exact or quasi-Newton). Mirrors `IpIpoptData::W_`.
75    pub w: Option<Rc<dyn SymMatrix>>,
76
77    pub iter_count: Index,
78    pub curr_mu: Number,
79    pub curr_tau: Number,
80    pub tol: Number,
81
82    pub perturbations: PdPerturbations,
83
84    /// KKT-factorization diagnostics for the debugger (set after a
85    /// search-direction solve when a debugger is installed). The full
86    /// matrix triplets and `LDLᵀ` factor are captured here whenever the
87    /// debugger is stepping (see `DebugHook::wants_kkt_capture`) and
88    /// dropped when it detaches, so `viz kkt` / `viz L` always have the
89    /// previous iteration's system to look back at without paying the
90    /// O(nnz) assembly during a free run.
91    pub kkt_debug: Option<KktDebug>,
92
93    /// Set after a successful trial-acceptance step in the line
94    /// search. Cleared on accept.
95    pub info_alpha_primal: Number,
96    pub info_alpha_dual: Number,
97
98    /// Mirrors `IpIpoptData::info_regu_x_`.
99    pub info_regu_x: Number,
100
101    /// Mirrors `IpIpoptData::info_skip_output_`.
102    pub info_skip_output: bool,
103
104    /// Mirrors `IpIpoptData::info_string_`. Free-form text the
105    /// iteration output appends to its line.
106    pub info_string: String,
107
108    /// Mirrors `IpIpoptData::tiny_step_flag_`. Set by the line search
109    /// when an alpha→0 trial is detected; the main loop reads it on
110    /// the next pass to decide between "tiny step accept" and bail.
111    pub tiny_step_flag: bool,
112
113    /// Emergency restoration request from the μ-update layer. Set by
114    /// [`AdaptiveMuUpdate`] when the probing oracle's input iterate
115    /// is corrupted (`curr_avrg_compl` ≫ `curr_mu`) so the main loop
116    /// invokes restoration instead of letting the oracle snap μ up
117    /// many orders of magnitude. Pounce-specific guard; no upstream
118    /// counterpart. See pounce#58.
119    pub request_resto: bool,
120
121    /// One-char marker the iteration output puts in front of
122    /// `alpha_primal` (e.g. `'f'` for filter, `'r'` for restoration,
123    /// `'h'` for the very first iterate). Mirrors
124    /// `IpIpoptData::info_alpha_primal_char_`.
125    pub info_alpha_primal_char: char,
126
127    /// Number of trial points evaluated in the most recent line
128    /// search. Mirrors `IpIpoptData::info_ls_count_`.
129    pub info_ls_count: Index,
130
131    /// The wall-clock at the last `OrigIterationOutput::WriteOutput`
132    /// pass. Phase 7 uses this to decide whether to re-print the
133    /// header. Mirrors `IpIpoptData::info_last_output_`.
134    pub info_last_output: Number,
135
136    /// Iterations since the iteration header was last printed. Phase
137    /// 7 reprints every `print_frequency_iter` lines. Mirrors
138    /// `IpIpoptData::info_iters_since_header_`.
139    pub info_iters_since_header: Index,
140
141    /// Shared per-subsystem timing accumulator. Mirrors upstream's
142    /// `IpoptData::TimingStats_`. `IpoptApplication` constructs a single
143    /// instance per solve and shares it (via `Rc`) with the algorithm,
144    /// NLP, and KKT solver so each can record its own contribution.
145    /// Defaults to a fresh empty instance for the structural unit tests
146    /// that don't go through `IpoptApplication`.
147    pub timing: Rc<TimingStatistics>,
148
149    /// Shared wall/CPU-time deadline for the whole solve (pounce#242).
150    /// `IpoptApplication` installs one at solve start from the
151    /// `max_wall_time` / `max_cpu_time` options, and the restoration inner
152    /// IPM copies the *same* deadline onto its own `IpoptData` so the
153    /// nested solve is bounded by the caller's global budget rather than
154    /// running unbounded (its fresh `timing.overall_alg` is never
155    /// started). When present it is the authoritative time gate — checked
156    /// at the granularity of the expensive inner steps (KKT factorization,
157    /// each line-search trial), not only between outer iterations. `None`
158    /// for direct-driver / unit-test paths, which fall back to the
159    /// `overall_alg` timer in [`crate::conv_check`].
160    pub deadline: Option<Deadline>,
161}
162
163impl Default for IpoptData {
164    fn default() -> Self {
165        Self::new()
166    }
167}
168
169impl IpoptData {
170    pub fn new() -> Self {
171        Self {
172            curr: None,
173            trial: None,
174            delta: None,
175            delta_aff: None,
176            delta_cen: None,
177            w: None,
178            iter_count: 0,
179            curr_mu: 0.1,
180            curr_tau: 0.99,
181            tol: 1e-8,
182            perturbations: PdPerturbations::default(),
183            kkt_debug: None,
184            info_alpha_primal: 0.0,
185            info_alpha_dual: 0.0,
186            info_regu_x: 0.0,
187            info_skip_output: false,
188            info_string: String::new(),
189            tiny_step_flag: false,
190            request_resto: false,
191            info_alpha_primal_char: ' ',
192            info_ls_count: 0,
193            info_last_output: -1.0,
194            info_iters_since_header: 0,
195            timing: Rc::new(TimingStatistics::new()),
196            deadline: None,
197        }
198    }
199
200    /// Append text to `info_string`. Mirrors `IpIpoptData::Append_info_string`.
201    pub fn append_info_string(&mut self, s: &str) {
202        self.info_string.push_str(s);
203    }
204
205    /// Reset per-iteration info fields. Mirrors the top of
206    /// `IpoptAlgorithm::Optimize`'s loop body.
207    pub fn reset_info(&mut self) {
208        self.info_string.clear();
209        self.info_skip_output = false;
210        self.info_alpha_primal_char = ' ';
211        self.info_ls_count = 0;
212        self.info_regu_x = 0.0;
213    }
214
215    /// Replace `curr` with the previously-set `trial`. Mirrors
216    /// `IpIpoptData::AcceptTrialPoint`, which `DBG_ASSERT`s a trial is
217    /// staged before promoting it (upstream always runs a line search that
218    /// stages one). pounce additionally supports a bookkeeping-only
219    /// `iterate()` path (no NLP + no search_dir, per the module docs) that
220    /// runs the per-iteration bookkeeping without computing a step, so
221    /// `trial` may be unset here. Promoting `None` would null out `curr`
222    /// and make the next iteration's CQ accessor (`IpoptCq::curr_iv`) hit
223    /// `unreachable!`; preserve `curr` when nothing is staged.
224    pub fn accept_trial_point(&mut self) {
225        if let Some(trial) = self.trial.take() {
226            self.curr = Some(trial);
227        }
228    }
229
230    /// Set the trial iterate from a primal step `delta_x`/`delta_s`
231    /// scaled by `alpha_p` and a dual step scaled by `alpha_d`.
232    /// Phase 5 ships only the structural plumbing; the actual
233    /// arithmetic is implemented once the line search lands in
234    /// Phase 7.
235    pub fn set_trial(&mut self, trial: IteratesVector) {
236        self.trial = Some(trial);
237    }
238
239    pub fn set_curr(&mut self, curr: IteratesVector) {
240        self.curr = Some(curr);
241    }
242
243    pub fn set_delta(&mut self, d: IteratesVector) {
244        self.delta = Some(d);
245    }
246
247    pub fn set_delta_aff(&mut self, d: IteratesVector) {
248        self.delta_aff = Some(d);
249    }
250
251    pub fn set_delta_cen(&mut self, d: IteratesVector) {
252        self.delta_cen = Some(d);
253    }
254}
255
256/// Convenience handle. Mirrors how upstream passes the data object
257/// around as `SmartPtr<IpoptData>`.
258pub type IpoptDataHandle = Rc<RefCell<IpoptData>>;
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use crate::iterates_vector::IteratesVector;
264    use pounce_linalg::Vector;
265    use pounce_linalg::dense_vector::DenseVectorSpace;
266    use std::rc::Rc as StdRc;
267
268    fn zero_iv() -> IteratesVector {
269        let z = |n| StdRc::new(DenseVectorSpace::new(n).make_new_dense()) as StdRc<dyn Vector>;
270        IteratesVector::new(z(2), z(1), z(1), z(1), z(2), z(2), z(1), z(1))
271    }
272
273    #[test]
274    fn accept_trial_point_promotes_trial_to_curr() {
275        let mut d = IpoptData::new();
276        d.set_trial(zero_iv());
277        assert!(d.curr.is_none());
278        d.accept_trial_point();
279        assert!(d.curr.is_some());
280        assert!(d.trial.is_none());
281    }
282
283    // Regression for M2 (dev-notes/code-review-2026-06.md): in the
284    // bookkeeping-only `iterate()` path (no NLP + no search_dir), step 5
285    // is skipped so no trial is staged, yet `accept_trial_point()` is still
286    // called. The old `curr = trial.take()` then nulled out `curr`, and the
287    // next iteration's CQ accessor (`ipopt_cq.rs` `curr_iv`) hit
288    // `unreachable!`. With no trial staged, `curr` must be preserved.
289    #[test]
290    fn accept_trial_point_preserves_curr_when_no_trial_staged() {
291        let mut d = IpoptData::new();
292        d.set_curr(zero_iv());
293        assert!(d.curr.is_some());
294        assert!(d.trial.is_none());
295        d.accept_trial_point();
296        // Must NOT destroy the current iterate when nothing is staged.
297        assert!(
298            d.curr.is_some(),
299            "accept_trial_point() nulled curr with no trial staged"
300        );
301        assert!(d.trial.is_none());
302    }
303}