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