Skip to main content

pounce_algorithm/mu/
monotone.rs

1//! Monotone Fiacco-McCormick mu update — port of
2//! `Algorithm/IpMonotoneMuUpdate.{hpp,cpp}`.
3//!
4//! Reduces mu by either `mu_linear_decrease_factor` or
5//! `pow(mu, mu_superlinear_decrease_power)`, taking the smaller value
6//! and clamping to `mu_min`. Bit-exact with upstream.
7
8use crate::ipopt_cq::IpoptCqHandle;
9use crate::ipopt_data::IpoptDataHandle;
10use crate::mu::r#trait::MuUpdate;
11use pounce_common::types::Number;
12
13pub struct MonotoneMuUpdate {
14    pub mu_init: Number,
15    pub mu_min: Number,
16    /// Upper bound on μ from `IpMonotoneMuUpdate.cpp:RegisterOptions`.
17    /// Used to clamp `mu_init` at [`MuUpdate::initialize`] so the
18    /// barrier doesn't start above the registered ceiling regardless
19    /// of what the user set. Default `1e5` mirrors upstream.
20    pub mu_max: Number,
21    pub mu_linear_decrease_factor: Number,
22    pub mu_superlinear_decrease_power: Number,
23    pub tau_min: Number,
24    /// `barrier_tol_factor` from `IpMonotoneMuUpdate.cpp:RegisterOptions`.
25    /// μ only decreases when the barrier subproblem error drops below
26    /// `barrier_tol_factor · μ`.
27    pub barrier_tol_factor: Number,
28    /// `mu_target` floor — μ never goes below this regardless of the
29    /// reduction formula. Defaults to 0 (the floor is `mu_min`).
30    pub mu_target: Number,
31    /// `mu_allow_fast_monotone_decrease` from
32    /// `IpMonotoneMuUpdate.cpp:RegisterOptions`. When `true` (the
33    /// upstream default), the reduction loop keeps iterating while
34    /// the sub-error stays below `barrier_tol_factor · μ`, allowing
35    /// multiple consecutive μ reductions in one outer call. When
36    /// `false`, the loop exits after the first successful reduction —
37    /// useful on stiff problems where a runaway μ collapse destroys
38    /// the line search.
39    pub mu_allow_fast_monotone_decrease: bool,
40    /// Complementarity tolerance — option `compl_inf_tol`, default 1e-4
41    /// per `IpAlgorithmRegOp.cpp`. Enters the dynamic μ floor via
42    /// `min(tol, compl_inf_tol) / (barrier_tol_factor + 1)` per
43    /// `IpMonotoneMuUpdate.cpp:CalcNewMuAndTau:215`. Without this floor,
44    /// μ can collapse to the absolute floor (`mu_min`) while primal
45    /// infeasibility is still large — observed on SSINE/DECONVBNE.
46    pub compl_inf_tol: Number,
47    /// `first_iter_resto_` flag from
48    /// `Algorithm/IpMonotoneMuUpdate.cpp:118-121,144,196`. When set,
49    /// the very next call to [`Self::update_barrier_parameter`] skips
50    /// the μ-reduction loop entirely and clears the flag. Wired by
51    /// the restoration sub-builder for the inner IPM (prefix
52    /// `"resto."`) so the inner doesn't immediately collapse μ on
53    /// iteration 0 — it must use the `resto_mu` value that
54    /// [`crate::resto::init::RestoIterateInitializer::SetInitialIterates`]
55    /// seeded into `data.curr_mu`.
56    pub first_iter_resto: bool,
57}
58
59impl Default for MonotoneMuUpdate {
60    fn default() -> Self {
61        // Defaults from `IpMonotoneMuUpdate.cpp:RegisterOptions`.
62        Self {
63            mu_init: 0.1,
64            mu_min: 1e-11,
65            mu_max: 1e5,
66            mu_linear_decrease_factor: 0.2,
67            mu_superlinear_decrease_power: 1.5,
68            tau_min: 0.99,
69            barrier_tol_factor: 10.0,
70            mu_target: 0.0,
71            mu_allow_fast_monotone_decrease: true,
72            compl_inf_tol: 1e-4,
73            first_iter_resto: false,
74        }
75    }
76}
77
78impl MonotoneMuUpdate {
79    pub fn new() -> Self {
80        Self::default()
81    }
82
83    /// Builder helper for the `first_iter_resto_` flag. Mirrors the
84    /// upstream `prefix == "resto."` branch in
85    /// `IpMonotoneMuUpdate.cpp:InitializeImpl`.
86    pub fn with_first_iter_resto(mut self, b: bool) -> Self {
87        self.first_iter_resto = b;
88        self
89    }
90
91    /// Builder for the `mu_min` floor. The restoration inner IPM uses
92    /// `100 * outer_mu_min` per upstream `IpAdaptiveMuUpdate.cpp:206-211`
93    /// (and the analogous monotone path); without the conservative
94    /// floor, near-feasible iterates collapse μ to the absolute floor
95    /// in a single step and the next direction is dominated by the
96    /// penalty/proximity terms instead of the barrier, which destroys
97    /// near-feasibility (DECONVBNE).
98    pub fn with_mu_min(mut self, mu_min: Number) -> Self {
99        self.mu_min = mu_min;
100        self
101    }
102
103    /// Fraction-to-the-bound parameter `tau` from upstream
104    /// `IpMonotoneMuUpdate.cpp:Update`:
105    ///
106    /// ```text
107    ///   tau = max(tau_min, 1 - mu)
108    /// ```
109    ///
110    /// Returns a value in `[tau_min, 1)`.
111    pub fn compute_tau(&self, mu: Number) -> Number {
112        self.tau_min.max(1.0 - mu)
113    }
114
115    /// Pure scalar reduction used by the trait impl. Exposed so unit
116    /// tests can drive the formula without standing up an
117    /// `IpoptData`/`IpoptCq` fixture.
118    pub fn compute_next_mu(&self, curr_mu: Number) -> Number {
119        let linear = self.mu_linear_decrease_factor * curr_mu;
120        let superlinear = curr_mu.powf(self.mu_superlinear_decrease_power);
121        linear.min(superlinear).max(self.mu_min)
122    }
123}
124
125impl MuUpdate for MonotoneMuUpdate {
126    /// Monotone μ throws `TINY_STEP_DETECTED` when a tiny step is
127    /// flagged and μ is already at its floor — see
128    /// `IpMonotoneMuUpdate.cpp`. The main loop realises that throw as a
129    /// `STOP_AT_TINY_STEP` termination.
130    fn terminates_on_tiny_step(&self) -> bool {
131        true
132    }
133
134    /// Port of `IpMonotoneMuUpdate.cpp:InitializeImpl`. Seeds
135    /// `curr_mu = min(mu_init, mu_max)`,
136    /// `curr_tau = max(tau_min, 1 - curr_mu)`.
137    fn initialize(&mut self, data: &IpoptDataHandle) {
138        let init_mu = self.mu_init.min(self.mu_max);
139        let mut d = data.borrow_mut();
140        d.curr_mu = init_mu;
141        d.curr_tau = self.compute_tau(init_mu);
142    }
143
144    /// Port of `IpMonotoneMuUpdate.cpp:UpdateBarrierParameter`.
145    /// Reduces μ only while the barrier-subproblem error is below
146    /// `barrier_tol_factor · μ` (or a tiny step was just detected).
147    /// Each successful reduction also refreshes `curr_tau` and the new
148    /// μ in `data`. Returns the post-update μ.
149    fn update_barrier_parameter(
150        &mut self,
151        data: &IpoptDataHandle,
152        cq: &IpoptCqHandle,
153        _nlp: Option<&std::rc::Rc<std::cell::RefCell<dyn crate::ipopt_nlp::IpoptNlp>>>,
154        _pd_search_dir: Option<&mut crate::kkt::pd_search_dir_calc::PdSearchDirCalc>,
155    ) -> Number {
156        let mut mu = data.borrow().curr_mu;
157        let mut tau = data.borrow().curr_tau;
158        let tiny_step = data.borrow().tiny_step_flag;
159
160        // `first_iter_resto_` (upstream `IpMonotoneMuUpdate.cpp:144`):
161        // on the first inner iteration of restoration, skip the μ
162        // reduction loop entirely so the inner uses the `resto_mu`
163        // seeded by `RestoIterateInitializer`. Cleared after this
164        // call so subsequent inner iterations behave normally.
165        if self.first_iter_resto {
166            self.first_iter_resto = false;
167            let mut d = data.borrow_mut();
168            d.curr_mu = mu;
169            d.curr_tau = tau;
170            return mu;
171        }
172
173        // Dynamic floor from `IpMonotoneMuUpdate.cpp:CalcNewMuAndTau:215`:
174        //     floor = max(mu_target, min(tol, compl_inf_tol) / (barrier_tol_factor + 1))
175        // Without this, μ collapses to `mu_min` (1e-11) while primal
176        // infeasibility is still large — observed on SSINE/DECONVBNE,
177        // where the next direction is dominated by ill-conditioned
178        // barrier terms and the line search stalls.
179        // We also `max` with `mu_min` so the restoration sub-builder's
180        // `with_mu_min(100 * outer_mu_min)` safeguard still applies.
181        let tol = data.borrow().tol;
182        let dynamic_floor = tol.min(self.compl_inf_tol) / (self.barrier_tol_factor + 1.0);
183        let floor = self.mu_target.max(self.mu_min).max(dynamic_floor);
184
185        // The barrier error depends on μ via the relaxed
186        // complementarity. Read it once per μ value.
187        loop {
188            let sub_err = cq.borrow().curr_barrier_error();
189            let kappa_eps_mu = self.barrier_tol_factor * mu;
190            if !(sub_err <= kappa_eps_mu || tiny_step) {
191                break;
192            }
193            let mut new_mu = self
194                .mu_linear_decrease_factor
195                .min(mu.powf(self.mu_superlinear_decrease_power - 1.0))
196                * mu;
197            if new_mu < floor {
198                new_mu = floor;
199            }
200            if new_mu >= mu {
201                // No further progress (already at floor).
202                break;
203            }
204            mu = new_mu;
205            tau = self.compute_tau(mu);
206            // Mirror upstream `IpData().Set_mu(mu)` *inside* the loop
207            // (`IpMonotoneMuUpdate.cpp:CalcNewMuAndTau`): the next
208            // `curr_barrier_error()` must see the reduced μ. The relaxed
209            // complementarity `s⊙z − μ` is keyed on `data.curr_mu`
210            // (see `ipopt_cq.rs::curr_relaxed_compl_*`), so without this
211            // write the re-tested `sub_err` stays pinned to the *old* μ
212            // while `kappa_eps_mu = barrier_tol_factor·mu` shrinks, and
213            // the loop over-drops μ in a single outer iteration. Writing
214            // it here makes the residual grow as μ falls (toward the
215            // current `s⊙z`), so the loop exits after one effective
216            // reduction — matching IPOPT.
217            data.borrow_mut().curr_mu = mu;
218            // Stop after one reduction in the tiny_step branch (matches
219            // upstream which clears tiny_step_flag once consumed).
220            if tiny_step {
221                data.borrow_mut().tiny_step_flag = false;
222                break;
223            }
224            // `mu_allow_fast_monotone_decrease=false` caps the loop at
225            // a single reduction. Mirrors upstream
226            // `IpMonotoneMuUpdate.cpp:CalcNewMuAndTau` when the option
227            // is off.
228            if !self.mu_allow_fast_monotone_decrease {
229                break;
230            }
231        }
232
233        let mut d = data.borrow_mut();
234        d.curr_mu = mu;
235        d.curr_tau = tau;
236        mu
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn picks_smaller_of_linear_and_superlinear() {
246        let m = MonotoneMuUpdate::new();
247        // mu = 0.1 → linear = 0.02, superlinear = 0.1^1.5 ≈ 0.0316.
248        // The smaller is `linear`.
249        let next = m.compute_next_mu(0.1);
250        assert!((next - 0.02).abs() < 1e-15);
251    }
252
253    #[test]
254    fn tau_at_small_mu_is_tau_min() {
255        let m = MonotoneMuUpdate::new();
256        // mu small → 1 - mu ~ 1; tau_min=0.99 → max → 1.0 (since 1-mu=0.999...).
257        // Actually 1 - 1e-3 = 0.999 > 0.99 → tau = 0.999.
258        assert!((m.compute_tau(1e-3) - 0.999).abs() < 1e-15);
259    }
260
261    #[test]
262    fn tau_floor_at_tau_min() {
263        let m = MonotoneMuUpdate::new();
264        // mu=0.5 → 1 - 0.5 = 0.5; floor at tau_min=0.99 → 0.99.
265        assert!((m.compute_tau(0.5) - 0.99).abs() < 1e-15);
266    }
267
268    #[test]
269    fn clamps_to_mu_min() {
270        let m = MonotoneMuUpdate {
271            mu_min: 1e-3,
272            ..Default::default()
273        };
274        let next = m.compute_next_mu(1e-10);
275        assert!((next - 1e-3).abs() < 1e-15);
276    }
277
278    #[test]
279    fn dynamic_floor_matches_upstream_calcnewmuandtau() {
280        // Replicate `IpMonotoneMuUpdate.cpp:CalcNewMuAndTau:215`:
281        //   floor = max(mu_target, min(tol, compl_inf_tol) / (barrier_tol_factor + 1))
282        // With default `tol=1e-8`, `compl_inf_tol=1e-4`, `barrier_tol_factor=10`,
283        // `mu_target=0`: floor ≈ 1e-8 / 11 ≈ 9.09e-10.
284        let m = MonotoneMuUpdate::default();
285        let tol: Number = 1e-8;
286        let expected_floor = tol.min(m.compl_inf_tol) / (m.barrier_tol_factor + 1.0);
287        assert!((expected_floor - 1e-8 / 11.0).abs() < 1e-20);
288        // The hardcoded `mu_min = 1e-11` is well below the dynamic floor
289        // with default tols — the runtime `floor = max(...)` picks the
290        // dynamic one. (Verified in `update_barrier_parameter`.)
291        assert!(m.mu_min < expected_floor);
292    }
293}