Skip to main content

pounce_algorithm/
handover.rs

1//! Interior-point → active-set handover inside one solve (gh#1054).
2//!
3//! # Why
4//!
5//! On large collocation models the interior-point method gets close fast and
6//! then spends a long tail meeting `tol`: the reporter of gh#1054 measured the
7//! objective within 0.1 % of final after ~40 % of the iterations, the rest
8//! going to a tail where the dual infeasibility oscillates and the filter
9//! backtracks. Near the solution the active set is already identified, and an
10//! SQP step on it converges in a few iterations. Running the two as separate
11//! solves needs a working set between them, and getting one at that size was
12//! the blocker: post-convergence crossover ran after the tail it was meant to
13//! avoid.
14//!
15//! # What it does
16//!
17//! With `ipm_sqp_handover=yes`, the interior-point loop offers each iterate
18//! that has not converged to [`HandoverTrigger::should_try`]. When the
19//! trigger fires, the iterate goes through the same bridge as post-convergence
20//! crossover ([`crate::crossover::run`]): identify the active set from the
21//! multipliers against the slacks, take one QP step over it, and fall back to
22//! the full active-set SQP from that working set. The point that comes back
23//! replaces the interior iterate only if it meets the stopping tolerances,
24//! and the solve then ends `Solve_Succeeded`. Otherwise the interior
25//! iteration continues from exactly where it was: nothing it carries is
26//! touched by a declined attempt, so the fallback the request asked for is
27//! the interior method itself, not a restart.
28//!
29//! # When it fires
30//!
31//! Either test, whichever comes first:
32//!
33//! - **tolerance**: the overall NLP error is at or below
34//!   `ipm_sqp_handover_tol` (a loosened `tol`);
35//! - **stall**: `ipm_sqp_handover_stall_iter > 0` and the best error seen has
36//!   not halved in that many iterations.
37//!
38//! A declined attempt re-arms only after the error has fallen another order
39//! of magnitude below the error at that attempt, or a full stall window has
40//! passed without one, and at most `ipm_sqp_handover_max_attempts` attempts
41//! are made. Without the back-off, a model whose active set is not yet
42//! identifiable would pay an SQP attempt on every remaining iteration.
43//!
44//! # Identification
45//!
46//! At an interior iterate a bound's slack `s` and multiplier `z` satisfy
47//! `s·z ≈ μ`, so an active bound has `s ≈ μ/z` and an inactive one
48//! `z ≈ μ/s`; `√μ` separates the two. The primal tolerance handed to the
49//! classifier is therefore `max(crossover_primal_tol, √μ)`: the crossover
50//! default (`1e-6`) is sized for a converged iterate, and at a loosened
51//! tolerance it would call every active bound inactive. A misclassified row
52//! costs pivots in the QP, not correctness.
53
54use pounce_common::types::{Index, Number};
55
56/// Tuning, read from the `ipm_sqp_handover*` options.
57#[derive(Debug, Clone)]
58pub struct HandoverOptions {
59    /// `ipm_sqp_handover`. Default off.
60    pub enabled: bool,
61    /// `ipm_sqp_handover_tol`: try once the overall NLP error is this small.
62    pub tol: Number,
63    /// `ipm_sqp_handover_stall_iter`: try once the best error has not halved
64    /// in this many iterations. `0` disables the stall test.
65    pub stall_iter: Index,
66    /// `ipm_sqp_handover_max_attempts`.
67    pub max_attempts: u32,
68}
69
70impl Default for HandoverOptions {
71    fn default() -> Self {
72        Self {
73            enabled: false,
74            tol: 1e-4,
75            stall_iter: 0,
76            max_attempts: 3,
77        }
78    }
79}
80
81/// Decides, iteration by iteration, whether to attempt a handover. See the
82/// module docs for the rules.
83#[derive(Debug, Clone)]
84pub struct HandoverTrigger {
85    opts: HandoverOptions,
86    attempts: u32,
87    /// Error and iteration at the last declined attempt.
88    last_attempt: Option<(Number, Index)>,
89    /// Best error seen, and the iteration it was first reached at.
90    best: Option<(Number, Index)>,
91}
92
93impl HandoverTrigger {
94    pub fn new(opts: HandoverOptions) -> Self {
95        Self {
96            opts,
97            attempts: 0,
98            last_attempt: None,
99            best: None,
100        }
101    }
102
103    /// Whether to attempt a handover at iteration `iter` with overall NLP
104    /// error `err`. Counts the attempt when it says yes.
105    pub fn should_try(&mut self, iter: Index, err: Number) -> bool {
106        if !self.opts.enabled || !err.is_finite() || self.attempts >= self.opts.max_attempts {
107            return false;
108        }
109        // "Best" moves only on a halving, so the stall window measures the
110        // time since the last real progress, not since the last new digit.
111        match self.best {
112            Some((b, _)) if err > 0.5 * b => {}
113            _ => self.best = Some((err, iter)),
114        }
115        let stall = self.opts.stall_iter;
116        let stalled = stall > 0 && self.best.is_some_and(|(_, at)| iter - at >= stall);
117        let fire = err <= self.opts.tol || stalled;
118        if !fire {
119            return false;
120        }
121        // Back-off after a declined attempt.
122        if let Some((e0, i0)) = self.last_attempt {
123            let improved = err <= 0.1 * e0;
124            let waited = stall > 0 && iter - i0 >= stall;
125            if !(improved || waited) {
126                return false;
127            }
128        }
129        self.attempts += 1;
130        self.last_attempt = Some((err, iter));
131        // A fresh stall window starts at the attempt.
132        self.best = Some((err, iter));
133        true
134    }
135
136    /// Attempts made so far.
137    pub fn attempts(&self) -> u32 {
138        self.attempts
139    }
140}
141
142/// What the handover did on the most recent solve. Retrieved from
143/// [`crate::application::IpoptApplication::handover_report`].
144#[derive(Debug, Clone, Default)]
145pub struct HandoverReport {
146    /// Attempts made.
147    pub attempts: u32,
148    /// Interior-point iteration at which an attempt was accepted, if one was.
149    pub accepted_at_iter: Option<Index>,
150    /// The crossover report of the last attempt (the accepted one, if any).
151    pub last: Option<crate::crossover::CrossoverReport>,
152}
153
154/// The primal tolerance for classifying the active set at an interior
155/// iterate whose barrier parameter is `mu`. See the module docs.
156pub fn identification_primal_tol(crossover_primal_tol: Number, mu: Number) -> Number {
157    let sep = if mu.is_finite() && mu > 0.0 {
158        mu.sqrt()
159    } else {
160        0.0
161    };
162    crossover_primal_tol.max(sep)
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    fn trig(tol: Number, stall: Index, max: u32) -> HandoverTrigger {
170        HandoverTrigger::new(HandoverOptions {
171            enabled: true,
172            tol,
173            stall_iter: stall,
174            max_attempts: max,
175        })
176    }
177
178    #[test]
179    fn off_never_fires() {
180        let mut t = HandoverTrigger::new(HandoverOptions::default());
181        assert!(!t.should_try(10, 0.0));
182    }
183
184    #[test]
185    fn tolerance_fires_once_reached() {
186        let mut t = trig(1e-4, 0, 3);
187        assert!(!t.should_try(1, 1.0));
188        assert!(!t.should_try(2, 1e-3));
189        assert!(t.should_try(3, 5e-5));
190        assert_eq!(t.attempts(), 1);
191    }
192
193    #[test]
194    fn a_declined_attempt_backs_off_until_an_order_of_magnitude() {
195        let mut t = trig(1e-4, 0, 3);
196        assert!(t.should_try(3, 5e-5));
197        assert!(!t.should_try(4, 3e-5), "not yet 10x below the last attempt");
198        assert!(!t.should_try(5, 6e-6));
199        assert!(t.should_try(6, 4e-6));
200        assert_eq!(t.attempts(), 2);
201    }
202
203    #[test]
204    fn stall_fires_without_reaching_the_tolerance() {
205        let mut t = trig(1e-8, 5, 3);
206        assert!(!t.should_try(0, 1e-2));
207        // Oscillating around 1e-2, never halving: the gh#1054 tail.
208        for (k, e) in [(1, 1.2e-2), (2, 0.9e-2), (3, 1.1e-2), (4, 0.8e-2)] {
209            assert!(!t.should_try(k, e));
210        }
211        assert!(t.should_try(5, 1.0e-2));
212        // A fresh window: no second attempt for another 5 iterations.
213        assert!(!t.should_try(8, 1.0e-2));
214        assert!(t.should_try(10, 1.0e-2));
215    }
216
217    #[test]
218    fn progress_resets_the_stall_window() {
219        let mut t = trig(1e-8, 3, 3);
220        assert!(!t.should_try(0, 1.0));
221        assert!(!t.should_try(2, 0.4)); // halved: window restarts at 2
222        assert!(!t.should_try(4, 0.35));
223        assert!(t.should_try(5, 0.35));
224    }
225
226    #[test]
227    fn attempts_are_capped() {
228        let mut t = trig(1.0, 1, 2);
229        assert!(t.should_try(0, 0.5));
230        assert!(t.should_try(1, 0.5));
231        assert!(!t.should_try(2, 0.5));
232        assert!(!t.should_try(3, 1e-9));
233    }
234
235    #[test]
236    fn identification_tolerance_follows_the_barrier_width() {
237        assert_eq!(identification_primal_tol(1e-6, 1e-4), 1e-2);
238        assert_eq!(identification_primal_tol(1e-6, 1e-14), 1e-6);
239        assert_eq!(identification_primal_tol(1e-6, Number::NAN), 1e-6);
240    }
241}