Skip to main content

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}