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            // Only one reduction per outer iteration unless we'd need
207            // to re-test sub_err for the new μ. Upstream re-tests, so
208            // we do the same — but `curr_barrier_error` is keyed on the
209            // current iterate (μ enters via the constant subtraction),
210            // so the re-tested value will generally be larger and the
211            // loop exits.
212            //
213            // Stop after one reduction in the tiny_step branch (matches
214            // upstream which clears tiny_step_flag once consumed).
215            if tiny_step {
216                data.borrow_mut().tiny_step_flag = false;
217                break;
218            }
219            // `mu_allow_fast_monotone_decrease=false` caps the loop at
220            // a single reduction. Mirrors upstream
221            // `IpMonotoneMuUpdate.cpp:CalcNewMuAndTau` when the option
222            // is off.
223            if !self.mu_allow_fast_monotone_decrease {
224                break;
225            }
226        }
227
228        let mut d = data.borrow_mut();
229        d.curr_mu = mu;
230        d.curr_tau = tau;
231        mu
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn picks_smaller_of_linear_and_superlinear() {
241        let m = MonotoneMuUpdate::new();
242        // mu = 0.1 → linear = 0.02, superlinear = 0.1^1.5 ≈ 0.0316.
243        // The smaller is `linear`.
244        let next = m.compute_next_mu(0.1);
245        assert!((next - 0.02).abs() < 1e-15);
246    }
247
248    #[test]
249    fn tau_at_small_mu_is_tau_min() {
250        let m = MonotoneMuUpdate::new();
251        // mu small → 1 - mu ~ 1; tau_min=0.99 → max → 1.0 (since 1-mu=0.999...).
252        // Actually 1 - 1e-3 = 0.999 > 0.99 → tau = 0.999.
253        assert!((m.compute_tau(1e-3) - 0.999).abs() < 1e-15);
254    }
255
256    #[test]
257    fn tau_floor_at_tau_min() {
258        let m = MonotoneMuUpdate::new();
259        // mu=0.5 → 1 - 0.5 = 0.5; floor at tau_min=0.99 → 0.99.
260        assert!((m.compute_tau(0.5) - 0.99).abs() < 1e-15);
261    }
262
263    #[test]
264    fn clamps_to_mu_min() {
265        let m = MonotoneMuUpdate {
266            mu_min: 1e-3,
267            ..Default::default()
268        };
269        let next = m.compute_next_mu(1e-10);
270        assert!((next - 1e-3).abs() < 1e-15);
271    }
272
273    #[test]
274    fn dynamic_floor_matches_upstream_calcnewmuandtau() {
275        // Replicate `IpMonotoneMuUpdate.cpp:CalcNewMuAndTau:215`:
276        //   floor = max(mu_target, min(tol, compl_inf_tol) / (barrier_tol_factor + 1))
277        // With default `tol=1e-8`, `compl_inf_tol=1e-4`, `barrier_tol_factor=10`,
278        // `mu_target=0`: floor ≈ 1e-8 / 11 ≈ 9.09e-10.
279        let m = MonotoneMuUpdate::default();
280        let tol: Number = 1e-8;
281        let expected_floor = tol.min(m.compl_inf_tol) / (m.barrier_tol_factor + 1.0);
282        assert!((expected_floor - 1e-8 / 11.0).abs() < 1e-20);
283        // The hardcoded `mu_min = 1e-11` is well below the dynamic floor
284        // with default tols — the runtime `floor = max(...)` picks the
285        // dynamic one. (Verified in `update_barrier_parameter`.)
286        assert!(m.mu_min < expected_floor);
287    }
288}