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::inf_pr_floor::InfPrFloor;
14use crate::init::warm_start::WarmStartDiagnostics;
15use crate::iterates_vector::IteratesVector;
16use pounce_common::timing::{Deadline, TimingStatistics};
17use pounce_common::types::{Index, Number};
18use pounce_linalg::SymMatrix;
19use std::cell::RefCell;
20use std::rc::Rc;
21
22/// Primal-dual perturbation triple (`delta_x`, `delta_s`, `delta_c`,
23/// `delta_d`) — port of the four `current_perturbation` fields in
24/// `IpIpoptData.hpp`.
25#[derive(Debug, Default, Clone, Copy)]
26pub struct PdPerturbations {
27    pub delta_x: Number,
28    pub delta_s: Number,
29    pub delta_c: Number,
30    pub delta_d: Number,
31}
32
33/// KKT-factorization diagnostics captured for the interactive debugger
34/// after a search-direction solve. Only populated when a debugger is
35/// installed (see `IpoptAlgorithm`); inspected via `DebugCtx::kkt`.
36#[derive(Clone, Debug, Default)]
37pub struct KktDebug {
38    /// The outer iteration this factorization was assembled at. Lets the
39    /// debugger label `viz kkt` / `viz L` with the iteration the system
40    /// actually came from — at an `iter_start` pause that's the *previous*
41    /// iteration (the step that produced the current point), not the
42    /// iterate you're standing on.
43    pub iter: i32,
44    /// Dimension of the augmented system (n + m).
45    pub dim: i32,
46    /// Negative eigenvalues reported by the factorization (-1 if the
47    /// backend doesn't provide inertia).
48    pub n_neg: i32,
49    /// Whether the backend reports inertia at all.
50    pub provides_inertia: bool,
51    /// Debug string of the last factorization status.
52    pub status: String,
53    /// Assembled KKT triplets `(dim, irn, jcn, vals)`, 1-based lower
54    /// triangle — for `viz kkt`. Captured when a debugger is attached.
55    pub matrix: Option<(i32, Vec<i32>, Vec<i32>, Vec<f64>)>,
56    /// `LDLᵀ` factor pattern (+ values) — for `viz L`. Captured only
57    /// after the debugger opts in (it's the expensive piece).
58    pub l_factor: Option<pounce_linsol::FactorPattern>,
59}
60
61/// Mutable state passed down through the algorithm. Owned by
62/// `IpoptAlgorithm`; strategies access via `Rc<RefCell<IpoptData>>`.
63pub struct IpoptData {
64    pub curr: Option<IteratesVector>,
65    pub trial: Option<IteratesVector>,
66    pub delta: Option<IteratesVector>,
67    pub delta_aff: Option<IteratesVector>,
68    /// Pure centering step — solution of the primal-dual system with
69    /// RHS `(0, 0, 0, 0, μ̄·1, μ̄·1, μ̄·1, μ̄·1)` (where μ̄ = avrg_compl)
70    /// per upstream `IpQualityFunctionMuOracle.cpp:227-247`. Used by
71    /// the quality-function oracle to assemble σ-step trial points
72    /// without re-factorising for each candidate σ.
73    pub delta_cen: Option<IteratesVector>,
74
75    /// Hessian of the Lagrangian for the *current* iterate. Set by
76    /// `HessianUpdater` (exact or quasi-Newton). Mirrors `IpIpoptData::W_`.
77    pub w: Option<Rc<dyn SymMatrix>>,
78
79    pub iter_count: Index,
80    pub curr_mu: Number,
81    pub curr_tau: Number,
82    pub tol: Number,
83
84    pub perturbations: PdPerturbations,
85
86    /// KKT-factorization diagnostics for the debugger (set after a
87    /// search-direction solve when a debugger is installed). The full
88    /// matrix triplets and `LDLᵀ` factor are captured here whenever the
89    /// debugger is stepping (see `DebugHook::wants_kkt_capture`) and
90    /// dropped when it detaches, so `viz kkt` / `viz L` always have the
91    /// previous iteration's system to look back at without paying the
92    /// O(nnz) assembly during a free run.
93    pub kkt_debug: Option<KktDebug>,
94
95    /// Set after a successful trial-acceptance step in the line
96    /// search. Cleared on accept.
97    pub info_alpha_primal: Number,
98    pub info_alpha_dual: Number,
99
100    /// Mirrors `IpIpoptData::info_regu_x_`.
101    pub info_regu_x: Number,
102
103    /// Mirrors `IpIpoptData::info_skip_output_`.
104    pub info_skip_output: bool,
105
106    /// Mirrors `IpIpoptData::info_string_`. Free-form text the
107    /// iteration output appends to its line.
108    pub info_string: String,
109
110    /// Mirrors `IpIpoptData::tiny_step_flag_`. Set by the line search
111    /// when an alpha→0 trial is detected; the main loop reads it on
112    /// the next pass to decide between "tiny step accept" and bail.
113    pub tiny_step_flag: bool,
114
115    /// Emergency restoration request from the μ-update layer. Set by
116    /// [`AdaptiveMuUpdate`] when the probing oracle's input iterate
117    /// is corrupted (`curr_avrg_compl` ≫ `curr_mu`) so the main loop
118    /// invokes restoration instead of letting the oracle snap μ up
119    /// many orders of magnitude. Pounce-specific guard; no upstream
120    /// counterpart. See pounce#58.
121    pub request_resto: bool,
122
123    /// How long this solve has sat at a constraint violation it could
124    /// not get below (gh#661, gh#664). Fed once per outer iteration from
125    /// the `inf_pr` the iteration output already computes.
126    ///
127    /// Read by restoration's divergence guard, which needs to know
128    /// whether the solve had *demonstrated* a floor before its
129    /// restoration sub-solve blew up — the premise the reconstructed
130    /// locally-infeasible gates assert and never tested. The evidence
131    /// lives here rather than in restoration because the trajectory that
132    /// carries it is the outer one: the sub-solves themselves run a
133    /// handful of iterations. Pounce-specific; no upstream counterpart.
134    pub inf_pr_floor: InfPrFloor,
135
136    /// What the warm-start initializer accepted, reconstructed, or
137    /// discarded from the supplied iterate, and the residuals it based
138    /// those calls on (gh#606). `None` on the cold path, which has no
139    /// supplied iterate to report on. Read back after a solve through
140    /// [`crate::application::IpoptApplication::warm_start_diagnostics`].
141    pub warm_start_diagnostics: Option<WarmStartDiagnostics>,
142
143    /// `curr` was installed by the post-convergence crossover phase
144    /// (gh#612) rather than produced by the interior iteration.
145    ///
146    /// The distinction is not bookkeeping: a crossed-over point sits
147    /// *on* the bounds the user declared, which is `bound_relax_factor`
148    /// **inside** the widened box the barrier quantities are measured
149    /// against, so every slack at an active bound reads exactly `δ`
150    /// where an interior iterate would have carried `μ/z` (gh#646,
151    /// gh#654). Anything that reads `curr` through the barrier — the
152    /// residual report, the sensitivity path's `Σ = z/s` — has to know
153    /// which of the two frames the iterate belongs to before it can
154    /// pick the bounds to measure against. Nothing inside the
155    /// algorithm reads this: the flag is set after `optimize()` has
156    /// returned and no further step is taken.
157    pub curr_from_crossover: bool,
158
159    /// Identifies this attempt's firing of the post-optimal
160    /// `IpoptApplication::set_on_converged` hook: `Some(id)` while the hook
161    /// runs, `None` before it. Ids are unique over the application's life.
162    ///
163    /// A hook fires once per converged *attempt*, and a solve can spend
164    /// several (the `mu_strategy_fallback` re-solve, the gh#884 retry, the
165    /// gh#983 scale audit). When a later attempt loses, the reported answer is
166    /// replayed from an earlier one, so "the last firing" is not necessarily
167    /// the answer. A consumer that keeps state from the hook records this id
168    /// beside it and, after the solve, keeps the firing
169    /// `IpoptApplication::reported_on_converged_id` names.
170    pub on_converged_id: Option<u64>,
171
172    /// Line-search reset request from the μ-update layer (pounce#510).
173    /// Upstream's μ updates hold a `linesearch_` handle and call
174    /// `linesearch_->Reset()` themselves at fixed points
175    /// (`IpAdaptiveMuUpdate.cpp:339, 386, 431`,
176    /// `IpMonotoneMuUpdate.cpp:165`); pounce's [`MuUpdate`] trait has no
177    /// such handle, so the updates raise this flag instead and the main
178    /// loop performs the reset immediately after
179    /// `update_barrier_parameter` returns. Consuming the flag clears it.
180    ///
181    /// [`MuUpdate`]: crate::mu::MuUpdate
182    pub request_ls_reset: bool,
183
184    /// Tiny-step termination request from the μ-update layer (pounce#512).
185    /// Upstream signals "problem solved to best possible numerical
186    /// accuracy" by throwing `TINY_STEP_DETECTED` from inside
187    /// `UpdateBarrierParameter`; a Rust port returns a μ instead, so the
188    /// two throw sites in `IpAdaptiveMuUpdate.cpp` (`:330-333` fixed
189    /// mode, `:377-380` on the free→fixed switch) raise this flag and
190    /// the main loop turns it into `SolverReturn::StopAtTinyStep`.
191    ///
192    /// The flag exists because the throw's exact branch matters:
193    /// "a tiny step was flagged and μ came back unchanged" is also true
194    /// on adaptive paths where upstream does *not* throw (the no-bounds
195    /// short-circuit, and a free-mode oracle that happens to re-pick the
196    /// current μ), so it cannot be reconstructed from the μ values alone.
197    /// [`MonotoneMuUpdate`](crate::mu::monotone::MonotoneMuUpdate) has a
198    /// single throw site covering its whole update and is served by the
199    /// main loop's `terminates_on_tiny_step()` μ-comparison instead.
200    pub request_tiny_step_stop: bool,
201
202    /// One-char marker the iteration output puts in front of
203    /// `alpha_primal` (e.g. `'f'` for filter, `'r'` for restoration,
204    /// `'h'` for the very first iterate). Mirrors
205    /// `IpIpoptData::info_alpha_primal_char_`.
206    pub info_alpha_primal_char: char,
207
208    /// Number of trial points evaluated in the most recent line
209    /// search. Mirrors `IpIpoptData::info_ls_count_`.
210    pub info_ls_count: Index,
211
212    /// The wall-clock at the last `OrigIterationOutput::WriteOutput`
213    /// pass. Phase 7 uses this to decide whether to re-print the
214    /// header. Mirrors `IpIpoptData::info_last_output_`.
215    pub info_last_output: Number,
216
217    /// Iterations since the iteration header was last printed. Phase
218    /// 7 reprints every `print_frequency_iter` lines. Mirrors
219    /// `IpIpoptData::info_iters_since_header_`.
220    pub info_iters_since_header: Index,
221
222    /// Shared per-subsystem timing accumulator. Mirrors upstream's
223    /// `IpoptData::TimingStats_`. `IpoptApplication` constructs a single
224    /// instance per solve and shares it (via `Rc`) with the algorithm,
225    /// NLP, and KKT solver so each can record its own contribution.
226    /// Defaults to a fresh empty instance for the structural unit tests
227    /// that don't go through `IpoptApplication`.
228    pub timing: Rc<TimingStatistics>,
229
230    /// Shared wall/CPU-time deadline for the whole solve (pounce#242).
231    /// `IpoptApplication` installs one at solve start from the
232    /// `max_wall_time` / `max_cpu_time` options, and the restoration inner
233    /// IPM copies the *same* deadline onto its own `IpoptData` so the
234    /// nested solve is bounded by the caller's global budget rather than
235    /// running unbounded (its fresh `timing.overall_alg` is never
236    /// started). When present it is the authoritative time gate — checked
237    /// at the granularity of the expensive inner steps (KKT factorization,
238    /// each line-search trial), not only between outer iterations. `None`
239    /// for direct-driver / unit-test paths, which fall back to the
240    /// `overall_alg` timer in [`crate::conv_check`].
241    pub deadline: Option<Deadline>,
242}
243
244impl Default for IpoptData {
245    fn default() -> Self {
246        Self::new()
247    }
248}
249
250impl IpoptData {
251    pub fn new() -> Self {
252        Self {
253            curr: None,
254            trial: None,
255            delta: None,
256            delta_aff: None,
257            delta_cen: None,
258            w: None,
259            iter_count: 0,
260            curr_mu: 0.1,
261            curr_tau: 0.99,
262            tol: 1e-8,
263            perturbations: PdPerturbations::default(),
264            kkt_debug: None,
265            info_alpha_primal: 0.0,
266            info_alpha_dual: 0.0,
267            info_regu_x: 0.0,
268            info_skip_output: false,
269            info_string: String::new(),
270            tiny_step_flag: false,
271            request_resto: false,
272            inf_pr_floor: InfPrFloor::default(),
273            warm_start_diagnostics: None,
274            curr_from_crossover: false,
275            on_converged_id: None,
276            request_ls_reset: false,
277            request_tiny_step_stop: false,
278            info_alpha_primal_char: ' ',
279            info_ls_count: 0,
280            info_last_output: -1.0,
281            info_iters_since_header: 0,
282            timing: Rc::new(TimingStatistics::new()),
283            deadline: None,
284        }
285    }
286
287    /// Append text to `info_string`. Mirrors `IpIpoptData::Append_info_string`.
288    pub fn append_info_string(&mut self, s: &str) {
289        self.info_string.push_str(s);
290    }
291
292    /// Reset per-iteration info fields. Mirrors the top of
293    /// `IpoptAlgorithm::Optimize`'s loop body.
294    pub fn reset_info(&mut self) {
295        self.info_string.clear();
296        self.info_skip_output = false;
297        self.info_alpha_primal_char = ' ';
298        self.info_ls_count = 0;
299        self.info_regu_x = 0.0;
300    }
301
302    /// Replace `curr` with the previously-set `trial`. Mirrors
303    /// `IpIpoptData::AcceptTrialPoint`, which `DBG_ASSERT`s a trial is
304    /// staged before promoting it (upstream always runs a line search that
305    /// stages one). pounce additionally supports a bookkeeping-only
306    /// `iterate()` path (no NLP + no search_dir, per the module docs) that
307    /// runs the per-iteration bookkeeping without computing a step, so
308    /// `trial` may be unset here. Promoting `None` would null out `curr`
309    /// and make the next iteration's CQ accessor (`IpoptCq::curr_iv`) hit
310    /// `unreachable!`; preserve `curr` when nothing is staged.
311    pub fn accept_trial_point(&mut self) {
312        if let Some(trial) = self.trial.take() {
313            self.curr = Some(trial);
314        }
315        // Upstream `IpoptData::AcceptTrialPoint` frees the affine-scaling
316        // step with the trial point: it was computed at the iterate just
317        // left, and the Mehrotra corrector (`pd_search_dir_calc.rs`) reads it
318        // whenever it is present. pounce only ever set it, so any iteration
319        // whose oracle did not recompute it -- the probing guard's recentre
320        // (gh#986 review item 8), a fixed-mu iteration of the adaptive
321        // strategy, an oracle that fell back to LOQO -- fed the PREVIOUS
322        // iterate's predictor into the corrector.
323        self.delta_aff = None;
324    }
325
326    /// Set the trial iterate from a primal step `delta_x`/`delta_s`
327    /// scaled by `alpha_p` and a dual step scaled by `alpha_d`.
328    /// Phase 5 ships only the structural plumbing; the actual
329    /// arithmetic is implemented once the line search lands in
330    /// Phase 7.
331    pub fn set_trial(&mut self, trial: IteratesVector) {
332        self.trial = Some(trial);
333    }
334
335    pub fn set_curr(&mut self, curr: IteratesVector) {
336        self.curr = Some(curr);
337    }
338
339    pub fn set_delta(&mut self, d: IteratesVector) {
340        self.delta = Some(d);
341    }
342
343    pub fn set_delta_aff(&mut self, d: IteratesVector) {
344        self.delta_aff = Some(d);
345    }
346
347    pub fn set_delta_cen(&mut self, d: IteratesVector) {
348        self.delta_cen = Some(d);
349    }
350}
351
352/// Convenience handle. Mirrors how upstream passes the data object
353/// around as `SmartPtr<IpoptData>`.
354pub type IpoptDataHandle = Rc<RefCell<IpoptData>>;
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use crate::iterates_vector::IteratesVector;
360    use pounce_linalg::Vector;
361    use pounce_linalg::dense_vector::DenseVectorSpace;
362    use std::rc::Rc as StdRc;
363
364    fn zero_iv() -> IteratesVector {
365        let z = |n| StdRc::new(DenseVectorSpace::new(n).make_new_dense()) as StdRc<dyn Vector>;
366        IteratesVector::new(z(2), z(1), z(1), z(1), z(2), z(2), z(1), z(1))
367    }
368
369    #[test]
370    fn accept_trial_point_drops_the_affine_step() {
371        // gh#986 review item 8: the predictor belongs to the iterate it was
372        // computed at; a corrector at the next iterate must not read it.
373        let mut d = IpoptData::new();
374        d.set_delta_aff(zero_iv());
375        d.set_trial(zero_iv());
376        d.accept_trial_point();
377        assert!(d.delta_aff.is_none());
378    }
379
380    #[test]
381    fn accept_trial_point_promotes_trial_to_curr() {
382        let mut d = IpoptData::new();
383        d.set_trial(zero_iv());
384        assert!(d.curr.is_none());
385        d.accept_trial_point();
386        assert!(d.curr.is_some());
387        assert!(d.trial.is_none());
388    }
389
390    // Regression for M2 (dev-notes/code-review-2026-06.md): in the
391    // bookkeeping-only `iterate()` path (no NLP + no search_dir), step 5
392    // is skipped so no trial is staged, yet `accept_trial_point()` is still
393    // called. The old `curr = trial.take()` then nulled out `curr`, and the
394    // next iteration's CQ accessor (`ipopt_cq.rs` `curr_iv`) hit
395    // `unreachable!`. With no trial staged, `curr` must be preserved.
396    #[test]
397    fn accept_trial_point_preserves_curr_when_no_trial_staged() {
398        let mut d = IpoptData::new();
399        d.set_curr(zero_iv());
400        assert!(d.curr.is_some());
401        assert!(d.trial.is_none());
402        d.accept_trial_point();
403        // Must NOT destroy the current iterate when nothing is staged.
404        assert!(
405            d.curr.is_some(),
406            "accept_trial_point() nulled curr with no trial staged"
407        );
408        assert!(d.trial.is_none());
409    }
410}