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