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