pounce_algorithm/ftb_floor.rs
1//! Centrality-floored fraction-to-the-boundary (pounce gh#1098).
2//!
3//! Ipopt's rule `α = τ·α_bnd`, `τ = max(τ_min, 1 − μ)`, lets the slack or
4//! bound multiplier that blocks a step land at `(1 − τ) ≈ μ` of its value
5//! once `μ` is small. A blocking pair that sat on the central path
6//! (`s·v ≈ μ`) is then knocked to `s·v ≈ μ²`, six orders off it at
7//! `μ = 1.8e-6`, and when the next direction reverses the step the solver
8//! spends 5–10 iterations recentering it. On Mittelmann `qcqp1500-1nc`
9//! that cycle is the whole of the crawl: a walk from vertex to vertex of a
10//! nonconvex polytope at ~15 iterations per net change of active set, for
11//! ~1200 iterations at a fixed `μ` (Ipopt 3.14 crawls identically).
12//!
13//! The remedy is Mehrotra's step-length heuristic (Mehrotra 1992 §5;
14//! Wright, *Primal-Dual Interior-Point Methods*, eq. (10.9)): keep a
15//! `1 − γ` floor on the fraction, but let the step approach the boundary
16//! beyond it only while the *blocking pair's* complementarity product
17//! stays `≥ γ·μ`, so a variable driven to its bound lands near its central
18//! value instead of at `μ` of itself.
19//!
20//! It is applied only to a blocking pair that is currently centered
21//! (`s·v ≥ γ·μ`). The rule presumes a neighbourhood of the central path;
22//! a pair already far below it has no centrality to preserve, and holding
23//! its step at `1 − γ` only slows its way to zero. Measured on Mittelmann
24//! `clnlbeam` (a bang-bang switching point migrating across 39 998
25//! doubly-bounded variables whose blockers sit at `s·v/μ ≈ 1e-3…1e-6`):
26//! the uncentered rule hit `max_iter` where the default converges.
27//!
28//! The floored step is capped at the τ rule's step, so the floor only ever
29//! *shortens* a step. Uncapped, a pair far above the central path early on
30//! (`s·v = 9` at `μ = 0.1`) was allowed a longer step than τ and landed at
31//! `γμ`, less central than upstream leaves it; that flipped
32//! `issue_390_nonlinear_equality_scale` at row scale 1e-8 from an
33//! infeasibility certificate to `Restoration_Failed`.
34//!
35//! **Off by default** (`γ = 0` restores upstream's rule exactly). Even
36//! capped, at `γ = 0.01` it is the only setting measured that solves
37//! Mittelmann `qcqp1500-1c` (148 iterations; main and the default both hit
38//! the CPU limit), but it also turns the `discs` fixture into a false
39//! `Infeasible_Problem_Detected` on the exact leg and changes the verdict
40//! of three purpose-built integration tests (`issue_884`,
41//! `masked_certificate_fuzz` with the certificate veto off,
42//! `line_search_options_wiring`). The measured opt-in for gh#1098 is
43//! `perturb_slack_fact=0.03` alone.
44
45use crate::ipopt_cq::IpoptCqHandle;
46use crate::iterates_vector::IteratesVector;
47use pounce_common::types::{Index, Number};
48use pounce_linalg::Vector;
49
50fn dense(v: &dyn Vector) -> Option<Vec<Number>> {
51 v.as_any()
52 .downcast_ref::<pounce_linalg::dense_vector::DenseVector>()
53 .map(|d| d.expanded_values())
54}
55
56/// Registered default of `ftb_centrality_floor` (γ).
57pub const DEFAULT_FTB_CENTRALITY_FLOOR: Number = 0.0;
58
59/// Registered default of `perturb_slack_fact` (`δ_s = fact · δ_x`). `1` is
60/// upstream Ipopt's tie; `0.03` solves gh#1098 but is opt-in, see the
61/// option's registration and `dev-notes/qcqp1500-crawl-slack-perturbation.md`.
62pub const DEFAULT_PERTURB_SLACK_FACT: Number = 1.0;
63
64/// Registered default of `perturb_slack_max_rows` (`δ_s = fact · min(1,
65/// max_rows / m_d) · δ_x`). On a model with at most this many inequality
66/// rows the rule is upstream's exactly; above it the slack-block
67/// perturbation is scaled down so that `δ_s · m_d` stays at the level a
68/// model with `max_rows` rows would see. `0` disables the cap. See
69/// `PdPerturbationHandler::delta_s_fact_effective` and
70/// `dev-notes/qcqp1500-crawl-slack-perturbation.md`.
71pub const DEFAULT_PERTURB_SLACK_MAX_ROWS: Index = 1000;
72
73/// Largest fraction of the step to the boundary the floor may take.
74/// Mathematically `f < 1` always (it is `1 − γμ/(s·v_new)`), but it rounds
75/// to `1` when the pair's product dwarfs `γμ`, which would put the blocking
76/// variable exactly on its bound.
77const MAX_FRACTION: Number = 1.0 - 1e-12;
78
79/// Returns `Some((alpha_p, alpha_d))` or `None` when a block is not a dense
80/// vector (restoration's compound iterates); the caller then keeps the
81/// ordinary τ rule.
82pub fn floored_alphas(
83 cq: &IpoptCqHandle,
84 delta: &IteratesVector,
85 mu: Number,
86 gamma: Number,
87 tau: Number,
88) -> Option<(Number, Number)> {
89 let cq = cq.borrow();
90 let iv = cq.curr_iv();
91 let nlp = cq.nlp().borrow();
92
93 // Slack blocks (4) with their projected steps and paired multipliers.
94 let s_x_l = cq.curr_slack_x_l();
95 let s_x_u = cq.curr_slack_x_u();
96 let s_s_l = cq.curr_slack_s_l();
97 let s_s_u = cq.curr_slack_s_u();
98 let mut d_x_l = s_x_l.make_new();
99 nlp.px_l()
100 .trans_mult_vector(1.0, &*delta.x, 0.0, &mut *d_x_l);
101 let mut d_x_u = s_x_u.make_new();
102 nlp.px_u()
103 .trans_mult_vector(-1.0, &*delta.x, 0.0, &mut *d_x_u);
104 let mut d_s_l = s_s_l.make_new();
105 nlp.pd_l()
106 .trans_mult_vector(1.0, &*delta.s, 0.0, &mut *d_s_l);
107 let mut d_s_u = s_s_u.make_new();
108 nlp.pd_u()
109 .trans_mult_vector(-1.0, &*delta.s, 0.0, &mut *d_s_u);
110
111 let blocks: [(&dyn Vector, &dyn Vector, &dyn Vector, &dyn Vector); 4] = [
112 (&*s_x_l, &*d_x_l, &*iv.z_l, &*delta.z_l),
113 (&*s_x_u, &*d_x_u, &*iv.z_u, &*delta.z_u),
114 (&*s_s_l, &*d_s_l, &*iv.v_l, &*delta.v_l),
115 (&*s_s_u, &*d_s_u, &*iv.v_u, &*delta.v_u),
116 ];
117 let mut dense_blocks = Vec::with_capacity(4);
118 for (s, ds, v, dv) in blocks {
119 if s.dim() == 0 {
120 continue;
121 }
122 dense_blocks.push((dense(s)?, dense(ds)?, dense(v)?, dense(dv)?));
123 }
124
125 Some(floored_from_blocks(&dense_blocks, mu, gamma, tau))
126}
127
128/// One complementarity block: slack `s`, its step `ds`, multiplier `v`,
129/// its step `dv`.
130pub(crate) type Block = (Vec<Number>, Vec<Number>, Vec<Number>, Vec<Number>);
131
132/// The step-length rule itself, on dense blocks.
133pub(crate) fn floored_from_blocks(
134 dense_blocks: &[Block],
135 mu: Number,
136 gamma: Number,
137 tau: Number,
138) -> (Number, Number) {
139 // Pure step to the boundary (τ = 1) and the blocking pair on each side.
140 let mut ap_bnd = 1.0_f64;
141 let mut ad_bnd = 1.0_f64;
142 let mut ip: Option<(usize, usize)> = None;
143 let mut id: Option<(usize, usize)> = None;
144 for (b, (s, ds, v, dv)) in dense_blocks.iter().enumerate() {
145 for i in 0..s.len() {
146 if ds[i] < 0.0 {
147 let a = -s[i] / ds[i];
148 if a < ap_bnd {
149 ap_bnd = a;
150 ip = Some((b, i));
151 }
152 }
153 if dv[i] < 0.0 {
154 let a = -v[i] / dv[i];
155 if a < ad_bnd {
156 ad_bnd = a;
157 id = Some((b, i));
158 }
159 }
160 }
161 }
162 let floor = 1.0 - gamma;
163 // A pair already below γμ is not on the central path: keep the τ rule.
164 let uncentered = |b: usize, i: usize| dense_blocks[b].0[i] * dense_blocks[b].2[i] < gamma * mu;
165
166 // Primal: the blocking slack may approach its bound only while its
167 // product with the (fully dual-stepped) multiplier stays ≥ γμ.
168 let alpha_p = match ip {
169 None => 1.0,
170 Some((b, i)) if uncentered(b, i) => (tau * ap_bnd).min(1.0),
171 Some((b, i)) => {
172 let (s, ds, v, dv) = &dense_blocks[b];
173 let v_new = v[i] + ad_bnd * dv[i];
174 let f = if v_new > 0.0 {
175 (gamma * mu / v_new - s[i]) / (ap_bnd * ds[i])
176 } else {
177 floor
178 };
179 (floor.max(f).min(tau).min(MAX_FRACTION) * ap_bnd).min(1.0)
180 }
181 };
182 let alpha_d = match id {
183 None => 1.0,
184 Some((b, i)) if uncentered(b, i) => (tau * ad_bnd).min(1.0),
185 Some((b, i)) => {
186 let (s, ds, v, dv) = &dense_blocks[b];
187 let s_new = s[i] + alpha_p * ds[i];
188 let f = if s_new > 0.0 {
189 (gamma * mu / s_new - v[i]) / (ad_bnd * dv[i])
190 } else {
191 floor
192 };
193 (floor.max(f).min(tau).min(MAX_FRACTION) * ad_bnd).min(1.0)
194 }
195 };
196 (alpha_p, alpha_d)
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 fn blk(s: &[f64], ds: &[f64], v: &[f64], dv: &[f64]) -> Block {
204 (s.to_vec(), ds.to_vec(), v.to_vec(), dv.to_vec())
205 }
206
207 const TAU: f64 = 0.99;
208
209 #[test]
210 fn no_blocking_variable_takes_full_step() {
211 let b = [blk(&[1.0], &[1.0], &[1.0], &[1.0])];
212 assert_eq!(floored_from_blocks(&b, 1e-6, 0.01, TAU), (1.0, 1.0));
213 }
214
215 #[test]
216 fn uncentered_blocker_keeps_tau_rule() {
217 // s·v = 1e-12 < γμ = 1e-8: no centrality to preserve.
218 let mu = 1e-6;
219 let b = [blk(&[1e-6], &[-2e-6], &[1e-6], &[-4e-6])];
220 let (ap, ad) = floored_from_blocks(&b, mu, 0.01, TAU);
221 assert!((ap - TAU * 0.5).abs() < 1e-15);
222 assert!((ad - TAU * 0.25).abs() < 1e-15);
223 }
224
225 #[test]
226 fn centered_blocker_stays_at_least_gamma_mu() {
227 // Centered pair s·v = μ, primal blocker only: under τ it would land
228 // at s·v ≈ (1−τ)·μ·(v_new/v); the floor keeps it ≥ γμ.
229 let (mu, gamma) = (1e-6, 0.01);
230 let b = [blk(&[1e-3], &[-2e-3], &[1e-3], &[0.0])];
231 let (ap, ad) = floored_from_blocks(&b, mu, gamma, 1.0 - mu);
232 assert_eq!(ad, 1.0);
233 let (s, ds, v, _) = &b[0];
234 let prod = (s[0] + ap * ds[0]) * v[0];
235 assert!(prod >= gamma * mu * (1.0 - 1e-9), "prod {prod}");
236 // α_bnd = 0.5; the floored step lies in [(1−γ)·α_bnd, α_bnd).
237 assert!(ap >= (1.0 - gamma) * 0.5 && ap < 0.5);
238 // The τ rule at this μ would have crushed it to ~μ².
239 let prod_tau = (s[0] + (1.0 - mu) * 0.5 * ds[0]) * v[0];
240 assert!(prod_tau < 1e-11);
241 }
242
243 #[test]
244 fn floor_bounds_the_fraction_from_below() {
245 // Multiplier collapses alongside the slack, so keeping s·v ≥ γμ
246 // would need a tiny step; the 1−γ floor wins.
247 let (mu, gamma) = (1e-6, 0.01);
248 let b = [blk(&[1e-3], &[-2e-3], &[1e-3], &[-0.999e-3])];
249 let (ap, _) = floored_from_blocks(&b, mu, gamma, TAU);
250 assert!((ap - (1.0 - gamma) * 0.5).abs() < 1e-12, "ap {ap}");
251 }
252
253 #[test]
254 fn fraction_never_rounds_onto_the_bound() {
255 // s·v ≫ γμ makes f round to 1 in floating point.
256 let b = [blk(&[1.0], &[-2.0], &[1e6], &[0.0])];
257 let (ap, _) = floored_from_blocks(&b, 1e-12, 0.01, TAU);
258 assert!(ap < 0.5);
259 assert!(1.0 + ap * -2.0 > 0.0);
260 }
261
262 #[test]
263 fn never_steps_further_than_the_tau_rule() {
264 // gh#1098 / issue_390: a pair far above the central path (s·v = 9,
265 // μ = 0.1). Mechanically "keep s·v ≥ γμ" would allow a longer step
266 // than τ and land the multiplier at γμ/s, i.e. less central than the
267 // τ rule leaves it. The floor only ever shortens.
268 let b = [blk(&[9.0], &[0.0], &[1.0], &[-1.09])];
269 let (_, ad) = floored_from_blocks(&b, 0.1, 0.01, TAU);
270 assert!((ad - TAU / 1.09).abs() < 1e-15, "ad {ad}");
271 }
272}