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}