Skip to main content

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    pub n_pairs: usize,
667    pub n_biactive: usize,
668    /// Pairs with neither side at zero — complementarity does not hold.
669    pub n_neither: usize,
670    /// Columns of the stationarity system are linearly dependent, so the
671    /// multipliers form a set rather than a point (MPCC-LICQ fails).
672    pub multipliers_unique: bool,
673    /// `‖A θ − ∇f‖∞` at the multipliers that decided the class.
674    pub residual: Number,
675    /// The band `residual` is judged against: `max(1e-6, tol)` plus the
676    /// `sqrt(tol)` sign band, both times the dual scale (see [`certify`]).
677    pub resid_tol: Number,
678    /// The residual is within the floor alone,
679    /// `max(1e-6, tol)` times the dual scale. An `S` point that is not
680    /// `tight` reproduces `∇f` only to the `sqrt(tol)` band: the driver keeps
681    /// it and reports it only if no formulation produces a tight one. With a
682    /// multiplier set, the test is on the sign-free fit, as the old gate
683    /// into the set search was; the search itself then takes the band.
684    pub tight: bool,
685    /// Largest violation of a row or variable bound at the point (pair
686    /// sides included), in the model's units.
687    pub primal_violation: Number,
688    /// `primal_violation` is within [`CertificateInput::feas_tol`]. When it
689    /// is not, the class is `None` whatever the multipliers say.
690    pub feasible: bool,
691    /// Largest-magnitude negative sign on a biactive pair, when the class
692    /// is decided by a sign (`W` with unique multipliers).
693    pub worst_biactive_sign: Number,
694}
695
696/// The point and model data [`certify`] reads. All indices 0-based, the
697/// Jacobian as `(row, col, value)` triplets of the *inner* model's rows.
698pub struct CertificateInput<'a> {
699    pub x: &'a [Number],
700    pub grad_f: &'a [Number],
701    pub g: &'a [Number],
702    pub jac: &'a [(usize, usize, Number)],
703    pub x_l: &'a [Number],
704    pub x_u: &'a [Number],
705    pub g_l: &'a [Number],
706    pub g_u: &'a [Number],
707    pub pairs: &'a [ComplementarityPair],
708    /// The tolerance the answer was solved to (`tol`).
709    pub solver_tol: Number,
710    /// The feasibility tolerance (`constr_viol_tol`): a point violating a
711    /// row or bound by more is not certified, stationary or not.
712    pub feas_tol: Number,
713}
714
715/// Above this many sign-constrained columns, a rank-deficient system is not
716/// searched (each NNLS step is an SVD of the passive columns).
717pub const MAX_SEARCHED_COLUMNS: usize = 600;
718
719/// Above this many entries in the `n × k` stationarity matrix, a system whose
720/// multipliers are **not unique** is not searched and reports
721/// [`StationarityClass::NotSearched`] (which [`answers`] accepts, so the
722/// ladder is unchanged). The set search factors the matrix densely; at
723/// `n ≈ 10⁴` with `k ≈ n + m` that is about 1 GB and 10¹² flops per call, a
724/// size contact-implicit trajectory models reach. 4·10⁶ entries (32 MB)
725/// keeps the call well under a second. A system with unique multipliers —
726/// the common case, MPCC-LICQ — is certified at any size, sparsely (see
727/// [`DENSE_ENTRIES`]).
728pub const MAX_DENSE_ENTRIES: usize = 4_000_000;
729
730/// Up to this many entries the stationarity system is factored densely (a
731/// thin SVD, rank by LAPACK's threshold); above it, by the interior-point
732/// method's own sparse LDLᵀ of the least-squares augmented system
733/// ([`sparse_least_squares`]). The dense SVD's cost grows as `n·k²`: on a
734/// 650-pair chain model (`n = 1950`) the solve took 4.8 s with the dense
735/// certificate and 0.51 s with the sparse one, and at 1100 pairs 31 s
736/// against 0.93 s, same class and answer. Below 2.5·10⁵ entries the SVD costs little and keeps the path
737/// the certificate was validated on against `stationarity.py` (the Gate 0
738/// corpus, bunny-hop, ball-in-cup); the CALIPSO rocket (`n = 856`) is past
739/// it and certifies S on the sparse path exactly as it did densely. The
740/// unit tests hold the two paths to the same class on every point they
741/// check.
742pub const DENSE_ENTRIES: usize = 250_000;
743
744const INF: Number = 1e19;
745
746#[derive(Clone, Copy, PartialEq)]
747enum Sign {
748    Free,
749    NonNeg,
750    /// Non-negative under S, free under W.
751    Biactive,
752}
753
754/// Which stationarity class `x` satisfies, as `benchmarks/calipso/
755/// stationarity.py` decides it — same columns, same thresholds — so the two
756/// can be held to each other on the same points.
757///
758/// The stationarity system is
759/// `∇f = Σ λ ∇g + Σ ν ∇G + Σ w ∇H + z_L − z_U` with: equality rows free;
760/// active inequality rows and bounds sign-constrained (inactive ones absent);
761/// a pair side free when only it is active, absent when inactive, and on a
762/// biactive pair non-negative under S and free under W.
763///
764/// * Activity: a pair side counts as zero within `sqrt(solver_tol)` times
765///   the family's largest magnitude (a product driven to `tol` pins each
766///   side only to its square root); rows and bounds within
767///   `sqrt(solver_tol)`.
768/// * Residual: judged against `max(1e-6, solver_tol)` times the dual scale
769///   — the largest entry of `∇f` and of every usable column.
770/// * Signs: a multiplier counts as negative only below `-sqrt(solver_tol)`
771///   times the dual scale. An answer near a biactive pair is itself only
772///   `sqrt(tol)`-accurate, so smaller signs are noise, not descent. This is
773///   the one place this departs from `stationarity.py`, whose BVLS has no
774///   band; on the points it was run on (exact or `tol=1e-8` answers away
775///   from biactive pairs) the two agree.
776///
777/// When the columns have full rank the multipliers are unique and the
778/// class is a sign check. When they do not (MPCC-LICQ fails) the class is a
779/// property of the multiplier *set* — reading one vector can report W for a
780/// point that is S — so the set is searched with NNLS, up to
781/// [`MAX_SEARCHED_COLUMNS`] and [`MAX_DENSE_ENTRIES`]; a larger set reports
782/// `NotSearched`. Above [`DENSE_ENTRIES`] the unique multipliers come from a
783/// sparse factorization rather than the SVD.
784pub fn certify(inp: &CertificateInput<'_>) -> Certificate {
785    certify_routed(inp, None)
786}
787
788/// Each pair's orientation, values and activity at a point, as both the
789/// certificate and the B-stationarity check read them.
790struct PairActivity {
791    mixed: Vec<bool>,
792    /// `(s_G, b)` and `(s_H, c)`: `G = s_G (x_j - b)`, `H = s_H (g_i - c)`.
793    g_side: Vec<(Number, Number)>,
794    h_side: Vec<(Number, Number)>,
795    hv: Vec<Number>,
796    g_act: Vec<bool>,
797    h_act: Vec<bool>,
798}
799
800fn pair_activity(inp: &CertificateInput<'_>, corner: Number) -> PairActivity {
801    // Pair sides and their family scales.
802    // Each side's orientation from its one finite bound, as MpccTnlp reads
803    // it: (+1, lower) or (-1, upper).
804    let side = |lo: Number, hi: Number| -> (Number, Number) {
805        if lo > -INF { (1.0, lo) } else { (-1.0, hi) }
806    };
807    // A mixed pair (`l <= x <= u ⟂ g`, the row free) is, at a given point,
808    // the one-sided pair of the bound `x` is nearer: `x - l ⟂ g` at `l`,
809    // `u - x ⟂ -g` at `u` (strictly between, `G` is inactive either way and
810    // `g = 0` is the condition).
811    let mixed: Vec<bool> = inp
812        .pairs
813        .iter()
814        .map(|p| {
815            pounce_nlp::mpcc::is_mixed(
816                inp.x_l[p.var],
817                inp.x_u[p.var],
818                inp.g_l[p.row],
819                inp.g_u[p.row],
820            )
821        })
822        .collect();
823    let g_side: Vec<(Number, Number)> = inp
824        .pairs
825        .iter()
826        .zip(&mixed)
827        .map(|(p, &mx)| {
828            let (lo, hi) = (inp.x_l[p.var], inp.x_u[p.var]);
829            if mx {
830                if inp.x[p.var] - lo <= hi - inp.x[p.var] {
831                    (1.0, lo)
832                } else {
833                    (-1.0, hi)
834                }
835            } else if lo > -INF && hi < INF {
836                // A boxed variable in a one-sided pair: the row's finite side
837                // names the bound in the pair, as MpccTnlp reads it; the
838                // other bound is an ordinary limit (columns below).
839                if inp.g_l[p.row] > -INF {
840                    (1.0, lo)
841                } else {
842                    (-1.0, hi)
843                }
844            } else {
845                side(lo, hi)
846            }
847        })
848        .collect();
849    let h_side: Vec<(Number, Number)> = inp
850        .pairs
851        .iter()
852        .zip(&mixed)
853        .zip(&g_side)
854        .map(|((p, &mx), &(sg, _))| {
855            if mx {
856                (sg, 0.0)
857            } else {
858                side(inp.g_l[p.row], inp.g_u[p.row])
859            }
860        })
861        .collect();
862    let gv: Vec<Number> = inp
863        .pairs
864        .iter()
865        .zip(&g_side)
866        .map(|(p, &(s, b))| s * (inp.x[p.var] - b))
867        .collect();
868    let hv: Vec<Number> = inp
869        .pairs
870        .iter()
871        .zip(&h_side)
872        .map(|(p, &(s, c))| s * (inp.g[p.row] - c))
873        .collect();
874    let gs = gv.iter().fold(1.0_f64, |a, v| a.max(v.abs()));
875    let hs = hv.iter().fold(1.0_f64, |a, v| a.max(v.abs()));
876    let g_act: Vec<bool> = gv.iter().map(|v| v.abs() <= corner * gs).collect();
877    let h_act: Vec<bool> = hv.iter().map(|v| v.abs() <= corner * hs).collect();
878    PairActivity {
879        mixed,
880        g_side,
881        h_side,
882        hv,
883        g_act,
884        h_act,
885    }
886}
887
888/// [`certify`], with the dense/sparse choice forced when `force_sparse` is
889/// set — for holding the two paths to each other in tests.
890fn certify_routed(inp: &CertificateInput<'_>, force_sparse: Option<bool>) -> Certificate {
891    let n = inp.x.len();
892    let m = inp.g.len();
893    let corner = inp.solver_tol.max(0.0).sqrt();
894    let row_tol = corner;
895
896    let PairActivity {
897        mixed,
898        g_side,
899        h_side,
900        hv,
901        g_act,
902        h_act,
903    } = pair_activity(inp, corner);
904    let n_biactive = g_act.iter().zip(&h_act).filter(|(a, b)| **a && **b).count();
905    let n_neither = g_act
906        .iter()
907        .zip(&h_act)
908        .filter(|(a, b)| !**a && !**b)
909        .count();
910
911    // Primal feasibility, in the model's units: every row and every bound,
912    // pair sides included. A point outside its own constraints is not a
913    // stationary point of the model, whatever multipliers reproduce `∇f`
914    // (an infeasibility verdict's point did read S before this check).
915    let viol = |v: Number, lo: Number, hi: Number| {
916        let below = if lo > -INF { lo - v } else { 0.0 };
917        let above = if hi < INF { v - hi } else { 0.0 };
918        below.max(above).max(0.0)
919    };
920    // A mixed pair's row is free, so no row bound carries the sign of `g` at
921    // an active bound (`g >= 0` at `l`, `g <= 0` at `u`): measure it here.
922    let mixed_sign = (0..inp.pairs.len())
923        .filter(|&k| mixed[k] && g_act[k])
924        .map(|k| (-hv[k]).max(0.0));
925    let primal_violation = (0..m)
926        .map(|i| viol(inp.g[i], inp.g_l[i], inp.g_u[i]))
927        .chain((0..n).map(|j| viol(inp.x[j], inp.x_l[j], inp.x_u[j])))
928        .chain(mixed_sign)
929        .fold(0.0, Number::max);
930    let feasible = primal_violation <= inp.feas_tol;
931
932    let mut pair_row = vec![None; m];
933    let mut pair_var = vec![None; n];
934    for (k, p) in inp.pairs.iter().enumerate() {
935        pair_row[p.row] = Some(k);
936        pair_var[p.var] = Some(k);
937    }
938    let mut rows_of: Vec<Vec<(usize, Number)>> = vec![Vec::new(); m];
939    for &(r, c, v) in inp.jac {
940        rows_of[r].push((c, v));
941    }
942
943    // Columns: (sparse entries, sign class).
944    let mut cols: Vec<(Vec<(usize, Number)>, Sign)> = Vec::new();
945    for i in 0..m {
946        if let Some(k) = pair_row[i] {
947            let sh = h_side[k].0;
948            let grad_h: Vec<(usize, Number)> =
949                rows_of[i].iter().map(|&(c, v)| (c, sh * v)).collect();
950            match (g_act[k], h_act[k]) {
951                (_, false) => {}
952                (true, true) => cols.push((grad_h, Sign::Biactive)),
953                (false, true) => cols.push((grad_h, Sign::Free)),
954            }
955            continue;
956        }
957        let (lo, hi) = (inp.g_l[i], inp.g_u[i]);
958        let at_lo = lo > -INF && inp.g[i] - lo <= row_tol;
959        let at_hi = hi < INF && hi - inp.g[i] <= row_tol;
960        let neg: Vec<(usize, Number)> = rows_of[i].iter().map(|&(c, v)| (c, -v)).collect();
961        match (at_lo, at_hi) {
962            (true, true) => cols.push((rows_of[i].clone(), Sign::Free)),
963            (true, false) => cols.push((rows_of[i].clone(), Sign::NonNeg)),
964            (false, true) => cols.push((neg, Sign::NonNeg)),
965            (false, false) => {}
966        }
967    }
968    for (j, pv) in pair_var.iter().enumerate() {
969        if let Some(k) = *pv {
970            let sg = g_side[k].0;
971            match (g_act[k], h_act[k]) {
972                (false, _) => {}
973                (true, true) => cols.push((vec![(j, sg)], Sign::Biactive)),
974                (true, false) => cols.push((vec![(j, sg)], Sign::Free)),
975            }
976            // A one-sided pair variable's other bound, when it has one, is an
977            // ordinary limit: a sign-constrained column when active. (A mixed
978            // pair's other bound is the other branch, handled above.)
979            if !mixed[k] {
980                let (lo, hi) = (inp.x_l[j], inp.x_u[j]);
981                let other_active = if sg > 0.0 {
982                    hi < INF && hi - inp.x[j] <= row_tol
983                } else {
984                    lo > -INF && inp.x[j] - lo <= row_tol
985                };
986                if other_active {
987                    cols.push((vec![(j, -sg)], Sign::NonNeg));
988                }
989            }
990            continue;
991        }
992        let (lo, hi) = (inp.x_l[j], inp.x_u[j]);
993        let at_lo = lo > -INF && inp.x[j] - lo <= row_tol;
994        let at_hi = hi < INF && hi - inp.x[j] <= row_tol;
995        match (at_lo, at_hi) {
996            (true, true) => cols.push((vec![(j, 1.0)], Sign::Free)),
997            (true, false) => cols.push((vec![(j, 1.0)], Sign::NonNeg)),
998            (false, true) => cols.push((vec![(j, -1.0)], Sign::NonNeg)),
999            (false, false) => {}
1000        }
1001    }
1002
1003    let b = inp.grad_f.to_vec();
1004    let k = cols.len();
1005    let col_max = cols
1006        .iter()
1007        .flat_map(|(ents, _)| ents)
1008        .fold(0.0_f64, |m, &(_, v)| m.max(v.abs()));
1009    let dual_scale = 1.0_f64
1010        .max(b.iter().fold(0.0_f64, |m, v| m.max(v.abs())))
1011        .max(col_max);
1012    let resid_tol = inp.solver_tol.max(1e-6) * dual_scale;
1013    // The noise band on a multiplier's sign. An answer near a biactive pair
1014    // is accurate only to `sqrt(tol)` (that is where the activity threshold
1015    // comes from), so `∇f` there — and every multiplier reproducing it —
1016    // carries an error of that order. qpec_small's converged point, accepted
1017    // at products <= 1e-4, has a biactive multiplier of -1.6e-4 while the
1018    // exact point's is 0. A sign inside the band is not evidence of descent.
1019    let sign_tol = corner * dual_scale;
1020    // One acceptance band on every path: the residual's own floor plus the
1021    // sign band. The point is `sqrt(tol)`-accurate near the boundary, so
1022    // `∇f` is reproduced only to that order; the set search always allowed
1023    // it, and the unique path holding the same point to `resid_tol` alone
1024    // rejected optimal IPM answers (MacMPEC `flp4-3`, `flp4-4`,
1025    // `portfl-i-1`: residuals 1.7e-6 to 2.1e-5 at f within 0.2% of f*).
1026    let accept_tol = resid_tol + sign_tol;
1027    let signs: Vec<Sign> = cols.iter().map(|(_, s)| *s).collect();
1028
1029    let mut cert = Certificate {
1030        class: StationarityClass::None,
1031        n_pairs: inp.pairs.len(),
1032        n_biactive,
1033        n_neither,
1034        multipliers_unique: true,
1035        residual: Number::INFINITY,
1036        resid_tol: accept_tol,
1037        tight: false,
1038        primal_violation,
1039        feasible,
1040        worst_biactive_sign: 0.0,
1041    };
1042    if n_neither > 0 || !feasible {
1043        // Complementarity, or feasibility itself, fails at this point: no
1044        // class applies.
1045        return cert;
1046    }
1047
1048    // Unique multipliers: the class is a sign check on them.
1049    let classify_unique = |cert: &mut Certificate, theta: &[Number]| {
1050        let ordinary_ok = signs
1051            .iter()
1052            .zip(theta)
1053            .all(|(s, t)| *s != Sign::NonNeg || *t >= -sign_tol);
1054        if !ordinary_ok {
1055            return;
1056        }
1057        let worst = signs
1058            .iter()
1059            .zip(theta)
1060            .filter(|(s, _)| **s == Sign::Biactive)
1061            .fold(0.0_f64, |w, (_, t)| w.min(*t));
1062        cert.worst_biactive_sign = worst;
1063        cert.class = if worst >= -sign_tol {
1064            StationarityClass::S
1065        } else {
1066            StationarityClass::W
1067        };
1068    };
1069
1070    let sparse = force_sparse.unwrap_or(n.saturating_mul(k) > DENSE_ENTRIES);
1071    if sparse {
1072        let entries: Vec<Vec<(usize, Number)>> = cols.iter().map(|(e, _)| e.clone()).collect();
1073        match sparse_least_squares(n, &entries, &b) {
1074            LeastSquares::Solved(theta) => {
1075                let mut r = b.clone();
1076                for (ents, t) in entries.iter().zip(&theta) {
1077                    for &(row, v) in ents {
1078                        r[row] -= v * t;
1079                    }
1080                }
1081                cert.residual = r.iter().fold(0.0, |m: Number, v| m.max(v.abs()));
1082                cert.tight = cert.residual <= resid_tol;
1083                if cert.residual <= accept_tol {
1084                    classify_unique(&mut cert, &theta);
1085                }
1086                return cert;
1087            }
1088            LeastSquares::RankDeficient if n.saturating_mul(k) <= MAX_DENSE_ENTRIES => {
1089                // The set search below is dense; it fits.
1090            }
1091            LeastSquares::RankDeficient | LeastSquares::Failed => {
1092                cert.multipliers_unique = false;
1093                cert.residual = Number::NAN;
1094                cert.class = StationarityClass::NotSearched;
1095                cert.tight = true; // nothing measured looser: accepted as before
1096                return cert;
1097            }
1098        }
1099    }
1100
1101    let mut a = Mat::<Number>::from_fn(n, k, |_, _| 0.0);
1102    for (c, (ents, _)) in cols.iter().enumerate() {
1103        for &(r, v) in ents {
1104            a[(r, c)] += v;
1105        }
1106    }
1107
1108    // Sign-free least squares: the W test's lower bound on the residual,
1109    // and the unique multipliers when the columns have full rank.
1110    let (theta, rank) = lstsq(&a, &b);
1111    let r_free = resid_inf(&a, &theta, &b);
1112    cert.residual = r_free;
1113    cert.tight = r_free <= resid_tol;
1114    if r_free > accept_tol {
1115        return cert; // not stationary for any multipliers, signs aside
1116    }
1117    if rank == k {
1118        classify_unique(&mut cert, &theta);
1119        return cert;
1120    }
1121
1122    // Non-unique multipliers: search the set.
1123    cert.multipliers_unique = false;
1124    let constrained = signs.iter().filter(|s| **s != Sign::Free).count();
1125    if constrained > MAX_SEARCHED_COLUMNS {
1126        cert.class = StationarityClass::NotSearched;
1127        cert.tight = true; // nothing measured looser: accepted as before
1128        return cert;
1129    }
1130    for (class, biactive_signed) in [(StationarityClass::S, true), (StationarityClass::W, false)] {
1131        let nonneg: Vec<bool> = signs
1132            .iter()
1133            .map(|s| *s == Sign::NonNeg || (biactive_signed && *s == Sign::Biactive))
1134            .collect();
1135        // Forcing a multiplier inside the noise band to zero moves the
1136        // residual by up to that band; allow it.
1137        let r = signed_lstsq_residual(&a, &b, &nonneg);
1138        if r <= accept_tol {
1139            cert.class = class;
1140            cert.residual = r;
1141            // Tight as the sign-free fit was: before the band was unified, a
1142            // point reached this search only with `r_free <= resid_tol`, and
1143            // then took the whole band (see
1144            // `the_set_search_band_is_resid_tol_plus_the_sign_band`). Those
1145            // points stay answers; one the old gate turned away is kept.
1146            cert.tight = r_free <= resid_tol;
1147            return cert;
1148        }
1149    }
1150    cert
1151}
1152
1153/// What [`sparse_least_squares`] found.
1154#[derive(Debug, Clone, PartialEq)]
1155enum LeastSquares {
1156    /// The unique minimizer `θ`.
1157    Solved(Vec<Number>),
1158    /// The columns are (numerically) dependent: the minimizers form a set.
1159    RankDeficient,
1160    /// Malformed input, or the factorization or solve failed.
1161    Failed,
1162}
1163
1164/// `min ‖A θ − b‖₂` for a sparse `A` (`n × k`, columns as `(row, value)`
1165/// lists, repeated rows summed), through the least-squares augmented system
1166///
1167/// ```text
1168/// [ s·I  A ] [ r ]   [ b ]
1169/// [ Aᵀ   0 ] [ θ ] = [ 0 ]      (s·r = b − A θ,  Aᵀ r = 0)
1170/// ```
1171///
1172/// factored by the same symmetric indefinite solver the interior-point
1173/// method uses on its own KKT systems (FERAL, through
1174/// [`SparseSymLinearSolverInterface`]), with `s = max(1, max|A|)`. The
1175/// system has exactly `n` positive and `k` negative eigenvalues when `A`
1176/// has full column rank, so the factorization's inertia is the rank test:
1177/// a zero pivot or any other negative count is [`LeastSquares::RankDeficient`],
1178/// and no answer is returned rather than a perturbed one.
1179fn sparse_least_squares(n: usize, cols: &[Vec<(usize, Number)>], b: &[Number]) -> LeastSquares {
1180    use pounce_linsol::{ESymSolverStatus, SparseSymLinearSolverInterface};
1181
1182    let k = cols.len();
1183    if b.len() != n || cols.iter().flatten().any(|&(r, _)| r >= n) {
1184        return LeastSquares::Failed;
1185    }
1186    if k == 0 {
1187        return LeastSquares::Solved(Vec::new());
1188    }
1189    if k > n {
1190        return LeastSquares::RankDeficient;
1191    }
1192    let a_max = cols
1193        .iter()
1194        .flatten()
1195        .fold(0.0_f64, |m, &(_, v)| m.max(v.abs()));
1196    let s = a_max.max(1.0);
1197    // Lower triangle, 1-based (FERAL's triplet convention): the identity
1198    // block on the diagonal, Aᵀ below it in rows n+1..n+k.
1199    let mut trip: Vec<(Index, Index, Number)> = (1..=n as Index).map(|i| (i, i, s)).collect();
1200    for (j, c) in cols.iter().enumerate() {
1201        let mut c = c.clone();
1202        c.sort_by_key(|&(r, _)| r);
1203        for (r, v) in c {
1204            let (row, col) = ((n + j + 1) as Index, (r + 1) as Index);
1205            match trip.last_mut() {
1206                Some(last) if last.0 == row && last.1 == col => last.2 += v,
1207                _ => trip.push((row, col, v)),
1208            }
1209        }
1210    }
1211    let irn: Vec<Index> = trip.iter().map(|t| t.0).collect();
1212    let jcn: Vec<Index> = trip.iter().map(|t| t.1).collect();
1213    let mut solver = pounce_feral::FeralSolverInterface::new();
1214    let dim = (n + k) as Index;
1215    if solver.initialize_structure(dim, irn.len() as Index, &irn, &jcn) != ESymSolverStatus::Success
1216    {
1217        return LeastSquares::Failed;
1218    }
1219    for (slot, t) in solver.values_array_mut().iter_mut().zip(&trip) {
1220        *slot = t.2;
1221    }
1222    let mut rhs0 = vec![0.0; n + k];
1223    rhs0[..n].copy_from_slice(b);
1224    let mut z = rhs0.clone();
1225    match solver.multi_solve(true, &irn, &jcn, 1, &mut z, true, k as Index) {
1226        ESymSolverStatus::Success => {}
1227        ESymSolverStatus::Singular | ESymSolverStatus::WrongInertia => {
1228            return LeastSquares::RankDeficient;
1229        }
1230        _ => return LeastSquares::Failed,
1231    }
1232    // Iterative refinement on the same factor: the multipliers' signs are
1233    // what the class is read from, and the augmented system is as
1234    // ill-conditioned as A's columns are close to dependent.
1235    for _ in 0..REFINEMENT_STEPS {
1236        let mut corr = rhs0.clone();
1237        for &(r, c, v) in &trip {
1238            let (r, c) = ((r - 1) as usize, (c - 1) as usize);
1239            corr[r] -= v * z[c];
1240            if r != c {
1241                corr[c] -= v * z[r];
1242            }
1243        }
1244        if solver.multi_solve(false, &irn, &jcn, 1, &mut corr, false, 0)
1245            != ESymSolverStatus::Success
1246        {
1247            return LeastSquares::Failed;
1248        }
1249        z.iter_mut().zip(&corr).for_each(|(zi, ci)| *zi += ci);
1250    }
1251    if z.iter().all(|v| v.is_finite()) {
1252        LeastSquares::Solved(z[n..].to_vec())
1253    } else {
1254        LeastSquares::Failed
1255    }
1256}
1257
1258const REFINEMENT_STEPS: usize = 2;
1259
1260/// Minimum-norm least squares by thin SVD; returns the solution and the
1261/// numerical rank (LAPACK's threshold).
1262fn lstsq(a: &Mat<Number>, b: &[Number]) -> (Vec<Number>, usize) {
1263    let (n, k) = (a.nrows(), a.ncols());
1264    if k == 0 {
1265        return (Vec::new(), 0);
1266    }
1267    let Ok(svd) = a.thin_svd() else {
1268        return (vec![0.0; k], 0);
1269    };
1270    let s: Vec<Number> = svd.S().column_vector().iter().copied().collect();
1271    let smax = s.first().copied().unwrap_or(0.0);
1272    let tol = smax * (n.max(k) as Number) * Number::EPSILON;
1273    let u = svd.U();
1274    let v = svd.V();
1275    let mut theta = vec![0.0; k];
1276    let mut rank = 0;
1277    for (i, &si) in s.iter().enumerate() {
1278        if si <= tol {
1279            continue;
1280        }
1281        rank += 1;
1282        let coef = (0..n).map(|r| u[(r, i)] * b[r]).sum::<Number>() / si;
1283        for (c, t) in theta.iter_mut().enumerate() {
1284            *t += coef * v[(c, i)];
1285        }
1286    }
1287    (theta, rank)
1288}
1289
1290fn resid_inf(a: &Mat<Number>, theta: &[Number], b: &[Number]) -> Number {
1291    (0..a.nrows())
1292        .map(|r| ((0..a.ncols()).map(|c| a[(r, c)] * theta[c]).sum::<Number>() - b[r]).abs())
1293        .fold(0.0, Number::max)
1294}
1295
1296/// `min ‖A θ − b‖₂` with `θ_c >= 0` where `nonneg[c]`, the rest free; returns
1297/// the ∞-norm residual. The free columns are projected out (their least-
1298/// squares part is exact), then Lawson–Hanson NNLS on the rest.
1299fn signed_lstsq_residual(a: &Mat<Number>, b: &[Number], nonneg: &[bool]) -> Number {
1300    let n = a.nrows();
1301    let free: Vec<usize> = (0..a.ncols()).filter(|&c| !nonneg[c]).collect();
1302    let cons: Vec<usize> = (0..a.ncols()).filter(|&c| nonneg[c]).collect();
1303    // Orthonormal basis of range(A_free).
1304    let basis: Vec<Vec<Number>> = if free.is_empty() {
1305        Vec::new()
1306    } else {
1307        let af = Mat::<Number>::from_fn(n, free.len(), |r, c| a[(r, free[c])]);
1308        match af.thin_svd() {
1309            Ok(svd) => {
1310                let s: Vec<Number> = svd.S().column_vector().iter().copied().collect();
1311                let smax = s.first().copied().unwrap_or(0.0);
1312                let tol = smax * (n.max(free.len()) as Number) * Number::EPSILON;
1313                let u = svd.U();
1314                (0..s.len())
1315                    .filter(|&i| s[i] > tol)
1316                    .map(|i| (0..n).map(|r| u[(r, i)]).collect())
1317                    .collect()
1318            }
1319            Err(_) => return Number::INFINITY,
1320        }
1321    };
1322    let project = |v: &mut Vec<Number>| {
1323        for q in &basis {
1324            let d: Number = q.iter().zip(v.iter()).map(|(x, y)| x * y).sum();
1325            for (vi, qi) in v.iter_mut().zip(q) {
1326                *vi -= d * qi;
1327            }
1328        }
1329    };
1330    let mut pb = b.to_vec();
1331    project(&mut pb);
1332    let pa: Vec<Vec<Number>> = cons
1333        .iter()
1334        .map(|&c| {
1335            let mut v: Vec<Number> = (0..n).map(|r| a[(r, c)]).collect();
1336            project(&mut v);
1337            v
1338        })
1339        .collect();
1340    let x = nnls(&pa, &pb, n);
1341    (0..n)
1342        .map(|r| {
1343            (pb[r]
1344                - pa.iter()
1345                    .zip(&x)
1346                    .map(|(col, xi)| col[r] * xi)
1347                    .sum::<Number>())
1348            .abs()
1349        })
1350        .fold(0.0, Number::max)
1351}
1352
1353/// Lawson–Hanson non-negative least squares, columns given explicitly.
1354fn nnls(cols: &[Vec<Number>], b: &[Number], n: usize) -> Vec<Number> {
1355    let k = cols.len();
1356    let mut x = vec![0.0; k];
1357    if k == 0 {
1358        return x;
1359    }
1360    let mut passive = vec![false; k];
1361    let resid = |x: &[Number]| -> Vec<Number> {
1362        (0..n)
1363            .map(|r| b[r] - cols.iter().zip(x).map(|(c, xi)| c[r] * xi).sum::<Number>())
1364            .collect()
1365    };
1366    let grad = |res: &[Number]| -> Vec<Number> {
1367        cols.iter()
1368            .map(|c| c.iter().zip(res).map(|(a, r)| a * r).sum())
1369            .collect()
1370    };
1371    let scale = cols
1372        .iter()
1373        .flatten()
1374        .fold(0.0_f64, |m, v| m.max(v.abs()))
1375        .max(1.0)
1376        * b.iter().fold(0.0_f64, |m, v| m.max(v.abs())).max(1.0);
1377    let wtol = 1e-12 * scale * (n.max(k) as Number);
1378    // A column freed and dropped again in the same step (its least-squares
1379    // coefficient is not positive, so `alpha = 0`) would be picked again
1380    // next step with nothing changed; it sits out until some other column
1381    // enters and moves `x`.
1382    let mut blocked = vec![false; k];
1383    for _outer in 0..3 * k + 10 {
1384        let w = grad(&resid(&x));
1385        let Some((t, wt)) = (0..k)
1386            .filter(|&j| !passive[j] && !blocked[j])
1387            .map(|j| (j, w[j]))
1388            .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
1389        else {
1390            break;
1391        };
1392        if wt <= wtol {
1393            break;
1394        }
1395        passive[t] = true;
1396        for _inner in 0..3 * k + 10 {
1397            let p: Vec<usize> = (0..k).filter(|&j| passive[j]).collect();
1398            let ap = Mat::<Number>::from_fn(n, p.len(), |r, c| cols[p[c]][r]);
1399            let (z, _) = lstsq(&ap, b);
1400            if z.iter().all(|&v| v > 0.0) {
1401                for (i, &j) in p.iter().enumerate() {
1402                    x[j] = z[i];
1403                }
1404                break;
1405            }
1406            let mut alpha = 1.0_f64;
1407            for (i, &j) in p.iter().enumerate() {
1408                if z[i] <= 0.0 {
1409                    let d = x[j] - z[i];
1410                    if d > 0.0 {
1411                        alpha = alpha.min(x[j] / d);
1412                    }
1413                }
1414            }
1415            for (i, &j) in p.iter().enumerate() {
1416                x[j] += alpha * (z[i] - x[j]);
1417                if x[j] <= 1e-15 * scale {
1418                    x[j] = 0.0;
1419                    passive[j] = false;
1420                }
1421            }
1422        }
1423        if passive[t] {
1424            blocked.fill(false);
1425        } else {
1426            blocked[t] = true;
1427        }
1428    }
1429    x
1430}
1431
1432// ---------------------------------------------------------------------------
1433// B-stationarity: whether a feasible direction along some branch descends.
1434// ---------------------------------------------------------------------------
1435
1436/// Which side of a pair is held at zero along a branch.
1437#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1438pub enum HeldSide {
1439    G,
1440    H,
1441}
1442
1443/// What [`check_b_stationarity`] found.
1444#[derive(Debug, Clone, PartialEq)]
1445pub enum BVerdict {
1446    /// No branch has a first-order descent direction: B-stationary.
1447    B,
1448    /// The branch `held` (one entry per pair) descends: `∇f·d = decrease`
1449    /// (negative) for a step `d` in the unit box.
1450    NotB {
1451        held: Vec<HeldSide>,
1452        decrease: Number,
1453    },
1454    /// Not decided, with the reason.
1455    NotChecked(&'static str),
1456}
1457
1458/// [`check_b_stationarity`]'s verdict and what it took.
1459#[derive(Debug, Clone, PartialEq)]
1460pub struct BCheck {
1461    pub verdict: BVerdict,
1462    pub n_biactive: usize,
1463    /// Branch LPs solved.
1464    pub branches_solved: usize,
1465    /// Whether every branch was enumerated (`false`: the heuristic above
1466    /// [`MAX_ENUMERATED_BIACTIVE`], which can find a descent but never
1467    /// certify B).
1468    pub enumerated: bool,
1469}
1470
1471/// Up to this many biactive pairs every one of the `2^b` branches is
1472/// checked; above it, one heuristic branch.
1473pub const MAX_ENUMERATED_BIACTIVE: usize = 10;
1474
1475/// Whether `x` is B-stationary: no direction `d` feasible for the linearized
1476/// constraints along any branch of the biactive pairs has `∇f·d < 0`.
1477///
1478/// Each branch is an LP: `min ∇f·d` over the linearized active rows and
1479/// bounds, with each pair's active side held (`∇G·d = 0` or `∇H·d = 0`) and,
1480/// on a biactive pair, the side the branch releases kept non-negative, in the
1481/// box `-1 <= d <= 1`. `d = 0` is always feasible, so every LP has optimum
1482/// `<= 0`; the point is B-stationary exactly when all of them are `0`. Up to
1483/// [`MAX_ENUMERATED_BIACTIVE`] biactive pairs all `2^b` branches are solved
1484/// (the LPEC by enumeration, no MILP); above it, the relaxed LP — both sides
1485/// of every biactive pair only non-negative — picks one branch (hold the side
1486/// its direction moves less), which is solved.
1487///
1488/// A decrease counts only below `-sqrt(solver_tol)·max(1, ‖∇f‖∞)`, the
1489/// certificate's sign band: near a biactive pair the point itself is only
1490/// `sqrt(tol)`-accurate. S implies B, so a caller checks only points the
1491/// certificate did not call S. The point must be feasible and every pair
1492/// complementary.
1493pub fn check_b_stationarity(inp: &CertificateInput<'_>) -> BCheck {
1494    let corner = inp.solver_tol.max(0.0).sqrt();
1495    let act = pair_activity(inp, corner);
1496    let np = inp.pairs.len();
1497    let biactive: Vec<usize> = (0..np).filter(|&k| act.g_act[k] && act.h_act[k]).collect();
1498    let mut out = BCheck {
1499        verdict: BVerdict::NotChecked(""),
1500        n_biactive: biactive.len(),
1501        branches_solved: 0,
1502        enumerated: false,
1503    };
1504    if (0..np).any(|k| !act.g_act[k] && !act.h_act[k]) {
1505        out.verdict = BVerdict::NotChecked("a pair is not complementary at the point");
1506        return out;
1507    }
1508    let scale = inp.grad_f.iter().fold(1.0_f64, |a, v| a.max(v.abs()));
1509    let band = corner * scale;
1510    // Held side per pair, `None` on a biactive pair left to the branch.
1511    let held_of = |choice: &dyn Fn(usize) -> Option<HeldSide>| -> Vec<Option<HeldSide>> {
1512        (0..np)
1513            .map(|k| match (act.g_act[k], act.h_act[k]) {
1514                (true, false) => Some(HeldSide::G),
1515                (false, true) => Some(HeldSide::H),
1516                _ => choice(k),
1517            })
1518            .collect()
1519    };
1520    let full = |h: &[Option<HeldSide>]| -> Vec<HeldSide> {
1521        h.iter().map(|s| s.unwrap_or(HeldSide::G)).collect()
1522    };
1523
1524    if biactive.len() <= MAX_ENUMERATED_BIACTIVE {
1525        out.enumerated = true;
1526        let mut best: Option<(Number, Vec<HeldSide>)> = None;
1527        for mask in 0..(1usize << biactive.len()) {
1528            let held = held_of(&|k| {
1529                let bit = biactive.iter().position(|&b| b == k).unwrap();
1530                Some(if mask >> bit & 1 == 0 {
1531                    HeldSide::G
1532                } else {
1533                    HeldSide::H
1534                })
1535            });
1536            let Some((v, _)) = branch_lp(inp, &act, &held) else {
1537                out.verdict = BVerdict::NotChecked("a branch LP did not solve");
1538                return out;
1539            };
1540            out.branches_solved += 1;
1541            if best.as_ref().is_none_or(|(b, _)| v < *b) {
1542                best = Some((v, full(&held)));
1543            }
1544        }
1545        out.verdict = match best {
1546            Some((v, held)) if v < -band => BVerdict::NotB { held, decrease: v },
1547            _ => BVerdict::B,
1548        };
1549        return out;
1550    }
1551
1552    // Too many to enumerate: the relaxed cone's direction names a branch.
1553    let relaxed = held_of(&|_| None);
1554    let Some((_, d)) = branch_lp(inp, &act, &relaxed) else {
1555        out.verdict = BVerdict::NotChecked("the relaxed LP did not solve");
1556        return out;
1557    };
1558    out.branches_solved += 1;
1559    let dg = |k: usize| act.g_side[k].0 * d[inp.pairs[k].var];
1560    let dh = |k: usize| {
1561        let i = inp.pairs[k].row;
1562        act.h_side[k].0
1563            * inp
1564                .jac
1565                .iter()
1566                .filter(|&&(r, _, _)| r == i)
1567                .map(|&(_, c, v)| v * d[c])
1568                .sum::<Number>()
1569    };
1570    let held = held_of(&|k| {
1571        Some(if dg(k) <= dh(k) {
1572            HeldSide::G
1573        } else {
1574            HeldSide::H
1575        })
1576    });
1577    let Some((v, _)) = branch_lp(inp, &act, &held) else {
1578        out.verdict = BVerdict::NotChecked("a branch LP did not solve");
1579        return out;
1580    };
1581    out.branches_solved += 1;
1582    out.verdict = if v < -band {
1583        BVerdict::NotB {
1584            held: full(&held),
1585            decrease: v,
1586        }
1587    } else {
1588        BVerdict::NotChecked(
1589            "too many biactive pairs to enumerate; the heuristic branch does not descend",
1590        )
1591    };
1592    out
1593}
1594
1595/// One branch LP (`held[k] = None` on a biactive pair: both sides only
1596/// non-negative, the relaxed cone). Returns the optimum and the direction,
1597/// or `None` if the LP did not solve.
1598fn branch_lp(
1599    inp: &CertificateInput<'_>,
1600    act: &PairActivity,
1601    held: &[Option<HeldSide>],
1602) -> Option<(Number, Vec<Number>)> {
1603    use pounce_convex::{QpOptions, QpProblem, QpStatus, Triplet, solve_qp_ipm};
1604    let n = inp.x.len();
1605    let m = inp.g.len();
1606    let row_tol = inp.solver_tol.max(0.0).sqrt();
1607    let mut rows_of: Vec<Vec<(usize, Number)>> = vec![Vec::new(); m];
1608    for &(r, c, v) in inp.jac {
1609        rows_of[r].push((c, v));
1610    }
1611    let (mut lb, mut ub) = (vec![-1.0; n], vec![1.0; n]);
1612    let (mut a, mut b, mut g, mut h) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());
1613    let eq = |coef: &[(usize, Number)], s: Number, a: &mut Vec<Triplet>, b: &mut Vec<Number>| {
1614        let r = b.len();
1615        a.extend(coef.iter().map(|&(c, v)| Triplet {
1616            row: r,
1617            col: c,
1618            val: s * v,
1619        }));
1620        b.push(0.0);
1621    };
1622    // `s·(coef·d) >= 0`, written `-s·coef·d <= 0`.
1623    let geq = |coef: &[(usize, Number)], s: Number, g: &mut Vec<Triplet>, h: &mut Vec<Number>| {
1624        let r = h.len();
1625        g.extend(coef.iter().map(|&(c, v)| Triplet {
1626            row: r,
1627            col: c,
1628            val: -s * v,
1629        }));
1630        h.push(0.0);
1631    };
1632    let mut pair_row = vec![None; m];
1633    let mut pair_var = vec![None; n];
1634    for (k, p) in inp.pairs.iter().enumerate() {
1635        pair_row[p.row] = Some(k);
1636        pair_var[p.var] = Some(k);
1637    }
1638    // Ordinary rows: the active ones, linearized.
1639    for i in (0..m).filter(|&i| pair_row[i].is_none()) {
1640        let (lo, hi) = (inp.g_l[i], inp.g_u[i]);
1641        let at_lo = lo > -INF && inp.g[i] - lo <= row_tol;
1642        let at_hi = hi < INF && hi - inp.g[i] <= row_tol;
1643        match (at_lo, at_hi) {
1644            (true, true) => eq(&rows_of[i], 1.0, &mut a, &mut b),
1645            (true, false) => geq(&rows_of[i], 1.0, &mut g, &mut h),
1646            (false, true) => geq(&rows_of[i], -1.0, &mut g, &mut h),
1647            (false, false) => {}
1648        }
1649    }
1650    // Ordinary bounds, and a pair variable's ordinary second bound.
1651    for j in 0..n {
1652        let (lo, hi) = (inp.x_l[j], inp.x_u[j]);
1653        let (mut at_lo, mut at_hi) = (
1654            lo > -INF && inp.x[j] - lo <= row_tol,
1655            hi < INF && hi - inp.x[j] <= row_tol,
1656        );
1657        if let Some(k) = pair_var[j] {
1658            if act.mixed[k] {
1659                continue; // both bounds are the pair's
1660            }
1661            // The pair's own bound is handled with the pair.
1662            if act.g_side[k].0 > 0.0 {
1663                at_lo = false;
1664            } else {
1665                at_hi = false;
1666            }
1667        }
1668        if at_lo {
1669            lb[j] = 0.0;
1670        }
1671        if at_hi {
1672            ub[j] = 0.0;
1673        }
1674    }
1675    // Pairs.
1676    for (k, p) in inp.pairs.iter().enumerate() {
1677        let (sg, sh) = (act.g_side[k].0, act.h_side[k].0);
1678        let gcoef = [(p.var, 1.0)];
1679        let hcoef = &rows_of[p.row];
1680        match held[k] {
1681            Some(HeldSide::G) => {
1682                lb[p.var] = 0.0;
1683                ub[p.var] = 0.0;
1684                if act.h_act[k] {
1685                    geq(hcoef, sh, &mut g, &mut h);
1686                }
1687            }
1688            Some(HeldSide::H) => {
1689                eq(hcoef, 1.0, &mut a, &mut b);
1690                if act.g_act[k] {
1691                    geq(&gcoef, sg, &mut g, &mut h);
1692                }
1693            }
1694            None => {
1695                geq(&gcoef, sg, &mut g, &mut h);
1696                geq(hcoef, sh, &mut g, &mut h);
1697            }
1698        }
1699    }
1700    let prob = QpProblem {
1701        n,
1702        p_lower: Vec::new(),
1703        c: inp.grad_f.to_vec(),
1704        a,
1705        b,
1706        g,
1707        h,
1708        lb,
1709        ub,
1710    };
1711    let opts = QpOptions::default();
1712    let sol = solve_qp_ipm(&prob, &opts, || {
1713        Box::new(pounce_feral::FeralSolverInterface::new())
1714            as Box<dyn pounce_linsol::SparseSymLinearSolverInterface>
1715    });
1716    matches!(sol.status, QpStatus::Optimal).then_some((sol.obj, sol.x))
1717}
1718
1719/// The polish runs when the reported point's [`natural_residual`] exceeds
1720/// this times `tol`. Not `tol` itself: the last relaxation is `τ = 1e-8 =
1721/// tol`, so a strictly complementary pair ends with its smaller side near
1722/// `τ`, on the threshold, and would trigger a re-solve that buys nothing. The
1723/// symptom it exists for sits near `sqrt(τ)`.
1724pub const POLISH_TRIGGER: Number = 10.0;
1725
1726/// A polished point must bring the natural residual to `max(tol,
1727/// POLISH_TIGHTEN · r_old)`. Not `tol` alone: a held `H` side is a row pinned
1728/// to zero, which the branch solve satisfies only to its constraint
1729/// tolerance, so a correct polish can end at `|h| = 3e-8`. On the 54
1730/// polishes measured (MacMPEC, the OPF and capacity suites) every one reached
1731/// `1e-8` or below, so this rejects nothing those accepted.
1732pub const POLISH_TIGHTEN: Number = 1e-3;
1733
1734/// How far a polish may *raise* the minimized objective: this times `r_old ·
1735/// max(1, |f|)`. Removing a residual `r` costs, to first order, `r` times the
1736/// slope the pair carries (a curve's slope change, a cost): measured over 54
1737/// polishes the rise was at most 15.7 times `r · max(1, |f|)`
1738/// (`vw_knot_2_right`, volt-watt slope 16). A larger rise has left the
1739/// answer's neighbourhood and the answer is kept. A *fall* is a lower,
1740/// complementary point and is taken whatever its size, as the B-escape takes
1741/// one.
1742pub const POLISH_RISE: Number = 100.0;
1743
1744/// May a polish that moved the minimized objective from `f_old` to `f_new`,
1745/// removing a natural residual `r_old`, replace the answer?
1746pub fn polish_objective_ok(f_old: Number, f_new: Number, r_old: Number) -> bool {
1747    f_new.is_finite()
1748        && f_old.is_finite()
1749        && f_new - f_old <= POLISH_RISE * r_old * f_old.abs().max(1.0)
1750}
1751
1752/// Is a polished point's natural residual `r_new` tight enough, given
1753/// `r_old` and the solver's `tol`?
1754pub fn polish_tightened(r_new: Number, r_old: Number, tol: Number) -> bool {
1755    r_new <= tol.max(POLISH_TIGHTEN * r_old)
1756}
1757
1758/// What a polished point may be reported as, from its certificate class.
1759/// `Ok(true)`: an answer (S, or not searched: what [`answers`] accepts).
1760/// `Ok(false)`: the weak fallback (W, [`kept_if_nothing_better`]). `Err`:
1761/// not taken, with the reason. An answer (`was_answer`) is demoted to W only
1762/// when `no_branch_descends()` -- the B-stationarity check at the polished
1763/// point reads B: the ladder reports W only when no S exists, and the S it
1764/// had was S only because a nearly biactive pair counted as one-sided (the
1765/// OPF volt-var knots, which polish to W and B-stationary). The check runs
1766/// only in that case.
1767pub fn polish_class(
1768    was_answer: bool,
1769    class: Option<StationarityClass>,
1770    no_branch_descends: impl FnOnce() -> bool,
1771) -> Result<bool, String> {
1772    match class {
1773        Some(k) if answers(k) => Ok(true),
1774        Some(k) if kept_if_nothing_better(k) => {
1775            if !was_answer || no_branch_descends() {
1776                Ok(false)
1777            } else {
1778                Err(
1779                    "it certifies only W where the answer was S, and a branch descends from it"
1780                        .into(),
1781                )
1782            }
1783        }
1784        Some(StationarityClass::None) => Err("the re-solved point is not stationary".into()),
1785        None => Err("the re-solved point is infeasible or could not be evaluated".into()),
1786        // `answers` and `kept_if_nothing_better` cover every other class.
1787        Some(k) => Err(format!("the re-solved point certifies {k:?}")),
1788    }
1789}
1790
1791/// The largest natural residual `min(|G_k|, |H_k|)` over the pairs at a
1792/// point, with the branch that removes it: each pair's smaller side held at
1793/// zero. A mixed pair is read as the one-sided pair of its nearer bound, as
1794/// the certificate reads it.
1795///
1796/// Every stage stops at a pair product near the last `τ`, which is harmless
1797/// where a pair is strictly complementary (the other side is `O(1)`, so the
1798/// smaller is `≈ τ`) and is not where a pair is nearly biactive: there both
1799/// sides sit near `sqrt(τ)`. On MPCCModels.jl's OPF volt-var cases (PR #13)
1800/// the answer at a curve knot had `r = 7.8e-5`, `s = 1.3e-4` at a product of
1801/// `1e-8`, the curve's slope of 7.5 put `Q` 5.9e-4 off it, and the objective
1802/// read *better* than the model's true optimum by what that slack bought.
1803/// `None` with no pairs or a non-finite side.
1804pub fn natural_residual(inp: &CertificateInput<'_>) -> Option<(Number, Vec<HeldSide>)> {
1805    let corner = inp.solver_tol.max(0.0).sqrt();
1806    let act = pair_activity(inp, corner);
1807    let mut worst: Number = 0.0;
1808    let mut held = Vec::with_capacity(inp.pairs.len());
1809    for (k, p) in inp.pairs.iter().enumerate() {
1810        let (sg, b) = act.g_side[k];
1811        let g = (sg * (inp.x[p.var] - b)).abs();
1812        let h = act.hv[k].abs();
1813        if !g.is_finite() || !h.is_finite() {
1814            return None;
1815        }
1816        worst = worst.max(g.min(h));
1817        held.push(if g <= h { HeldSide::G } else { HeldSide::H });
1818    }
1819    (!held.is_empty()).then_some((worst, held))
1820}
1821
1822/// The branch NLP's bounds for `held` (one per pair): the held side pinned
1823/// at zero, and, on a mixed pair held at a bound, the free row given the
1824/// sign the pair requires there. For [`pounce_nlp::mpcc::MpccTnlp::set_branch`].
1825pub fn branch_bounds(
1826    inp: &CertificateInput<'_>,
1827    held: &[HeldSide],
1828) -> pounce_nlp::mpcc::BranchBounds {
1829    let corner = inp.solver_tol.max(0.0).sqrt();
1830    let act = pair_activity(inp, corner);
1831    let mut out = pounce_nlp::mpcc::BranchBounds::default();
1832    for (k, p) in inp.pairs.iter().enumerate() {
1833        let ((sg, bnd), (_, c)) = (act.g_side[k], act.h_side[k]);
1834        match held[k] {
1835            HeldSide::G => {
1836                out.vars.push((p.var, bnd, bnd));
1837                if act.mixed[k] {
1838                    // H = s_G·g >= 0 at the held bound.
1839                    out.rows.push(if sg > 0.0 {
1840                        (p.row, 0.0, 2.0 * INF)
1841                    } else {
1842                        (p.row, -2.0 * INF, 0.0)
1843                    });
1844                }
1845            }
1846            HeldSide::H => out.rows.push((p.row, c, c)),
1847        }
1848    }
1849    out
1850}
1851
1852#[cfg(test)]
1853mod tests {
1854    use super::*;
1855
1856    /// Near-dependent columns, one of them pointing away from `b`: the
1857    /// least-squares solve on the passive set gives a non-positive
1858    /// coefficient, which must not be picked again forever.
1859    #[test]
1860    fn nnls_on_near_dependent_columns_terminates_at_the_fit() {
1861        let a = vec![1.0, 2.0, -1.0];
1862        let a2: Vec<Number> = a.iter().map(|v| v * (1.0 + 1e-13)).collect();
1863        let neg: Vec<Number> = a.iter().map(|v| -v).collect();
1864        let b = vec![2.0, 4.0, -2.0];
1865        let x = nnls(&[a.clone(), a2.clone(), neg], &b, 3);
1866        assert!(x.iter().all(|&v| v >= 0.0));
1867        let r: Number = (0..3)
1868            .map(|i| (b[i] - x[0] * a[i] - x[1] * a2[i] + x[2] * a[i]).powi(2))
1869            .sum();
1870        assert!(r.sqrt() < 1e-9, "residual {}", r.sqrt());
1871    }
1872
1873    #[test]
1874    fn a_warm_rung_is_budgeted_from_the_rung_before_it() {
1875        // ieee300_reserve_q98: rungs of 25-41 iterations, then a crawl to
1876        // max_iter = 3000. Ten times the previous rung, floored at 500.
1877        assert_eq!(warm_rung_budget(25), WARM_RUNG_FLOOR);
1878        assert_eq!(warm_rung_budget(0), WARM_RUNG_FLOOR);
1879        assert_eq!(warm_rung_budget(120), 1200);
1880        // A caller's smaller max_iter still wins.
1881        assert_eq!(budgeted_max_iter(warm_rung_budget(25), 12), 12);
1882        assert_eq!(budgeted_max_iter(warm_rung_budget(25), 3000), 500);
1883    }
1884
1885    /// Both branches of the warm rung's budget: with the fallback live the
1886    /// rung is budgeted, without it the caller's `max_iter` stands.
1887    #[test]
1888    fn a_warm_rung_is_budgeted_only_while_the_fallback_can_rescue_it() {
1889        assert_eq!(warm_rung_max_iter(true, 25, 3000), Some(500));
1890        assert_eq!(warm_rung_max_iter(true, 25, 12), Some(12));
1891        assert_eq!(warm_rung_max_iter(false, 25, 3000), None);
1892        // From the rescuing re-solve's 124, not the call's 624: still capped.
1893        assert_eq!(warm_rung_max_iter(true, 124, 3000), Some(1240));
1894    }
1895
1896    /// Every branch of the polish's acceptance, one assertion per branch.
1897    #[test]
1898    fn the_polish_takes_only_a_tightened_nearby_point_of_an_accepted_class() {
1899        use StationarityClass::*;
1900        // Class: S and not searched answer; W demotes an answer only where no
1901        // branch descends, and that check is not even run otherwise.
1902        assert_eq!(polish_class(true, Some(S), || unreachable!()), Ok(true));
1903        assert_eq!(
1904            polish_class(true, Some(NotSearched), || unreachable!()),
1905            Ok(true)
1906        );
1907        assert_eq!(polish_class(true, Some(W), || true), Ok(false));
1908        assert!(polish_class(true, Some(W), || false).is_err());
1909        assert_eq!(polish_class(false, Some(W), || unreachable!()), Ok(false));
1910        assert!(polish_class(false, Some(StationarityClass::None), || true).is_err());
1911        assert!(polish_class(true, Option::None, || true).is_err());
1912        // Tightening: to tol, or by POLISH_TIGHTEN where that is looser.
1913        assert!(polish_tightened(1e-12, 7.8e-5, 1e-8));
1914        assert!(polish_tightened(3e-8, 7.8e-5, 1e-8)); // a held row at 3e-8
1915        assert!(!polish_tightened(1e-6, 7.8e-5, 1e-8));
1916        // Objective: a fall of any size; a rise up to POLISH_RISE r max(1,|f|).
1917        assert!(polish_objective_ok(0.2028, 0.1, 2.5e-5));
1918        assert!(polish_objective_ok(0.2027957, 0.2031882, 2.5e-5)); // vw_knot_2_right
1919        assert!(!polish_objective_ok(
1920            0.2028,
1921            0.2028 + 101.0 * 2.5e-5,
1922            2.5e-5
1923        ));
1924        assert!(!polish_objective_ok(-322.0, -322.0 * (1.0 - 0.009), 1e-6)); // 0.9% worse
1925        assert!(!polish_objective_ok(1.0, Number::NAN, 1e-5));
1926    }
1927
1928    #[test]
1929    fn a_converged_lowering_with_a_large_product_is_not_an_answer() {
1930        let ok = ApplicationReturnStatus::SolveSucceeded;
1931        assert!(accepts(ok, 1e-9, 1e-4));
1932        assert!(!accepts(ok, 1e-3, 1e-4));
1933        assert!(!accepts(ok, Number::NAN, 1e-4));
1934        assert!(!accepts(
1935            ApplicationReturnStatus::InfeasibleProblemDetected,
1936            0.0,
1937            1e-4
1938        ));
1939        assert!(accepts(
1940            ApplicationReturnStatus::FeasiblePointFound,
1941            0.0,
1942            1e-4
1943        ));
1944    }
1945
1946    #[test]
1947    fn warm_mu_is_clamped_and_never_zero() {
1948        let mu = |v| {
1949            warm_start_assignments(v)
1950                .into_iter()
1951                .find(|a| a.starts_with("mu_init"))
1952                .unwrap()
1953        };
1954        assert_eq!(mu(1e-12), "mu_init 1e-9\n");
1955        assert_eq!(mu(3.0), "mu_init 1e-1\n");
1956        assert_eq!(mu(0.0), "mu_init 1e-6\n");
1957        assert_eq!(mu(Number::NAN), "mu_init 1e-6\n");
1958    }
1959
1960    /// Both branches of the exact stage's budget: with no tight answer in
1961    /// hand it runs in full, whatever the continuation spent; with one, it
1962    /// gets the factor times that work, never less than the floor.
1963    #[test]
1964    fn the_exact_stage_is_budgeted_only_after_an_answer() {
1965        assert_eq!(exact_stage_budget(false, 0), None);
1966        assert_eq!(exact_stage_budget(false, 5000), None);
1967        assert_eq!(exact_stage_budget(true, 0), Some(EXACT_BUDGET_FLOOR));
1968        assert_eq!(exact_stage_budget(true, 10), Some(EXACT_BUDGET_FLOOR));
1969        assert_eq!(exact_stage_budget(true, 189), Some(3 * 189));
1970        assert_eq!(exact_stage_budget(true, usize::MAX), Some(usize::MAX));
1971    }
1972
1973    /// `scholtes4` with its objective multiplied by `c`:
1974    /// `min c·(z1 + z2 - z3)`, `z3 <= 4 z1`, `z3 <= 4 z2`, `0 <= z1 ⟂ z2 >= 0`.
1975    /// Relaxed to `z1·z2 <= τ` the optimum is `z1 = z2 = sqrt(τ)`,
1976    /// `f = -2c·sqrt(τ)`, with product-row multiplier `c/sqrt(τ)`; the exact
1977    /// `f* = 0`. Returns `(f_relaxed, relaxed_slack)`.
1978    fn scholtes4_relaxed(c: Number, tau: Number) -> (Number, Number) {
1979        let lambda = c / tau.sqrt();
1980        (-2.0 * c * tau.sqrt(), relaxation_slack(&[tau], &[lambda]))
1981    }
1982
1983    #[test]
1984    fn relaxation_slack_is_the_first_order_value_of_the_products() {
1985        assert_eq!(relaxation_slack(&[], &[]), 0.0);
1986        assert!((relaxation_slack(&[1e-8, 2e-8], &[1e4, -3.0]) - (1e-4 + 6e-8)).abs() < 1e-18);
1987        // A mixed pair's legitimately negative product buys nothing.
1988        assert_eq!(relaxation_slack(&[-0.5], &[7.0]), 0.0);
1989        assert!(relaxation_slack(&[1e-8], &[Number::NAN]).is_infinite());
1990        assert!(relaxation_slack(&[Number::INFINITY], &[1.0]).is_infinite());
1991    }
1992
1993    #[test]
1994    fn a_finish_that_drifts_above_its_relaxed_start_is_flagged() {
1995        // pack-comp1p-32: 0.653 relaxed (products 5e-9), 0.783 after the
1996        // finish wandered.
1997        let slack = relaxation_slack(&[5e-9; 4], &[30.0; 4]);
1998        assert!(finish_drifted(0.783, 0.65304, slack));
1999        // A finish that tightens the products at (almost) the same objective,
2000        // or improves it, is kept.
2001        assert!(!finish_drifted(0.65304 + 1e-6, 0.65304, slack));
2002        assert!(!finish_drifted(0.6529, 0.65304, slack));
2003        // scholtes4 and ralph1: the relaxed point's slack at a biactive
2004        // corner buys -2e-4 / -1e-4 = O(sqrt(1e-8)); the finish removing it
2005        // has not drifted.
2006        let (f_s4, s_s4) = scholtes4_relaxed(1.0, 1e-8);
2007        assert!(!finish_drifted(3e-11, f_s4, s_s4));
2008        assert!(!finish_drifted(
2009            2e-9,
2010            -9.999818e-5,
2011            relaxation_slack(&[1e-8], &[1e4])
2012        ));
2013        assert!(finish_drifted(Number::NAN, 1.0, 1e-8));
2014        assert!(!finish_drifted(1.0, Number::INFINITY, 1e-8));
2015        assert!(!finish_drifted(5.0, 1.0, Number::INFINITY));
2016    }
2017
2018    /// gh#1128: the verdict must not depend on the objective's units. With
2019    /// the old allowance `10·sqrt(p)·max(1, |f|)`, scholtes4 ×10 (relaxed
2020    /// `-2e-3`, finish `3e-10`) read as drifted -- the allowance stayed at
2021    /// `1e-3` while the corner's slack grew to `2e-3` -- and the driver kept
2022    /// a relaxed point below `f*`.
2023    #[test]
2024    fn the_drift_verdict_does_not_change_with_the_objectives_scale() {
2025        for c in [1e-6, 1e-3, 0.1, 1.0, 10.0, 1e3, 1e6] {
2026            let (fr, slack) = scholtes4_relaxed(c, 1e-8);
2027            assert!(!finish_drifted(c * 3e-11, fr, slack), "scholtes4 x{c}");
2028            assert!(!finish_drifted(0.0, fr, slack), "scholtes4 x{c}");
2029            // The same relaxed point with a finish that genuinely wandered
2030            // (to +f_relaxed's magnitude a thousand times over) is flagged.
2031            assert!(finish_drifted(-1e3 * fr, fr, slack), "scholtes4 x{c}");
2032            let pack = relaxation_slack(&[5e-9; 4], &[30.0 * c; 4]);
2033            assert!(finish_drifted(0.783 * c, 0.65304 * c, pack), "pack x{c}");
2034            assert!(!finish_drifted(0.6529 * c, 0.65304 * c, pack), "pack x{c}");
2035        }
2036        // The case the issue derived: scholtes4 x10 at the old constants.
2037        assert!(!finish_drifted(
2038            3e-10,
2039            -2e-3,
2040            relaxation_slack(&[1e-8], &[1e5])
2041        ));
2042    }
2043
2044    /// Every branch of the cold-solve skip: agreement alone skips, two exact
2045    /// answers skip, and a relaxed point on either side keeps the cold solve.
2046    #[test]
2047    fn the_cold_solve_is_skipped_only_with_agreement_or_two_exact_answers() {
2048        assert!(skips_cold_solve(true, false, false));
2049        assert!(skips_cold_solve(false, true, true));
2050        assert!(!skips_cold_solve(false, false, true));
2051        assert!(!skips_cold_solve(false, true, false));
2052    }
2053
2054    #[test]
2055    fn answers_agree_within_the_relative_tolerance() {
2056        // pack-comp1p-32: continuation 0.65304, relaxed exact path 0.65310.
2057        assert!(answers_agree(0.65304, 0.65310, 0.0, 0.0));
2058        // water-net: 953 against 964 is two branches.
2059        assert!(!answers_agree(953.23, 964.12, 0.0, 0.0));
2060        // Near zero, what a relaxed answer's slack buys still agrees: an
2061        // exact finish at 1e-9 and a relaxed point at -5e-5 whose products
2062        // are worth 2.5e-5.
2063        assert!(answers_agree(1e-9, -5e-5, 2.5e-5, 0.0));
2064        assert!(!answers_agree(f64::NAN, 0.0, 0.0, 0.0));
2065        assert!(!answers_agree(0.0, 0.0, f64::INFINITY, 0.0));
2066        assert!(answers_agree(0.0, 0.0, 0.0, 0.0));
2067        assert!(!answers_agree(0.0, 0.0, 0.0, f64::INFINITY));
2068    }
2069
2070    /// The floor near zero: two exact points (no slack) on one branch at
2071    /// `f* = 0` agree when they differ by no more than their summed gaps,
2072    /// and no further. Numbers from `min 0.01·x + (y - 1)^2`,
2073    /// `0 <= x ⟂ y >= 0`: the continuation's `1e-11` and the relaxed path's
2074    /// `5e-20`, gaps `1e-11` and `2.5e-11`.
2075    #[test]
2076    fn two_answers_at_zero_agree_within_their_gaps() {
2077        for c in [1e-6, 1.0, 1e6] {
2078            assert!(
2079                answers_agree(c * 1e-11, c * 5e-20, 0.0, c * 3.5e-11),
2080                "x{c}"
2081            );
2082            assert!(!answers_agree(c * 1e-11, c * 5e-20, 0.0, 0.0), "x{c}");
2083            assert!(
2084                !answers_agree(c * 1e-9, c * 5e-20, 0.0, c * 3.5e-11),
2085                "x{c}"
2086            );
2087        }
2088    }
2089
2090    #[test]
2091    fn the_complementarity_gap_sums_multiplier_times_distance() {
2092        let inf = Number::INFINITY;
2093        // x = [1e-9 above 0, 2 below 3, free]; rows: g = 0.5 in [0, inf),
2094        // g = 1 equality at 1, g = 7 free.
2095        let gap = complementarity_gap(
2096            &[1e-9, 2.0, 5.0],
2097            &[0.01, 0.0, 0.0],
2098            &[0.0, -4.0, 0.0],
2099            &[0.0, -inf, -inf],
2100            &[inf, 3.0, inf],
2101            &[0.5, 1.0, 7.0],
2102            &[-1e-8, 30.0, 0.0],
2103            &[0.0, 1.0, -inf],
2104            &[inf, 1.0, inf],
2105        );
2106        assert!((gap - (1e-11 + 4.0 + 0.5e-8)).abs() < 1e-15, "{gap:e}");
2107        // Multipliers scale with the objective; the gap follows.
2108        let scaled = complementarity_gap(
2109            &[1e-9],
2110            &[1e3 * 0.01],
2111            &[0.0],
2112            &[0.0],
2113            &[inf],
2114            &[],
2115            &[],
2116            &[],
2117            &[],
2118        );
2119        assert!((scaled - 1e3 * 1e-11).abs() < 1e-20, "{scaled:e}");
2120        // Mismatched lengths, or a non-finite multiplier: cannot tell.
2121        assert!(
2122            complementarity_gap(&[1.0], &[], &[0.0], &[0.0], &[inf], &[], &[], &[], &[])
2123                .is_infinite()
2124        );
2125        assert!(
2126            complementarity_gap(
2127                &[1.0],
2128                &[f64::NAN],
2129                &[0.0],
2130                &[0.0],
2131                &[inf],
2132                &[],
2133                &[],
2134                &[],
2135                &[]
2136            )
2137            .is_infinite()
2138        );
2139        assert_eq!(
2140            complementarity_gap(&[], &[], &[], &[], &[], &[], &[], &[], &[]),
2141            0.0
2142        );
2143    }
2144
2145    /// gh#1128: near zero the old test was an absolute `1e-4`, so two exact
2146    /// answers 5e-5 apart "agreed" -- the cold solve was skipped -- however
2147    /// small the objective's own scale, while the same model in larger units
2148    /// ran it. The verdict is now the same at every scale, both ways.
2149    #[test]
2150    fn answers_agree_does_not_change_with_the_objectives_scale() {
2151        for c in [1e-6, 1e-3, 0.1, 1.0, 10.0, 1e3, 1e6] {
2152            assert!(answers_agree(c * 1e-9, -c * 5e-5, c * 2.5e-5, 0.0), "x{c}");
2153            assert!(!answers_agree(c * 1e-9, -c * 5e-5, 0.0, 0.0), "x{c}");
2154            assert!(answers_agree(c * 0.65304, c * 0.65310, 0.0, 0.0), "x{c}");
2155            assert!(!answers_agree(c * 953.23, c * 964.12, 0.0, 0.0), "x{c}");
2156            let (fr, slack) = scholtes4_relaxed(c, 1e-8);
2157            assert!(answers_agree(c * 3e-11, fr, slack, 0.0), "scholtes4 x{c}");
2158        }
2159    }
2160
2161    #[test]
2162    fn the_finish_budget_has_a_floor_and_follows_the_schedule() {
2163        assert_eq!(finish_budget(0), FINISH_BUDGET_FLOOR);
2164        assert_eq!(finish_budget(721), 721);
2165    }
2166
2167    /// A budget never raises the caller's own `max_iter`.
2168    #[test]
2169    fn a_budget_never_exceeds_the_callers_max_iter() {
2170        assert_eq!(budgeted_max_iter(567, 3000), 567);
2171        assert_eq!(budgeted_max_iter(567, 100), 100);
2172        assert_eq!(budgeted_max_iter(usize::MAX, 3000), 3000);
2173        assert_eq!(budgeted_max_iter(10, -1), 0);
2174    }
2175
2176    #[test]
2177    fn every_assignment_is_restored() {
2178        for a in warm_start_assignments(1e-5) {
2179            let name = a.split_whitespace().next().unwrap();
2180            assert!(
2181                TOUCHED_OPTIONS.contains(&name),
2182                "{name} is written but not restored"
2183            );
2184        }
2185    }
2186
2187    // ---- certify: the same points `benchmarks/calipso/stationarity.py`'s
2188    // selftest and `benchmarks/mpcc/cases.py` decide, so the two
2189    // implementations are held to each other. Python's M (and C) is "not S"
2190    // here, reported as W.
2191
2192    const NI: Number = -1e20;
2193    const PI: Number = 1e20;
2194
2195    #[derive(Clone)]
2196    struct Model {
2197        x_l: Vec<Number>,
2198        x_u: Vec<Number>,
2199        g_l: Vec<Number>,
2200        g_u: Vec<Number>,
2201        pairs: Vec<ComplementarityPair>,
2202        jac: Vec<(usize, usize, Number)>,
2203    }
2204
2205    /// Certifies on both paths and holds them to the same class, so every
2206    /// point a test below checks is also a dense-vs-sparse parity check.
2207    fn run(m: &Model, x: &[Number], grad_f: &[Number], g: &[Number]) -> Certificate {
2208        let inp = CertificateInput {
2209            x,
2210            grad_f,
2211            g,
2212            jac: &m.jac,
2213            x_l: &m.x_l,
2214            x_u: &m.x_u,
2215            g_l: &m.g_l,
2216            g_u: &m.g_u,
2217            pairs: &m.pairs,
2218            solver_tol: 1e-8,
2219            feas_tol: 1e-4,
2220        };
2221        let dense = certify_routed(&inp, Some(false));
2222        let sparse = certify_routed(&inp, Some(true));
2223        assert_eq!(
2224            (dense.class, dense.multipliers_unique),
2225            (sparse.class, sparse.multipliers_unique),
2226            "dense {dense:?}\nsparse {sparse:?}"
2227        );
2228        // The residuals agree far inside the tolerance the class is decided
2229        // against (the SVD reaches ~1e-16, the LDLᵀ solve ~1e-13).
2230        if dense.multipliers_unique && dense.residual.is_finite() {
2231            assert!(
2232                (dense.residual - sparse.residual).abs() <= 1e-6 * dense.resid_tol,
2233                "dense {dense:?}\nsparse {sparse:?}"
2234            );
2235        }
2236        dense
2237    }
2238
2239    /// A mixed pair, `0 <= x <= 1 ⟂ g = y - x` with `y` and the row free,
2240    /// certified at each place it can be: at either bound with the sign the
2241    /// pair requires, strictly between with `g = 0`, at a bound with the wrong
2242    /// sign (infeasible: no row bound states it), and strictly between with
2243    /// `g != 0` (not complementary).
2244    #[test]
2245    fn a_mixed_pair_is_certified_from_the_bound_it_is_at() {
2246        let m = Model {
2247            x_l: vec![0.0, NI],
2248            x_u: vec![1.0, PI],
2249            g_l: vec![NI],
2250            g_u: vec![PI],
2251            pairs: vec![ComplementarityPair { row: 0, var: 0 }],
2252            jac: vec![(0, 0, -1.0), (0, 1, 1.0)],
2253        };
2254        let g = |x: &[Number]| vec![x[1] - x[0]];
2255
2256        // x at 0, g = 2 >= 0: min (x - 0.5)² + (y - 2)², grad (-1, 0) held by
2257        // the bound alone.
2258        let x = [0.0, 2.0];
2259        let c = run(&m, &x, &[-1.0, 0.0], &g(&x));
2260        assert_eq!(c.class, StationarityClass::S, "{c:?}");
2261        assert!(c.feasible, "{c:?}");
2262
2263        // x at 1, g = -1 <= 0: min (x - 2)² + y², grad (-2, 0).
2264        let x = [1.0, 0.0];
2265        let c = run(&m, &x, &[-2.0, 0.0], &g(&x));
2266        assert_eq!(c.class, StationarityClass::S, "{c:?}");
2267
2268        // Strictly between, g = 0: the row is an equality there, grad = λ ∇g.
2269        let x = [0.5, 0.5];
2270        let c = run(&m, &x, &[-1.0, 1.0], &g(&x));
2271        assert_eq!(c.class, StationarityClass::S, "{c:?}");
2272
2273        // x at 0 with g = -1 < 0: violates the pair; no row bound says so.
2274        let x = [0.0, -1.0];
2275        let c = run(&m, &x, &[0.0, 0.0], &g(&x));
2276        assert_eq!(c.class, StationarityClass::None, "{c:?}");
2277        assert!(
2278            !c.feasible && (c.primal_violation - 1.0).abs() < 1e-12,
2279            "{c:?}"
2280        );
2281
2282        // Strictly between with g = 0.5: neither side at zero.
2283        let x = [0.5, 1.0];
2284        let c = run(&m, &x, &[0.0, 0.0], &g(&x));
2285        assert_eq!(
2286            (c.class, c.n_neither),
2287            (StationarityClass::None, 1),
2288            "{c:?}"
2289        );
2290    }
2291
2292    /// A one-sided pair whose variable also has an ordinary second bound:
2293    /// `x >= 0 ⟂ g = y >= 0` with `x <= 1` a plain limit. `min (x - 2)² +
2294    /// (y - 1)²` has its optimum at `(1, 0)` -- `x` on the ordinary bound, not
2295    /// the pair's -- which is stationary only with that bound's multiplier.
2296    #[test]
2297    fn a_pair_variables_ordinary_second_bound_carries_a_multiplier() {
2298        let m = Model {
2299            x_l: vec![0.0, NI],
2300            x_u: vec![1.0, PI],
2301            g_l: vec![0.0],
2302            g_u: vec![PI],
2303            pairs: vec![ComplementarityPair { row: 0, var: 0 }],
2304            jac: vec![(0, 1, 1.0)],
2305        };
2306        let x = [1.0, 0.0];
2307        let c = run(&m, &x, &[-2.0, -2.0], &[x[1]]);
2308        assert_eq!(c.class, StationarityClass::S, "{c:?}");
2309        // The pair's own bound is the lower one: at x = 0 with y = 1 the
2310        // gradient (−4, 0) is held by it.
2311        let x = [0.0, 1.0];
2312        let c = run(&m, &x, &[-4.0, 0.0], &[x[1]]);
2313        assert_eq!(c.class, StationarityClass::S, "{c:?}");
2314    }
2315
2316    fn input<'a>(
2317        m: &'a Model,
2318        x: &'a [Number],
2319        grad_f: &'a [Number],
2320        g: &'a [Number],
2321    ) -> CertificateInput<'a> {
2322        CertificateInput {
2323            x,
2324            grad_f,
2325            g,
2326            jac: &m.jac,
2327            x_l: &m.x_l,
2328            x_u: &m.x_u,
2329            g_l: &m.g_l,
2330            g_u: &m.g_u,
2331            pairs: &m.pairs,
2332            solver_tol: 1e-8,
2333            feas_tol: 1e-4,
2334        }
2335    }
2336
2337    /// ralph1 at the origin, `min 2x - y`, `x >= 0`, `0 <= y ⟂ y - x >= 0`:
2338    /// weakly but not strongly stationary, and B-stationary -- both branch
2339    /// LPs have optimum 0. The check must not report a descent there.
2340    #[test]
2341    fn ralph1s_origin_is_b_stationary() {
2342        let m = Model {
2343            x_l: vec![0.0, 0.0],
2344            x_u: vec![PI, PI],
2345            g_l: vec![0.0],
2346            g_u: vec![PI],
2347            pairs: vec![ComplementarityPair { row: 0, var: 1 }],
2348            jac: vec![(0, 0, -1.0), (0, 1, 1.0)],
2349        };
2350        let (x, gf, g) = ([0.0, 0.0], [2.0, -1.0], [0.0]);
2351        assert_eq!(run(&m, &x, &gf, &g).class, StationarityClass::W);
2352        let b = check_b_stationarity(&input(&m, &x, &gf, &g));
2353        assert_eq!(b.verdict, BVerdict::B, "{b:?}");
2354        assert_eq!(
2355            (b.n_biactive, b.branches_solved, b.enumerated),
2356            (1, 2, true)
2357        );
2358    }
2359
2360    /// `min (x - 1)² + (y - 1)²`, `0 <= x ⟂ y >= 0` at the origin: biactive,
2361    /// and either branch descends (`∇f·d = -2` with `d` in the unit box).
2362    /// The branch bounds pin the held side.
2363    #[test]
2364    fn a_biactive_corner_with_descent_is_not_b_stationary() {
2365        let m = Model {
2366            x_l: vec![0.0, NI],
2367            x_u: vec![PI, PI],
2368            g_l: vec![0.0],
2369            g_u: vec![PI],
2370            pairs: vec![ComplementarityPair { row: 0, var: 0 }],
2371            jac: vec![(0, 1, 1.0)],
2372        };
2373        let (x, gf, g) = ([0.0, 0.0], [-2.0, -2.0], [0.0]);
2374        let inp = input(&m, &x, &gf, &g);
2375        let b = check_b_stationarity(&inp);
2376        let BVerdict::NotB { held, decrease } = &b.verdict else {
2377            panic!("{b:?}")
2378        };
2379        assert!((decrease + 2.0).abs() < 1e-6, "{b:?}");
2380        let bb = branch_bounds(&inp, held);
2381        match held[0] {
2382            HeldSide::G => assert_eq!(bb.vars, vec![(0, 0.0, 0.0)], "{bb:?}"),
2383            HeldSide::H => assert_eq!(bb.rows, vec![(0, 0.0, 0.0)], "{bb:?}"),
2384        }
2385        // Away from the corner, a point on one branch whose gradient is held
2386        // by the pinned side is B-stationary: x = 0, y = 1 with ∇f = (-2, 0).
2387        let (x, gf, g) = ([0.0, 1.0], [-2.0, 0.0], [1.0]);
2388        assert_eq!(
2389            check_b_stationarity(&input(&m, &x, &gf, &g)).verdict,
2390            BVerdict::B
2391        );
2392    }
2393
2394    /// Past [`MAX_ENUMERATED_BIACTIVE`], the relaxed LP's direction picks one
2395    /// branch: it finds the descent here, and could never certify B.
2396    #[test]
2397    fn past_the_enumeration_cap_the_heuristic_finds_a_descent() {
2398        let k = MAX_ENUMERATED_BIACTIVE + 1;
2399        let n = 2 * k;
2400        let m = Model {
2401            x_l: (0..n).map(|j| if j < k { 0.0 } else { NI }).collect(),
2402            x_u: vec![PI; n],
2403            g_l: vec![0.0; k],
2404            g_u: vec![PI; k],
2405            pairs: (0..k)
2406                .map(|i| ComplementarityPair { row: i, var: i })
2407                .collect(),
2408            jac: (0..k).map(|i| (i, k + i, 1.0)).collect(),
2409        };
2410        let (x, gf, g) = (vec![0.0; n], vec![-1.0; n], vec![0.0; k]);
2411        let b = check_b_stationarity(&input(&m, &x, &gf, &g));
2412        assert!(!b.enumerated && b.branches_solved == 2, "{b:?}");
2413        assert!(matches!(b.verdict, BVerdict::NotB { .. }), "{b:?}");
2414    }
2415
2416    #[test]
2417    fn a_point_where_a_pair_is_not_complementary_is_not_checked() {
2418        let m = Model {
2419            x_l: vec![0.0, NI],
2420            x_u: vec![PI, PI],
2421            g_l: vec![0.0],
2422            g_u: vec![PI],
2423            pairs: vec![ComplementarityPair { row: 0, var: 0 }],
2424            jac: vec![(0, 1, 1.0)],
2425        };
2426        let (x, gf, g) = ([1.0, 1.0], [0.0, 0.0], [1.0]);
2427        let b = check_b_stationarity(&input(&m, &x, &gf, &g));
2428        assert!(matches!(b.verdict, BVerdict::NotChecked(_)), "{b:?}");
2429    }
2430
2431    /// CALIPSO.jl `nonconvex/complementarity.jl`, its x-x pairs written as
2432    /// rows `g_{4+k} = x_{5+k} >= 0` paired with `x_{2+k}` (and those
2433    /// variables' own bounds dropped, so the row is the only statement of
2434    /// them — the same system the Python classifier builds).
2435    #[allow(clippy::type_complexity)]
2436    fn complementarity() -> (Model, impl Fn(&[Number]) -> (Vec<Number>, Vec<Number>)) {
2437        let mut jac = vec![
2438            (0, 1, 0.5),
2439            (0, 2, 1.0),
2440            (0, 3, -0.5),
2441            (0, 4, 1.0),
2442            (1, 0, 3.0),
2443            (1, 1, -1.0),
2444            (1, 5, -1.0),
2445            (2, 0, -1.0),
2446            (2, 1, 0.5),
2447            (2, 6, -1.0),
2448            (3, 0, -1.0),
2449            (3, 1, -1.0),
2450            (3, 7, -1.0),
2451        ];
2452        for k in 0..3 {
2453            jac.push((4 + k, 5 + k, 1.0));
2454        }
2455        let m = Model {
2456            x_l: vec![0.0, 0.0, 0.0, 0.0, 0.0, NI, NI, NI],
2457            x_u: vec![PI; 8],
2458            g_l: vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
2459            g_u: vec![0.0, 0.0, 0.0, 0.0, PI, PI, PI],
2460            pairs: (0..3)
2461                .map(|k| ComplementarityPair {
2462                    row: 4 + k,
2463                    var: 2 + k,
2464                })
2465                .collect(),
2466            jac,
2467        };
2468        let eval = |x: &[Number]| {
2469            let grad = vec![
2470                2.0 * (x[0] - 5.0),
2471                4.0 * (2.0 * x[1] + 1.0),
2472                0.0,
2473                0.0,
2474                0.0,
2475                0.0,
2476                0.0,
2477                0.0,
2478            ];
2479            let g = vec![
2480                2.0 * (x[1] - 1.0) - 1.5 * x[1] + x[2] - 0.5 * x[3] + x[4],
2481                3.0 * x[0] - x[1] - 3.0 - x[5],
2482                -x[0] + 0.5 * x[1] + 4.0 - x[6],
2483                -x[0] - x[1] + 7.0 - x[7],
2484                x[5],
2485                x[6],
2486                x[7],
2487            ];
2488            (grad, g)
2489        };
2490        (m, eval)
2491    }
2492
2493    /// Past [`DENSE_ENTRIES`] a system with unique multipliers is certified
2494    /// sparsely, at any size; it used to be skipped past
2495    /// [`MAX_DENSE_ENTRIES`] (PR review: the stall guard was off for
2496    /// mid-size models). 2001 active bounds give a 2001 × 2001 system,
2497    /// 4 004 001 entries, past both limits. A wrong-signed bound multiplier
2498    /// is still caught there.
2499    #[test]
2500    fn a_large_system_with_unique_multipliers_is_certified_sparsely() {
2501        let n = 2001;
2502        let m = Model {
2503            x_l: vec![0.0; n],
2504            x_u: vec![PI; n],
2505            g_l: vec![],
2506            g_u: vec![],
2507            pairs: vec![],
2508            jac: vec![],
2509        };
2510        assert!(n * n > MAX_DENSE_ENTRIES);
2511        let mut grad = vec![1.0; n];
2512        let cert = |grad: &[Number]| {
2513            certify(&CertificateInput {
2514                x: &vec![0.0; n],
2515                grad_f: grad,
2516                g: &[],
2517                jac: &m.jac,
2518                x_l: &m.x_l,
2519                x_u: &m.x_u,
2520                g_l: &m.g_l,
2521                g_u: &m.g_u,
2522                pairs: &m.pairs,
2523                solver_tol: 1e-8,
2524                feas_tol: 1e-4,
2525            })
2526        };
2527        let c = cert(&grad);
2528        assert_eq!(
2529            (c.class, c.multipliers_unique),
2530            (StationarityClass::S, true),
2531            "{c:?}"
2532        );
2533        grad[7] = -1.0;
2534        let c = cert(&grad);
2535        assert_eq!(c.class, StationarityClass::None, "{c:?}");
2536    }
2537
2538    /// A badly scaled column (PR review): columns `e₀` and `(1, ε)`, swept
2539    /// from well conditioned to exactly dependent. The sparse path's
2540    /// multipliers are exact (after refinement) at every ε > 0, and it calls
2541    /// the system rank-deficient only at ε = 0, where the factorization's
2542    /// inertia is wrong. The dense SVD draws the same line (its threshold is
2543    /// `ε_mach · max(n, k) · σ_max`), so class and uniqueness agree on both
2544    /// paths at every scale (`run` asserts it).
2545    #[test]
2546    fn a_badly_scaled_column_gets_the_same_verdict_on_both_paths() {
2547        for eps in [1e-3, 1e-5, 1e-7, 1e-9, 1e-12, 1e-14, 0.0] {
2548            let m = Model {
2549                x_l: vec![0.0, -2e19],
2550                x_u: vec![2e19, 2e19],
2551                g_l: vec![0.0],
2552                g_u: vec![2e19],
2553                pairs: vec![],
2554                jac: vec![(0, 0, 1.0), (0, 1, eps)],
2555            };
2556            // grad f = A (1, 1): multipliers +1 and +1, so S.
2557            let c = run(&m, &[0.0, 0.0], &[2.0, eps], &[0.0]);
2558            assert_eq!(c.class, StationarityClass::S, "{eps:e}: {c:?}");
2559            assert_eq!(c.multipliers_unique, eps != 0.0, "{eps:e}: {c:?}");
2560            let cols = vec![vec![(0, 1.0)], vec![(0, 1.0), (1, eps)]];
2561            match sparse_least_squares(2, &cols, &[2.0, eps]) {
2562                LeastSquares::Solved(theta) => {
2563                    assert!(eps != 0.0, "{theta:?}");
2564                    assert!(
2565                        theta.iter().all(|t| (t - 1.0).abs() < 1e-12),
2566                        "{eps:e}: {theta:?}"
2567                    );
2568                }
2569                other => assert!(
2570                    eps == 0.0 && other == LeastSquares::RankDeficient,
2571                    "{eps:e}: {other:?}"
2572                ),
2573            }
2574        }
2575    }
2576
2577    /// One acceptance band on every path (PR review, MacMPEC): an IPM
2578    /// answer reproduces `∇f` only to about `sqrt(tol)` near the boundary,
2579    /// and the unique-multiplier path used to hold it to `max(1e-6, tol)`
2580    /// while the set search allowed the `sqrt(tol)` band too -- so `flp4-3`,
2581    /// `flp4-4` and `portfl-i-1`, optimal to f within 0.2%, read `none` with
2582    /// residuals of 1.7e-6 to 2.1e-5. Here a lone free variable with
2583    /// `∂f = r` leaves residual `r` (dual scale 1, band `1e-6 + 1e-4`).
2584    #[test]
2585    fn the_residual_band_is_the_same_on_the_unique_path() {
2586        let m = Model {
2587            x_l: vec![-2e19],
2588            x_u: vec![2e19],
2589            g_l: vec![],
2590            g_u: vec![],
2591            pairs: vec![],
2592            jac: vec![],
2593        };
2594        // Inside the floor: S and tight. Inside the band only: S, not tight
2595        // (the driver keeps it and looks for a tighter answer). Outside: none.
2596        for (r, class, tight) in [
2597            (5e-7, StationarityClass::S, true),
2598            (2e-5, StationarityClass::S, false),
2599            (9e-5, StationarityClass::S, false),
2600            (2e-4, StationarityClass::None, false),
2601        ] {
2602            let c = run(&m, &[0.3], &[r], &[]);
2603            assert_eq!((c.class, c.tight), (class, tight), "{r:e}: {c:?}");
2604            assert!((c.resid_tol - (1e-6 + 1e-4)).abs() < 1e-15, "{c:?}");
2605        }
2606    }
2607
2608    /// A point outside its own constraints is not certified (PR review,
2609    /// MacMPEC `pack-rig2*`: infeasibility verdicts whose points violate
2610    /// rows by 4.6e-4 to 9.9e-4 read "S"). Row `x₀ ≥ 1` at `x₀ = 1 − 5e-4`
2611    /// with `∇f = e₀`: the row's multiplier is +1, which would be S.
2612    #[test]
2613    fn a_point_violating_a_row_beyond_constr_viol_tol_is_not_certified() {
2614        let m = Model {
2615            x_l: vec![-2e19],
2616            x_u: vec![2e19],
2617            g_l: vec![1.0],
2618            g_u: vec![2e19],
2619            pairs: vec![],
2620            jac: vec![(0, 0, 1.0)],
2621        };
2622        let x = 1.0 - 5e-4;
2623        let c = run(&m, &[x], &[1.0], &[x]);
2624        assert_eq!(
2625            (c.class, c.feasible),
2626            (StationarityClass::None, false),
2627            "{c:?}"
2628        );
2629        assert!((c.primal_violation - 5e-4).abs() < 1e-12, "{c:?}");
2630        // Inside constr_viol_tol (1e-4) the same point certifies.
2631        let x = 1.0 - 5e-5;
2632        let c = run(&m, &[x], &[1.0], &[x]);
2633        assert_eq!((c.class, c.feasible), (StationarityClass::S, true), "{c:?}");
2634        // A violated variable bound counts the same way.
2635        let m = Model {
2636            x_l: vec![0.0],
2637            g_l: vec![],
2638            g_u: vec![],
2639            jac: vec![],
2640            ..m
2641        };
2642        let c = run(&m, &[-2e-3], &[1.0], &[]);
2643        assert_eq!(
2644            (c.class, c.feasible),
2645            (StationarityClass::None, false),
2646            "{c:?}"
2647        );
2648    }
2649
2650    /// A large system whose multipliers are a set is still not searched:
2651    /// the set search is dense. One active row `x₀ ≥ 0` duplicating the
2652    /// bound makes the 2001 bound columns plus the row dependent, and
2653    /// 2001 × 2002 entries is past [`MAX_DENSE_ENTRIES`].
2654    #[test]
2655    fn a_large_rank_deficient_system_is_not_searched() {
2656        let n = 2001;
2657        let c = certify(&CertificateInput {
2658            x: &vec![0.0; n],
2659            grad_f: &vec![1.0; n],
2660            g: &[0.0],
2661            jac: &[(0, 0, 1.0)],
2662            x_l: &vec![0.0; n],
2663            x_u: &vec![PI; n],
2664            g_l: &[0.0],
2665            g_u: &[2e19],
2666            pairs: &[],
2667            solver_tol: 1e-8,
2668            feas_tol: 1e-4,
2669        });
2670        assert_eq!(
2671            (c.class, c.multipliers_unique),
2672            (StationarityClass::NotSearched, false),
2673            "{c:?}"
2674        );
2675        assert!(n * (n + 1) > MAX_DENSE_ENTRIES);
2676    }
2677
2678    /// The set search's acceptance band (PR review): with non-unique
2679    /// multipliers a point passes when the signed least-squares residual is
2680    /// within `resid_tol + sign_tol` — `1e-6 + 1e-4` here (`tol = 1e-8`,
2681    /// dual scale 1) — ~100× looser than `resid_tol` alone, by design (a
2682    /// sign inside the noise band is not evidence of descent). Two copies of
2683    /// the active row `x ≥ 0` make the multipliers a set; `∇f = −ε` leaves
2684    /// residual `ε` at the best non-negative multipliers. Pinned just inside
2685    /// and just outside the band so it cannot drift silently.
2686    #[test]
2687    fn the_set_search_band_is_resid_tol_plus_the_sign_band() {
2688        let m = Model {
2689            x_l: vec![NI],
2690            x_u: vec![PI],
2691            g_l: vec![0.0, 0.0],
2692            g_u: vec![PI, PI],
2693            pairs: vec![],
2694            jac: vec![(0, 0, 1.0), (1, 0, 1.0)],
2695        };
2696        let inside = run(&m, &[0.0], &[-5e-5], &[0.0, 0.0]);
2697        assert!(!inside.multipliers_unique, "{inside:?}");
2698        assert_eq!(inside.class, StationarityClass::S, "{inside:?}");
2699        let outside = run(&m, &[0.0], &[-2e-4], &[0.0, 0.0]);
2700        assert!(!outside.multipliers_unique, "{outside:?}");
2701        assert_eq!(outside.class, StationarityClass::None, "{outside:?}");
2702    }
2703
2704    #[test]
2705    fn certify_matches_the_python_classifier_on_complementarity() {
2706        let (m, eval) = complementarity();
2707        for (x, want, nb) in [
2708            (
2709                [1.0, 0.0, 2.0, 0.0, 0.0, 0.0, 3.0, 6.0],
2710                StationarityClass::S,
2711                0,
2712            ), // f = 17
2713            (
2714                [5.0, 2.0, 0.0, 0.0, 1.0, 10.0, 0.0, 0.0],
2715                StationarityClass::S,
2716                1,
2717            ), // f = 25
2718            (
2719                [3.0, 4.0, 0.0, 0.0, 0.0, 2.0, 3.0, 0.0],
2720                StationarityClass::W,
2721                1,
2722            ), // f = 85, Python: M
2723        ] {
2724            let (grad, g) = eval(&x);
2725            assert!(
2726                g[..4].iter().all(|v| v.abs() < 1e-12),
2727                "{x:?} is not feasible: {g:?}"
2728            );
2729            let c = run(&m, &x, &grad, &g);
2730            assert_eq!((c.class, c.n_biactive), (want, nb), "{x:?}: {c:?}");
2731        }
2732    }
2733
2734    #[test]
2735    fn certify_reads_kkt_on_a_plain_nlp() {
2736        // maratos: min 2(x0^2 + x1^2 - 1) - x0  s.t.  x0^2 + x1^2 = 1.
2737        let m = Model {
2738            x_l: vec![NI; 2],
2739            x_u: vec![PI; 2],
2740            g_l: vec![1.0],
2741            g_u: vec![1.0],
2742            pairs: vec![],
2743            jac: vec![],
2744        };
2745        let at = |x: [Number; 2]| {
2746            let mut mm = Model {
2747                jac: vec![(0, 0, 2.0 * x[0]), (0, 1, 2.0 * x[1])],
2748                ..m.clone()
2749            };
2750            mm.jac.retain(|e| e.2 != 0.0);
2751            let grad = [4.0 * x[0] - 1.0, 4.0 * x[1]];
2752            run(&mm, &x, &grad, &[x[0] * x[0] + x[1] * x[1]]).class
2753        };
2754        assert_eq!(at([1.0, 0.0]), StationarityClass::S);
2755        assert_eq!(at([0.0, 1.0]), StationarityClass::None); // feasible, not stationary
2756    }
2757
2758    #[test]
2759    fn certify_ralph1_ctrap_and_qpec_small() {
2760        // ralph1: min 2x - y,  x >= 0,  0 <= y ⟂ y - x >= 0. Origin: M, not S.
2761        let m = Model {
2762            x_l: vec![0.0, 0.0],
2763            x_u: vec![PI; 2],
2764            g_l: vec![0.0],
2765            g_u: vec![PI],
2766            pairs: vec![ComplementarityPair { row: 0, var: 1 }],
2767            jac: vec![(0, 0, -1.0), (0, 1, 1.0)],
2768        };
2769        let c = run(&m, &[0.0, 0.0], &[2.0, -1.0], &[0.0]);
2770        assert_eq!((c.class, c.n_biactive), (StationarityClass::W, 1), "{c:?}");
2771
2772        // ralph1 mirrored through x -> -x, y -> -y (both sides upper-bounded:
2773        // y <= 0 ⟂ y - x <= 0): the same point, the same verdict.
2774        let mirrored = Model {
2775            x_l: vec![NI, NI],
2776            x_u: vec![0.0, 0.0],
2777            g_l: vec![NI],
2778            g_u: vec![0.0],
2779            pairs: vec![ComplementarityPair { row: 0, var: 1 }],
2780            jac: vec![(0, 0, -1.0), (0, 1, 1.0)],
2781        };
2782        let c = run(&mirrored, &[0.0, 0.0], &[-2.0, 1.0], &[0.0]);
2783        assert_eq!((c.class, c.n_biactive), (StationarityClass::W, 1), "{c:?}");
2784
2785        // ctrap: min x1^2 + x2^2 - x1 - x2,  0 <= x1 ⟂ x2 >= 0. Origin: C, not S.
2786        let m = Model {
2787            x_l: vec![0.0, NI],
2788            x_u: vec![PI; 2],
2789            g_l: vec![0.0],
2790            g_u: vec![PI],
2791            pairs: vec![ComplementarityPair { row: 0, var: 0 }],
2792            jac: vec![(0, 1, 1.0)],
2793        };
2794        let c = run(&m, &[0.0, 0.0], &[-1.0, -1.0], &[0.0]);
2795        assert_eq!(c.class, StationarityClass::W, "{c:?}");
2796        // ... and its minimizer (1/2, 0) is S, strictly complementary.
2797        let c = run(&m, &[0.5, 0.0], &[0.0, -1.0], &[0.0]);
2798        assert_eq!((c.class, c.n_biactive), (StationarityClass::S, 0), "{c:?}");
2799
2800        // qpec_small at (1, 1, 0): grad f = 0, one biactive pair, S.
2801        let m = Model {
2802            x_l: vec![0.0, 0.0, 0.0],
2803            x_u: vec![2.0, PI, PI],
2804            g_l: vec![0.0, 0.0],
2805            g_u: vec![PI, PI],
2806            pairs: vec![
2807                ComplementarityPair { row: 0, var: 1 },
2808                ComplementarityPair { row: 1, var: 2 },
2809            ],
2810            jac: vec![(0, 0, -1.0), (0, 1, 2.0), (1, 0, 1.0), (1, 2, 2.0)],
2811        };
2812        let c = run(&m, &[1.0, 1.0, 0.0], &[0.0, 0.0, 0.0], &[0.0, 0.0]);
2813        assert_eq!((c.class, c.n_biactive), (StationarityClass::S, 1), "{c:?}");
2814        // ctrap's minimizer mirrored (x1 <= 0 ⟂ x2 <= 0, at (-1/2, 0)): S,
2815        // reading the orientation from the finite bounds.
2816        let m = Model {
2817            x_l: vec![NI, NI],
2818            x_u: vec![0.0, PI],
2819            g_l: vec![NI],
2820            g_u: vec![0.0],
2821            pairs: vec![ComplementarityPair { row: 0, var: 0 }],
2822            jac: vec![(0, 1, 1.0)],
2823        };
2824        let c = run(&m, &[-0.5, 0.0], &[0.0, 1.0], &[0.0]);
2825        assert_eq!((c.class, c.n_biactive), (StationarityClass::S, 0), "{c:?}");
2826        let c = run(&m, &[0.0, 0.0], &[1.0, 1.0], &[0.0]);
2827        assert_eq!(c.class, StationarityClass::W, "{c:?}");
2828    }
2829
2830    /// The class is a property of the multiplier *set*. Here an equality row
2831    /// is parallel to the pair's `H` side, so the multipliers are not unique;
2832    /// the minimum-norm vector splits the `-0.1` between them and puts a
2833    /// negative sign on the biactive `w`, but `w = 0, λ = -0.1, ν = 1` is an
2834    /// S vector. Reading one vector would report W.
2835    ///
2836    /// `min x0 - 0.1 x1   s.t.  x1 = 0,   0 <= x0 ⟂ x1 >= 0`, at the origin.
2837    #[test]
2838    fn certify_searches_the_multiplier_set_when_it_is_not_a_point() {
2839        let m = Model {
2840            x_l: vec![0.0, NI],
2841            x_u: vec![PI; 2],
2842            g_l: vec![0.0, 0.0],
2843            g_u: vec![PI, 0.0],
2844            pairs: vec![ComplementarityPair { row: 0, var: 0 }],
2845            jac: vec![(0, 1, 1.0), (1, 1, 1.0)],
2846        };
2847        let c = run(&m, &[0.0, 0.0], &[1.0, -0.1], &[0.0, 0.0]);
2848        assert!(!c.multipliers_unique, "{c:?}");
2849        assert_eq!((c.class, c.n_biactive), (StationarityClass::S, 1), "{c:?}");
2850        // The minimum-norm vector alone would have said W.
2851        let a = Mat::<Number>::from_fn(2, 3, |r, col| match (r, col) {
2852            (0, 0) => 1.0,
2853            (1, 1) | (1, 2) => 1.0,
2854            _ => 0.0,
2855        });
2856        let (theta, _) = lstsq(&a, &[1.0, -0.1]);
2857        assert!(theta[1] < 0.0, "{theta:?}");
2858    }
2859
2860    #[test]
2861    fn certify_refuses_a_point_where_a_pair_is_not_complementary() {
2862        let m = Model {
2863            x_l: vec![0.0, NI],
2864            x_u: vec![PI; 2],
2865            g_l: vec![0.0],
2866            g_u: vec![PI],
2867            pairs: vec![ComplementarityPair { row: 0, var: 0 }],
2868            jac: vec![(0, 1, 1.0)],
2869        };
2870        let c = run(&m, &[0.5, 0.5], &[0.0, 0.0], &[0.5]);
2871        assert_eq!(
2872            (c.class, c.n_neither),
2873            (StationarityClass::None, 1),
2874            "{c:?}"
2875        );
2876    }
2877}