pounce_algorithm/mpcc.rs
1//! Policy for solving a model with declared complementarity pairs: which
2//! lowerings of [`pounce_nlp::mpcc::MpccTnlp`] to try, in what order, and
3//! what counts as an answer.
4//!
5//! Workstream B of `dev-notes/research/calipso-contact-plan.md`. Like
6//! [`crate::second_opinion`], this module holds the policy and runs no
7//! solves; the driver that runs them is
8//! `pounce_restoration::mpcc_driver`, so every entry point shares one
9//! implementation.
10//!
11//! ## Why a ladder and not one route
12//!
13//! Measured on `benchmarks/calipso/` (seven complementarity models, POUNCE
14//! and Ipopt), no single lowering wins: the exact product is fast and
15//! solves most models from most starts but fails where a pair is biactive
16//! at the answer; the Scholtes continuation with full warm starts and an
17//! exact finish was the only route physical on 5/5 starts on bunny-hop and
18//! the drifting car, at several times the iterations; the ℓ₁ penalty was the
19//! only route that solved the quadruped on every seed. So the driver tries
20//! them in [`STAGES`] order: the continuation and the exact product both
21//! run and the better certified answer wins ([`compared`]); the penalty
22//! runs only if neither answers.
23//!
24//! ## Why the continuation goes first
25//!
26//! Measured on the Gate 0 corpus (`benchmarks/mpcc/declared.py`, 60 feasible
27//! cells: every case, both scaling legs, every start). Exact first: 60
28//! solved, 0 below `f*`, but only **48** at `f*` — the exact product
29//! converges fast (median 19 iterations) to S-stationary local minima that
30//! are not the optimum (`regular_strict` 4 for 1, the selectors 0.75 for
31//! 0.25). Continuation first: 60 solved, **60** at `f*`, 0 below, median 60
32//! iterations — against 57 at `f*` for `scholtes_then_ncp`, the route Gate 0
33//! chose as supported. The relaxation at `τ = 1` sees the whole feasible
34//! set before it commits to a branch; the exact product commits from the
35//! start. The cost is about 3× the iterations where the exact product
36//! would have found the optimum anyway. The exact product stays as the
37//! second stage, for models whose relaxation path fails.
38//!
39//! ## What an answer is
40//!
41//! A converged status is not enough: a lowered solve "succeeds" on its own
42//! reformulation, and the reformulation's tolerance is not the model's. A
43//! stage is accepted only if it converged, every product `G·H` is within
44//! `constr_viol_tol` — the bar the model's own rows are held to — **and**
45//! [`certify`] finds the point stationary ([`answers`]). The last test is
46//! not redundant: through the Python callbacks, qpec_small's exact-product
47//! stage exits "acceptable" with every product at 0 at a point 9e-4 short of
48//! the answer, which is not stationary (residual 1.7e-3 against 2e-6), and
49//! the continuation reaches the answer.
50
51use faer::Mat;
52use pounce_common::types::{Index, Number};
53use pounce_nlp::mpcc::ComplementarityPair;
54use pounce_nlp::return_codes::ApplicationReturnStatus;
55
56/// The stages, in the order the driver tries them.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum MpccStage {
59 /// Two solves from the model's own start, each a candidate: a relaxed
60 /// path (`G·H <= `[`EXACT_STAGE_TAU`], one warm step to the
61 /// continuation's last `τ`, then its exact finish), and the exact product
62 /// `G·H = 0` solved cold (`G·H <= 0` where the equality would leave the
63 /// model with more equalities than variables).
64 Exact,
65 /// `G·H <= τ` for τ down [`SCHOLTES_SCHEDULE`], each stage warm-started
66 /// from the last, then one `G·H = 0` solve from the end of it
67 /// (`G·H <= 0` where the equality would leave the model with more
68 /// equalities than variables).
69 Continuation,
70 /// `G·H = 0` through the ℓ₁ exact-penalty barrier.
71 Penalty,
72 /// A branch NLP: each pair with one side held at zero, the other kept
73 /// non-negative — run from a reported point that is not B-stationary,
74 /// along the branch its B-stationarity check found a descent on
75 /// ([`check_b_stationarity`]). Not in [`STAGES`].
76 Branch,
77 /// The polish: the reported point re-solved on the branch that holds
78 /// each pair's smaller side at zero, when its pairs are complementary
79 /// only to more than [`POLISH_TRIGGER`]` · tol` ([`natural_residual`]).
80 /// A branch NLP like [`Self::Branch`], but a refinement of the answer
81 /// rather than a move off it, so it is recorded apart. Not in [`STAGES`].
82 Polish,
83}
84
85impl MpccStage {
86 pub fn label(self) -> &'static str {
87 match self {
88 Self::Exact => "exact product",
89 Self::Continuation => "Scholtes continuation",
90 Self::Penalty => "l1 penalty",
91 Self::Branch => "branch NLP",
92 Self::Polish => "branch polish",
93 }
94 }
95}
96
97pub const STAGES: [MpccStage; 3] = [
98 MpccStage::Continuation,
99 MpccStage::Exact,
100 MpccStage::Penalty,
101];
102
103/// The relaxation schedule, as in `benchmarks/mpcc/routes.py`: it starts at
104/// 1 so a relaxation whose crossover lies in `[0.25, 1]` is seen, and ends
105/// four orders below the default `constr_viol_tol`.
106pub const SCHOLTES_SCHEDULE: [Number; 9] = [1e0, 1e-1, 1e-2, 1e-3, 1e-4, 1e-5, 1e-6, 1e-7, 1e-8];
107
108/// The options a warm-started continuation stage sets, as
109/// `pounce.WarmStart.options()` does: the seeds are honoured, the barrier
110/// starts where the last stage stopped instead of re-walking from 0.1, and
111/// the pushes are tightened so a point at a bound stays there. Each is an
112/// upstream Ipopt option. The driver restores every one of them to the
113/// caller's set-ness afterwards.
114pub fn warm_start_assignments(final_mu: Number) -> Vec<String> {
115 let mu = if final_mu.is_finite() && final_mu > 0.0 {
116 final_mu.clamp(1e-9, 1e-1)
117 } else {
118 1e-6
119 };
120 let p = 1e-9;
121 vec![
122 "warm_start_init_point yes\n".to_string(),
123 format!("mu_init {mu:e}\n"),
124 format!("warm_start_bound_push {p:e}\n"),
125 format!("warm_start_bound_frac {p:e}\n"),
126 format!("warm_start_slack_bound_push {p:e}\n"),
127 format!("warm_start_slack_bound_frac {p:e}\n"),
128 format!("warm_start_mult_bound_push {p:e}\n"),
129 ]
130}
131
132/// The exact stage's relaxation: one solve of `G·H <= EXACT_STAGE_TAU` from
133/// the model's own start, then an exact finish. Measured against the cold
134/// exact product `G·H = 0` on the 28 MacMPEC models where the exact stage
135/// costs most or matters most: 1879 iterations against 25712, 26 converged
136/// against 20, 23 at or better than the published value against 19. `1e-8`
137/// does worse (3311 iterations, 20 at): a thinner interior, back towards the
138/// degenerate system.
139pub const EXACT_STAGE_TAU: Number = 1e-6;
140
141/// The exact-product stage's iteration budget once the continuation has
142/// already answered with a tight S point: [`EXACT_BUDGET_FACTOR`] times the
143/// iterations the continuation spent, never below [`EXACT_BUDGET_FLOOR`].
144///
145/// With an answer in hand, the exact stage is a second opinion: it can only
146/// change the report by converging to a strictly lower S point (a different
147/// branch, `eq_split`'s global optimum or MacMPEC's `water-net`). Run in
148/// full it was 55% of every iteration POUNCE spent on MacMPEC, most of it
149/// grinding to `max_iter` on models the continuation had already solved
150/// (`pack-comp1-32`: 3000 of 3327). Every exact-stage answer that changed a
151/// MacMPEC outcome took at most 2.5 times the continuation's iterations
152/// (`water-net`, 470 against 189), so the factor leaves it room.
153pub const EXACT_BUDGET_FACTOR: usize = 3;
154/// See [`EXACT_BUDGET_FACTOR`].
155pub const EXACT_BUDGET_FLOOR: usize = 200;
156/// The continuation's finish budget: the exact-product solve warm-started at
157/// the end of the schedule. Never below this, nor below the iterations the
158/// schedule itself spent. Every finish that converged to `Solve_Succeeded`
159/// on MacMPEC took at most 411 iterations; the ones that ran past 500 all
160/// crawled to `Solved_To_Acceptable_Level` (up to 3188) at a point the
161/// schedule's own answer already satisfies to `τ = 1e-8`, which the driver
162/// falls back to when the finish does not converge.
163pub const FINISH_BUDGET_FLOOR: usize = 500;
164
165/// The exact-product stage's iteration budget: `None` (run in full) unless
166/// the continuation has already answered with a tight S point, in which case
167/// [`EXACT_BUDGET_FACTOR`] times the `continuation_iterations` it spent, at
168/// least [`EXACT_BUDGET_FLOOR`].
169pub fn exact_stage_budget(answered: bool, continuation_iterations: usize) -> Option<usize> {
170 answered.then(|| {
171 EXACT_BUDGET_FACTOR
172 .saturating_mul(continuation_iterations)
173 .max(EXACT_BUDGET_FLOOR)
174 })
175}
176
177/// The continuation finish's iteration budget: what the schedule spent, at
178/// least [`FINISH_BUDGET_FLOOR`].
179pub fn finish_budget(schedule_iterations: usize) -> usize {
180 schedule_iterations.max(FINISH_BUDGET_FLOOR)
181}
182
183/// Whether the exact stage's cold `G·H = 0` solve is skipped, given the
184/// continuation's certified answer and the relaxed path's answer: when the two
185/// agree ([`answers_agree`]), or when both are exact-product points (each
186/// route's finish converged). Two exact answers in hand make a third route to
187/// the same exact problem a third opinion; it still runs when either answer is
188/// only a relaxed point (a continuation whose finish failed keeps its
189/// `τ = 1e-8` point; CALIPSO bunny-hop seed 1 is where the cold solve then
190/// wins, 462.605 against 463.195).
191pub fn skips_cold_solve(agree: bool, continuation_exact: bool, path_exact: bool) -> bool {
192 agree || (continuation_exact && path_exact)
193}
194
195/// How far, relative to `max(|f_a|, |f_b|)`, two minimized objectives may
196/// differ and still be "the same answer" in the driver's two tests below, on
197/// top of what the relaxations' slack accounts for ([`same_answer_allowance`]).
198/// Far below the gaps between genuinely different branches (MacMPEC
199/// `water-net`: 953 against 964, 1.1%).
200pub const SAME_ANSWER_REL_TOL: Number = 1e-4;
201
202/// The multiple of a relaxed point's first-order slack value
203/// ([`relaxation_slack`]) that [`same_answer_allowance`] grants. `Σ λ_k·p_k`
204/// is the objective the products' slack buys to first order; where a pair is
205/// biactive the objective moves as `sqrt(τ)`, not `τ`, and the first-order
206/// estimate is then half the true gap (MacMPEC `scholtes4`: the `τ = 1e-8`
207/// point sits at `-2e-4 = -2·sqrt(τ)` against the exact `f* = 0`, with
208/// `λ = 1/sqrt(τ)`, so `λ·τ = 1e-4`). Ten leaves a margin of five over that.
209pub const CORNER_SLACK_FACTOR: Number = 10.0;
210
211/// The objective a relaxed point's products buy, to first order: `Σ |λ_k|·
212/// max(p_k, 0)` over the product rows, `λ_k` the row's multiplier and `p_k`
213/// its product `G_k·H_k`. Relaxing row `k` from `0` to `p_k` lowers the
214/// minimized objective by `λ_k·p_k` to first order, so this is how much worse
215/// an exact finish that removes the slack may look without having moved.
216///
217/// Unlike `sqrt(max p)·max(1, |f|)`, which this replaced (gh#1128), it scales
218/// with the objective: multiplying `f` by `c` multiplies every `λ_k` by `c`,
219/// so a verdict built on it does not change with the objective's units.
220/// `+inf` if a multiplier or product is not finite; `0` with no product rows.
221pub fn relaxation_slack(products: &[Number], product_lambda: &[Number]) -> Number {
222 products
223 .iter()
224 .zip(product_lambda)
225 .map(|(&p, &l)| {
226 if p.is_finite() && l.is_finite() {
227 l.abs() * p.max(0.0)
228 } else {
229 Number::INFINITY
230 }
231 })
232 .sum()
233}
234
235/// The objective error a converged point carries from stopping at a
236/// tolerance rather than at the optimum: its complementarity gap in the
237/// model's own units,
238///
239/// ```text
240/// Σ_i |z_l,i|·|x_i - x_l,i| + |z_u,i|·|x_u,i - x_i| + Σ_j |λ_j|·d_j
241/// ```
242///
243/// over the finite bounds, `d_j` the distance from `g_j` to its nearest
244/// finite bound (`0` for a free row). To first order a point is above the
245/// optimum of the branch it sits on by at most this much (weak duality on
246/// the local model), so two points on one branch differ by at most the sum
247/// of their gaps however close to zero their objectives are. Multipliers
248/// scale with the objective and distances do not, so the gap does too.
249///
250/// gh#1128 follow-up: with only the relative and slack terms, two exact
251/// points at `f* = 0` agreed only if both read exactly `0`. On
252/// `min a·x + (y - 1)^2, 0 <= x ⟂ y >= 0` the continuation stopped at
253/// `1e-11` (`z_l·x` with `z_l = a = 0.01`) and the relaxed path at `5e-20`,
254/// so the cold exact solve ran on a model whose two cheap answers were one
255/// point. `+inf` if any input is not finite or the lengths disagree, which
256/// the tests that use it read as "cannot tell".
257#[allow(clippy::too_many_arguments)]
258pub fn complementarity_gap(
259 x: &[Number],
260 z_l: &[Number],
261 z_u: &[Number],
262 x_l: &[Number],
263 x_u: &[Number],
264 g: &[Number],
265 lambda: &[Number],
266 g_l: &[Number],
267 g_u: &[Number],
268) -> Number {
269 let n = x.len();
270 let m = g.len();
271 if [z_l.len(), z_u.len(), x_l.len(), x_u.len()] != [n; 4]
272 || [lambda.len(), g_l.len(), g_u.len()] != [m; 3]
273 {
274 return Number::INFINITY;
275 }
276 // `mult·dist` over the finite bounds of one entry; `+inf` on a
277 // non-finite value or multiplier.
278 let term = |mult: Number, v: Number, lo: Number, hi: Number| -> Number {
279 if !mult.is_finite() || !v.is_finite() {
280 return Number::INFINITY;
281 }
282 let d = [lo, hi]
283 .into_iter()
284 .filter(|b| b.is_finite())
285 .map(|b| (v - b).abs())
286 .fold(Number::INFINITY, Number::min);
287 if d.is_finite() { mult.abs() * d } else { 0.0 }
288 };
289 let bounds: Number = (0..n)
290 .map(|i| {
291 let lo = term(z_l[i], x[i], x_l[i], Number::INFINITY);
292 let hi = term(z_u[i], x[i], Number::NEG_INFINITY, x_u[i]);
293 lo + hi
294 })
295 .sum();
296 let rows: Number = (0..m).map(|j| term(lambda[j], g[j], g_l[j], g_u[j])).sum();
297 bounds + rows
298}
299
300/// How far two minimized objectives may differ and still be one answer:
301/// `SAME_ANSWER_REL_TOL·max(|f_a|, |f_b|) + CORNER_SLACK_FACTOR·slack + gap`,
302/// `slack` the [`relaxation_slack`] of the points compared (their sum when
303/// both are relaxed) and `gap` the sum of their [`complementarity_gap`]s.
304/// Every term scales with the objective, so neither test that uses it
305/// changes its verdict when `f` is multiplied by a constant (gh#1128: an
306/// absolute `1e-4` floor near zero, and an allowance proportional to
307/// `max(1, |f|)` rather than to the multipliers, both did). The gap is the
308/// floor near `f = 0`: what the two solves' own tolerance leaves.
309pub fn same_answer_allowance(f_a: Number, f_b: Number, slack: Number, gap: Number) -> Number {
310 SAME_ANSWER_REL_TOL * f_a.abs().max(f_b.abs())
311 + CORNER_SLACK_FACTOR * slack.max(0.0)
312 + gap.max(0.0)
313}
314
315/// Did an exact finish drift off the relaxed answer it started from? True
316/// when the finish's minimized objective is worse than the relaxed point's by
317/// more than [`same_answer_allowance`], with `relaxed_slack` the relaxed
318/// point's [`relaxation_slack`] and no [`complementarity_gap`] term. A finish
319/// solves the degenerate exact product and can wander: MacMPEC
320/// `pack-comp1p-32`'s continuation reaches 0.65304 at `τ = 1e-8` (products
321/// 5e-9) and its finish then crawls through restoration to 0.783, which used
322/// to replace it; the relaxed point is kept instead. On `scholtes4` the
323/// finish's `3e-11` is the exact answer and the relaxed `-2e-4` is slack
324/// (`λ·τ = 1e-4`, allowance `1e-3`), and the finish is kept -- at any
325/// scaling of the objective. A slack that is not finite keeps the finish:
326/// there is nothing to measure the drift against.
327///
328/// The gap is left out on measurement. A finish that wandered is typically
329/// poorly converged, and its gap is then large enough to excuse the wander:
330/// with the two points' summed gaps in the allowance, MacMPEC
331/// `incid-set2c-8` (finish 7.77e-3 against 5.47e-3 relaxed),
332/// `pack-comp2p-16`, `qpec-100-3` and `incid-set2c-32` all kept their
333/// drifted finishes and fell from `at` to `worse`. Both objectives are values
334/// at real points, so a finish that reads higher is worse however loosely it
335/// converged.
336pub fn finish_drifted(f_finish: Number, f_relaxed: Number, relaxed_slack: Number) -> bool {
337 if !f_relaxed.is_finite() || !relaxed_slack.is_finite() {
338 return false;
339 }
340 !f_finish.is_finite()
341 || f_finish > f_relaxed + same_answer_allowance(f_relaxed, f_relaxed, relaxed_slack, 0.0)
342}
343
344/// Do two answers agree, within [`same_answer_allowance`]? `slack` is the
345/// sum of the two points' [`relaxation_slack`]s and `gap` of their
346/// [`complementarity_gap`]s. The exact stage's cold
347/// `G·H = 0` solve is skipped when the continuation's answer and the exact
348/// stage's relaxed path agree: two different routes landing on one point is
349/// evidence the cheap paths found the branch, and the cold solve --
350/// degenerate, often `max_iter`-bound, the largest single cost on MacMPEC --
351/// exists for the models where they do not (`water-net`, `dempe`). A slack
352/// or gap that is not finite never agrees, so the cold solve runs.
353pub fn answers_agree(f_a: Number, f_b: Number, slack: Number, gap: Number) -> bool {
354 f_a.is_finite()
355 && f_b.is_finite()
356 && slack.is_finite()
357 && gap.is_finite()
358 && (f_a - f_b).abs() <= same_answer_allowance(f_a, f_b, slack, gap)
359}
360
361/// The budget of a continuation rung retried after its warm-started solve
362/// failed: twice what the previous rung took, at least this.
363pub const RUNG_RETRY_FLOOR: usize = 200;
364
365/// The budget of a warm-started continuation rung, as a multiple of what the
366/// previous rung took ([`warm_rung_budget`]).
367pub const WARM_RUNG_FACTOR: usize = 10;
368
369/// The least budget of a warm-started continuation rung.
370pub const WARM_RUNG_FLOOR: usize = 500;
371
372/// The budget of a warm-started continuation rung: [`WARM_RUNG_FACTOR`]
373/// times what the previous rung took, at least [`WARM_RUNG_FLOOR`].
374///
375/// A warm rung starts at the previous rung's point with the barrier where it
376/// ended, and tightening `τ` leaves that point infeasible by up to the step
377/// in `τ`. Where the barrier is already small the iterate is pinned against
378/// its bounds and can crawl: on MPCCModels.jl's `ieee300_reserve_q98` the
379/// `τ = 1e-4` rung, started at `μ = 3e-8` and `9e-4` infeasible, took
380/// steps of `1e-4` with 14 backtracks each for all 3000 of `max_iter`
381/// (16 s of the run's 20), after rungs of 25 to 41 iterations; the cold
382/// re-solve that followed took 124. Unbudgeted, a crawl costs `max_iter`
383/// before anything else is tried. Budgeted, it stops early and the same
384/// rescue runs: the budget is a `max_iter` only, leaving
385/// `mu_strategy_fallback` live (a finish's budget switches it off), and that
386/// fallback is the cold re-solve. On q98 the run goes from 20 s to 6.7 s, in
387/// the same 13 lowered solves and to the same objective.
388pub fn warm_rung_budget(previous_rung_iterations: usize) -> usize {
389 (WARM_RUNG_FACTOR * previous_rung_iterations).max(WARM_RUNG_FLOOR)
390}
391
392/// The `max_iter` a budgeted solve runs under: `budget`, but never above the
393/// caller's own `max_iter`.
394pub fn budgeted_max_iter(budget: usize, caller_max_iter: Index) -> Index {
395 let b = Index::try_from(budget).unwrap_or(Index::MAX);
396 b.min(caller_max_iter.max(0))
397}
398
399/// The `max_iter` a warm-started continuation rung runs under, or `None` to
400/// leave the caller's.
401///
402/// [`warm_rung_budget`] is safe only because a rung that reaches it is
403/// re-solved cold by `mu_strategy_fallback`. Where that retry will not run
404/// (`fallback_live` false: the caller switched it off, or named a
405/// `mu_strategy`), the budget would be a hard stop that cuts a slow but
406/// converging rung and abandons the schedule, so there is none.
407///
408/// `previous_point_iterations` is what the attempt that produced the previous
409/// rung's point took, not everything its call spent: after a capped rung is
410/// rescued (500 + 124 on `ieee300_reserve_q98`), ten times the total clamps
411/// to the caller's `max_iter` and leaves the next rung unbudgeted.
412pub fn warm_rung_max_iter(
413 fallback_live: bool,
414 previous_point_iterations: usize,
415 caller_max_iter: Index,
416) -> Option<Index> {
417 fallback_live
418 .then(|| budgeted_max_iter(warm_rung_budget(previous_point_iterations), caller_max_iter))
419}
420
421/// Every option any stage writes; the driver snapshots these before the
422/// first stage and restores them after the last.
423pub const TOUCHED_OPTIONS: [&str; 13] = [
424 "max_iter",
425 "mu_strategy_fallback",
426 "warm_start_init_point",
427 "mu_init",
428 "warm_start_bound_push",
429 "warm_start_bound_frac",
430 "warm_start_slack_bound_push",
431 "warm_start_slack_bound_frac",
432 "warm_start_mult_bound_push",
433 "l1_exact_penalty_barrier",
434 "bound_relax_factor",
435 "honor_original_bounds",
436 "perturb_slack_max_rows",
437];
438
439/// Defaults every stage runs under unless the caller set the option
440/// themselves. `bound_relax_factor`'s default of `1e-8` relaxes every bound
441/// and row before the solve, which on a complementarity pair means accepting
442/// `G >= -1e-8`, and at a cone tip or a pair's corner turns into a violation
443/// of its square root: on `benchmarks/calipso/` every reported success that
444/// violated the model was a run under that default. `honor_original_bounds`
445/// moves the answer back inside the bounds the model stated.
446///
447/// `perturb_slack_max_rows` (gh#1098's row budget on the slack-block
448/// perturbation, default 1000) is off: a lowered model crosses the budget
449/// through its own product rows (MacMPEC's 961-pair models have 1954
450/// inequality rows), which are near-active as `τ` shrinks, not the inactive
451/// rows the budget is for. Measured on the 167 MacMPEC stubs with the rest of
452/// this driver unchanged: off moves `pack-comp2p-32` from `worse` back to
453/// its best known value and changes no other classification, at +0.9% wall
454/// time (2,528 s to 2,551 s); individual models trade time both ways
455/// (`incid-set2-32` 118 to 31 s, `pack-comp1c-32` 29 to 96 s).
456pub const SAFE_DEFAULTS: [(&str, &str); 3] = [
457 ("bound_relax_factor", "0"),
458 ("honor_original_bounds", "yes"),
459 ("perturb_slack_max_rows", "0"),
460];
461
462/// Which point the driver reported, and on what grounds.
463#[derive(Debug, Clone, Copy, PartialEq, Eq)]
464pub enum Reported {
465 /// A stage answered: converged, products in tolerance, certified S (or
466 /// not determined).
467 Answer(MpccStage),
468 /// No stage certified S; this stage's W-stationary point is reported.
469 WeakFallback(MpccStage),
470 /// No stage certified any stationarity; this stage's converged point with
471 /// every product in tolerance is reported.
472 FeasibleUncertified(MpccStage),
473 /// No stage produced a feasible point; the exact-product stage's own
474 /// verdict (or, if it was refused with no point, the first stage that
475 /// has one) is reported.
476 Verdict,
477}
478
479/// One line for the console: what answered, and what the answer is.
480pub fn describe_outcome(
481 reported: Reported,
482 n_solves: usize,
483 total_iterations: usize,
484 max_abs_product: Number,
485 certificate: Option<&Certificate>,
486) -> String {
487 let solves = format!(
488 "{n_solves} lowered solve{}, {total_iterations} iterations in all",
489 if n_solves == 1 { "" } else { "s" }
490 );
491 let head = match reported {
492 Reported::Answer(stage) => format!(
493 "pounce: complementarity: answered by the {} formulation ({solves}, violation = \
494 {max_abs_product:.1e})",
495 stage.label(),
496 ),
497 Reported::WeakFallback(stage) => format!(
498 "pounce: complementarity: no formulation produced a strongly stationary answer \
499 ({solves}); reporting the {} formulation's weakly stationary point (violation = \
500 {max_abs_product:.1e})",
501 stage.label(),
502 ),
503 Reported::FeasibleUncertified(stage) => format!(
504 "pounce: complementarity: no formulation produced a stationary answer ({solves}); \
505 reporting the {} formulation's feasible point (violation = {max_abs_product:.1e})",
506 stage.label(),
507 ),
508 Reported::Verdict => format!(
509 "pounce: complementarity: no formulation produced an answer ({solves}); reporting \
510 the exact-product solve's verdict"
511 ),
512 };
513 let tail = match certificate {
514 None => "; stationarity not evaluated".to_string(),
515 Some(c) => {
516 let class = match c.class {
517 StationarityClass::S => "S (strongly stationary)",
518 StationarityClass::W => {
519 "W only (a biactive pair has a wrong-sign multiplier: a branch may still descend)"
520 }
521 StationarityClass::None if !c.feasible => {
522 return format!(
523 "{head}; {} pair{}, {} biactive; stationarity: none (the point violates a \
524 row or bound by {:.1e}, beyond constr_viol_tol).",
525 c.n_pairs,
526 if c.n_pairs == 1 { "" } else { "s" },
527 c.n_biactive,
528 c.primal_violation
529 );
530 }
531 StationarityClass::None => "none (not a stationary point of the model)",
532 StationarityClass::NotSearched => {
533 "not determined (the model or its multiplier set is too large to certify)"
534 }
535 };
536 format!(
537 "; {} pair{}, {} biactive; stationarity: {class}.",
538 c.n_pairs,
539 if c.n_pairs == 1 { "" } else { "s" },
540 c.n_biactive
541 )
542 }
543 };
544 head + &tail
545}
546
547/// Which formulations a solve runs, from `complementarity_strategy`. The
548/// penalty runs under every strategy, as the fallback when nothing answers.
549#[derive(Debug, Clone, Copy, PartialEq, Eq)]
550pub enum Strategy {
551 Both,
552 Exact,
553 Continuation,
554}
555
556impl Strategy {
557 /// The option's value; `None` for anything else (the registry rejects
558 /// other values before a solve starts).
559 pub fn parse(s: &str) -> Option<Strategy> {
560 match s {
561 "both" => Some(Strategy::Both),
562 "exact" => Some(Strategy::Exact),
563 "continuation" => Some(Strategy::Continuation),
564 _ => None,
565 }
566 }
567
568 /// The option's value for this strategy; the inverse of [`Self::parse`].
569 pub fn name(self) -> &'static str {
570 match self {
571 Strategy::Both => "both",
572 Strategy::Exact => "exact",
573 Strategy::Continuation => "continuation",
574 }
575 }
576
577 pub fn runs(self, stage: MpccStage) -> bool {
578 !matches!(
579 (self, stage),
580 (Strategy::Exact, MpccStage::Continuation) | (Strategy::Continuation, MpccStage::Exact)
581 )
582 }
583}
584
585/// Statuses a stage can converge with.
586pub fn converged(status: ApplicationReturnStatus) -> bool {
587 matches!(
588 status,
589 ApplicationReturnStatus::SolveSucceeded
590 | ApplicationReturnStatus::SolvedToAcceptableLevel
591 | ApplicationReturnStatus::FeasiblePointFound
592 )
593}
594
595/// Are a stage's S answers compared with the other compared stage's, the
596/// lower objective winning, rather than ending the ladder? The continuation
597/// and the exact product: on a nonconvex model each reaches local minima the
598/// other avoids, and neither order wins on both corpora we have. Gate 0
599/// (60 feasible cells) at `f*`: continuation first 60, exact first 48.
600/// CALIPSO bunny-hop (5 seeds) at the best known objective: exact first 5,
601/// continuation first 2 — the continuation settles on S-stationary
602/// trajectories at 1111–1169 against 462.6. Running both and keeping the
603/// lower objective gets every one of those, for the iterations of both.
604/// The penalty runs only when neither answers.
605pub fn compared(stage: MpccStage) -> bool {
606 matches!(stage, MpccStage::Continuation | MpccStage::Exact)
607}
608
609/// Does a certified class make a converged, physical point the answer, ending
610/// the ladder? S, the goal; and `NotSearched`, which claims nothing either way
611/// and only arises on models large enough that more stages are costly. A W
612/// point is not: it admits a descent direction along some branch, and a later
613/// stage can reach an S point below it ([`kept_if_nothing_better`]). A point
614/// that is not stationary at all is a stall, and the driver moves on.
615pub fn answers(class: StationarityClass) -> bool {
616 matches!(class, StationarityClass::S | StationarityClass::NotSearched)
617}
618
619/// Is a converged, physical point that does not [`answers`] still worth
620/// reporting if no later stage certifies? W is: it is the honest answer
621/// where no S point exists nearby (ralph1's origin). Measured on the CALIPSO
622/// corpus's `complementarity` model, stopping at the first W point returned
623/// objective 85 where the continuation reaches the S optimum at 17.
624pub fn kept_if_nothing_better(class: StationarityClass) -> bool {
625 class == StationarityClass::W
626}
627
628/// Is a stage's answer an answer to the *model*, not just to the lowering?
629pub fn accepts(
630 status: ApplicationReturnStatus,
631 max_abs_product: Number,
632 constr_viol_tol: Number,
633) -> bool {
634 converged(status) && max_abs_product.is_finite() && max_abs_product <= constr_viol_tol
635}
636
637// ---------------------------------------------------------------------------
638// The certificate: which stationarity class the reported point satisfies.
639// ---------------------------------------------------------------------------
640
641/// The stationarity class a point was shown to satisfy; see [`certify`].
642#[derive(Debug, Clone, Copy, PartialEq, Eq)]
643pub enum StationarityClass {
644 /// Strong stationarity: a KKT point of the tightened NLP, B-stationary
645 /// under MPCC-LICQ. The strongest first-order statement there is.
646 S,
647 /// Weak stationarity only: multipliers reproduce `∇f`, but every
648 /// admissible choice puts a negative sign on some biactive pair, so the
649 /// point admits a first-order descent direction along a branch.
650 W,
651 /// No multipliers reproduce `∇f` to the tolerance: not a stationary
652 /// point of the model (or a pair is not complementary at all).
653 None,
654 /// Not determined; nothing is claimed. The multipliers are not unique
655 /// and the set is too large to search: more than [`MAX_DENSE_ENTRIES`]
656 /// entries in the system, or more than [`MAX_SEARCHED_COLUMNS`]
657 /// sign-constrained columns. Also reported if the sparse factorization
658 /// fails outright.
659 NotSearched,
660}
661
662/// What [`certify`] measured. Everything the verdict rests on is here.
663#[derive(Debug, Clone)]
664pub struct Certificate {
665 pub class: StationarityClass,
666 /// The declared model's multipliers that decided the class, when they
667 /// are unique (full-rank system) and reproduce `∇f` within
668 /// `resid_tol`: the least-squares fit on the active set at the point,
669 /// in Ipopt's convention (`∇f + Jᵀλ - z_L + z_U = 0`). A pair's bound
670 /// multiplier sits, signed, on the bound in the pair; a row in a pair
671 /// carries its multiplier whatever its sign. `None` otherwise.
672 pub multipliers: Option<ModelMultipliers>,
673 pub n_pairs: usize,
674 pub n_biactive: usize,
675 /// Pairs with neither side at zero — complementarity does not hold.
676 pub n_neither: usize,
677 /// Columns of the stationarity system are linearly dependent, so the
678 /// multipliers form a set rather than a point (MPCC-LICQ fails).
679 pub multipliers_unique: bool,
680 /// `‖A θ − ∇f‖∞` at the multipliers that decided the class.
681 pub residual: Number,
682 /// The band `residual` is judged against: `max(1e-6, tol)` plus the
683 /// `sqrt(tol)` sign band, both times the dual scale (see [`certify`]).
684 pub resid_tol: Number,
685 /// The residual is within the floor alone,
686 /// `max(1e-6, tol)` times the dual scale. An `S` point that is not
687 /// `tight` reproduces `∇f` only to the `sqrt(tol)` band: the driver keeps
688 /// it and reports it only if no formulation produces a tight one. With a
689 /// multiplier set, the test is on the sign-free fit, as the old gate
690 /// into the set search was; the search itself then takes the band.
691 pub tight: bool,
692 /// Largest violation of a row or variable bound at the point (pair
693 /// sides included), in the model's units.
694 pub primal_violation: Number,
695 /// `primal_violation` is within [`CertificateInput::feas_tol`]. When it
696 /// is not, the class is `None` whatever the multipliers say.
697 pub feasible: bool,
698 /// Largest-magnitude negative sign on a biactive pair, when the class
699 /// is decided by a sign (`W` with unique multipliers).
700 pub worst_biactive_sign: Number,
701}
702
703/// The point and model data [`certify`] reads. All indices 0-based, the
704/// Jacobian as `(row, col, value)` triplets of the *inner* model's rows.
705/// A model's multipliers at a point: one per row and per variable bound.
706#[derive(Debug, Clone, PartialEq)]
707pub struct ModelMultipliers {
708 pub lambda: Vec<Number>,
709 pub z_l: Vec<Number>,
710 pub z_u: Vec<Number>,
711}
712
713/// Which multiplier a stationarity column stands for (see [`certify`]):
714/// the column is `s ∇g_i` or `s e_j`, and `Aθ = ∇f`.
715#[derive(Debug, Clone, Copy)]
716enum Slot {
717 /// `λ_i = -s θ`.
718 Row(usize, Number),
719 /// `s > 0`: `z_L[j] = θ`; `s < 0`: `z_U[j] = θ`. Signed: a bound held
720 /// by its pair, whose multiplier is sign-free.
721 Bound(usize, Number),
722 /// A fixed variable (both bounds active, one column): `θ` split onto
723 /// `z_L` (positive part) and `z_U` (negative part).
724 Fixed(usize),
725}
726
727fn multipliers_from(slots: &[Slot], theta: &[Number], n: usize, m: usize) -> ModelMultipliers {
728 let mut mm = ModelMultipliers {
729 lambda: vec![0.0; m],
730 z_l: vec![0.0; n],
731 z_u: vec![0.0; n],
732 };
733 for (slot, &t) in slots.iter().zip(theta) {
734 match *slot {
735 Slot::Row(i, s) => mm.lambda[i] += -s * t,
736 Slot::Bound(j, s) if s > 0.0 => mm.z_l[j] += t,
737 Slot::Bound(j, _) => mm.z_u[j] += t,
738 Slot::Fixed(j) => {
739 mm.z_l[j] += t.max(0.0);
740 mm.z_u[j] += (-t).max(0.0);
741 }
742 }
743 }
744 mm
745}
746
747pub struct CertificateInput<'a> {
748 pub x: &'a [Number],
749 pub grad_f: &'a [Number],
750 pub g: &'a [Number],
751 pub jac: &'a [(usize, usize, Number)],
752 pub x_l: &'a [Number],
753 pub x_u: &'a [Number],
754 pub g_l: &'a [Number],
755 pub g_u: &'a [Number],
756 pub pairs: &'a [ComplementarityPair],
757 /// The tolerance the answer was solved to (`tol`).
758 pub solver_tol: Number,
759 /// The feasibility tolerance (`constr_viol_tol`): a point violating a
760 /// row or bound by more is not certified, stationary or not.
761 pub feas_tol: Number,
762}
763
764/// Above this many sign-constrained columns, a rank-deficient system is not
765/// searched (each NNLS step is an SVD of the passive columns).
766pub const MAX_SEARCHED_COLUMNS: usize = 600;
767
768/// Above this many entries in the `n × k` stationarity matrix, a system whose
769/// multipliers are **not unique** is not searched and reports
770/// [`StationarityClass::NotSearched`] (which [`answers`] accepts, so the
771/// ladder is unchanged). The set search factors the matrix densely; at
772/// `n ≈ 10⁴` with `k ≈ n + m` that is about 1 GB and 10¹² flops per call, a
773/// size contact-implicit trajectory models reach. 4·10⁶ entries (32 MB)
774/// keeps the call well under a second. A system with unique multipliers —
775/// the common case, MPCC-LICQ — is certified at any size, sparsely (see
776/// [`DENSE_ENTRIES`]).
777pub const MAX_DENSE_ENTRIES: usize = 4_000_000;
778
779/// Up to this many entries the stationarity system is factored densely (a
780/// thin SVD, rank by LAPACK's threshold); above it, by the interior-point
781/// method's own sparse LDLᵀ of the least-squares augmented system
782/// ([`sparse_least_squares`]). The dense SVD's cost grows as `n·k²`: on a
783/// 650-pair chain model (`n = 1950`) the solve took 4.8 s with the dense
784/// certificate and 0.51 s with the sparse one, and at 1100 pairs 31 s
785/// against 0.93 s, same class and answer. Below 2.5·10⁵ entries the SVD costs little and keeps the path
786/// the certificate was validated on against `stationarity.py` (the Gate 0
787/// corpus, bunny-hop, ball-in-cup); the CALIPSO rocket (`n = 856`) is past
788/// it and certifies S on the sparse path exactly as it did densely. The
789/// unit tests hold the two paths to the same class on every point they
790/// check.
791pub const DENSE_ENTRIES: usize = 250_000;
792
793const INF: Number = 1e19;
794
795#[derive(Clone, Copy, PartialEq)]
796enum Sign {
797 Free,
798 NonNeg,
799 /// Non-negative under S, free under W.
800 Biactive,
801}
802
803/// Which stationarity class `x` satisfies, as `benchmarks/calipso/
804/// stationarity.py` decides it — same columns, same thresholds — so the two
805/// can be held to each other on the same points.
806///
807/// The stationarity system is
808/// `∇f = Σ λ ∇g + Σ ν ∇G + Σ w ∇H + z_L − z_U` with: equality rows free;
809/// active inequality rows and bounds sign-constrained (inactive ones absent);
810/// a pair side free when only it is active, absent when inactive, and on a
811/// biactive pair non-negative under S and free under W.
812///
813/// * Activity: a pair side counts as zero within `sqrt(solver_tol)` times
814/// the family's largest magnitude (a product driven to `tol` pins each
815/// side only to its square root); rows and bounds within
816/// `sqrt(solver_tol)`.
817/// * Residual: judged against `max(1e-6, solver_tol)` times the dual scale
818/// — the largest entry of `∇f` and of every usable column.
819/// * Signs: a multiplier counts as negative only below `-sqrt(solver_tol)`
820/// times the dual scale. An answer near a biactive pair is itself only
821/// `sqrt(tol)`-accurate, so smaller signs are noise, not descent. This is
822/// the one place this departs from `stationarity.py`, whose BVLS has no
823/// band; on the points it was run on (exact or `tol=1e-8` answers away
824/// from biactive pairs) the two agree.
825///
826/// When the columns have full rank the multipliers are unique and the
827/// class is a sign check. When they do not (MPCC-LICQ fails) the class is a
828/// property of the multiplier *set* — reading one vector can report W for a
829/// point that is S — so the set is searched with NNLS, up to
830/// [`MAX_SEARCHED_COLUMNS`] and [`MAX_DENSE_ENTRIES`]; a larger set reports
831/// `NotSearched`. Above [`DENSE_ENTRIES`] the unique multipliers come from a
832/// sparse factorization rather than the SVD.
833pub fn certify(inp: &CertificateInput<'_>) -> Certificate {
834 certify_routed(inp, None)
835}
836
837/// Each pair's orientation, values and activity at a point, as both the
838/// certificate and the B-stationarity check read them.
839struct PairActivity {
840 mixed: Vec<bool>,
841 /// `(s_G, b)` and `(s_H, c)`: `G = s_G (x_j - b)`, `H = s_H (g_i - c)`.
842 g_side: Vec<(Number, Number)>,
843 h_side: Vec<(Number, Number)>,
844 hv: Vec<Number>,
845 g_act: Vec<bool>,
846 h_act: Vec<bool>,
847}
848
849fn pair_activity(inp: &CertificateInput<'_>, corner: Number) -> PairActivity {
850 // Pair sides and their family scales.
851 // Each side's orientation from its one finite bound, as MpccTnlp reads
852 // it: (+1, lower) or (-1, upper).
853 let side = |lo: Number, hi: Number| -> (Number, Number) {
854 if lo > -INF { (1.0, lo) } else { (-1.0, hi) }
855 };
856 // A mixed pair (`l <= x <= u ⟂ g`, the row free) is, at a given point,
857 // the one-sided pair of the bound `x` is nearer: `x - l ⟂ g` at `l`,
858 // `u - x ⟂ -g` at `u` (strictly between, `G` is inactive either way and
859 // `g = 0` is the condition).
860 let mixed: Vec<bool> = inp
861 .pairs
862 .iter()
863 .map(|p| {
864 pounce_nlp::mpcc::is_mixed(
865 inp.x_l[p.var],
866 inp.x_u[p.var],
867 inp.g_l[p.row],
868 inp.g_u[p.row],
869 )
870 })
871 .collect();
872 let g_side: Vec<(Number, Number)> = inp
873 .pairs
874 .iter()
875 .zip(&mixed)
876 .map(|(p, &mx)| {
877 let (lo, hi) = (inp.x_l[p.var], inp.x_u[p.var]);
878 if mx {
879 if inp.x[p.var] - lo <= hi - inp.x[p.var] {
880 (1.0, lo)
881 } else {
882 (-1.0, hi)
883 }
884 } else if lo > -INF && hi < INF {
885 // A boxed variable in a one-sided pair: the row's finite side
886 // names the bound in the pair, as MpccTnlp reads it; the
887 // other bound is an ordinary limit (columns below).
888 if inp.g_l[p.row] > -INF {
889 (1.0, lo)
890 } else {
891 (-1.0, hi)
892 }
893 } else {
894 side(lo, hi)
895 }
896 })
897 .collect();
898 let h_side: Vec<(Number, Number)> = inp
899 .pairs
900 .iter()
901 .zip(&mixed)
902 .zip(&g_side)
903 .map(|((p, &mx), &(sg, _))| {
904 if mx {
905 (sg, 0.0)
906 } else {
907 side(inp.g_l[p.row], inp.g_u[p.row])
908 }
909 })
910 .collect();
911 let gv: Vec<Number> = inp
912 .pairs
913 .iter()
914 .zip(&g_side)
915 .map(|(p, &(s, b))| s * (inp.x[p.var] - b))
916 .collect();
917 let hv: Vec<Number> = inp
918 .pairs
919 .iter()
920 .zip(&h_side)
921 .map(|(p, &(s, c))| s * (inp.g[p.row] - c))
922 .collect();
923 let gs = gv.iter().fold(1.0_f64, |a, v| a.max(v.abs()));
924 let hs = hv.iter().fold(1.0_f64, |a, v| a.max(v.abs()));
925 let g_act: Vec<bool> = gv.iter().map(|v| v.abs() <= corner * gs).collect();
926 let h_act: Vec<bool> = hv.iter().map(|v| v.abs() <= corner * hs).collect();
927 PairActivity {
928 mixed,
929 g_side,
930 h_side,
931 hv,
932 g_act,
933 h_act,
934 }
935}
936
937/// [`certify`], with the dense/sparse choice forced when `force_sparse` is
938/// set — for holding the two paths to each other in tests.
939fn certify_routed(inp: &CertificateInput<'_>, force_sparse: Option<bool>) -> Certificate {
940 let n = inp.x.len();
941 let m = inp.g.len();
942 let corner = inp.solver_tol.max(0.0).sqrt();
943 let row_tol = corner;
944
945 let PairActivity {
946 mixed,
947 g_side,
948 h_side,
949 hv,
950 g_act,
951 h_act,
952 } = pair_activity(inp, corner);
953 let n_biactive = g_act.iter().zip(&h_act).filter(|(a, b)| **a && **b).count();
954 let n_neither = g_act
955 .iter()
956 .zip(&h_act)
957 .filter(|(a, b)| !**a && !**b)
958 .count();
959
960 // Primal feasibility, in the model's units: every row and every bound,
961 // pair sides included. A point outside its own constraints is not a
962 // stationary point of the model, whatever multipliers reproduce `∇f`
963 // (an infeasibility verdict's point did read S before this check).
964 let viol = |v: Number, lo: Number, hi: Number| {
965 let below = if lo > -INF { lo - v } else { 0.0 };
966 let above = if hi < INF { v - hi } else { 0.0 };
967 below.max(above).max(0.0)
968 };
969 // A mixed pair's row is free, so no row bound carries the sign of `g` at
970 // an active bound (`g >= 0` at `l`, `g <= 0` at `u`): measure it here.
971 let mixed_sign = (0..inp.pairs.len())
972 .filter(|&k| mixed[k] && g_act[k])
973 .map(|k| (-hv[k]).max(0.0));
974 let primal_violation = (0..m)
975 .map(|i| viol(inp.g[i], inp.g_l[i], inp.g_u[i]))
976 .chain((0..n).map(|j| viol(inp.x[j], inp.x_l[j], inp.x_u[j])))
977 .chain(mixed_sign)
978 .fold(0.0, Number::max);
979 let feasible = primal_violation <= inp.feas_tol;
980
981 let mut pair_row = vec![None; m];
982 let mut pair_var = vec![None; n];
983 for (k, p) in inp.pairs.iter().enumerate() {
984 pair_row[p.row] = Some(k);
985 pair_var[p.var] = Some(k);
986 }
987 let mut rows_of: Vec<Vec<(usize, Number)>> = vec![Vec::new(); m];
988 for &(r, c, v) in inp.jac {
989 rows_of[r].push((c, v));
990 }
991
992 // Columns: (sparse entries, sign class).
993 let mut cols: Vec<(Vec<(usize, Number)>, Sign)> = Vec::new();
994 // Per column, the multiplier it stands for; pushed with it.
995 let mut slots: Vec<Slot> = Vec::new();
996 for i in 0..m {
997 if let Some(k) = pair_row[i] {
998 let sh = h_side[k].0;
999 let grad_h: Vec<(usize, Number)> =
1000 rows_of[i].iter().map(|&(c, v)| (c, sh * v)).collect();
1001 match (g_act[k], h_act[k]) {
1002 (_, false) => {}
1003 (true, true) => {
1004 cols.push((grad_h, Sign::Biactive));
1005 slots.push(Slot::Row(i, sh));
1006 }
1007 (false, true) => {
1008 cols.push((grad_h, Sign::Free));
1009 slots.push(Slot::Row(i, sh));
1010 }
1011 }
1012 continue;
1013 }
1014 let (lo, hi) = (inp.g_l[i], inp.g_u[i]);
1015 let at_lo = lo > -INF && inp.g[i] - lo <= row_tol;
1016 let at_hi = hi < INF && hi - inp.g[i] <= row_tol;
1017 let neg: Vec<(usize, Number)> = rows_of[i].iter().map(|&(c, v)| (c, -v)).collect();
1018 match (at_lo, at_hi) {
1019 (true, true) => {
1020 cols.push((rows_of[i].clone(), Sign::Free));
1021 slots.push(Slot::Row(i, 1.0));
1022 }
1023 (true, false) => {
1024 cols.push((rows_of[i].clone(), Sign::NonNeg));
1025 slots.push(Slot::Row(i, 1.0));
1026 }
1027 (false, true) => {
1028 cols.push((neg, Sign::NonNeg));
1029 slots.push(Slot::Row(i, -1.0));
1030 }
1031 (false, false) => {}
1032 }
1033 }
1034 for (j, pv) in pair_var.iter().enumerate() {
1035 if let Some(k) = *pv {
1036 let sg = g_side[k].0;
1037 match (g_act[k], h_act[k]) {
1038 (false, _) => {}
1039 (true, true) => {
1040 cols.push((vec![(j, sg)], Sign::Biactive));
1041 slots.push(Slot::Bound(j, sg));
1042 }
1043 (true, false) => {
1044 cols.push((vec![(j, sg)], Sign::Free));
1045 slots.push(Slot::Bound(j, sg));
1046 }
1047 }
1048 // A one-sided pair variable's other bound, when it has one, is an
1049 // ordinary limit: a sign-constrained column when active. (A mixed
1050 // pair's other bound is the other branch, handled above.)
1051 if !mixed[k] {
1052 let (lo, hi) = (inp.x_l[j], inp.x_u[j]);
1053 let other_active = if sg > 0.0 {
1054 hi < INF && hi - inp.x[j] <= row_tol
1055 } else {
1056 lo > -INF && inp.x[j] - lo <= row_tol
1057 };
1058 if other_active {
1059 cols.push((vec![(j, -sg)], Sign::NonNeg));
1060 slots.push(Slot::Bound(j, -sg));
1061 }
1062 }
1063 continue;
1064 }
1065 let (lo, hi) = (inp.x_l[j], inp.x_u[j]);
1066 let at_lo = lo > -INF && inp.x[j] - lo <= row_tol;
1067 let at_hi = hi < INF && hi - inp.x[j] <= row_tol;
1068 match (at_lo, at_hi) {
1069 (true, true) => {
1070 cols.push((vec![(j, 1.0)], Sign::Free));
1071 slots.push(Slot::Fixed(j));
1072 }
1073 (true, false) => {
1074 cols.push((vec![(j, 1.0)], Sign::NonNeg));
1075 slots.push(Slot::Bound(j, 1.0));
1076 }
1077 (false, true) => {
1078 cols.push((vec![(j, -1.0)], Sign::NonNeg));
1079 slots.push(Slot::Bound(j, -1.0));
1080 }
1081 (false, false) => {}
1082 }
1083 }
1084 debug_assert_eq!(slots.len(), cols.len());
1085
1086 let b = inp.grad_f.to_vec();
1087 let k = cols.len();
1088 let col_max = cols
1089 .iter()
1090 .flat_map(|(ents, _)| ents)
1091 .fold(0.0_f64, |m, &(_, v)| m.max(v.abs()));
1092 let dual_scale = 1.0_f64
1093 .max(b.iter().fold(0.0_f64, |m, v| m.max(v.abs())))
1094 .max(col_max);
1095 let resid_tol = inp.solver_tol.max(1e-6) * dual_scale;
1096 // The noise band on a multiplier's sign. An answer near a biactive pair
1097 // is accurate only to `sqrt(tol)` (that is where the activity threshold
1098 // comes from), so `∇f` there — and every multiplier reproducing it —
1099 // carries an error of that order. qpec_small's converged point, accepted
1100 // at products <= 1e-4, has a biactive multiplier of -1.6e-4 while the
1101 // exact point's is 0. A sign inside the band is not evidence of descent.
1102 let sign_tol = corner * dual_scale;
1103 // One acceptance band on every path: the residual's own floor plus the
1104 // sign band. The point is `sqrt(tol)`-accurate near the boundary, so
1105 // `∇f` is reproduced only to that order; the set search always allowed
1106 // it, and the unique path holding the same point to `resid_tol` alone
1107 // rejected optimal IPM answers (MacMPEC `flp4-3`, `flp4-4`,
1108 // `portfl-i-1`: residuals 1.7e-6 to 2.1e-5 at f within 0.2% of f*).
1109 let accept_tol = resid_tol + sign_tol;
1110 let signs: Vec<Sign> = cols.iter().map(|(_, s)| *s).collect();
1111
1112 let mut cert = Certificate {
1113 class: StationarityClass::None,
1114 n_pairs: inp.pairs.len(),
1115 n_biactive,
1116 n_neither,
1117 multipliers_unique: true,
1118 residual: Number::INFINITY,
1119 resid_tol: accept_tol,
1120 tight: false,
1121 primal_violation,
1122 feasible,
1123 worst_biactive_sign: 0.0,
1124 multipliers: None,
1125 };
1126 if n_neither > 0 || !feasible {
1127 // Complementarity, or feasibility itself, fails at this point: no
1128 // class applies.
1129 return cert;
1130 }
1131
1132 // Unique multipliers: the class is a sign check on them.
1133 let classify_unique = |cert: &mut Certificate, theta: &[Number]| {
1134 let ordinary_ok = signs
1135 .iter()
1136 .zip(theta)
1137 .all(|(s, t)| *s != Sign::NonNeg || *t >= -sign_tol);
1138 if !ordinary_ok {
1139 return;
1140 }
1141 let worst = signs
1142 .iter()
1143 .zip(theta)
1144 .filter(|(s, _)| **s == Sign::Biactive)
1145 .fold(0.0_f64, |w, (_, t)| w.min(*t));
1146 cert.worst_biactive_sign = worst;
1147 cert.class = if worst >= -sign_tol {
1148 StationarityClass::S
1149 } else {
1150 StationarityClass::W
1151 };
1152 };
1153
1154 let sparse = force_sparse.unwrap_or(n.saturating_mul(k) > DENSE_ENTRIES);
1155 if sparse {
1156 let entries: Vec<Vec<(usize, Number)>> = cols.iter().map(|(e, _)| e.clone()).collect();
1157 match sparse_least_squares(n, &entries, &b) {
1158 LeastSquares::Solved(theta) => {
1159 let mut r = b.clone();
1160 for (ents, t) in entries.iter().zip(&theta) {
1161 for &(row, v) in ents {
1162 r[row] -= v * t;
1163 }
1164 }
1165 cert.residual = r.iter().fold(0.0, |m: Number, v| m.max(v.abs()));
1166 cert.tight = cert.residual <= resid_tol;
1167 if cert.residual <= accept_tol {
1168 classify_unique(&mut cert, &theta);
1169 }
1170 if cert.tight {
1171 cert.multipliers = Some(multipliers_from(&slots, &theta, n, m));
1172 }
1173 return cert;
1174 }
1175 LeastSquares::RankDeficient if n.saturating_mul(k) <= MAX_DENSE_ENTRIES => {
1176 // The set search below is dense; it fits.
1177 }
1178 LeastSquares::RankDeficient | LeastSquares::Failed => {
1179 cert.multipliers_unique = false;
1180 cert.residual = Number::NAN;
1181 cert.class = StationarityClass::NotSearched;
1182 cert.tight = true; // nothing measured looser: accepted as before
1183 return cert;
1184 }
1185 }
1186 }
1187
1188 let mut a = Mat::<Number>::from_fn(n, k, |_, _| 0.0);
1189 for (c, (ents, _)) in cols.iter().enumerate() {
1190 for &(r, v) in ents {
1191 a[(r, c)] += v;
1192 }
1193 }
1194
1195 // Sign-free least squares: the W test's lower bound on the residual,
1196 // and the unique multipliers when the columns have full rank.
1197 let (theta, rank) = lstsq(&a, &b);
1198 let r_free = resid_inf(&a, &theta, &b);
1199 cert.residual = r_free;
1200 cert.tight = r_free <= resid_tol;
1201 if r_free > accept_tol {
1202 return cert; // not stationary for any multipliers, signs aside
1203 }
1204 if rank == k {
1205 classify_unique(&mut cert, &theta);
1206 if cert.tight {
1207 cert.multipliers = Some(multipliers_from(&slots, &theta, n, m));
1208 }
1209 return cert;
1210 }
1211
1212 // Non-unique multipliers: search the set.
1213 cert.multipliers_unique = false;
1214 let constrained = signs.iter().filter(|s| **s != Sign::Free).count();
1215 if constrained > MAX_SEARCHED_COLUMNS {
1216 cert.class = StationarityClass::NotSearched;
1217 cert.tight = true; // nothing measured looser: accepted as before
1218 return cert;
1219 }
1220 for (class, biactive_signed) in [(StationarityClass::S, true), (StationarityClass::W, false)] {
1221 let nonneg: Vec<bool> = signs
1222 .iter()
1223 .map(|s| *s == Sign::NonNeg || (biactive_signed && *s == Sign::Biactive))
1224 .collect();
1225 // Forcing a multiplier inside the noise band to zero moves the
1226 // residual by up to that band; allow it.
1227 let r = signed_lstsq_residual(&a, &b, &nonneg);
1228 if r <= accept_tol {
1229 cert.class = class;
1230 cert.residual = r;
1231 // Tight as the sign-free fit was: before the band was unified, a
1232 // point reached this search only with `r_free <= resid_tol`, and
1233 // then took the whole band (see
1234 // `the_set_search_band_is_resid_tol_plus_the_sign_band`). Those
1235 // points stay answers; one the old gate turned away is kept.
1236 cert.tight = r_free <= resid_tol;
1237 return cert;
1238 }
1239 }
1240 cert
1241}
1242
1243/// What [`sparse_least_squares`] found.
1244#[derive(Debug, Clone, PartialEq)]
1245enum LeastSquares {
1246 /// The unique minimizer `θ`.
1247 Solved(Vec<Number>),
1248 /// The columns are (numerically) dependent: the minimizers form a set.
1249 RankDeficient,
1250 /// Malformed input, or the factorization or solve failed.
1251 Failed,
1252}
1253
1254/// `min ‖A θ − b‖₂` for a sparse `A` (`n × k`, columns as `(row, value)`
1255/// lists, repeated rows summed), through the least-squares augmented system
1256///
1257/// ```text
1258/// [ s·I A ] [ r ] [ b ]
1259/// [ Aᵀ 0 ] [ θ ] = [ 0 ] (s·r = b − A θ, Aᵀ r = 0)
1260/// ```
1261///
1262/// factored by the same symmetric indefinite solver the interior-point
1263/// method uses on its own KKT systems (FERAL, through
1264/// [`SparseSymLinearSolverInterface`]), with `s = max(1, max|A|)`. The
1265/// system has exactly `n` positive and `k` negative eigenvalues when `A`
1266/// has full column rank, so the factorization's inertia is the rank test:
1267/// a zero pivot or any other negative count is [`LeastSquares::RankDeficient`],
1268/// and no answer is returned rather than a perturbed one.
1269fn sparse_least_squares(n: usize, cols: &[Vec<(usize, Number)>], b: &[Number]) -> LeastSquares {
1270 use pounce_linsol::{ESymSolverStatus, SparseSymLinearSolverInterface};
1271
1272 let k = cols.len();
1273 if b.len() != n || cols.iter().flatten().any(|&(r, _)| r >= n) {
1274 return LeastSquares::Failed;
1275 }
1276 if k == 0 {
1277 return LeastSquares::Solved(Vec::new());
1278 }
1279 if k > n {
1280 return LeastSquares::RankDeficient;
1281 }
1282 let a_max = cols
1283 .iter()
1284 .flatten()
1285 .fold(0.0_f64, |m, &(_, v)| m.max(v.abs()));
1286 let s = a_max.max(1.0);
1287 // Lower triangle, 1-based (FERAL's triplet convention): the identity
1288 // block on the diagonal, Aᵀ below it in rows n+1..n+k.
1289 let mut trip: Vec<(Index, Index, Number)> = (1..=n as Index).map(|i| (i, i, s)).collect();
1290 for (j, c) in cols.iter().enumerate() {
1291 let mut c = c.clone();
1292 c.sort_by_key(|&(r, _)| r);
1293 for (r, v) in c {
1294 let (row, col) = ((n + j + 1) as Index, (r + 1) as Index);
1295 match trip.last_mut() {
1296 Some(last) if last.0 == row && last.1 == col => last.2 += v,
1297 _ => trip.push((row, col, v)),
1298 }
1299 }
1300 }
1301 let irn: Vec<Index> = trip.iter().map(|t| t.0).collect();
1302 let jcn: Vec<Index> = trip.iter().map(|t| t.1).collect();
1303 let mut solver = pounce_feral::FeralSolverInterface::new();
1304 let dim = (n + k) as Index;
1305 if solver.initialize_structure(dim, irn.len() as Index, &irn, &jcn) != ESymSolverStatus::Success
1306 {
1307 return LeastSquares::Failed;
1308 }
1309 for (slot, t) in solver.values_array_mut().iter_mut().zip(&trip) {
1310 *slot = t.2;
1311 }
1312 let mut rhs0 = vec![0.0; n + k];
1313 rhs0[..n].copy_from_slice(b);
1314 let mut z = rhs0.clone();
1315 match solver.multi_solve(true, &irn, &jcn, 1, &mut z, true, k as Index) {
1316 ESymSolverStatus::Success => {}
1317 ESymSolverStatus::Singular | ESymSolverStatus::WrongInertia => {
1318 return LeastSquares::RankDeficient;
1319 }
1320 _ => return LeastSquares::Failed,
1321 }
1322 // Iterative refinement on the same factor: the multipliers' signs are
1323 // what the class is read from, and the augmented system is as
1324 // ill-conditioned as A's columns are close to dependent.
1325 for _ in 0..REFINEMENT_STEPS {
1326 let mut corr = rhs0.clone();
1327 for &(r, c, v) in &trip {
1328 let (r, c) = ((r - 1) as usize, (c - 1) as usize);
1329 corr[r] -= v * z[c];
1330 if r != c {
1331 corr[c] -= v * z[r];
1332 }
1333 }
1334 if solver.multi_solve(false, &irn, &jcn, 1, &mut corr, false, 0)
1335 != ESymSolverStatus::Success
1336 {
1337 return LeastSquares::Failed;
1338 }
1339 z.iter_mut().zip(&corr).for_each(|(zi, ci)| *zi += ci);
1340 }
1341 if z.iter().all(|v| v.is_finite()) {
1342 LeastSquares::Solved(z[n..].to_vec())
1343 } else {
1344 LeastSquares::Failed
1345 }
1346}
1347
1348const REFINEMENT_STEPS: usize = 2;
1349
1350/// Minimum-norm least squares by thin SVD; returns the solution and the
1351/// numerical rank (LAPACK's threshold).
1352fn lstsq(a: &Mat<Number>, b: &[Number]) -> (Vec<Number>, usize) {
1353 let (n, k) = (a.nrows(), a.ncols());
1354 if k == 0 {
1355 return (Vec::new(), 0);
1356 }
1357 let Ok(svd) = a.thin_svd() else {
1358 return (vec![0.0; k], 0);
1359 };
1360 let s: Vec<Number> = svd.S().column_vector().iter().copied().collect();
1361 let smax = s.first().copied().unwrap_or(0.0);
1362 let tol = smax * (n.max(k) as Number) * Number::EPSILON;
1363 let u = svd.U();
1364 let v = svd.V();
1365 let mut theta = vec![0.0; k];
1366 let mut rank = 0;
1367 for (i, &si) in s.iter().enumerate() {
1368 if si <= tol {
1369 continue;
1370 }
1371 rank += 1;
1372 let coef = (0..n).map(|r| u[(r, i)] * b[r]).sum::<Number>() / si;
1373 for (c, t) in theta.iter_mut().enumerate() {
1374 *t += coef * v[(c, i)];
1375 }
1376 }
1377 (theta, rank)
1378}
1379
1380fn resid_inf(a: &Mat<Number>, theta: &[Number], b: &[Number]) -> Number {
1381 (0..a.nrows())
1382 .map(|r| ((0..a.ncols()).map(|c| a[(r, c)] * theta[c]).sum::<Number>() - b[r]).abs())
1383 .fold(0.0, Number::max)
1384}
1385
1386/// `min ‖A θ − b‖₂` with `θ_c >= 0` where `nonneg[c]`, the rest free; returns
1387/// the ∞-norm residual. The free columns are projected out (their least-
1388/// squares part is exact), then Lawson–Hanson NNLS on the rest.
1389fn signed_lstsq_residual(a: &Mat<Number>, b: &[Number], nonneg: &[bool]) -> Number {
1390 let n = a.nrows();
1391 let free: Vec<usize> = (0..a.ncols()).filter(|&c| !nonneg[c]).collect();
1392 let cons: Vec<usize> = (0..a.ncols()).filter(|&c| nonneg[c]).collect();
1393 // Orthonormal basis of range(A_free).
1394 let basis: Vec<Vec<Number>> = if free.is_empty() {
1395 Vec::new()
1396 } else {
1397 let af = Mat::<Number>::from_fn(n, free.len(), |r, c| a[(r, free[c])]);
1398 match af.thin_svd() {
1399 Ok(svd) => {
1400 let s: Vec<Number> = svd.S().column_vector().iter().copied().collect();
1401 let smax = s.first().copied().unwrap_or(0.0);
1402 let tol = smax * (n.max(free.len()) as Number) * Number::EPSILON;
1403 let u = svd.U();
1404 (0..s.len())
1405 .filter(|&i| s[i] > tol)
1406 .map(|i| (0..n).map(|r| u[(r, i)]).collect())
1407 .collect()
1408 }
1409 Err(_) => return Number::INFINITY,
1410 }
1411 };
1412 let project = |v: &mut Vec<Number>| {
1413 for q in &basis {
1414 let d: Number = q.iter().zip(v.iter()).map(|(x, y)| x * y).sum();
1415 for (vi, qi) in v.iter_mut().zip(q) {
1416 *vi -= d * qi;
1417 }
1418 }
1419 };
1420 let mut pb = b.to_vec();
1421 project(&mut pb);
1422 let pa: Vec<Vec<Number>> = cons
1423 .iter()
1424 .map(|&c| {
1425 let mut v: Vec<Number> = (0..n).map(|r| a[(r, c)]).collect();
1426 project(&mut v);
1427 v
1428 })
1429 .collect();
1430 let x = nnls(&pa, &pb, n);
1431 (0..n)
1432 .map(|r| {
1433 (pb[r]
1434 - pa.iter()
1435 .zip(&x)
1436 .map(|(col, xi)| col[r] * xi)
1437 .sum::<Number>())
1438 .abs()
1439 })
1440 .fold(0.0, Number::max)
1441}
1442
1443/// Lawson–Hanson non-negative least squares, columns given explicitly.
1444fn nnls(cols: &[Vec<Number>], b: &[Number], n: usize) -> Vec<Number> {
1445 let k = cols.len();
1446 let mut x = vec![0.0; k];
1447 if k == 0 {
1448 return x;
1449 }
1450 let mut passive = vec![false; k];
1451 let resid = |x: &[Number]| -> Vec<Number> {
1452 (0..n)
1453 .map(|r| b[r] - cols.iter().zip(x).map(|(c, xi)| c[r] * xi).sum::<Number>())
1454 .collect()
1455 };
1456 let grad = |res: &[Number]| -> Vec<Number> {
1457 cols.iter()
1458 .map(|c| c.iter().zip(res).map(|(a, r)| a * r).sum())
1459 .collect()
1460 };
1461 let scale = cols
1462 .iter()
1463 .flatten()
1464 .fold(0.0_f64, |m, v| m.max(v.abs()))
1465 .max(1.0)
1466 * b.iter().fold(0.0_f64, |m, v| m.max(v.abs())).max(1.0);
1467 let wtol = 1e-12 * scale * (n.max(k) as Number);
1468 // A column freed and dropped again in the same step (its least-squares
1469 // coefficient is not positive, so `alpha = 0`) would be picked again
1470 // next step with nothing changed; it sits out until some other column
1471 // enters and moves `x`.
1472 let mut blocked = vec![false; k];
1473 for _outer in 0..3 * k + 10 {
1474 let w = grad(&resid(&x));
1475 let Some((t, wt)) = (0..k)
1476 .filter(|&j| !passive[j] && !blocked[j])
1477 .map(|j| (j, w[j]))
1478 .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
1479 else {
1480 break;
1481 };
1482 if wt <= wtol {
1483 break;
1484 }
1485 passive[t] = true;
1486 for _inner in 0..3 * k + 10 {
1487 let p: Vec<usize> = (0..k).filter(|&j| passive[j]).collect();
1488 let ap = Mat::<Number>::from_fn(n, p.len(), |r, c| cols[p[c]][r]);
1489 let (z, _) = lstsq(&ap, b);
1490 if z.iter().all(|&v| v > 0.0) {
1491 for (i, &j) in p.iter().enumerate() {
1492 x[j] = z[i];
1493 }
1494 break;
1495 }
1496 let mut alpha = 1.0_f64;
1497 for (i, &j) in p.iter().enumerate() {
1498 if z[i] <= 0.0 {
1499 let d = x[j] - z[i];
1500 if d > 0.0 {
1501 alpha = alpha.min(x[j] / d);
1502 }
1503 }
1504 }
1505 for (i, &j) in p.iter().enumerate() {
1506 x[j] += alpha * (z[i] - x[j]);
1507 if x[j] <= 1e-15 * scale {
1508 x[j] = 0.0;
1509 passive[j] = false;
1510 }
1511 }
1512 }
1513 if passive[t] {
1514 blocked.fill(false);
1515 } else {
1516 blocked[t] = true;
1517 }
1518 }
1519 x
1520}
1521
1522// ---------------------------------------------------------------------------
1523// B-stationarity: whether a feasible direction along some branch descends.
1524// ---------------------------------------------------------------------------
1525
1526/// Which side of a pair is held at zero along a branch.
1527#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1528pub enum HeldSide {
1529 G,
1530 H,
1531}
1532
1533/// What [`check_b_stationarity`] found.
1534#[derive(Debug, Clone, PartialEq)]
1535pub enum BVerdict {
1536 /// No branch has a first-order descent direction: B-stationary.
1537 B,
1538 /// The branch `held` (one entry per pair) descends: `∇f·d = decrease`
1539 /// (negative) for a step `d` in the unit box.
1540 NotB {
1541 held: Vec<HeldSide>,
1542 decrease: Number,
1543 },
1544 /// Not decided, with the reason.
1545 NotChecked(&'static str),
1546}
1547
1548/// [`check_b_stationarity`]'s verdict and what it took.
1549#[derive(Debug, Clone, PartialEq)]
1550pub struct BCheck {
1551 pub verdict: BVerdict,
1552 pub n_biactive: usize,
1553 /// Branch LPs solved.
1554 pub branches_solved: usize,
1555 /// Whether every branch was enumerated (`false`: the heuristic above
1556 /// [`MAX_ENUMERATED_BIACTIVE`], which can find a descent but never
1557 /// certify B).
1558 pub enumerated: bool,
1559}
1560
1561/// Up to this many biactive pairs every one of the `2^b` branches is
1562/// checked; above it, one heuristic branch.
1563pub const MAX_ENUMERATED_BIACTIVE: usize = 10;
1564
1565/// Whether `x` is B-stationary: no direction `d` feasible for the linearized
1566/// constraints along any branch of the biactive pairs has `∇f·d < 0`.
1567///
1568/// Each branch is an LP: `min ∇f·d` over the linearized active rows and
1569/// bounds, with each pair's active side held (`∇G·d = 0` or `∇H·d = 0`) and,
1570/// on a biactive pair, the side the branch releases kept non-negative, in the
1571/// box `-1 <= d <= 1`. `d = 0` is always feasible, so every LP has optimum
1572/// `<= 0`; the point is B-stationary exactly when all of them are `0`. Up to
1573/// [`MAX_ENUMERATED_BIACTIVE`] biactive pairs all `2^b` branches are solved
1574/// (the LPEC by enumeration, no MILP); above it, the relaxed LP — both sides
1575/// of every biactive pair only non-negative — picks one branch (hold the side
1576/// its direction moves less), which is solved.
1577///
1578/// A decrease counts only below `-sqrt(solver_tol)·max(1, ‖∇f‖∞)`, the
1579/// certificate's sign band: near a biactive pair the point itself is only
1580/// `sqrt(tol)`-accurate. S implies B, so a caller checks only points the
1581/// certificate did not call S. The point must be feasible and every pair
1582/// complementary.
1583pub fn check_b_stationarity(inp: &CertificateInput<'_>) -> BCheck {
1584 let corner = inp.solver_tol.max(0.0).sqrt();
1585 let act = pair_activity(inp, corner);
1586 let np = inp.pairs.len();
1587 let biactive: Vec<usize> = (0..np).filter(|&k| act.g_act[k] && act.h_act[k]).collect();
1588 let mut out = BCheck {
1589 verdict: BVerdict::NotChecked(""),
1590 n_biactive: biactive.len(),
1591 branches_solved: 0,
1592 enumerated: false,
1593 };
1594 if (0..np).any(|k| !act.g_act[k] && !act.h_act[k]) {
1595 out.verdict = BVerdict::NotChecked("a pair is not complementary at the point");
1596 return out;
1597 }
1598 let scale = inp.grad_f.iter().fold(1.0_f64, |a, v| a.max(v.abs()));
1599 let band = corner * scale;
1600 // Held side per pair, `None` on a biactive pair left to the branch.
1601 let held_of = |choice: &dyn Fn(usize) -> Option<HeldSide>| -> Vec<Option<HeldSide>> {
1602 (0..np)
1603 .map(|k| match (act.g_act[k], act.h_act[k]) {
1604 (true, false) => Some(HeldSide::G),
1605 (false, true) => Some(HeldSide::H),
1606 _ => choice(k),
1607 })
1608 .collect()
1609 };
1610 let full = |h: &[Option<HeldSide>]| -> Vec<HeldSide> {
1611 h.iter().map(|s| s.unwrap_or(HeldSide::G)).collect()
1612 };
1613
1614 if biactive.len() <= MAX_ENUMERATED_BIACTIVE {
1615 out.enumerated = true;
1616 let mut best: Option<(Number, Vec<HeldSide>)> = None;
1617 for mask in 0..(1usize << biactive.len()) {
1618 let held = held_of(&|k| {
1619 let bit = biactive.iter().position(|&b| b == k).unwrap();
1620 Some(if mask >> bit & 1 == 0 {
1621 HeldSide::G
1622 } else {
1623 HeldSide::H
1624 })
1625 });
1626 let Some((v, _)) = branch_lp(inp, &act, &held) else {
1627 out.verdict = BVerdict::NotChecked("a branch LP did not solve");
1628 return out;
1629 };
1630 out.branches_solved += 1;
1631 if best.as_ref().is_none_or(|(b, _)| v < *b) {
1632 best = Some((v, full(&held)));
1633 }
1634 }
1635 out.verdict = match best {
1636 Some((v, held)) if v < -band => BVerdict::NotB { held, decrease: v },
1637 _ => BVerdict::B,
1638 };
1639 return out;
1640 }
1641
1642 // Too many to enumerate: the relaxed cone's direction names a branch.
1643 let relaxed = held_of(&|_| None);
1644 let Some((_, d)) = branch_lp(inp, &act, &relaxed) else {
1645 out.verdict = BVerdict::NotChecked("the relaxed LP did not solve");
1646 return out;
1647 };
1648 out.branches_solved += 1;
1649 let dg = |k: usize| act.g_side[k].0 * d[inp.pairs[k].var];
1650 let dh = |k: usize| {
1651 let i = inp.pairs[k].row;
1652 act.h_side[k].0
1653 * inp
1654 .jac
1655 .iter()
1656 .filter(|&&(r, _, _)| r == i)
1657 .map(|&(_, c, v)| v * d[c])
1658 .sum::<Number>()
1659 };
1660 let held = held_of(&|k| {
1661 Some(if dg(k) <= dh(k) {
1662 HeldSide::G
1663 } else {
1664 HeldSide::H
1665 })
1666 });
1667 let Some((v, _)) = branch_lp(inp, &act, &held) else {
1668 out.verdict = BVerdict::NotChecked("a branch LP did not solve");
1669 return out;
1670 };
1671 out.branches_solved += 1;
1672 out.verdict = if v < -band {
1673 BVerdict::NotB {
1674 held: full(&held),
1675 decrease: v,
1676 }
1677 } else {
1678 BVerdict::NotChecked(
1679 "too many biactive pairs to enumerate; the heuristic branch does not descend",
1680 )
1681 };
1682 out
1683}
1684
1685/// One branch LP (`held[k] = None` on a biactive pair: both sides only
1686/// non-negative, the relaxed cone). Returns the optimum and the direction,
1687/// or `None` if the LP did not solve.
1688fn branch_lp(
1689 inp: &CertificateInput<'_>,
1690 act: &PairActivity,
1691 held: &[Option<HeldSide>],
1692) -> Option<(Number, Vec<Number>)> {
1693 use pounce_convex::{QpOptions, QpProblem, QpStatus, Triplet, solve_qp_ipm};
1694 let n = inp.x.len();
1695 let m = inp.g.len();
1696 let row_tol = inp.solver_tol.max(0.0).sqrt();
1697 let mut rows_of: Vec<Vec<(usize, Number)>> = vec![Vec::new(); m];
1698 for &(r, c, v) in inp.jac {
1699 rows_of[r].push((c, v));
1700 }
1701 let (mut lb, mut ub) = (vec![-1.0; n], vec![1.0; n]);
1702 let (mut a, mut b, mut g, mut h) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());
1703 let eq = |coef: &[(usize, Number)], s: Number, a: &mut Vec<Triplet>, b: &mut Vec<Number>| {
1704 let r = b.len();
1705 a.extend(coef.iter().map(|&(c, v)| Triplet {
1706 row: r,
1707 col: c,
1708 val: s * v,
1709 }));
1710 b.push(0.0);
1711 };
1712 // `s·(coef·d) >= 0`, written `-s·coef·d <= 0`.
1713 let geq = |coef: &[(usize, Number)], s: Number, g: &mut Vec<Triplet>, h: &mut Vec<Number>| {
1714 let r = h.len();
1715 g.extend(coef.iter().map(|&(c, v)| Triplet {
1716 row: r,
1717 col: c,
1718 val: -s * v,
1719 }));
1720 h.push(0.0);
1721 };
1722 let mut pair_row = vec![None; m];
1723 let mut pair_var = vec![None; n];
1724 for (k, p) in inp.pairs.iter().enumerate() {
1725 pair_row[p.row] = Some(k);
1726 pair_var[p.var] = Some(k);
1727 }
1728 // Ordinary rows: the active ones, linearized.
1729 for i in (0..m).filter(|&i| pair_row[i].is_none()) {
1730 let (lo, hi) = (inp.g_l[i], inp.g_u[i]);
1731 let at_lo = lo > -INF && inp.g[i] - lo <= row_tol;
1732 let at_hi = hi < INF && hi - inp.g[i] <= row_tol;
1733 match (at_lo, at_hi) {
1734 (true, true) => eq(&rows_of[i], 1.0, &mut a, &mut b),
1735 (true, false) => geq(&rows_of[i], 1.0, &mut g, &mut h),
1736 (false, true) => geq(&rows_of[i], -1.0, &mut g, &mut h),
1737 (false, false) => {}
1738 }
1739 }
1740 // Ordinary bounds, and a pair variable's ordinary second bound.
1741 for j in 0..n {
1742 let (lo, hi) = (inp.x_l[j], inp.x_u[j]);
1743 let (mut at_lo, mut at_hi) = (
1744 lo > -INF && inp.x[j] - lo <= row_tol,
1745 hi < INF && hi - inp.x[j] <= row_tol,
1746 );
1747 if let Some(k) = pair_var[j] {
1748 if act.mixed[k] {
1749 continue; // both bounds are the pair's
1750 }
1751 // The pair's own bound is handled with the pair.
1752 if act.g_side[k].0 > 0.0 {
1753 at_lo = false;
1754 } else {
1755 at_hi = false;
1756 }
1757 }
1758 if at_lo {
1759 lb[j] = 0.0;
1760 }
1761 if at_hi {
1762 ub[j] = 0.0;
1763 }
1764 }
1765 // Pairs.
1766 for (k, p) in inp.pairs.iter().enumerate() {
1767 let (sg, sh) = (act.g_side[k].0, act.h_side[k].0);
1768 let gcoef = [(p.var, 1.0)];
1769 let hcoef = &rows_of[p.row];
1770 match held[k] {
1771 Some(HeldSide::G) => {
1772 lb[p.var] = 0.0;
1773 ub[p.var] = 0.0;
1774 if act.h_act[k] {
1775 geq(hcoef, sh, &mut g, &mut h);
1776 }
1777 }
1778 Some(HeldSide::H) => {
1779 eq(hcoef, 1.0, &mut a, &mut b);
1780 if act.g_act[k] {
1781 geq(&gcoef, sg, &mut g, &mut h);
1782 }
1783 }
1784 None => {
1785 geq(&gcoef, sg, &mut g, &mut h);
1786 geq(hcoef, sh, &mut g, &mut h);
1787 }
1788 }
1789 }
1790 let prob = QpProblem {
1791 n,
1792 p_lower: Vec::new(),
1793 c: inp.grad_f.to_vec(),
1794 a,
1795 b,
1796 g,
1797 h,
1798 lb,
1799 ub,
1800 };
1801 let opts = QpOptions::default();
1802 let sol = solve_qp_ipm(&prob, &opts, || {
1803 Box::new(pounce_feral::FeralSolverInterface::new())
1804 as Box<dyn pounce_linsol::SparseSymLinearSolverInterface>
1805 });
1806 matches!(sol.status, QpStatus::Optimal).then_some((sol.obj, sol.x))
1807}
1808
1809/// The polish runs when the reported point's [`natural_residual`] exceeds
1810/// this times `tol`. Not `tol` itself: the last relaxation is `τ = 1e-8 =
1811/// tol`, so a strictly complementary pair ends with its smaller side near
1812/// `τ`, on the threshold, and would trigger a re-solve that buys nothing. The
1813/// symptom it exists for sits near `sqrt(τ)`.
1814pub const POLISH_TRIGGER: Number = 10.0;
1815
1816/// A polished point must bring the natural residual to `max(tol,
1817/// POLISH_TIGHTEN · r_old)`. Not `tol` alone: a held `H` side is a row pinned
1818/// to zero, which the branch solve satisfies only to its constraint
1819/// tolerance, so a correct polish can end at `|h| = 3e-8`. On the 54
1820/// polishes measured (MacMPEC, the OPF and capacity suites) every one reached
1821/// `1e-8` or below, so this rejects nothing those accepted.
1822pub const POLISH_TIGHTEN: Number = 1e-3;
1823
1824/// How far a polish may *raise* the minimized objective: this times `r_old ·
1825/// max(1, |f|)`. Removing a residual `r` costs, to first order, `r` times the
1826/// slope the pair carries (a curve's slope change, a cost): measured over 54
1827/// polishes the rise was at most 15.7 times `r · max(1, |f|)`
1828/// (`vw_knot_2_right`, volt-watt slope 16). A larger rise has left the
1829/// answer's neighbourhood and the answer is kept. A *fall* is a lower,
1830/// complementary point and is taken whatever its size, as the B-escape takes
1831/// one.
1832pub const POLISH_RISE: Number = 100.0;
1833
1834/// May a polish that moved the minimized objective from `f_old` to `f_new`,
1835/// removing a natural residual `r_old`, replace the answer?
1836pub fn polish_objective_ok(f_old: Number, f_new: Number, r_old: Number) -> bool {
1837 f_new.is_finite()
1838 && f_old.is_finite()
1839 && f_new - f_old <= POLISH_RISE * r_old * f_old.abs().max(1.0)
1840}
1841
1842/// Is a polished point's natural residual `r_new` tight enough, given
1843/// `r_old` and the solver's `tol`?
1844pub fn polish_tightened(r_new: Number, r_old: Number, tol: Number) -> bool {
1845 r_new <= tol.max(POLISH_TIGHTEN * r_old)
1846}
1847
1848/// What a polished point may be reported as, from its certificate class.
1849/// `Ok(true)`: an answer (S, or not searched: what [`answers`] accepts).
1850/// `Ok(false)`: the weak fallback (W, [`kept_if_nothing_better`]). `Err`:
1851/// not taken, with the reason. An answer (`was_answer`) is demoted to W only
1852/// when `no_branch_descends()` -- the B-stationarity check at the polished
1853/// point reads B: the ladder reports W only when no S exists, and the S it
1854/// had was S only because a nearly biactive pair counted as one-sided (the
1855/// OPF volt-var knots, which polish to W and B-stationary). The check runs
1856/// only in that case.
1857pub fn polish_class(
1858 was_answer: bool,
1859 class: Option<StationarityClass>,
1860 no_branch_descends: impl FnOnce() -> bool,
1861) -> Result<bool, String> {
1862 match class {
1863 Some(k) if answers(k) => Ok(true),
1864 Some(k) if kept_if_nothing_better(k) => {
1865 if !was_answer || no_branch_descends() {
1866 Ok(false)
1867 } else {
1868 Err(
1869 "it certifies only W where the answer was S, and a branch descends from it"
1870 .into(),
1871 )
1872 }
1873 }
1874 Some(StationarityClass::None) => Err("the re-solved point is not stationary".into()),
1875 None => Err("the re-solved point is infeasible or could not be evaluated".into()),
1876 // `answers` and `kept_if_nothing_better` cover every other class.
1877 Some(k) => Err(format!("the re-solved point certifies {k:?}")),
1878 }
1879}
1880
1881/// The largest natural residual `min(|G_k|, |H_k|)` over the pairs at a
1882/// point, with the branch that removes it: each pair's smaller side held at
1883/// zero. A mixed pair is read as the one-sided pair of its nearer bound, as
1884/// the certificate reads it.
1885///
1886/// Every stage stops at a pair product near the last `τ`, which is harmless
1887/// where a pair is strictly complementary (the other side is `O(1)`, so the
1888/// smaller is `≈ τ`) and is not where a pair is nearly biactive: there both
1889/// sides sit near `sqrt(τ)`. On MPCCModels.jl's OPF volt-var cases (PR #13)
1890/// the answer at a curve knot had `r = 7.8e-5`, `s = 1.3e-4` at a product of
1891/// `1e-8`, the curve's slope of 7.5 put `Q` 5.9e-4 off it, and the objective
1892/// read *better* than the model's true optimum by what that slack bought.
1893/// `None` with no pairs or a non-finite side.
1894pub fn natural_residual(inp: &CertificateInput<'_>) -> Option<(Number, Vec<HeldSide>)> {
1895 let corner = inp.solver_tol.max(0.0).sqrt();
1896 let act = pair_activity(inp, corner);
1897 let mut worst: Number = 0.0;
1898 let mut held = Vec::with_capacity(inp.pairs.len());
1899 for (k, p) in inp.pairs.iter().enumerate() {
1900 let (sg, b) = act.g_side[k];
1901 let g = (sg * (inp.x[p.var] - b)).abs();
1902 let h = act.hv[k].abs();
1903 if !g.is_finite() || !h.is_finite() {
1904 return None;
1905 }
1906 worst = worst.max(g.min(h));
1907 held.push(if g <= h { HeldSide::G } else { HeldSide::H });
1908 }
1909 (!held.is_empty()).then_some((worst, held))
1910}
1911
1912/// The branch NLP's bounds for `held` (one per pair): the held side pinned
1913/// at zero, and, on a mixed pair held at a bound, the free row given the
1914/// sign the pair requires there. For [`pounce_nlp::mpcc::MpccTnlp::set_branch`].
1915pub fn branch_bounds(
1916 inp: &CertificateInput<'_>,
1917 held: &[HeldSide],
1918) -> pounce_nlp::mpcc::BranchBounds {
1919 let corner = inp.solver_tol.max(0.0).sqrt();
1920 let act = pair_activity(inp, corner);
1921 let mut out = pounce_nlp::mpcc::BranchBounds::default();
1922 for (k, p) in inp.pairs.iter().enumerate() {
1923 let ((sg, bnd), (_, c)) = (act.g_side[k], act.h_side[k]);
1924 match held[k] {
1925 HeldSide::G => {
1926 out.vars.push((p.var, bnd, bnd));
1927 if act.mixed[k] {
1928 // H = s_G·g >= 0 at the held bound.
1929 out.rows.push(if sg > 0.0 {
1930 (p.row, 0.0, 2.0 * INF)
1931 } else {
1932 (p.row, -2.0 * INF, 0.0)
1933 });
1934 }
1935 }
1936 HeldSide::H => out.rows.push((p.row, c, c)),
1937 }
1938 }
1939 out
1940}
1941
1942#[cfg(test)]
1943mod tests {
1944 use super::*;
1945
1946 /// Near-dependent columns, one of them pointing away from `b`: the
1947 /// least-squares solve on the passive set gives a non-positive
1948 /// coefficient, which must not be picked again forever.
1949 #[test]
1950 fn nnls_on_near_dependent_columns_terminates_at_the_fit() {
1951 let a = vec![1.0, 2.0, -1.0];
1952 let a2: Vec<Number> = a.iter().map(|v| v * (1.0 + 1e-13)).collect();
1953 let neg: Vec<Number> = a.iter().map(|v| -v).collect();
1954 let b = vec![2.0, 4.0, -2.0];
1955 let x = nnls(&[a.clone(), a2.clone(), neg], &b, 3);
1956 assert!(x.iter().all(|&v| v >= 0.0));
1957 let r: Number = (0..3)
1958 .map(|i| (b[i] - x[0] * a[i] - x[1] * a2[i] + x[2] * a[i]).powi(2))
1959 .sum();
1960 assert!(r.sqrt() < 1e-9, "residual {}", r.sqrt());
1961 }
1962
1963 #[test]
1964 fn a_warm_rung_is_budgeted_from_the_rung_before_it() {
1965 // ieee300_reserve_q98: rungs of 25-41 iterations, then a crawl to
1966 // max_iter = 3000. Ten times the previous rung, floored at 500.
1967 assert_eq!(warm_rung_budget(25), WARM_RUNG_FLOOR);
1968 assert_eq!(warm_rung_budget(0), WARM_RUNG_FLOOR);
1969 assert_eq!(warm_rung_budget(120), 1200);
1970 // A caller's smaller max_iter still wins.
1971 assert_eq!(budgeted_max_iter(warm_rung_budget(25), 12), 12);
1972 assert_eq!(budgeted_max_iter(warm_rung_budget(25), 3000), 500);
1973 }
1974
1975 /// Both branches of the warm rung's budget: with the fallback live the
1976 /// rung is budgeted, without it the caller's `max_iter` stands.
1977 #[test]
1978 fn a_warm_rung_is_budgeted_only_while_the_fallback_can_rescue_it() {
1979 assert_eq!(warm_rung_max_iter(true, 25, 3000), Some(500));
1980 assert_eq!(warm_rung_max_iter(true, 25, 12), Some(12));
1981 assert_eq!(warm_rung_max_iter(false, 25, 3000), None);
1982 // From the rescuing re-solve's 124, not the call's 624: still capped.
1983 assert_eq!(warm_rung_max_iter(true, 124, 3000), Some(1240));
1984 }
1985
1986 /// Every branch of the polish's acceptance, one assertion per branch.
1987 #[test]
1988 fn the_polish_takes_only_a_tightened_nearby_point_of_an_accepted_class() {
1989 use StationarityClass::*;
1990 // Class: S and not searched answer; W demotes an answer only where no
1991 // branch descends, and that check is not even run otherwise.
1992 assert_eq!(polish_class(true, Some(S), || unreachable!()), Ok(true));
1993 assert_eq!(
1994 polish_class(true, Some(NotSearched), || unreachable!()),
1995 Ok(true)
1996 );
1997 assert_eq!(polish_class(true, Some(W), || true), Ok(false));
1998 assert!(polish_class(true, Some(W), || false).is_err());
1999 assert_eq!(polish_class(false, Some(W), || unreachable!()), Ok(false));
2000 assert!(polish_class(false, Some(StationarityClass::None), || true).is_err());
2001 assert!(polish_class(true, Option::None, || true).is_err());
2002 // Tightening: to tol, or by POLISH_TIGHTEN where that is looser.
2003 assert!(polish_tightened(1e-12, 7.8e-5, 1e-8));
2004 assert!(polish_tightened(3e-8, 7.8e-5, 1e-8)); // a held row at 3e-8
2005 assert!(!polish_tightened(1e-6, 7.8e-5, 1e-8));
2006 // Objective: a fall of any size; a rise up to POLISH_RISE r max(1,|f|).
2007 assert!(polish_objective_ok(0.2028, 0.1, 2.5e-5));
2008 assert!(polish_objective_ok(0.2027957, 0.2031882, 2.5e-5)); // vw_knot_2_right
2009 assert!(!polish_objective_ok(
2010 0.2028,
2011 0.2028 + 101.0 * 2.5e-5,
2012 2.5e-5
2013 ));
2014 assert!(!polish_objective_ok(-322.0, -322.0 * (1.0 - 0.009), 1e-6)); // 0.9% worse
2015 assert!(!polish_objective_ok(1.0, Number::NAN, 1e-5));
2016 }
2017
2018 #[test]
2019 fn a_converged_lowering_with_a_large_product_is_not_an_answer() {
2020 let ok = ApplicationReturnStatus::SolveSucceeded;
2021 assert!(accepts(ok, 1e-9, 1e-4));
2022 assert!(!accepts(ok, 1e-3, 1e-4));
2023 assert!(!accepts(ok, Number::NAN, 1e-4));
2024 assert!(!accepts(
2025 ApplicationReturnStatus::InfeasibleProblemDetected,
2026 0.0,
2027 1e-4
2028 ));
2029 assert!(accepts(
2030 ApplicationReturnStatus::FeasiblePointFound,
2031 0.0,
2032 1e-4
2033 ));
2034 }
2035
2036 #[test]
2037 fn warm_mu_is_clamped_and_never_zero() {
2038 let mu = |v| {
2039 warm_start_assignments(v)
2040 .into_iter()
2041 .find(|a| a.starts_with("mu_init"))
2042 .unwrap()
2043 };
2044 assert_eq!(mu(1e-12), "mu_init 1e-9\n");
2045 assert_eq!(mu(3.0), "mu_init 1e-1\n");
2046 assert_eq!(mu(0.0), "mu_init 1e-6\n");
2047 assert_eq!(mu(Number::NAN), "mu_init 1e-6\n");
2048 }
2049
2050 /// Both branches of the exact stage's budget: with no tight answer in
2051 /// hand it runs in full, whatever the continuation spent; with one, it
2052 /// gets the factor times that work, never less than the floor.
2053 #[test]
2054 fn the_exact_stage_is_budgeted_only_after_an_answer() {
2055 assert_eq!(exact_stage_budget(false, 0), None);
2056 assert_eq!(exact_stage_budget(false, 5000), None);
2057 assert_eq!(exact_stage_budget(true, 0), Some(EXACT_BUDGET_FLOOR));
2058 assert_eq!(exact_stage_budget(true, 10), Some(EXACT_BUDGET_FLOOR));
2059 assert_eq!(exact_stage_budget(true, 189), Some(3 * 189));
2060 assert_eq!(exact_stage_budget(true, usize::MAX), Some(usize::MAX));
2061 }
2062
2063 /// `scholtes4` with its objective multiplied by `c`:
2064 /// `min c·(z1 + z2 - z3)`, `z3 <= 4 z1`, `z3 <= 4 z2`, `0 <= z1 ⟂ z2 >= 0`.
2065 /// Relaxed to `z1·z2 <= τ` the optimum is `z1 = z2 = sqrt(τ)`,
2066 /// `f = -2c·sqrt(τ)`, with product-row multiplier `c/sqrt(τ)`; the exact
2067 /// `f* = 0`. Returns `(f_relaxed, relaxed_slack)`.
2068 fn scholtes4_relaxed(c: Number, tau: Number) -> (Number, Number) {
2069 let lambda = c / tau.sqrt();
2070 (-2.0 * c * tau.sqrt(), relaxation_slack(&[tau], &[lambda]))
2071 }
2072
2073 #[test]
2074 fn relaxation_slack_is_the_first_order_value_of_the_products() {
2075 assert_eq!(relaxation_slack(&[], &[]), 0.0);
2076 assert!((relaxation_slack(&[1e-8, 2e-8], &[1e4, -3.0]) - (1e-4 + 6e-8)).abs() < 1e-18);
2077 // A mixed pair's legitimately negative product buys nothing.
2078 assert_eq!(relaxation_slack(&[-0.5], &[7.0]), 0.0);
2079 assert!(relaxation_slack(&[1e-8], &[Number::NAN]).is_infinite());
2080 assert!(relaxation_slack(&[Number::INFINITY], &[1.0]).is_infinite());
2081 }
2082
2083 #[test]
2084 fn a_finish_that_drifts_above_its_relaxed_start_is_flagged() {
2085 // pack-comp1p-32: 0.653 relaxed (products 5e-9), 0.783 after the
2086 // finish wandered.
2087 let slack = relaxation_slack(&[5e-9; 4], &[30.0; 4]);
2088 assert!(finish_drifted(0.783, 0.65304, slack));
2089 // A finish that tightens the products at (almost) the same objective,
2090 // or improves it, is kept.
2091 assert!(!finish_drifted(0.65304 + 1e-6, 0.65304, slack));
2092 assert!(!finish_drifted(0.6529, 0.65304, slack));
2093 // scholtes4 and ralph1: the relaxed point's slack at a biactive
2094 // corner buys -2e-4 / -1e-4 = O(sqrt(1e-8)); the finish removing it
2095 // has not drifted.
2096 let (f_s4, s_s4) = scholtes4_relaxed(1.0, 1e-8);
2097 assert!(!finish_drifted(3e-11, f_s4, s_s4));
2098 assert!(!finish_drifted(
2099 2e-9,
2100 -9.999818e-5,
2101 relaxation_slack(&[1e-8], &[1e4])
2102 ));
2103 assert!(finish_drifted(Number::NAN, 1.0, 1e-8));
2104 assert!(!finish_drifted(1.0, Number::INFINITY, 1e-8));
2105 assert!(!finish_drifted(5.0, 1.0, Number::INFINITY));
2106 }
2107
2108 /// gh#1128: the verdict must not depend on the objective's units. With
2109 /// the old allowance `10·sqrt(p)·max(1, |f|)`, scholtes4 ×10 (relaxed
2110 /// `-2e-3`, finish `3e-10`) read as drifted -- the allowance stayed at
2111 /// `1e-3` while the corner's slack grew to `2e-3` -- and the driver kept
2112 /// a relaxed point below `f*`.
2113 #[test]
2114 fn the_drift_verdict_does_not_change_with_the_objectives_scale() {
2115 for c in [1e-6, 1e-3, 0.1, 1.0, 10.0, 1e3, 1e6] {
2116 let (fr, slack) = scholtes4_relaxed(c, 1e-8);
2117 assert!(!finish_drifted(c * 3e-11, fr, slack), "scholtes4 x{c}");
2118 assert!(!finish_drifted(0.0, fr, slack), "scholtes4 x{c}");
2119 // The same relaxed point with a finish that genuinely wandered
2120 // (to +f_relaxed's magnitude a thousand times over) is flagged.
2121 assert!(finish_drifted(-1e3 * fr, fr, slack), "scholtes4 x{c}");
2122 let pack = relaxation_slack(&[5e-9; 4], &[30.0 * c; 4]);
2123 assert!(finish_drifted(0.783 * c, 0.65304 * c, pack), "pack x{c}");
2124 assert!(!finish_drifted(0.6529 * c, 0.65304 * c, pack), "pack x{c}");
2125 }
2126 // The case the issue derived: scholtes4 x10 at the old constants.
2127 assert!(!finish_drifted(
2128 3e-10,
2129 -2e-3,
2130 relaxation_slack(&[1e-8], &[1e5])
2131 ));
2132 }
2133
2134 /// Every branch of the cold-solve skip: agreement alone skips, two exact
2135 /// answers skip, and a relaxed point on either side keeps the cold solve.
2136 #[test]
2137 fn the_cold_solve_is_skipped_only_with_agreement_or_two_exact_answers() {
2138 assert!(skips_cold_solve(true, false, false));
2139 assert!(skips_cold_solve(false, true, true));
2140 assert!(!skips_cold_solve(false, false, true));
2141 assert!(!skips_cold_solve(false, true, false));
2142 }
2143
2144 #[test]
2145 fn answers_agree_within_the_relative_tolerance() {
2146 // pack-comp1p-32: continuation 0.65304, relaxed exact path 0.65310.
2147 assert!(answers_agree(0.65304, 0.65310, 0.0, 0.0));
2148 // water-net: 953 against 964 is two branches.
2149 assert!(!answers_agree(953.23, 964.12, 0.0, 0.0));
2150 // Near zero, what a relaxed answer's slack buys still agrees: an
2151 // exact finish at 1e-9 and a relaxed point at -5e-5 whose products
2152 // are worth 2.5e-5.
2153 assert!(answers_agree(1e-9, -5e-5, 2.5e-5, 0.0));
2154 assert!(!answers_agree(f64::NAN, 0.0, 0.0, 0.0));
2155 assert!(!answers_agree(0.0, 0.0, f64::INFINITY, 0.0));
2156 assert!(answers_agree(0.0, 0.0, 0.0, 0.0));
2157 assert!(!answers_agree(0.0, 0.0, 0.0, f64::INFINITY));
2158 }
2159
2160 /// The floor near zero: two exact points (no slack) on one branch at
2161 /// `f* = 0` agree when they differ by no more than their summed gaps,
2162 /// and no further. Numbers from `min 0.01·x + (y - 1)^2`,
2163 /// `0 <= x ⟂ y >= 0`: the continuation's `1e-11` and the relaxed path's
2164 /// `5e-20`, gaps `1e-11` and `2.5e-11`.
2165 #[test]
2166 fn two_answers_at_zero_agree_within_their_gaps() {
2167 for c in [1e-6, 1.0, 1e6] {
2168 assert!(
2169 answers_agree(c * 1e-11, c * 5e-20, 0.0, c * 3.5e-11),
2170 "x{c}"
2171 );
2172 assert!(!answers_agree(c * 1e-11, c * 5e-20, 0.0, 0.0), "x{c}");
2173 assert!(
2174 !answers_agree(c * 1e-9, c * 5e-20, 0.0, c * 3.5e-11),
2175 "x{c}"
2176 );
2177 }
2178 }
2179
2180 #[test]
2181 fn the_complementarity_gap_sums_multiplier_times_distance() {
2182 let inf = Number::INFINITY;
2183 // x = [1e-9 above 0, 2 below 3, free]; rows: g = 0.5 in [0, inf),
2184 // g = 1 equality at 1, g = 7 free.
2185 let gap = complementarity_gap(
2186 &[1e-9, 2.0, 5.0],
2187 &[0.01, 0.0, 0.0],
2188 &[0.0, -4.0, 0.0],
2189 &[0.0, -inf, -inf],
2190 &[inf, 3.0, inf],
2191 &[0.5, 1.0, 7.0],
2192 &[-1e-8, 30.0, 0.0],
2193 &[0.0, 1.0, -inf],
2194 &[inf, 1.0, inf],
2195 );
2196 assert!((gap - (1e-11 + 4.0 + 0.5e-8)).abs() < 1e-15, "{gap:e}");
2197 // Multipliers scale with the objective; the gap follows.
2198 let scaled = complementarity_gap(
2199 &[1e-9],
2200 &[1e3 * 0.01],
2201 &[0.0],
2202 &[0.0],
2203 &[inf],
2204 &[],
2205 &[],
2206 &[],
2207 &[],
2208 );
2209 assert!((scaled - 1e3 * 1e-11).abs() < 1e-20, "{scaled:e}");
2210 // Mismatched lengths, or a non-finite multiplier: cannot tell.
2211 assert!(
2212 complementarity_gap(&[1.0], &[], &[0.0], &[0.0], &[inf], &[], &[], &[], &[])
2213 .is_infinite()
2214 );
2215 assert!(
2216 complementarity_gap(
2217 &[1.0],
2218 &[f64::NAN],
2219 &[0.0],
2220 &[0.0],
2221 &[inf],
2222 &[],
2223 &[],
2224 &[],
2225 &[]
2226 )
2227 .is_infinite()
2228 );
2229 assert_eq!(
2230 complementarity_gap(&[], &[], &[], &[], &[], &[], &[], &[], &[]),
2231 0.0
2232 );
2233 }
2234
2235 /// gh#1128: near zero the old test was an absolute `1e-4`, so two exact
2236 /// answers 5e-5 apart "agreed" -- the cold solve was skipped -- however
2237 /// small the objective's own scale, while the same model in larger units
2238 /// ran it. The verdict is now the same at every scale, both ways.
2239 #[test]
2240 fn answers_agree_does_not_change_with_the_objectives_scale() {
2241 for c in [1e-6, 1e-3, 0.1, 1.0, 10.0, 1e3, 1e6] {
2242 assert!(answers_agree(c * 1e-9, -c * 5e-5, c * 2.5e-5, 0.0), "x{c}");
2243 assert!(!answers_agree(c * 1e-9, -c * 5e-5, 0.0, 0.0), "x{c}");
2244 assert!(answers_agree(c * 0.65304, c * 0.65310, 0.0, 0.0), "x{c}");
2245 assert!(!answers_agree(c * 953.23, c * 964.12, 0.0, 0.0), "x{c}");
2246 let (fr, slack) = scholtes4_relaxed(c, 1e-8);
2247 assert!(answers_agree(c * 3e-11, fr, slack, 0.0), "scholtes4 x{c}");
2248 }
2249 }
2250
2251 #[test]
2252 fn the_finish_budget_has_a_floor_and_follows_the_schedule() {
2253 assert_eq!(finish_budget(0), FINISH_BUDGET_FLOOR);
2254 assert_eq!(finish_budget(721), 721);
2255 }
2256
2257 /// A budget never raises the caller's own `max_iter`.
2258 #[test]
2259 fn a_budget_never_exceeds_the_callers_max_iter() {
2260 assert_eq!(budgeted_max_iter(567, 3000), 567);
2261 assert_eq!(budgeted_max_iter(567, 100), 100);
2262 assert_eq!(budgeted_max_iter(usize::MAX, 3000), 3000);
2263 assert_eq!(budgeted_max_iter(10, -1), 0);
2264 }
2265
2266 #[test]
2267 fn every_assignment_is_restored() {
2268 for a in warm_start_assignments(1e-5) {
2269 let name = a.split_whitespace().next().unwrap();
2270 assert!(
2271 TOUCHED_OPTIONS.contains(&name),
2272 "{name} is written but not restored"
2273 );
2274 }
2275 }
2276
2277 // ---- certify: the same points `benchmarks/calipso/stationarity.py`'s
2278 // selftest and `benchmarks/mpcc/cases.py` decide, so the two
2279 // implementations are held to each other. Python's M (and C) is "not S"
2280 // here, reported as W.
2281
2282 const NI: Number = -1e20;
2283 const PI: Number = 1e20;
2284
2285 #[derive(Clone)]
2286 struct Model {
2287 x_l: Vec<Number>,
2288 x_u: Vec<Number>,
2289 g_l: Vec<Number>,
2290 g_u: Vec<Number>,
2291 pairs: Vec<ComplementarityPair>,
2292 jac: Vec<(usize, usize, Number)>,
2293 }
2294
2295 /// Certifies on both paths and holds them to the same class, so every
2296 /// point a test below checks is also a dense-vs-sparse parity check.
2297 fn run(m: &Model, x: &[Number], grad_f: &[Number], g: &[Number]) -> Certificate {
2298 let inp = CertificateInput {
2299 x,
2300 grad_f,
2301 g,
2302 jac: &m.jac,
2303 x_l: &m.x_l,
2304 x_u: &m.x_u,
2305 g_l: &m.g_l,
2306 g_u: &m.g_u,
2307 pairs: &m.pairs,
2308 solver_tol: 1e-8,
2309 feas_tol: 1e-4,
2310 };
2311 let dense = certify_routed(&inp, Some(false));
2312 let sparse = certify_routed(&inp, Some(true));
2313 assert_eq!(
2314 (dense.class, dense.multipliers_unique),
2315 (sparse.class, sparse.multipliers_unique),
2316 "dense {dense:?}\nsparse {sparse:?}"
2317 );
2318 // The residuals agree far inside the tolerance the class is decided
2319 // against (the SVD reaches ~1e-16, the LDLᵀ solve ~1e-13).
2320 if dense.multipliers_unique && dense.residual.is_finite() {
2321 assert!(
2322 (dense.residual - sparse.residual).abs() <= 1e-6 * dense.resid_tol,
2323 "dense {dense:?}\nsparse {sparse:?}"
2324 );
2325 }
2326 // The multipliers, when returned: the same on both paths, and
2327 // `∇f + Jᵀλ - z_L + z_U = 0` to the residual the class was judged on
2328 // (every model below is a check of the column-to-multiplier map).
2329 assert_eq!(
2330 dense.multipliers.is_some(),
2331 sparse.multipliers.is_some(),
2332 "dense {dense:?}\nsparse {sparse:?}"
2333 );
2334 if let (Some(d), Some(sp)) = (&dense.multipliers, &sparse.multipliers) {
2335 let pairs = d
2336 .lambda
2337 .iter()
2338 .zip(&sp.lambda)
2339 .chain(d.z_l.iter().zip(&sp.z_l))
2340 .chain(d.z_u.iter().zip(&sp.z_u));
2341 for (a, b) in pairs {
2342 assert!(
2343 (a - b).abs() <= 1e-8 * (1.0 + a.abs()),
2344 "dense {d:?}\nsparse {sp:?}"
2345 );
2346 }
2347 let mut r: Vec<Number> = grad_f.to_vec();
2348 for &(row, col, v) in &m.jac {
2349 r[col] += v * d.lambda[row];
2350 }
2351 for ((rj, zl), zu) in r.iter_mut().zip(&d.z_l).zip(&d.z_u) {
2352 *rj += -zl + zu;
2353 }
2354 let worst = r.iter().fold(0.0_f64, |w, v| w.max(v.abs()));
2355 assert!(worst <= dense.resid_tol, "stationarity {worst:e}: {d:?}");
2356 }
2357 dense
2358 }
2359
2360 /// A mixed pair, `0 <= x <= 1 ⟂ g = y - x` with `y` and the row free,
2361 /// certified at each place it can be: at either bound with the sign the
2362 /// pair requires, strictly between with `g = 0`, at a bound with the wrong
2363 /// sign (infeasible: no row bound states it), and strictly between with
2364 /// `g != 0` (not complementary).
2365 #[test]
2366 fn a_mixed_pair_is_certified_from_the_bound_it_is_at() {
2367 let m = Model {
2368 x_l: vec![0.0, NI],
2369 x_u: vec![1.0, PI],
2370 g_l: vec![NI],
2371 g_u: vec![PI],
2372 pairs: vec![ComplementarityPair { row: 0, var: 0 }],
2373 jac: vec![(0, 0, -1.0), (0, 1, 1.0)],
2374 };
2375 let g = |x: &[Number]| vec![x[1] - x[0]];
2376
2377 // x at 0, g = 2 >= 0: min (x - 0.5)² + (y - 2)², grad (-1, 0) held by
2378 // the bound alone.
2379 let x = [0.0, 2.0];
2380 let c = run(&m, &x, &[-1.0, 0.0], &g(&x));
2381 assert_eq!(c.class, StationarityClass::S, "{c:?}");
2382 assert!(c.feasible, "{c:?}");
2383
2384 // x at 1, g = -1 <= 0: min (x - 2)² + y², grad (-2, 0).
2385 let x = [1.0, 0.0];
2386 let c = run(&m, &x, &[-2.0, 0.0], &g(&x));
2387 assert_eq!(c.class, StationarityClass::S, "{c:?}");
2388
2389 // Strictly between, g = 0: the row is an equality there, grad = λ ∇g.
2390 let x = [0.5, 0.5];
2391 let c = run(&m, &x, &[-1.0, 1.0], &g(&x));
2392 assert_eq!(c.class, StationarityClass::S, "{c:?}");
2393
2394 // x at 0 with g = -1 < 0: violates the pair; no row bound says so.
2395 let x = [0.0, -1.0];
2396 let c = run(&m, &x, &[0.0, 0.0], &g(&x));
2397 assert_eq!(c.class, StationarityClass::None, "{c:?}");
2398 assert!(
2399 !c.feasible && (c.primal_violation - 1.0).abs() < 1e-12,
2400 "{c:?}"
2401 );
2402
2403 // Strictly between with g = 0.5: neither side at zero.
2404 let x = [0.5, 1.0];
2405 let c = run(&m, &x, &[0.0, 0.0], &g(&x));
2406 assert_eq!(
2407 (c.class, c.n_neither),
2408 (StationarityClass::None, 1),
2409 "{c:?}"
2410 );
2411 }
2412
2413 /// A one-sided pair whose variable also has an ordinary second bound:
2414 /// `x >= 0 ⟂ g = y >= 0` with `x <= 1` a plain limit. `min (x - 2)² +
2415 /// (y - 1)²` has its optimum at `(1, 0)` -- `x` on the ordinary bound, not
2416 /// the pair's -- which is stationary only with that bound's multiplier.
2417 #[test]
2418 fn a_pair_variables_ordinary_second_bound_carries_a_multiplier() {
2419 let m = Model {
2420 x_l: vec![0.0, NI],
2421 x_u: vec![1.0, PI],
2422 g_l: vec![0.0],
2423 g_u: vec![PI],
2424 pairs: vec![ComplementarityPair { row: 0, var: 0 }],
2425 jac: vec![(0, 1, 1.0)],
2426 };
2427 let x = [1.0, 0.0];
2428 let c = run(&m, &x, &[-2.0, -2.0], &[x[1]]);
2429 assert_eq!(c.class, StationarityClass::S, "{c:?}");
2430 // The pair's own bound is the lower one: at x = 0 with y = 1 the
2431 // gradient (−4, 0) is held by it.
2432 let x = [0.0, 1.0];
2433 let c = run(&m, &x, &[-4.0, 0.0], &[x[1]]);
2434 assert_eq!(c.class, StationarityClass::S, "{c:?}");
2435 }
2436
2437 fn input<'a>(
2438 m: &'a Model,
2439 x: &'a [Number],
2440 grad_f: &'a [Number],
2441 g: &'a [Number],
2442 ) -> CertificateInput<'a> {
2443 CertificateInput {
2444 x,
2445 grad_f,
2446 g,
2447 jac: &m.jac,
2448 x_l: &m.x_l,
2449 x_u: &m.x_u,
2450 g_l: &m.g_l,
2451 g_u: &m.g_u,
2452 pairs: &m.pairs,
2453 solver_tol: 1e-8,
2454 feas_tol: 1e-4,
2455 }
2456 }
2457
2458 /// ralph1 at the origin, `min 2x - y`, `x >= 0`, `0 <= y ⟂ y - x >= 0`:
2459 /// weakly but not strongly stationary, and B-stationary -- both branch
2460 /// LPs have optimum 0. The check must not report a descent there.
2461 #[test]
2462 fn ralph1s_origin_is_b_stationary() {
2463 let m = Model {
2464 x_l: vec![0.0, 0.0],
2465 x_u: vec![PI, PI],
2466 g_l: vec![0.0],
2467 g_u: vec![PI],
2468 pairs: vec![ComplementarityPair { row: 0, var: 1 }],
2469 jac: vec![(0, 0, -1.0), (0, 1, 1.0)],
2470 };
2471 let (x, gf, g) = ([0.0, 0.0], [2.0, -1.0], [0.0]);
2472 assert_eq!(run(&m, &x, &gf, &g).class, StationarityClass::W);
2473 let b = check_b_stationarity(&input(&m, &x, &gf, &g));
2474 assert_eq!(b.verdict, BVerdict::B, "{b:?}");
2475 assert_eq!(
2476 (b.n_biactive, b.branches_solved, b.enumerated),
2477 (1, 2, true)
2478 );
2479 }
2480
2481 /// `min (x - 1)² + (y - 1)²`, `0 <= x ⟂ y >= 0` at the origin: biactive,
2482 /// and either branch descends (`∇f·d = -2` with `d` in the unit box).
2483 /// The branch bounds pin the held side.
2484 #[test]
2485 fn a_biactive_corner_with_descent_is_not_b_stationary() {
2486 let m = Model {
2487 x_l: vec![0.0, NI],
2488 x_u: vec![PI, PI],
2489 g_l: vec![0.0],
2490 g_u: vec![PI],
2491 pairs: vec![ComplementarityPair { row: 0, var: 0 }],
2492 jac: vec![(0, 1, 1.0)],
2493 };
2494 let (x, gf, g) = ([0.0, 0.0], [-2.0, -2.0], [0.0]);
2495 let inp = input(&m, &x, &gf, &g);
2496 let b = check_b_stationarity(&inp);
2497 let BVerdict::NotB { held, decrease } = &b.verdict else {
2498 panic!("{b:?}")
2499 };
2500 assert!((decrease + 2.0).abs() < 1e-6, "{b:?}");
2501 let bb = branch_bounds(&inp, held);
2502 match held[0] {
2503 HeldSide::G => assert_eq!(bb.vars, vec![(0, 0.0, 0.0)], "{bb:?}"),
2504 HeldSide::H => assert_eq!(bb.rows, vec![(0, 0.0, 0.0)], "{bb:?}"),
2505 }
2506 // Away from the corner, a point on one branch whose gradient is held
2507 // by the pinned side is B-stationary: x = 0, y = 1 with ∇f = (-2, 0).
2508 let (x, gf, g) = ([0.0, 1.0], [-2.0, 0.0], [1.0]);
2509 assert_eq!(
2510 check_b_stationarity(&input(&m, &x, &gf, &g)).verdict,
2511 BVerdict::B
2512 );
2513 }
2514
2515 /// Past [`MAX_ENUMERATED_BIACTIVE`], the relaxed LP's direction picks one
2516 /// branch: it finds the descent here, and could never certify B.
2517 #[test]
2518 fn past_the_enumeration_cap_the_heuristic_finds_a_descent() {
2519 let k = MAX_ENUMERATED_BIACTIVE + 1;
2520 let n = 2 * k;
2521 let m = Model {
2522 x_l: (0..n).map(|j| if j < k { 0.0 } else { NI }).collect(),
2523 x_u: vec![PI; n],
2524 g_l: vec![0.0; k],
2525 g_u: vec![PI; k],
2526 pairs: (0..k)
2527 .map(|i| ComplementarityPair { row: i, var: i })
2528 .collect(),
2529 jac: (0..k).map(|i| (i, k + i, 1.0)).collect(),
2530 };
2531 let (x, gf, g) = (vec![0.0; n], vec![-1.0; n], vec![0.0; k]);
2532 let b = check_b_stationarity(&input(&m, &x, &gf, &g));
2533 assert!(!b.enumerated && b.branches_solved == 2, "{b:?}");
2534 assert!(matches!(b.verdict, BVerdict::NotB { .. }), "{b:?}");
2535 }
2536
2537 #[test]
2538 fn a_point_where_a_pair_is_not_complementary_is_not_checked() {
2539 let m = Model {
2540 x_l: vec![0.0, NI],
2541 x_u: vec![PI, PI],
2542 g_l: vec![0.0],
2543 g_u: vec![PI],
2544 pairs: vec![ComplementarityPair { row: 0, var: 0 }],
2545 jac: vec![(0, 1, 1.0)],
2546 };
2547 let (x, gf, g) = ([1.0, 1.0], [0.0, 0.0], [1.0]);
2548 let b = check_b_stationarity(&input(&m, &x, &gf, &g));
2549 assert!(matches!(b.verdict, BVerdict::NotChecked(_)), "{b:?}");
2550 }
2551
2552 /// CALIPSO.jl `nonconvex/complementarity.jl`, its x-x pairs written as
2553 /// rows `g_{4+k} = x_{5+k} >= 0` paired with `x_{2+k}` (and those
2554 /// variables' own bounds dropped, so the row is the only statement of
2555 /// them — the same system the Python classifier builds).
2556 #[allow(clippy::type_complexity)]
2557 fn complementarity() -> (Model, impl Fn(&[Number]) -> (Vec<Number>, Vec<Number>)) {
2558 let mut jac = vec![
2559 (0, 1, 0.5),
2560 (0, 2, 1.0),
2561 (0, 3, -0.5),
2562 (0, 4, 1.0),
2563 (1, 0, 3.0),
2564 (1, 1, -1.0),
2565 (1, 5, -1.0),
2566 (2, 0, -1.0),
2567 (2, 1, 0.5),
2568 (2, 6, -1.0),
2569 (3, 0, -1.0),
2570 (3, 1, -1.0),
2571 (3, 7, -1.0),
2572 ];
2573 for k in 0..3 {
2574 jac.push((4 + k, 5 + k, 1.0));
2575 }
2576 let m = Model {
2577 x_l: vec![0.0, 0.0, 0.0, 0.0, 0.0, NI, NI, NI],
2578 x_u: vec![PI; 8],
2579 g_l: vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
2580 g_u: vec![0.0, 0.0, 0.0, 0.0, PI, PI, PI],
2581 pairs: (0..3)
2582 .map(|k| ComplementarityPair {
2583 row: 4 + k,
2584 var: 2 + k,
2585 })
2586 .collect(),
2587 jac,
2588 };
2589 let eval = |x: &[Number]| {
2590 let grad = vec![
2591 2.0 * (x[0] - 5.0),
2592 4.0 * (2.0 * x[1] + 1.0),
2593 0.0,
2594 0.0,
2595 0.0,
2596 0.0,
2597 0.0,
2598 0.0,
2599 ];
2600 let g = vec![
2601 2.0 * (x[1] - 1.0) - 1.5 * x[1] + x[2] - 0.5 * x[3] + x[4],
2602 3.0 * x[0] - x[1] - 3.0 - x[5],
2603 -x[0] + 0.5 * x[1] + 4.0 - x[6],
2604 -x[0] - x[1] + 7.0 - x[7],
2605 x[5],
2606 x[6],
2607 x[7],
2608 ];
2609 (grad, g)
2610 };
2611 (m, eval)
2612 }
2613
2614 /// Past [`DENSE_ENTRIES`] a system with unique multipliers is certified
2615 /// sparsely, at any size; it used to be skipped past
2616 /// [`MAX_DENSE_ENTRIES`] (PR review: the stall guard was off for
2617 /// mid-size models). 2001 active bounds give a 2001 × 2001 system,
2618 /// 4 004 001 entries, past both limits. A wrong-signed bound multiplier
2619 /// is still caught there.
2620 #[test]
2621 fn a_large_system_with_unique_multipliers_is_certified_sparsely() {
2622 let n = 2001;
2623 let m = Model {
2624 x_l: vec![0.0; n],
2625 x_u: vec![PI; n],
2626 g_l: vec![],
2627 g_u: vec![],
2628 pairs: vec![],
2629 jac: vec![],
2630 };
2631 assert!(n * n > MAX_DENSE_ENTRIES);
2632 let mut grad = vec![1.0; n];
2633 let cert = |grad: &[Number]| {
2634 certify(&CertificateInput {
2635 x: &vec![0.0; n],
2636 grad_f: grad,
2637 g: &[],
2638 jac: &m.jac,
2639 x_l: &m.x_l,
2640 x_u: &m.x_u,
2641 g_l: &m.g_l,
2642 g_u: &m.g_u,
2643 pairs: &m.pairs,
2644 solver_tol: 1e-8,
2645 feas_tol: 1e-4,
2646 })
2647 };
2648 let c = cert(&grad);
2649 assert_eq!(
2650 (c.class, c.multipliers_unique),
2651 (StationarityClass::S, true),
2652 "{c:?}"
2653 );
2654 grad[7] = -1.0;
2655 let c = cert(&grad);
2656 assert_eq!(c.class, StationarityClass::None, "{c:?}");
2657 }
2658
2659 /// A badly scaled column (PR review): columns `e₀` and `(1, ε)`, swept
2660 /// from well conditioned to exactly dependent. The sparse path's
2661 /// multipliers are exact (after refinement) at every ε > 0, and it calls
2662 /// the system rank-deficient only at ε = 0, where the factorization's
2663 /// inertia is wrong. The dense SVD draws the same line (its threshold is
2664 /// `ε_mach · max(n, k) · σ_max`), so class and uniqueness agree on both
2665 /// paths at every scale (`run` asserts it).
2666 #[test]
2667 fn a_badly_scaled_column_gets_the_same_verdict_on_both_paths() {
2668 for eps in [1e-3, 1e-5, 1e-7, 1e-9, 1e-12, 1e-14, 0.0] {
2669 let m = Model {
2670 x_l: vec![0.0, -2e19],
2671 x_u: vec![2e19, 2e19],
2672 g_l: vec![0.0],
2673 g_u: vec![2e19],
2674 pairs: vec![],
2675 jac: vec![(0, 0, 1.0), (0, 1, eps)],
2676 };
2677 // grad f = A (1, 1): multipliers +1 and +1, so S.
2678 let c = run(&m, &[0.0, 0.0], &[2.0, eps], &[0.0]);
2679 assert_eq!(c.class, StationarityClass::S, "{eps:e}: {c:?}");
2680 assert_eq!(c.multipliers_unique, eps != 0.0, "{eps:e}: {c:?}");
2681 let cols = vec![vec![(0, 1.0)], vec![(0, 1.0), (1, eps)]];
2682 match sparse_least_squares(2, &cols, &[2.0, eps]) {
2683 LeastSquares::Solved(theta) => {
2684 assert!(eps != 0.0, "{theta:?}");
2685 assert!(
2686 theta.iter().all(|t| (t - 1.0).abs() < 1e-12),
2687 "{eps:e}: {theta:?}"
2688 );
2689 }
2690 other => assert!(
2691 eps == 0.0 && other == LeastSquares::RankDeficient,
2692 "{eps:e}: {other:?}"
2693 ),
2694 }
2695 }
2696 }
2697
2698 /// One acceptance band on every path (PR review, MacMPEC): an IPM
2699 /// answer reproduces `∇f` only to about `sqrt(tol)` near the boundary,
2700 /// and the unique-multiplier path used to hold it to `max(1e-6, tol)`
2701 /// while the set search allowed the `sqrt(tol)` band too -- so `flp4-3`,
2702 /// `flp4-4` and `portfl-i-1`, optimal to f within 0.2%, read `none` with
2703 /// residuals of 1.7e-6 to 2.1e-5. Here a lone free variable with
2704 /// `∂f = r` leaves residual `r` (dual scale 1, band `1e-6 + 1e-4`).
2705 #[test]
2706 fn the_residual_band_is_the_same_on_the_unique_path() {
2707 let m = Model {
2708 x_l: vec![-2e19],
2709 x_u: vec![2e19],
2710 g_l: vec![],
2711 g_u: vec![],
2712 pairs: vec![],
2713 jac: vec![],
2714 };
2715 // Inside the floor: S and tight. Inside the band only: S, not tight
2716 // (the driver keeps it and looks for a tighter answer). Outside: none.
2717 for (r, class, tight) in [
2718 (5e-7, StationarityClass::S, true),
2719 (2e-5, StationarityClass::S, false),
2720 (9e-5, StationarityClass::S, false),
2721 (2e-4, StationarityClass::None, false),
2722 ] {
2723 let c = run(&m, &[0.3], &[r], &[]);
2724 assert_eq!((c.class, c.tight), (class, tight), "{r:e}: {c:?}");
2725 assert!((c.resid_tol - (1e-6 + 1e-4)).abs() < 1e-15, "{c:?}");
2726 }
2727 }
2728
2729 /// A point outside its own constraints is not certified (PR review,
2730 /// MacMPEC `pack-rig2*`: infeasibility verdicts whose points violate
2731 /// rows by 4.6e-4 to 9.9e-4 read "S"). Row `x₀ ≥ 1` at `x₀ = 1 − 5e-4`
2732 /// with `∇f = e₀`: the row's multiplier is +1, which would be S.
2733 #[test]
2734 fn a_point_violating_a_row_beyond_constr_viol_tol_is_not_certified() {
2735 let m = Model {
2736 x_l: vec![-2e19],
2737 x_u: vec![2e19],
2738 g_l: vec![1.0],
2739 g_u: vec![2e19],
2740 pairs: vec![],
2741 jac: vec![(0, 0, 1.0)],
2742 };
2743 let x = 1.0 - 5e-4;
2744 let c = run(&m, &[x], &[1.0], &[x]);
2745 assert_eq!(
2746 (c.class, c.feasible),
2747 (StationarityClass::None, false),
2748 "{c:?}"
2749 );
2750 assert!((c.primal_violation - 5e-4).abs() < 1e-12, "{c:?}");
2751 // Inside constr_viol_tol (1e-4) the same point certifies.
2752 let x = 1.0 - 5e-5;
2753 let c = run(&m, &[x], &[1.0], &[x]);
2754 assert_eq!((c.class, c.feasible), (StationarityClass::S, true), "{c:?}");
2755 // A violated variable bound counts the same way.
2756 let m = Model {
2757 x_l: vec![0.0],
2758 g_l: vec![],
2759 g_u: vec![],
2760 jac: vec![],
2761 ..m
2762 };
2763 let c = run(&m, &[-2e-3], &[1.0], &[]);
2764 assert_eq!(
2765 (c.class, c.feasible),
2766 (StationarityClass::None, false),
2767 "{c:?}"
2768 );
2769 }
2770
2771 /// A large system whose multipliers are a set is still not searched:
2772 /// the set search is dense. One active row `x₀ ≥ 0` duplicating the
2773 /// bound makes the 2001 bound columns plus the row dependent, and
2774 /// 2001 × 2002 entries is past [`MAX_DENSE_ENTRIES`].
2775 #[test]
2776 fn a_large_rank_deficient_system_is_not_searched() {
2777 let n = 2001;
2778 let c = certify(&CertificateInput {
2779 x: &vec![0.0; n],
2780 grad_f: &vec![1.0; n],
2781 g: &[0.0],
2782 jac: &[(0, 0, 1.0)],
2783 x_l: &vec![0.0; n],
2784 x_u: &vec![PI; n],
2785 g_l: &[0.0],
2786 g_u: &[2e19],
2787 pairs: &[],
2788 solver_tol: 1e-8,
2789 feas_tol: 1e-4,
2790 });
2791 assert_eq!(
2792 (c.class, c.multipliers_unique),
2793 (StationarityClass::NotSearched, false),
2794 "{c:?}"
2795 );
2796 assert!(n * (n + 1) > MAX_DENSE_ENTRIES);
2797 }
2798
2799 /// The set search's acceptance band (PR review): with non-unique
2800 /// multipliers a point passes when the signed least-squares residual is
2801 /// within `resid_tol + sign_tol` — `1e-6 + 1e-4` here (`tol = 1e-8`,
2802 /// dual scale 1) — ~100× looser than `resid_tol` alone, by design (a
2803 /// sign inside the noise band is not evidence of descent). Two copies of
2804 /// the active row `x ≥ 0` make the multipliers a set; `∇f = −ε` leaves
2805 /// residual `ε` at the best non-negative multipliers. Pinned just inside
2806 /// and just outside the band so it cannot drift silently.
2807 #[test]
2808 fn the_set_search_band_is_resid_tol_plus_the_sign_band() {
2809 let m = Model {
2810 x_l: vec![NI],
2811 x_u: vec![PI],
2812 g_l: vec![0.0, 0.0],
2813 g_u: vec![PI, PI],
2814 pairs: vec![],
2815 jac: vec![(0, 0, 1.0), (1, 0, 1.0)],
2816 };
2817 let inside = run(&m, &[0.0], &[-5e-5], &[0.0, 0.0]);
2818 assert!(!inside.multipliers_unique, "{inside:?}");
2819 assert_eq!(inside.class, StationarityClass::S, "{inside:?}");
2820 let outside = run(&m, &[0.0], &[-2e-4], &[0.0, 0.0]);
2821 assert!(!outside.multipliers_unique, "{outside:?}");
2822 assert_eq!(outside.class, StationarityClass::None, "{outside:?}");
2823 }
2824
2825 #[test]
2826 fn certify_matches_the_python_classifier_on_complementarity() {
2827 let (m, eval) = complementarity();
2828 for (x, want, nb) in [
2829 (
2830 [1.0, 0.0, 2.0, 0.0, 0.0, 0.0, 3.0, 6.0],
2831 StationarityClass::S,
2832 0,
2833 ), // f = 17
2834 (
2835 [5.0, 2.0, 0.0, 0.0, 1.0, 10.0, 0.0, 0.0],
2836 StationarityClass::S,
2837 1,
2838 ), // f = 25
2839 (
2840 [3.0, 4.0, 0.0, 0.0, 0.0, 2.0, 3.0, 0.0],
2841 StationarityClass::W,
2842 1,
2843 ), // f = 85, Python: M
2844 ] {
2845 let (grad, g) = eval(&x);
2846 assert!(
2847 g[..4].iter().all(|v| v.abs() < 1e-12),
2848 "{x:?} is not feasible: {g:?}"
2849 );
2850 let c = run(&m, &x, &grad, &g);
2851 assert_eq!((c.class, c.n_biactive), (want, nb), "{x:?}: {c:?}");
2852 }
2853 }
2854
2855 #[test]
2856 fn certify_reads_kkt_on_a_plain_nlp() {
2857 // maratos: min 2(x0^2 + x1^2 - 1) - x0 s.t. x0^2 + x1^2 = 1.
2858 let m = Model {
2859 x_l: vec![NI; 2],
2860 x_u: vec![PI; 2],
2861 g_l: vec![1.0],
2862 g_u: vec![1.0],
2863 pairs: vec![],
2864 jac: vec![],
2865 };
2866 let at = |x: [Number; 2]| {
2867 let mut mm = Model {
2868 jac: vec![(0, 0, 2.0 * x[0]), (0, 1, 2.0 * x[1])],
2869 ..m.clone()
2870 };
2871 mm.jac.retain(|e| e.2 != 0.0);
2872 let grad = [4.0 * x[0] - 1.0, 4.0 * x[1]];
2873 run(&mm, &x, &grad, &[x[0] * x[0] + x[1] * x[1]]).class
2874 };
2875 assert_eq!(at([1.0, 0.0]), StationarityClass::S);
2876 assert_eq!(at([0.0, 1.0]), StationarityClass::None); // feasible, not stationary
2877 }
2878
2879 #[test]
2880 fn certify_ralph1_ctrap_and_qpec_small() {
2881 // ralph1: min 2x - y, x >= 0, 0 <= y ⟂ y - x >= 0. Origin: M, not S.
2882 let m = Model {
2883 x_l: vec![0.0, 0.0],
2884 x_u: vec![PI; 2],
2885 g_l: vec![0.0],
2886 g_u: vec![PI],
2887 pairs: vec![ComplementarityPair { row: 0, var: 1 }],
2888 jac: vec![(0, 0, -1.0), (0, 1, 1.0)],
2889 };
2890 let c = run(&m, &[0.0, 0.0], &[2.0, -1.0], &[0.0]);
2891 assert_eq!((c.class, c.n_biactive), (StationarityClass::W, 1), "{c:?}");
2892
2893 // ralph1 mirrored through x -> -x, y -> -y (both sides upper-bounded:
2894 // y <= 0 ⟂ y - x <= 0): the same point, the same verdict.
2895 let mirrored = Model {
2896 x_l: vec![NI, NI],
2897 x_u: vec![0.0, 0.0],
2898 g_l: vec![NI],
2899 g_u: vec![0.0],
2900 pairs: vec![ComplementarityPair { row: 0, var: 1 }],
2901 jac: vec![(0, 0, -1.0), (0, 1, 1.0)],
2902 };
2903 let c = run(&mirrored, &[0.0, 0.0], &[-2.0, 1.0], &[0.0]);
2904 assert_eq!((c.class, c.n_biactive), (StationarityClass::W, 1), "{c:?}");
2905
2906 // ctrap: min x1^2 + x2^2 - x1 - x2, 0 <= x1 ⟂ x2 >= 0. Origin: C, not S.
2907 let m = Model {
2908 x_l: vec![0.0, NI],
2909 x_u: vec![PI; 2],
2910 g_l: vec![0.0],
2911 g_u: vec![PI],
2912 pairs: vec![ComplementarityPair { row: 0, var: 0 }],
2913 jac: vec![(0, 1, 1.0)],
2914 };
2915 let c = run(&m, &[0.0, 0.0], &[-1.0, -1.0], &[0.0]);
2916 assert_eq!(c.class, StationarityClass::W, "{c:?}");
2917 // ... and its minimizer (1/2, 0) is S, strictly complementary.
2918 let c = run(&m, &[0.5, 0.0], &[0.0, -1.0], &[0.0]);
2919 assert_eq!((c.class, c.n_biactive), (StationarityClass::S, 0), "{c:?}");
2920
2921 // qpec_small at (1, 1, 0): grad f = 0, one biactive pair, S.
2922 let m = Model {
2923 x_l: vec![0.0, 0.0, 0.0],
2924 x_u: vec![2.0, PI, PI],
2925 g_l: vec![0.0, 0.0],
2926 g_u: vec![PI, PI],
2927 pairs: vec![
2928 ComplementarityPair { row: 0, var: 1 },
2929 ComplementarityPair { row: 1, var: 2 },
2930 ],
2931 jac: vec![(0, 0, -1.0), (0, 1, 2.0), (1, 0, 1.0), (1, 2, 2.0)],
2932 };
2933 let c = run(&m, &[1.0, 1.0, 0.0], &[0.0, 0.0, 0.0], &[0.0, 0.0]);
2934 assert_eq!((c.class, c.n_biactive), (StationarityClass::S, 1), "{c:?}");
2935 // ctrap's minimizer mirrored (x1 <= 0 ⟂ x2 <= 0, at (-1/2, 0)): S,
2936 // reading the orientation from the finite bounds.
2937 let m = Model {
2938 x_l: vec![NI, NI],
2939 x_u: vec![0.0, PI],
2940 g_l: vec![NI],
2941 g_u: vec![0.0],
2942 pairs: vec![ComplementarityPair { row: 0, var: 0 }],
2943 jac: vec![(0, 1, 1.0)],
2944 };
2945 let c = run(&m, &[-0.5, 0.0], &[0.0, 1.0], &[0.0]);
2946 assert_eq!((c.class, c.n_biactive), (StationarityClass::S, 0), "{c:?}");
2947 let c = run(&m, &[0.0, 0.0], &[1.0, 1.0], &[0.0]);
2948 assert_eq!(c.class, StationarityClass::W, "{c:?}");
2949 }
2950
2951 /// The class is a property of the multiplier *set*. Here an equality row
2952 /// is parallel to the pair's `H` side, so the multipliers are not unique;
2953 /// the minimum-norm vector splits the `-0.1` between them and puts a
2954 /// negative sign on the biactive `w`, but `w = 0, λ = -0.1, ν = 1` is an
2955 /// S vector. Reading one vector would report W.
2956 ///
2957 /// `min x0 - 0.1 x1 s.t. x1 = 0, 0 <= x0 ⟂ x1 >= 0`, at the origin.
2958 #[test]
2959 fn certify_searches_the_multiplier_set_when_it_is_not_a_point() {
2960 let m = Model {
2961 x_l: vec![0.0, NI],
2962 x_u: vec![PI; 2],
2963 g_l: vec![0.0, 0.0],
2964 g_u: vec![PI, 0.0],
2965 pairs: vec![ComplementarityPair { row: 0, var: 0 }],
2966 jac: vec![(0, 1, 1.0), (1, 1, 1.0)],
2967 };
2968 let c = run(&m, &[0.0, 0.0], &[1.0, -0.1], &[0.0, 0.0]);
2969 assert!(!c.multipliers_unique, "{c:?}");
2970 assert_eq!((c.class, c.n_biactive), (StationarityClass::S, 1), "{c:?}");
2971 // The minimum-norm vector alone would have said W.
2972 let a = Mat::<Number>::from_fn(2, 3, |r, col| match (r, col) {
2973 (0, 0) => 1.0,
2974 (1, 1) | (1, 2) => 1.0,
2975 _ => 0.0,
2976 });
2977 let (theta, _) = lstsq(&a, &[1.0, -0.1]);
2978 assert!(theta[1] < 0.0, "{theta:?}");
2979 }
2980
2981 #[test]
2982 fn certify_refuses_a_point_where_a_pair_is_not_complementary() {
2983 let m = Model {
2984 x_l: vec![0.0, NI],
2985 x_u: vec![PI; 2],
2986 g_l: vec![0.0],
2987 g_u: vec![PI],
2988 pairs: vec![ComplementarityPair { row: 0, var: 0 }],
2989 jac: vec![(0, 1, 1.0)],
2990 };
2991 let c = run(&m, &[0.5, 0.5], &[0.0, 0.0], &[0.5]);
2992 assert_eq!(
2993 (c.class, c.n_neither),
2994 (StationarityClass::None, 1),
2995 "{c:?}"
2996 );
2997 }
2998}