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}