Skip to main content

pounce_cli/
verify.rs

1//! `pounce verify <problem.nl> <claim.sol>` — independent solution checker.
2//!
3//! # Why this exists
4//!
5//! When pounce is a *tool an agent calls*, the agent should never be the
6//! thing you trust for "the solution satisfies the constraints." Trust
7//! belongs to a small, deterministic checker that re-derives the answer
8//! from the **canonical** problem — not from the agent's narration and not
9//! even from the solver's own exit string. Optimization is the rare setting
10//! where this is cheap: a claimed `x*` is just numbers, and feasibility is
11//! one constraint evaluation (`g_l ≤ g(x*) ≤ g_u`, `x_l ≤ x* ≤ x_u`),
12//! `O(nnz)` work with no resolve.
13//!
14//! `pounce verify` loads the canonical `.nl`, reads a claimed `.sol`, and
15//! reports the worst constraint/bound violation (and, when the `.sol`
16//! carries constraint duals, a first-order/KKT stationarity residual). It
17//! defends the three agent-workflow failure modes:
18//!
19//! * **fabrication** ("here's a solution that looks like pounce ran") —
20//!   invented numbers fail the residual check against the real model;
21//! * **ignoring the solver** — a downstream consumer gates on the receipt's
22//!   `verified: true` plus the problem hash, not on prose;
23//! * **solving the wrong problem** (dropping/relaxing a constraint to dodge
24//!   infeasibility) — the check runs against the *canonical* constraints
25//!   and bounds, so a point that is only feasible for a relaxed model is
26//!   caught here.
27//!
28//! The JSON receipt content-addresses both inputs by SHA-256 so a consumer
29//! can confirm *which* problem was verified. When the `POUNCE_VERIFY_KEY`
30//! environment variable holds a secret the agent does not have, the receipt
31//! is additionally signed with HMAC-SHA256 over a float-free preimage (see
32//! [`signing_preimage`]) — so an agent cannot mint a receipt that a consumer
33//! holding the key will accept. The consumer recomputes the HMAC over the
34//! same preimage and compares.
35//!
36//! Verdict / exit code: `0` when every violation is within tolerance
37//! (`FEASIBLE`); `20` when a violation exceeds tolerance (`INFEASIBLE`);
38//! `2` on a usage or I/O error. Optimality is reported but, by default,
39//! does not gate — feasibility is the rigorous, sign-convention-independent
40//! guarantee; pass `--require-optimal` to also gate on the stationarity
41//! residual.
42//!
43//! # Two different complementarity quantities (gh #516)
44//!
45//! "Complementarity" names two distinct residuals, and printing either one
46//! under the bare label invites a comparison against the other:
47//!
48//! * **constraint** complementarity — `max_i |λ_i| · dist(g_i, nearest
49//!   finite side)` over **rows**, from the `.sol`'s constraint duals. This
50//!   is the one `verify` has always computed.
51//! * **bound** complementarity — `max_j max(|z_L·(x−x_L)|, |z_U·(x_U−x)|)`
52//!   over **variables**, from the bound multipliers. This is what Ipopt
53//!   prints as `Complementarity`, and it needs the `ipopt_zL_out` /
54//!   `ipopt_zU_out` `.sol` suffixes.
55//!
56//! They can differ by many orders of magnitude at the same point and
57//! neither is wrong for what it measures. `verify` therefore names both
58//! explicitly, reads the bound multipliers when the `.sol` carries them,
59//! and says "not checked" — rather than nothing — when it does not.
60//!
61//! Those same suffixes also sharpen stationarity: without them the residual
62//! is bound-*projected* and cannot see a bound multiplier that is missing or
63//! wrong (gh #495); with them the exact residual is available, and
64//! `--require-optimal` gates on it.
65
66use crate::nl_reader::{self, DiscreteCensus};
67use pounce_common::tolerance::is_negligible;
68use pounce_common::types::{Number, lower_bound_present, upper_bound_present};
69use pounce_nlp::tnlp::{BoundsInfo, IndexStyle, SparsityRequest, TNLP};
70use std::path::PathBuf;
71use std::process::ExitCode;
72
73/// Parsed `verify` subcommand arguments.
74#[derive(Debug, Clone)]
75pub struct VerifyArgs {
76    pub nl: PathBuf,
77    pub sol: PathBuf,
78    /// Max `|violation|` of any constraint or bound still called feasible.
79    ///
80    /// Applied **per row, relative to the row's magnitude** — the accepting
81    /// test is `|viol| <= feas_tol * max(1, |row|)` (see [`row_is_violated`]),
82    /// so on a row of size `2e6` a violation of `1.0` passes at `1e-6`. Use
83    /// [`abs_feas_tol`](VerifyArgs::abs_feas_tol) for a plain absolute test.
84    pub feas_tol: Number,
85    /// `--abs-feas-tol T` — judge every row by the plain absolute test
86    /// `|viol| <= T`, replacing the relative test above (gh#987).
87    pub abs_feas_tol: Option<Number>,
88    /// Max stationarity residual still called first-order optimal.
89    pub opt_tol: Number,
90    /// `--json-output PATH` — write the machine-readable receipt to PATH.
91    pub json_output: Option<PathBuf>,
92    /// `--require-optimal` — also gate the exit code on the stationarity
93    /// residual (needs duals in the `.sol`).
94    pub require_optimal: bool,
95}
96
97impl Default for VerifyArgs {
98    fn default() -> Self {
99        VerifyArgs {
100            nl: PathBuf::new(),
101            sol: PathBuf::new(),
102            feas_tol: 1e-6,
103            abs_feas_tol: None,
104            opt_tol: 1e-6,
105            json_output: None,
106            require_optimal: false,
107        }
108    }
109}
110
111const USAGE: &str = "\
112Usage: pounce verify <problem.nl> <claim.sol> [OPTIONS]
113
114Independently check that the solution in <claim.sol> satisfies the
115constraints and bounds of the canonical problem <problem.nl>. Re-derives
116feasibility from the model itself — it does not trust the .sol's status
117line or rerun the solver.
118
119Arguments:
120  <problem.nl>            canonical AMPL .nl problem (the source of truth)
121  <claim.sol>            claimed AMPL .sol solution to check
122
123Options:
124  --feas-tol <t>         feasibility tolerance (default 1e-6). RELATIVE per
125                         row: a row passes when |violation| <= t*max(1,|row|),
126                         so a violation of 1.0 on a row of size 2e6 passes at
127                         1e-6. Use --abs-feas-tol for an absolute test.
128  --abs-feas-tol <t>     judge every row by the absolute test |violation| <= t
129                         (replaces the relative --feas-tol test)
130  --opt-tol <t>          stationarity tolerance (default 1e-6)
131  --require-optimal      also fail if the KKT stationarity residual
132                         exceeds --opt-tol (needs duals in the .sol)
133  --json-output <path>   write a JSON verification receipt to <path>
134  -h, --help             print this message
135
136Complementarity: two different residuals carry that name, and they can
137differ by many orders of magnitude at the same point.
138  * constraint complementarity (rows, |lambda|*slack) is computed from the
139    .sol's constraint duals and is always reported alongside stationarity.
140  * bound complementarity (vars, |z|*slack) is the quantity Ipopt prints as
141    `Complementarity`. It needs the bound multipliers, which reach a .sol
142    only as the `ipopt_zL_out` / `ipopt_zU_out` suffixes; without them it is
143    reported as `not checked`, never as a number.
144Do not compare the row quantity against a solver's `Complementarity` line.
145
146Exit code: 0 = verified feasible, 20 = violation exceeds tolerance,
1472 = usage/IO error.";
148
149/// Entry point dispatched from `main` when argv[1] == "verify".
150pub fn run_from_argv(rest: &[String]) -> ExitCode {
151    let args = match parse_verify_argv(rest) {
152        Ok(Some(a)) => a,
153        Ok(None) => {
154            // help was requested
155            println!("{USAGE}");
156            return ExitCode::SUCCESS;
157        }
158        Err(msg) => {
159            eprintln!("pounce verify: {msg}");
160            eprintln!("{USAGE}");
161            return ExitCode::from(2);
162        }
163    };
164    run(&args)
165}
166
167fn parse_verify_argv(rest: &[String]) -> Result<Option<VerifyArgs>, String> {
168    let mut a = VerifyArgs::default();
169    let mut positionals: Vec<PathBuf> = Vec::new();
170    let mut it = rest.iter();
171    while let Some(arg) = it.next() {
172        match arg.as_str() {
173            "-h" | "--help" => return Ok(None),
174            "--feas-tol" => {
175                let v = it.next().ok_or("--feas-tol requires a value")?;
176                a.feas_tol = v.parse().map_err(|e| format!("--feas-tol: {e}"))?;
177            }
178            "--abs-feas-tol" => {
179                let v = it.next().ok_or("--abs-feas-tol requires a value")?;
180                a.abs_feas_tol = Some(v.parse().map_err(|e| format!("--abs-feas-tol: {e}"))?);
181            }
182            "--opt-tol" => {
183                let v = it.next().ok_or("--opt-tol requires a value")?;
184                a.opt_tol = v.parse().map_err(|e| format!("--opt-tol: {e}"))?;
185            }
186            "--require-optimal" => a.require_optimal = true,
187            "--json-output" => {
188                let v = it.next().ok_or("--json-output requires a value")?;
189                a.json_output = Some(PathBuf::from(v));
190            }
191            other if other.starts_with('-') => {
192                return Err(format!("unknown flag `{other}`"));
193            }
194            _ => positionals.push(PathBuf::from(arg)),
195        }
196    }
197    match positionals.len() {
198        0 | 1 => Err("expected two positional arguments: <problem.nl> <claim.sol>".to_string()),
199        2 => {
200            a.nl = positionals[0].clone();
201            a.sol = positionals[1].clone();
202            Ok(Some(a))
203        }
204        n => Err(format!("expected 2 positional arguments, got {n}")),
205    }
206}
207
208/// The fully-evaluated verification result. Serialized to the JSON
209/// receipt and rendered to the console.
210#[derive(Debug)]
211pub struct VerifyOutcome {
212    pub n_vars: usize,
213    pub n_cons: usize,
214    pub nl_sha256: String,
215    pub sol_sha256: String,
216    pub solve_result_num: Option<i32>,
217    pub feas_tol: Number,
218    pub opt_tol: Number,
219    // feasibility
220    pub max_con_violation: Number,
221    pub worst_con: Option<RowReport>,
222    pub max_bound_violation: Number,
223    pub worst_bound: Option<RowReport>,
224    pub feasible: bool,
225    // optimality (only when duals supplied)
226    pub objective: Option<Number>,
227    pub duals_present: bool,
228    pub stationarity: Option<Number>,
229    pub dual_sign: Option<i32>,
230    /// `max_i |λ_i| · dist(g_i, active side)` over **rows**. NOT the
231    /// quantity a solver reports as `Complementarity` — see
232    /// [`bound_complementarity`](VerifyOutcome::bound_complementarity).
233    pub constraint_complementarity: Option<Number>,
234    /// Whether the `.sol` carried `ipopt_zL_out` / `ipopt_zU_out`.
235    pub bound_multipliers_present: bool,
236    /// `max_j max(|z_L·(x−x_L)|, |z_U·(x_U−x)|)` over **variables** — the
237    /// quantity Ipopt prints as `Complementarity`. `None` when the `.sol`
238    /// carried no bound multipliers, in which case it is *not checked*
239    /// rather than zero.
240    pub bound_complementarity: Option<Number>,
241    /// Exact (non-projected) dual infeasibility
242    /// `‖∇f + sign·Jᵀλ − (z_L^suffix + z_U^suffix)‖∞`, available only when
243    /// both duals and bound multipliers are present.
244    pub stationarity_with_bound_multipliers: Option<Number>,
245    pub optimal: Option<bool>,
246    /// The `.nl` header's declared binary / integer census (gh#987).
247    pub discrete: Option<DiscreteCensus>,
248    /// Max distance to the nearest integer over the declared integer
249    /// columns, and the worst column. `None` when integrality was not
250    /// checked (no discrete variables, or their columns cannot be told apart
251    /// from the header alone).
252    pub integrality: Option<(Number, usize)>,
253    /// The tolerance used for the absolute test, when `--abs-feas-tol` was set.
254    pub abs_feas_tol: Option<Number>,
255    // final
256    pub verified: bool,
257}
258
259#[derive(Debug, Clone)]
260pub struct RowReport {
261    pub index: usize,
262    pub name: String,
263    pub value: Number,
264    pub lo: Number,
265    pub hi: Number,
266    pub violation: Number,
267}
268
269// `is_finite_bound(b) = b > NLP_LOWER_BOUND_INF && b < NLP_UPPER_BOUND_INF`
270// used to live here — a *band* membership test applied to lower and upper
271// bounds alike (gh #403). A real upper bound of `-5e20` failed it, so
272// `box_violation` scored `0.0` against it and `verify` reported ACCEPTED for a
273// `.sol` that violates a declared bound. Presence is directional; use
274// `lower_bound_present` / `upper_bound_present` from `pounce_common::types`,
275// picking the one that matches the side you hold.
276
277/// `g_l ≤ v ≤ g_u` violation: how far `v` is outside the box, 0 if inside.
278///
279/// A non-finite `v` (NaN or ±∞) is treated as an infinite violation, never
280/// as feasible: `NaN`-laden arithmetic would otherwise collapse to `0.0`
281/// through `f64::max` (which drops NaN operands) and let a fabricated `.sol`
282/// slip past the feasibility gate — the exact threat this checker defends
283/// against. An unbounded variable pinned at ±∞ is likewise not a real point.
284/// The natural magnitude of a row, for a scale-relative feasibility test.
285///
286/// `verify` reads a `.nl` and a `.sol`; no solver scaling has been applied, so
287/// the magnitude has to come from the row's own numbers — the evaluated value
288/// and whichever bounds are finite. Infinite bounds carry no magnitude
289/// information and are skipped.
290pub(crate) fn row_magnitude(value: Number, lo: Number, hi: Number) -> Number {
291    let mut m = if value.is_finite() { value.abs() } else { 0.0 };
292    if lower_bound_present(lo) {
293        m = m.max(lo.abs());
294    }
295    if upper_bound_present(hi) {
296        m = m.max(hi.abs());
297    }
298    m
299}
300
301/// Whether a row's violation is real, judged relative to the row's own
302/// magnitude.
303///
304/// An absolute tolerance is meaningless against a row evaluating near `1e13`:
305/// `--feas-tol 1e-6` is unreachable there, so a solution correct to eleven
306/// relative digits was reported REJECTED. Scaling the tolerance by the row
307/// magnitude makes the verdict independent of how the model happens to be
308/// written.
309///
310/// Uses the **accepting** direction (`is_negligible`), which is never stricter
311/// than the plain absolute `tol`. A pure relative test was tried first and
312/// rejected genuine solutions: the solver converges to *absolute* residuals, so
313/// on a row of magnitude `1e-3` a residual of `1e-8` is converged, while a
314/// relative test at `tol = 1e-6` would demand `1e-9`.
315///
316/// The non-finite case is handled here rather than inside the primitive, which
317/// reports an unjudgeable value as not-negligible-and-not-significant. A `.sol`
318/// carrying `NaN` or `±inf` is not a point at all and must be rejected — which
319/// is what `box_violation` returning infinity encodes.
320pub(crate) fn row_is_violated(viol: Number, magnitude: Number, feas_tol: Number) -> bool {
321    if !viol.is_finite() {
322        return true;
323    }
324    !is_negligible(viol, magnitude, feas_tol)
325}
326
327/// Row verdict under the active tolerance mode: the relative accepting test by
328/// default, the plain absolute test when `--abs-feas-tol` was given.
329fn judge_row(args: &VerifyArgs, viol: Number, magnitude: Number) -> bool {
330    match args.abs_feas_tol {
331        Some(t) => !viol.is_finite() || viol > t,
332        None => row_is_violated(viol, magnitude, args.feas_tol),
333    }
334}
335
336/// The one-line warning for a `.nl` that declares integer variables, shared by
337/// the solve path and `verify` (gh#987). `None` when there are none.
338pub fn integrality_notice(c: Option<DiscreteCensus>) -> Option<String> {
339    let c = c.filter(|c| c.total() > 0)?;
340    Some(format!(
341        "the .nl declares {} discrete variable(s) ({} binary and {} integer appearing \
342         linearly, {} appearing nonlinearly); pounce is a continuous solver and solved \
343         the RELAXATION, so the result is a bound on the MIP optimum, not an \
344         integer-feasible solution",
345        c.total(),
346        c.binary,
347        c.integer,
348        c.nonlinear_discrete()
349    ))
350}
351
352pub(crate) fn box_violation(v: Number, lo: Number, hi: Number) -> Number {
353    if !v.is_finite() {
354        return Number::INFINITY;
355    }
356    let below = if lower_bound_present(lo) {
357        lo - v
358    } else {
359        Number::NEG_INFINITY
360    };
361    let above = if upper_bound_present(hi) {
362        v - hi
363    } else {
364        Number::NEG_INFINITY
365    };
366    below.max(above).max(0.0)
367}
368
369pub fn run(args: &VerifyArgs) -> ExitCode {
370    let outcome = match evaluate(args) {
371        Ok(o) => o,
372        Err(msg) => {
373            eprintln!("pounce verify: {msg}");
374            return ExitCode::from(2);
375        }
376    };
377    print_report(args, &outcome);
378
379    if let Some(path) = &args.json_output {
380        let json = receipt_json(args, &outcome);
381        if let Err(e) = std::fs::write(path, json.as_bytes()) {
382            eprintln!(
383                "pounce verify: failed to write receipt {}: {e}",
384                path.display()
385            );
386            return ExitCode::from(2);
387        }
388        let signed = std::env::var(KEY_ENV)
389            .map(|k| !k.is_empty())
390            .unwrap_or(false);
391        println!(
392            "  receipt: {}{}",
393            path.display(),
394            if signed {
395                "  (signed: HMAC-SHA256)"
396            } else {
397                ""
398            }
399        );
400    }
401
402    if outcome.verified {
403        ExitCode::SUCCESS
404    } else {
405        ExitCode::from(20)
406    }
407}
408
409fn evaluate(args: &VerifyArgs) -> Result<VerifyOutcome, String> {
410    // --- read + hash the two inputs (content-address the receipt) ---
411    let nl_bytes =
412        std::fs::read(&args.nl).map_err(|e| format!("cannot read {}: {e}", args.nl.display()))?;
413    let sol_bytes =
414        std::fs::read(&args.sol).map_err(|e| format!("cannot read {}: {e}", args.sol.display()))?;
415    let nl_sha256 = sha256::hex(&nl_bytes);
416    let sol_sha256 = sha256::hex(&sol_bytes);
417
418    // --- canonical problem ---
419    let prob = nl_reader::read_nl_file(&args.nl)?;
420    if !prob.complementarity.is_empty() {
421        // Verification checks the rows and bounds; a complementarity pair is
422        // neither, so a point violating it would pass.
423        return Err(format!(
424            "{} declares {} complementarity pair(s), which verify does not check; refusing \
425             rather than verifying the relaxation",
426            args.nl.display(),
427            prob.complementarity.len()
428        )
429        .into());
430    }
431    let n = prob.n;
432    let m = prob.m;
433    let con_names = prob.con_names.clone();
434    let var_names = prob.var_names.clone();
435    let prob_discrete = prob.n_discrete;
436    let prob_nl_counts = prob.nl_counts;
437    // `NlTnlp` evaluates a maximize objective negated, while the `.sol`
438    // bound blocks are `sigma*z_L` / `-sigma*z_U` (gh#959); the exact
439    // dual-infeasibility check has to bring them into the same frame.
440    let obj_sigma: Number = if prob.minimize { 1.0 } else { -1.0 };
441    let mut tnlp = nl_reader::NlTnlp::try_new(prob)?;
442
443    let info = tnlp
444        .get_nlp_info()
445        .ok_or("get_nlp_info failed on the .nl")?;
446    let nnz = info.nnz_jac_g.max(0) as usize;
447    let fortran = matches!(info.index_style, IndexStyle::Fortran);
448
449    // --- claimed solution ---
450    let sol_text = String::from_utf8_lossy(&sol_bytes);
451    let parsed = parse_sol(&sol_text)?;
452    if parsed.x.len() != n {
453        return Err(format!(
454            "solution has {} primal values but the problem has {n} variables \
455             (is this the right .sol for this .nl?)",
456            parsed.x.len()
457        ));
458    }
459    let x = parsed.x;
460    let duals_present = !parsed.lambda.is_empty();
461    if duals_present && parsed.lambda.len() != m {
462        return Err(format!(
463            "solution carries {} dual values but the problem has {m} constraints",
464            parsed.lambda.len()
465        ));
466    }
467
468    // --- bounds ---
469    let mut x_l = vec![0.0; n];
470    let mut x_u = vec![0.0; n];
471    let mut g_l = vec![0.0; m];
472    let mut g_u = vec![0.0; m];
473    if !tnlp.get_bounds_info(BoundsInfo {
474        x_l: &mut x_l,
475        x_u: &mut x_u,
476        g_l: &mut g_l,
477        g_u: &mut g_u,
478    }) {
479        return Err("get_bounds_info failed".to_string());
480    }
481
482    // --- bound feasibility ---
483    let mut max_bound_violation = 0.0_f64;
484    let mut worst_bound: Option<RowReport> = None;
485    let mut any_bound_violated = false;
486    for j in 0..n {
487        let viol = box_violation(x[j], x_l[j], x_u[j]);
488        if judge_row(args, viol, row_magnitude(x[j], x_l[j], x_u[j])) {
489            any_bound_violated = true;
490        }
491        if viol > max_bound_violation {
492            max_bound_violation = viol;
493            worst_bound = Some(RowReport {
494                index: j,
495                name: name_at(&var_names, j, 'x'),
496                value: x[j],
497                lo: x_l[j],
498                hi: x_u[j],
499                violation: viol,
500            });
501        }
502    }
503
504    // --- constraint feasibility ---
505    let mut g = vec![0.0; m];
506    if !tnlp.eval_g(&x, true, &mut g) {
507        return Err("eval_g failed at the claimed solution".to_string());
508    }
509    let mut max_con_violation = 0.0_f64;
510    let mut worst_con: Option<RowReport> = None;
511    let mut any_con_violated = false;
512    for i in 0..m {
513        let viol = box_violation(g[i], g_l[i], g_u[i]);
514        if judge_row(args, viol, row_magnitude(g[i], g_l[i], g_u[i])) {
515            any_con_violated = true;
516        }
517        if viol > max_con_violation {
518            max_con_violation = viol;
519            worst_con = Some(RowReport {
520                index: i,
521                name: name_at(&con_names, i, 'c'),
522                value: g[i],
523                lo: g_l[i],
524                hi: g_u[i],
525                violation: viol,
526            });
527        }
528    }
529
530    // Per-row and scale-relative: a single absolute threshold across rows of
531    // wildly different magnitude answers a different question for each of them.
532    // Integrality (gh#987). pounce solves the continuous relaxation, so a
533    // `.sol` for a MIP `.nl` can be feasible for every row and still be
534    // fractional. Header line 7 says how many columns are discrete — `nbv`
535    // and `niv` for those appearing only linearly, `nlvbi nlvci nlvoi` for
536    // those appearing nonlinearly — and Gay's variable ordering places them
537    // (see [`DiscreteCensus::integer_columns`]). When the columns cannot be
538    // identified the check is skipped and the report says NOT CHECKED rather
539    // than implying it passed.
540    let discrete = prob_discrete;
541    let integrality = discrete
542        .filter(|d| d.total() > 0)
543        .and_then(|d| d.integer_columns(n, prob_nl_counts))
544        .filter(|cols| !cols.is_empty())
545        .map(|cols| {
546            let mut worst = (0.0_f64, cols[0]);
547            for j in cols {
548                let xj = x[j];
549                let dist = if xj.is_finite() {
550                    (xj - xj.round()).abs()
551                } else {
552                    Number::INFINITY
553                };
554                if dist > worst.0 {
555                    worst = (dist, j);
556                }
557            }
558            worst
559        });
560    let integrality_ok =
561        integrality.is_none_or(|(d, _)| d <= args.abs_feas_tol.unwrap_or(args.feas_tol));
562    let feasible = !any_con_violated && !any_bound_violated && integrality_ok;
563
564    // --- objective ---
565    let objective = tnlp.eval_f(&x, true);
566
567    // --- bound multipliers, when the `.sol` exported them (gh #516) ---
568    //
569    // The bound complementarity Ipopt prints as `Complementarity` cannot be
570    // computed from the primal and the constraint duals alone; it needs
571    // `z_L` / `z_U`, which reach a `.sol` only as the `ipopt_zL_out` /
572    // `ipopt_zU_out` variable suffixes. Absent them the quantity is *not
573    // checked* — never silently reported as the row quantity.
574    let bound_multipliers_present = parsed.z_l.is_some() || parsed.z_u.is_some();
575    let z_l_suf = parsed.z_l.clone().unwrap_or_else(|| vec![0.0; n]);
576    let z_u_suf = parsed.z_u.clone().unwrap_or_else(|| vec![0.0; n]);
577    let bound_complementarity = if bound_multipliers_present {
578        Some(bound_complementarity(&x, &x_l, &x_u, &z_l_suf, &z_u_suf))
579    } else {
580        None
581    };
582
583    // --- first-order / KKT stationarity (only when duals are supplied) ---
584    let mut stationarity = None;
585    let mut dual_sign = None;
586    let mut constraint_complementarity = None;
587    let mut stationarity_with_bound_multipliers = None;
588    let mut optimal = None;
589    // A problem with no rows has no constraint duals to carry, so `∇f` alone
590    // is the Lagrangian gradient and the residual is available from an empty
591    // dual block — which is what a `.sol` for a bounds-only model has.
592    if duals_present || m == 0 {
593        let lambda = &parsed.lambda;
594
595        // ∇f(x*)
596        let mut grad_f = vec![0.0; n];
597        tnlp.eval_grad_f(&x, true, &mut grad_f);
598
599        // Jacobian triplets (structure then values).
600        let mut irow = vec![0i32; nnz];
601        let mut jcol = vec![0i32; nnz];
602        tnlp.eval_jac_g(
603            Some(&x),
604            true,
605            SparsityRequest::Structure {
606                irow: &mut irow,
607                jcol: &mut jcol,
608            },
609        );
610        let mut jval = vec![0.0; nnz];
611        tnlp.eval_jac_g(
612            Some(&x),
613            true,
614            SparsityRequest::Values { values: &mut jval },
615        );
616
617        // AMPL's dual sign convention can flip relative to ours; rather
618        // than guess, compute the bound-projected stationarity residual
619        // for both signs and keep the better one. A genuine KKT point is
620        // stationary for exactly one of them; we report which.
621        let s_pos = lagrangian_gradient(1.0, &grad_f, &irow, &jcol, &jval, fortran, lambda);
622        let s_neg = lagrangian_gradient(-1.0, &grad_f, &irow, &jcol, &jval, fortran, lambda);
623        let resid_pos = bound_projected_residual(&s_pos, &x, &x_l, &x_u, args.opt_tol);
624        let resid_neg = bound_projected_residual(&s_neg, &x, &x_l, &x_u, args.opt_tol);
625        let (best_resid, sign, s) = if resid_pos <= resid_neg {
626            (resid_pos, 1, &s_pos)
627        } else {
628            (resid_neg, -1, &s_neg)
629        };
630        stationarity = Some(best_resid);
631        dual_sign = Some(sign);
632        constraint_complementarity = Some(row_complementarity(lambda, &g, &g_l, &g_u));
633
634        // With the bound multipliers in hand the residual no longer has to
635        // be projected: the exact dual infeasibility is available, and it
636        // is what a solver reports. It is also the strictly sharper check —
637        // the projection can only *remove* residual — so `--require-optimal`
638        // gates on it whenever it exists.
639        if bound_multipliers_present {
640            stationarity_with_bound_multipliers =
641                Some(exact_dual_infeasibility(s, &z_l_suf, &z_u_suf, obj_sigma));
642        }
643        let gate = stationarity_with_bound_multipliers.unwrap_or(best_resid);
644        optimal = Some(gate <= args.opt_tol);
645    }
646
647    // Verified = feasible (always required) AND, if --require-optimal,
648    // also first-order optimal.
649    let verified = feasible && (!args.require_optimal || optimal.unwrap_or(false));
650
651    Ok(VerifyOutcome {
652        n_vars: n,
653        n_cons: m,
654        nl_sha256,
655        sol_sha256,
656        solve_result_num: parsed.solve_result_num,
657        feas_tol: args.feas_tol,
658        opt_tol: args.opt_tol,
659        max_con_violation,
660        worst_con,
661        max_bound_violation,
662        worst_bound,
663        feasible,
664        objective,
665        duals_present,
666        stationarity,
667        dual_sign,
668        constraint_complementarity,
669        bound_multipliers_present,
670        bound_complementarity,
671        stationarity_with_bound_multipliers,
672        optimal,
673        discrete,
674        integrality,
675        abs_feas_tol: args.abs_feas_tol,
676        verified,
677    })
678}
679
680/// `s = ∇f + sign·Jᵀλ` — the part of the Lagrangian gradient the constraint
681/// duals can account for, before any bound multiplier enters.
682fn lagrangian_gradient(
683    sign: Number,
684    grad_f: &[Number],
685    irow: &[i32],
686    jcol: &[i32],
687    jval: &[Number],
688    fortran: bool,
689    lambda: &[Number],
690) -> Vec<Number> {
691    let n = grad_f.len();
692    let off = if fortran { 1 } else { 0 };
693    let mut s = grad_f.to_vec();
694    for k in 0..jval.len() {
695        let row = (irow[k] as usize).wrapping_sub(off);
696        let col = (jcol[k] as usize).wrapping_sub(off);
697        if row < lambda.len() && col < n {
698            s[col] += sign * jval[k] * lambda[row];
699        }
700    }
701    s
702}
703
704/// Bound-**projected** stationarity (a.k.a. "dual infeasibility"): for each
705/// variable, the part of `s` that a valid sign-constrained bound multiplier
706/// `z_L, z_U ≥ 0` cannot absorb. Returns `‖projected s‖∞`.
707///
708/// Stationarity is `z_L − z_U = s`, so a positive `s_j` is absorbable by the
709/// lower bound and a negative one by the upper. A bound may absorb it when
710/// the multiplier that would take is complementary with the variable's gap
711/// to that bound: `|s_j|·gap ≤ compl_tol`, or the gap is within `1e-8`
712/// relative. The product is the test an interior-point answer passes. It
713/// stops a barrier's distance from the bound, not on it, and that distance
714/// is `μ/z`, so an activity test on the gap alone misreads its active bounds
715/// as interior variables and reports their whole gradient as residual. On
716/// PGLib case6468_rte, variables 1.9e-8 inside a bound of 0.2189 (under the
717/// relative test's 1.2e-8) read as a residual of 0.44 against a solver dual
718/// infeasibility of 2.3e-8, with their `ipopt_zU_out` matching the gradient
719/// to every printed digit.
720///
721/// This is a *relaxation*: it projects out exactly the component a bound
722/// multiplier would carry, so it cannot see a missing or wrong `z` (gh #495).
723/// When the `.sol` exports the multipliers, prefer
724/// [`exact_dual_infeasibility`].
725fn bound_projected_residual(
726    s: &[Number],
727    x: &[Number],
728    x_l: &[Number],
729    x_u: &[Number],
730    compl_tol: Number,
731) -> Number {
732    let n = s.len();
733    let mut dual_inf = 0.0_f64;
734    for j in 0..n {
735        let fixed = lower_bound_present(x_l[j])
736            && upper_bound_present(x_u[j])
737            && (x_u[j] - x_l[j]).abs() <= 1e-12;
738        if fixed {
739            continue;
740        }
741        let absorbable = |present: bool, bound: Number, gap: Number, z: Number| {
742            present && (gap.abs() <= 1e-8 * (1.0 + bound.abs()) || z * gap.max(0.0) <= compl_tol)
743        };
744        let r = if s[j] > 0.0 {
745            if absorbable(lower_bound_present(x_l[j]), x_l[j], x[j] - x_l[j], s[j]) {
746                0.0
747            } else {
748                s[j]
749            }
750        } else if absorbable(upper_bound_present(x_u[j]), x_u[j], x_u[j] - x[j], -s[j]) {
751            0.0
752        } else {
753            -s[j]
754        };
755        dual_inf = dual_inf.max(r);
756    }
757    dual_inf
758}
759
760/// Exact dual infeasibility `‖s − (z_L^suffix + z_U^suffix)‖∞`, with `s` the
761/// [`lagrangian_gradient`] at the sign matching the `.sol`'s dual convention.
762///
763/// Stationarity in pounce's internal convention is
764/// `∇f + Jᵀλ − z_L + z_U = 0` with `z_L, z_U ≥ 0`, and the `.sol` suffixes
765/// carry `ipopt_zL_out = +z_L`, `ipopt_zU_out = −z_U` — both equal to the
766/// objective-gradient component at the bound, matching Ipopt 3.14 (gh #296).
767/// So `−z_L + z_U` is exactly `−(zL_out + zU_out)`, and no sign has to be
768/// guessed here beyond the one already chosen for `λ`.
769///
770/// Unlike [`bound_projected_residual`] this sees a bound multiplier that is
771/// missing or wrong, because nothing is projected away.
772fn exact_dual_infeasibility(
773    s: &[Number],
774    z_l_suf: &[Number],
775    z_u_suf: &[Number],
776    obj_sigma: Number,
777) -> Number {
778    let mut dual_inf = 0.0_f64;
779    for (j, &s_j) in s.iter().enumerate() {
780        let z = obj_sigma
781            * (z_l_suf.get(j).copied().unwrap_or(0.0) + z_u_suf.get(j).copied().unwrap_or(0.0));
782        dual_inf = dual_inf.max((s_j - z).abs());
783    }
784    dual_inf
785}
786
787/// Bound complementarity over **variables**:
788/// `max_j max(|z_L·(x−x_L)|, |z_U·(x_U−x)|)` — the quantity Ipopt prints as
789/// `Complementarity` (gh #516). Only variables with a finite bound on the
790/// side in question contribute.
791///
792/// Magnitudes throughout, so the result does not depend on which sign
793/// convention the writer used for the multipliers, nor on which side of a
794/// bound the point sits.
795fn bound_complementarity(
796    x: &[Number],
797    x_l: &[Number],
798    x_u: &[Number],
799    z_l_suf: &[Number],
800    z_u_suf: &[Number],
801) -> Number {
802    let mut comp = 0.0_f64;
803    for j in 0..x.len() {
804        if lower_bound_present(x_l[j]) {
805            let z = z_l_suf.get(j).copied().unwrap_or(0.0);
806            comp = comp.max((z * (x[j] - x_l[j])).abs());
807        }
808        if upper_bound_present(x_u[j]) {
809            let z = z_u_suf.get(j).copied().unwrap_or(0.0);
810            comp = comp.max((z * (x_u[j] - x[j])).abs());
811        }
812    }
813    comp
814}
815
816/// `max_i |λ_i| · dist(g_i, active side)` over constraints with a finite
817/// range — a constraint with a nonzero multiplier should be active.
818/// Equalities (`g_l == g_u`) contribute 0. Best-effort, informational.
819///
820/// This is **constraint** complementarity, over rows, and is not the
821/// quantity a solver reports as `Complementarity` — that one is
822/// [`bound_complementarity`], over variables. The two are unrelated in
823/// magnitude; see the module docs (gh #516).
824fn row_complementarity(lambda: &[Number], g: &[Number], g_l: &[Number], g_u: &[Number]) -> Number {
825    let mut comp = 0.0_f64;
826    for i in 0..lambda.len() {
827        // An equality needs *both* bounds present (gh #403): `g_l = g_u = -5e20`
828        // is the one-sided `g <= -5e20`, not an equality at `-5e20`, and
829        // skipping it here would drop a real complementarity term.
830        if lower_bound_present(g_l[i])
831            && upper_bound_present(g_u[i])
832            && (g_u[i] - g_l[i]).abs() <= 1e-12
833        {
834            continue; // equality: multiplier is free, no complementarity
835        }
836        let dl = if lower_bound_present(g_l[i]) {
837            (g[i] - g_l[i]).abs()
838        } else {
839            Number::INFINITY
840        };
841        let du = if upper_bound_present(g_u[i]) {
842            (g_u[i] - g[i]).abs()
843        } else {
844            Number::INFINITY
845        };
846        let dist = dl.min(du);
847        if dist.is_finite() {
848            comp = comp.max(lambda[i].abs() * dist);
849        }
850    }
851    comp
852}
853
854pub(crate) fn name_at(names: &[String], i: usize, kind: char) -> String {
855    match names.get(i) {
856        Some(s) if !s.is_empty() => s.clone(),
857        _ => format!("{kind}[{i}]"),
858    }
859}
860
861// ---------------------------------------------------------------------------
862// AMPL .sol parser (the inverse of `crate::nl_writer`).
863// ---------------------------------------------------------------------------
864
865#[derive(Debug)]
866struct ParsedSol {
867    x: Vec<Number>,
868    lambda: Vec<Number>,
869    solve_result_num: Option<i32>,
870    /// `ipopt_zL_out` variable suffix, densified to `n`, when present.
871    z_l: Option<Vec<Number>>,
872    /// `ipopt_zU_out` variable suffix, densified to `n`, when present.
873    z_u: Option<Vec<Number>>,
874}
875
876/// Parse the ASCII AMPL `.sol` form pounce writes: a free-text banner, a
877/// blank line, `Options`, an option count + that many option words, the
878/// four-integer count block `<n_dual> <m> <n_primal> <n>`, then the dual
879/// block followed by the primal block, then an optional `objno` line and any
880/// number of suffix blocks.
881fn parse_sol(text: &str) -> Result<ParsedSol, String> {
882    // Find the "Options" delimiter line, then tokenize everything after it.
883    let mut after_options = None;
884    for (i, line) in text.lines().enumerate() {
885        if line.trim() == "Options" {
886            after_options = Some(i);
887            break;
888        }
889    }
890    let start = after_options.ok_or("malformed .sol: no `Options` section found")?;
891    let tail: String = text.lines().skip(start + 1).collect::<Vec<_>>().join(" ");
892    let mut toks = tail.split_whitespace();
893
894    let nopts: usize = toks
895        .next()
896        .ok_or("malformed .sol: missing option count")?
897        .parse()
898        .map_err(|e| format!("malformed .sol: bad option count: {e}"))?;
899    for _ in 0..nopts {
900        toks.next()
901            .ok_or("malformed .sol: truncated option words")?;
902    }
903
904    let next_usize = |toks: &mut std::str::SplitWhitespace, what: &str| -> Result<usize, String> {
905        toks.next()
906            .ok_or_else(|| format!("malformed .sol: missing {what}"))?
907            .parse::<usize>()
908            .map_err(|e| format!("malformed .sol: bad {what}: {e}"))
909    };
910    let n_dual = next_usize(&mut toks, "dual count")?;
911    let _m = next_usize(&mut toks, "constraint count")?;
912    let n_primal = next_usize(&mut toks, "primal count")?;
913    let _n = next_usize(&mut toks, "variable count")?;
914
915    let mut lambda = Vec::with_capacity(n_dual);
916    for k in 0..n_dual {
917        let t = toks
918            .next()
919            .ok_or_else(|| format!("malformed .sol: truncated dual block at {k}"))?;
920        lambda.push(
921            t.parse::<Number>()
922                .map_err(|e| format!("malformed .sol: bad dual {k}: {e}"))?,
923        );
924    }
925    let mut x = Vec::with_capacity(n_primal);
926    for k in 0..n_primal {
927        let t = toks
928            .next()
929            .ok_or_else(|| format!("malformed .sol: truncated primal block at {k}"))?;
930        x.push(
931            t.parse::<Number>()
932                .map_err(|e| format!("malformed .sol: bad primal {k}: {e}"))?,
933        );
934    }
935
936    // Trailing section: an optional `objno <objno> <solve_result_num>` and
937    // any number of suffix blocks.
938    let rest: Vec<&str> = toks.collect();
939    let (solve_result_num, var_suffixes) = parse_sol_tail(&rest, n_primal);
940    let suffix = |name: &str| -> Option<Vec<Number>> {
941        var_suffixes
942            .iter()
943            .find(|(n, _)| n == name)
944            .map(|(_, v)| v.clone())
945    };
946
947    Ok(ParsedSol {
948        x,
949        lambda,
950        solve_result_num,
951        z_l: suffix("ipopt_zL_out"),
952        z_u: suffix("ipopt_zU_out"),
953    })
954}
955
956/// Walk the tokens after the primal block: an optional
957/// `objno <objno> <solve_result_num>` and any number of suffix blocks, each
958/// `suffix <kind> <nvalues> <namelen> <tablen> <tabline>`, the name on its
959/// own line, then `<idx> <value>` pairs (see `pounce_nl::sol_writer` for the
960/// shape pounce writes and Ipopt's AMPL interface writes back).
961///
962/// Returns the `solve_result_num` and every **variable-indexed real**
963/// suffix, densified to `n` — a `.sol` sparse-trims zero entries, so an
964/// absent index means zero, not missing.
965///
966/// A malformed or unsupported block stops the walk and keeps what was read
967/// so far: a `.sol` is still perfectly usable for the feasibility check that
968/// is this tool's actual gate, and a parse error there must not turn a
969/// checkable solution into an I/O failure.
970fn parse_sol_tail(rest: &[&str], n: usize) -> (Option<i32>, Vec<(String, Vec<Number>)>) {
971    let mut solve_result_num = None;
972    let mut out: Vec<(String, Vec<Number>)> = Vec::new();
973    let mut i = 0;
974    while i < rest.len() {
975        match rest[i] {
976            "objno" => {
977                solve_result_num = rest.get(i + 2).and_then(|t| t.parse::<i32>().ok());
978                i += 3;
979            }
980            "suffix" => {
981                let int_at = |k: usize| rest.get(i + k).and_then(|t| t.parse::<i64>().ok());
982                let (Some(kind), Some(nvalues), Some(tablen)) = (int_at(1), int_at(2), int_at(4))
983                else {
984                    break;
985                };
986                let (Some(name), true) = (rest.get(i + 6), nvalues >= 0) else {
987                    break;
988                };
989                let name = (*name).to_string();
990                i += 7;
991                // A suffix value table follows the name as free text we
992                // cannot delimit by whitespace, so its tokens would be
993                // mis-read as values. Neither pounce nor Ipopt writes one.
994                if tablen != 0 {
995                    break;
996                }
997                // Low two bits pick the target (0 = var), 0x4 flags a real
998                // payload — ASL's `ASL_Sufkind_*` bits.
999                let want = (kind & 0x3) == 0 && (kind & 0x4) != 0;
1000                let mut dense = vec![0.0; n];
1001                let mut complete = true;
1002                for _ in 0..nvalues as usize {
1003                    let (Some(it), Some(vt)) = (rest.get(i), rest.get(i + 1)) else {
1004                        complete = false;
1005                        break;
1006                    };
1007                    if let (true, Ok(idx), Ok(v)) =
1008                        (want, it.parse::<usize>(), vt.parse::<Number>())
1009                        && idx < n
1010                    {
1011                        dense[idx] = v;
1012                    }
1013                    i += 2;
1014                }
1015                if !complete {
1016                    break;
1017                }
1018                if want {
1019                    out.push((name, dense));
1020                }
1021            }
1022            _ => i += 1,
1023        }
1024    }
1025    (solve_result_num, out)
1026}
1027
1028// ---------------------------------------------------------------------------
1029// Console + JSON rendering.
1030// ---------------------------------------------------------------------------
1031
1032fn print_report(args: &VerifyArgs, o: &VerifyOutcome) {
1033    println!("pounce verify — independent solution check");
1034    println!(
1035        "  problem : {}  ({} vars, {} cons)",
1036        args.nl.display(),
1037        o.n_vars,
1038        o.n_cons
1039    );
1040    println!("            sha256:{}", o.nl_sha256);
1041    println!("  solution: {}", args.sol.display());
1042    println!("            sha256:{}", o.sol_sha256);
1043    if let Some(srn) = o.solve_result_num {
1044        println!("  claimed solve_result_num: {srn}");
1045    }
1046    println!();
1047    match o.abs_feas_tol {
1048        Some(t) => println!("  feasibility (absolute tol {t:.1e}):"),
1049        None => println!(
1050            "  feasibility (tol {:.1e}, relative per row: |viol| <= tol*max(1,|row|)):",
1051            o.feas_tol
1052        ),
1053    }
1054    print_row(
1055        "max constraint violation",
1056        o.max_con_violation,
1057        &o.worst_con,
1058    );
1059    print_row(
1060        "max bound violation     ",
1061        o.max_bound_violation,
1062        &o.worst_bound,
1063    );
1064    match (o.discrete.filter(|d| d.total() > 0), o.integrality) {
1065        (Some(d), Some((v, j))) => println!(
1066            "    integrality ({} discrete columns: {} binary + {} integer linear, {} nonlinear): \
1067             max distance to an integer {v:.3e} at x[{j}]",
1068            d.total(),
1069            d.binary,
1070            d.integer,
1071            d.nonlinear_discrete()
1072        ),
1073        (Some(d), None) => println!(
1074            "    integrality: NOT CHECKED — the .nl declares {} discrete variable(s) ({} appearing \
1075             nonlinearly) whose columns cannot be identified from the header",
1076            d.total(),
1077            d.nonlinear_discrete()
1078        ),
1079        (None, _) => {}
1080    }
1081    if let Some(obj) = o.objective {
1082        println!("  objective at x*: {obj:.10e}");
1083    }
1084    if o.stationarity.is_some() || o.bound_multipliers_present {
1085        let source = match (o.duals_present, o.bound_multipliers_present) {
1086            (true, true) => "duals + bound multipliers supplied",
1087            (true, false) => "duals supplied",
1088            (false, true) => "bound multipliers supplied",
1089            (false, false) => "no rows, so no duals to supply",
1090        };
1091        println!();
1092        println!("  optimality (tol {:.1e}, {source}):", o.opt_tol);
1093        if let Some(s) = o.stationarity {
1094            let sign = o.dual_sign.unwrap_or(1);
1095            println!(
1096                "    KKT stationarity residual (bound-projected)  : {s:.3e}  (dual sign {sign:+})"
1097            );
1098        }
1099        if let Some(s) = o.stationarity_with_bound_multipliers {
1100            println!("    dual infeasibility (with z_L/z_U suffixes)   : {s:.3e}");
1101        }
1102        // Two different residuals answer to "complementarity", and the row
1103        // one is NOT what a solver prints as `Complementarity` — label both
1104        // by what they range over so the numbers cannot be crossed (gh #516).
1105        if let Some(c) = o.constraint_complementarity {
1106            println!("    constraint complementarity (rows, |λ|·slack) : {c:.3e}");
1107        }
1108        match o.bound_complementarity {
1109            Some(c) => println!("    bound complementarity (vars, |z|·slack)      : {c:.3e}"),
1110            None => {
1111                println!(
1112                    "    bound complementarity (vars, |z|·slack)      : not checked \
1113                     — the .sol carries no"
1114                );
1115                println!(
1116                    "      ipopt_zL_out/ipopt_zU_out suffixes. This, not the row line \
1117                     above, is the"
1118                );
1119                println!("      quantity a solver reports as `Complementarity`.");
1120            }
1121        }
1122    } else {
1123        println!();
1124        println!("  optimality: not checked (.sol carried no duals)");
1125    }
1126    println!();
1127    if o.verified && !args.require_optimal {
1128        if let Some(s) = o.stationarity_with_bound_multipliers {
1129            if s > o.opt_tol {
1130                println!(
1131                    "  CAVEAT: dual infeasibility {s:.3e} exceeds --opt-tol {:.1e}; VERIFIED here \
1132                     means FEASIBLE only, not optimal (pass --require-optimal to gate on it).",
1133                    o.opt_tol
1134                );
1135            }
1136        } else if let Some(s) = o.stationarity {
1137            if s > o.opt_tol {
1138                println!(
1139                    "  CAVEAT: KKT stationarity residual {s:.3e} exceeds --opt-tol {:.1e}; \
1140                     VERIFIED here means FEASIBLE only, not optimal (pass --require-optimal to gate on it).",
1141                    o.opt_tol
1142                );
1143            }
1144        }
1145    }
1146    let verdict = if o.verified {
1147        "VERIFIED — solution is feasible for the canonical problem".to_string()
1148    } else if !o.feasible
1149        && o.integrality
1150            .is_some_and(|(d, _)| d > o.abs_feas_tol.unwrap_or(o.feas_tol))
1151        && o.max_con_violation <= o.abs_feas_tol.unwrap_or(o.feas_tol)
1152        && o.max_bound_violation <= o.abs_feas_tol.unwrap_or(o.feas_tol)
1153    {
1154        "REJECTED — solution is not integer-feasible (declared integer columns are fractional)"
1155            .to_string()
1156    } else if !o.feasible {
1157        "REJECTED — solution VIOLATES the canonical constraints".to_string()
1158    } else if o.optimal.is_none() {
1159        // Feasible, --require-optimal was asked for, but optimality could not
1160        // be checked at all because the .sol carried no duals — say so rather
1161        // than implying we found it non-optimal.
1162        "REJECTED — feasible, but --require-optimal needs duals and the .sol \
1163         carried none"
1164            .to_string()
1165    } else {
1166        "REJECTED — feasible but not first-order optimal (--require-optimal)".to_string()
1167    };
1168    println!("  VERDICT: {verdict}");
1169}
1170
1171fn print_row(label: &str, v: Number, worst: &Option<RowReport>) {
1172    match worst {
1173        Some(r) => println!(
1174            "    {label}: {v:.3e}  at {} (value {:.6e}, bounds [{:.6e}, {:.6e}])",
1175            r.name, r.value, r.lo, r.hi
1176        ),
1177        None => println!("    {label}: {v:.3e}"),
1178    }
1179}
1180
1181/// Environment variable holding the HMAC key. When set (non-empty) and a
1182/// `--json-output` receipt is requested, the receipt is signed.
1183pub const KEY_ENV: &str = "POUNCE_VERIFY_KEY";
1184
1185/// The exact byte string that gets HMAC-signed. Deliberately **float-free**
1186/// — only hex hashes, integer counts, and the verdict — so any language
1187/// reproduces it byte-for-byte (no float-formatting parity problems between
1188/// Rust and a Python/JS consumer). One `key=value` per line, fixed order,
1189/// trailing newline. The consumer re-derives this from the receipt fields,
1190/// recomputes `HMAC-SHA256(key, preimage)`, and compares to `signature`.
1191/// Documented in `docs/src/verify.md`.
1192///
1193/// The signed fields are exactly the security-critical bindings: *which*
1194/// problem (`nl_sha256`), *which* solution (`sol_sha256`), the problem
1195/// dimensions, and the verdict. The numeric violations in the receipt are
1196/// supporting evidence; trust flows from the hashes + `verified` flag.
1197pub fn signing_preimage(o: &VerifyOutcome) -> String {
1198    format!(
1199        "pounce-verify-receipt/v1\n\
1200         verify_version=1\n\
1201         nl_sha256={}\n\
1202         sol_sha256={}\n\
1203         n_vars={}\n\
1204         n_cons={}\n\
1205         feasible={}\n\
1206         verified={}\n\
1207         verdict={}\n",
1208        o.nl_sha256,
1209        o.sol_sha256,
1210        o.n_vars,
1211        o.n_cons,
1212        o.feasible,
1213        o.verified,
1214        if o.verified { "VERIFIED" } else { "REJECTED" },
1215    )
1216}
1217
1218fn receipt_json(args: &VerifyArgs, o: &VerifyOutcome) -> String {
1219    use serde_json::json;
1220    let worst_con = o.worst_con.as_ref().map(row_json);
1221    let worst_bound = o.worst_bound.as_ref().map(row_json);
1222    let optimality = if o.duals_present || o.bound_multipliers_present {
1223        // Optimality is a property of a FEASIBLE point, so this must not report
1224        // `true` for one that violates the constraints. The stationarity
1225        // residual of an infeasible point can be legitimately zero, which
1226        // previously surfaced as `optimality.optimal: true` inside a receipt
1227        // whose verdict was REJECTED — the top-level fields were correct, but a
1228        // consumer reading this nested field alone was told the opposite.
1229        // The raw residuals are still reported unconditioned: they are useful
1230        // for diagnosing *why* a point failed.
1231        let optimal = o.optimal.map(|opt| opt && o.feasible);
1232        json!({
1233            "available": true,
1234            "objective": o.objective,
1235            "stationarity_residual": o.stationarity,
1236            "dual_sign": o.dual_sign,
1237            "stationarity_residual_with_bound_multipliers":
1238                o.stationarity_with_bound_multipliers,
1239            "constraint_complementarity_residual": o.constraint_complementarity,
1240            "bound_complementarity_residual": o.bound_complementarity,
1241            "bound_multipliers_present": o.bound_multipliers_present,
1242            // Deprecated alias, kept so a v1 consumer does not break. Its bare
1243            // name is the trap gh #516 is about: read
1244            // `constraint_complementarity_residual` instead.
1245            "complementarity_residual": o.constraint_complementarity,
1246            "optimal": optimal,
1247            "note": "`stationarity_residual` is the BOUND-PROJECTED dual infeasibility from \
1248                     the .sol's constraint duals, with bound multipliers inferred from \
1249                     activity; the sign is chosen to match the supplied dual convention. \
1250                     `constraint_complementarity_residual` is max_i |lambda_i| * dist(g_i, \
1251                     nearest finite side) over ROWS — it is NOT what a solver reports as \
1252                     `Complementarity`. That is `bound_complementarity_residual`, \
1253                     max_j max(|z_L*(x-x_L)|, |z_U*(x_U-x)|) over VARIABLES, available only \
1254                     when the .sol carries the ipopt_zL_out/ipopt_zU_out suffixes (null \
1255                     otherwise, meaning not checked — not zero). When those suffixes are \
1256                     present, `stationarity_residual_with_bound_multipliers` is the exact, \
1257                     unprojected residual and is what `--require-optimal` gates on. \
1258                     `complementarity_residual` is a deprecated alias of \
1259                     `constraint_complementarity_residual`. Feasibility is the rigorous \
1260                     gate, and `optimal` is reported false for an infeasible point \
1261                     regardless of its stationarity residual."
1262        })
1263    } else {
1264        json!({ "available": false })
1265    };
1266    let mut receipt = json!({
1267        "pounce_verify_version": 1,
1268        "solver": format!("pounce {}", env!("CARGO_PKG_VERSION")),
1269        "problem": {
1270            "path": args.nl.display().to_string(),
1271            "sha256": o.nl_sha256,
1272            "n_vars": o.n_vars,
1273            "n_cons": o.n_cons,
1274        },
1275        "solution": {
1276            "path": args.sol.display().to_string(),
1277            "sha256": o.sol_sha256,
1278            "claimed_solve_result_num": o.solve_result_num,
1279            "duals_present": o.duals_present,
1280        },
1281        "tolerances": {
1282            "feasibility": o.feas_tol,
1283            "absolute_feasibility": o.abs_feas_tol,
1284            "feasibility_mode": if o.abs_feas_tol.is_some() { "absolute" } else { "relative_per_row" },
1285            "optimality": o.opt_tol,
1286        },
1287        "integrality": match (o.discrete, o.integrality) {
1288            (Some(d), Some((v, j))) if d.total() > 0 => json!({
1289                "checked": true, "binary": d.binary, "integer": d.integer,
1290                "nonlinear": d.nonlinear_discrete(),
1291                "max_distance_to_integer": v, "worst_column": j,
1292            }),
1293            (Some(d), None) if d.total() > 0 => json!({
1294                "checked": false, "binary": d.binary, "integer": d.integer,
1295                "nonlinear": d.nonlinear_discrete(),
1296            }),
1297            _ => json!({ "checked": false, "binary": 0, "integer": 0, "nonlinear": 0 }),
1298        },
1299        "feasibility": {
1300            "max_constraint_violation": o.max_con_violation,
1301            "worst_constraint": worst_con,
1302            "max_bound_violation": o.max_bound_violation,
1303            "worst_bound": worst_bound,
1304            "feasible": o.feasible,
1305        },
1306        "optimality": optimality,
1307        "verdict": if o.verified { "VERIFIED" } else { "REJECTED" },
1308        "verified": o.verified,
1309    });
1310
1311    // Sign the receipt when a key is present. The signature covers the
1312    // float-free `signing_preimage`, NOT the pretty JSON, so a consumer in
1313    // any language can recompute it without matching float formatting.
1314    if let Ok(key) = std::env::var(KEY_ENV) {
1315        if !key.is_empty() {
1316            if let Some(obj) = receipt.as_object_mut() {
1317                let sig = sha256::hmac_hex(key.as_bytes(), signing_preimage(o).as_bytes());
1318                obj.insert("signature_alg".into(), json!("HMAC-SHA256"));
1319                obj.insert(
1320                    "signed_fields".into(),
1321                    json!([
1322                        "verify_version",
1323                        "nl_sha256",
1324                        "sol_sha256",
1325                        "n_vars",
1326                        "n_cons",
1327                        "feasible",
1328                        "verified",
1329                        "verdict"
1330                    ]),
1331                );
1332                obj.insert("signature".into(), json!(sig));
1333            }
1334        }
1335    }
1336
1337    serde_json::to_string_pretty(&receipt).unwrap_or_else(|_| "{}".to_string())
1338}
1339
1340fn row_json(r: &RowReport) -> serde_json::Value {
1341    serde_json::json!({
1342        "index": r.index,
1343        "name": r.name,
1344        "value": r.value,
1345        "lower": r.lo,
1346        "upper": r.hi,
1347        "violation": r.violation,
1348    })
1349}
1350
1351// ---------------------------------------------------------------------------
1352// Self-contained SHA-256 (FIPS 180-4) — content-addresses the receipt's
1353// inputs with zero new dependencies, matching the crate's hand-rolled,
1354// dependency-light style. Known-answer tested below.
1355// ---------------------------------------------------------------------------
1356
1357pub mod sha256 {
1358    const K: [u32; 64] = [
1359        0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
1360        0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
1361        0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
1362        0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
1363        0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
1364        0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
1365        0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
1366        0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
1367        0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
1368        0xc67178f2,
1369    ];
1370
1371    /// Raw 32-byte SHA-256 digest.
1372    pub fn digest(data: &[u8]) -> [u8; 32] {
1373        let mut h: [u32; 8] = [
1374            0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
1375            0x5be0cd19,
1376        ];
1377
1378        // Pad: message || 0x80 || 0x00... || 64-bit big-endian bit length.
1379        let bit_len = (data.len() as u64).wrapping_mul(8);
1380        let mut msg = data.to_vec();
1381        msg.push(0x80);
1382        while msg.len() % 64 != 56 {
1383            msg.push(0);
1384        }
1385        msg.extend_from_slice(&bit_len.to_be_bytes());
1386
1387        let mut w = [0u32; 64];
1388        for chunk in msg.chunks_exact(64) {
1389            for i in 0..16 {
1390                w[i] = u32::from_be_bytes([
1391                    chunk[4 * i],
1392                    chunk[4 * i + 1],
1393                    chunk[4 * i + 2],
1394                    chunk[4 * i + 3],
1395                ]);
1396            }
1397            for i in 16..64 {
1398                let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
1399                let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
1400                w[i] = w[i - 16]
1401                    .wrapping_add(s0)
1402                    .wrapping_add(w[i - 7])
1403                    .wrapping_add(s1);
1404            }
1405
1406            let (mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh) =
1407                (h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7]);
1408            for i in 0..64 {
1409                let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
1410                let ch = (e & f) ^ ((!e) & g);
1411                let t1 = hh
1412                    .wrapping_add(s1)
1413                    .wrapping_add(ch)
1414                    .wrapping_add(K[i])
1415                    .wrapping_add(w[i]);
1416                let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
1417                let maj = (a & b) ^ (a & c) ^ (b & c);
1418                let t2 = s0.wrapping_add(maj);
1419                hh = g;
1420                g = f;
1421                f = e;
1422                e = d.wrapping_add(t1);
1423                d = c;
1424                c = b;
1425                b = a;
1426                a = t1.wrapping_add(t2);
1427            }
1428            h[0] = h[0].wrapping_add(a);
1429            h[1] = h[1].wrapping_add(b);
1430            h[2] = h[2].wrapping_add(c);
1431            h[3] = h[3].wrapping_add(d);
1432            h[4] = h[4].wrapping_add(e);
1433            h[5] = h[5].wrapping_add(f);
1434            h[6] = h[6].wrapping_add(g);
1435            h[7] = h[7].wrapping_add(hh);
1436        }
1437
1438        let mut out = [0u8; 32];
1439        for (i, word) in h.iter().enumerate() {
1440            out[4 * i..4 * i + 4].copy_from_slice(&word.to_be_bytes());
1441        }
1442        out
1443    }
1444
1445    fn to_hex(bytes: &[u8]) -> String {
1446        let mut out = String::with_capacity(bytes.len() * 2);
1447        for b in bytes {
1448            out.push_str(&format!("{b:02x}"));
1449        }
1450        out
1451    }
1452
1453    /// Lowercase-hex SHA-256 of `data`.
1454    pub fn hex(data: &[u8]) -> String {
1455        to_hex(&digest(data))
1456    }
1457
1458    /// HMAC-SHA256(key, msg) per RFC 2104, raw 32 bytes.
1459    pub fn hmac(key: &[u8], msg: &[u8]) -> [u8; 32] {
1460        const BLOCK: usize = 64;
1461        let mut k = [0u8; BLOCK];
1462        if key.len() > BLOCK {
1463            k[..32].copy_from_slice(&digest(key));
1464        } else {
1465            k[..key.len()].copy_from_slice(key);
1466        }
1467        let mut ipad = [0x36u8; BLOCK];
1468        let mut opad = [0x5cu8; BLOCK];
1469        for i in 0..BLOCK {
1470            ipad[i] ^= k[i];
1471            opad[i] ^= k[i];
1472        }
1473        let mut inner = Vec::with_capacity(BLOCK + msg.len());
1474        inner.extend_from_slice(&ipad);
1475        inner.extend_from_slice(msg);
1476        let inner_digest = digest(&inner);
1477        let mut outer = Vec::with_capacity(BLOCK + 32);
1478        outer.extend_from_slice(&opad);
1479        outer.extend_from_slice(&inner_digest);
1480        digest(&outer)
1481    }
1482
1483    /// HMAC-SHA256 as lowercase hex.
1484    pub fn hmac_hex(key: &[u8], msg: &[u8]) -> String {
1485        to_hex(&hmac(key, msg))
1486    }
1487}
1488
1489#[cfg(test)]
1490mod tests {
1491    use super::*;
1492    use crate::nl_writer::{SolutionFile, format_sol};
1493    use pounce_common::types::{NLP_LOWER_BOUND_INF, NLP_UPPER_BOUND_INF};
1494
1495    #[test]
1496    fn sha256_known_answers() {
1497        // FIPS 180-4 test vectors.
1498        assert_eq!(
1499            sha256::hex(b""),
1500            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1501        );
1502        assert_eq!(
1503            sha256::hex(b"abc"),
1504            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
1505        );
1506        assert_eq!(
1507            sha256::hex(b"The quick brown fox jumps over the lazy dog"),
1508            "d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592"
1509        );
1510    }
1511
1512    #[test]
1513    fn hmac_sha256_known_answers() {
1514        // RFC 4231 test case 2.
1515        assert_eq!(
1516            sha256::hmac_hex(b"Jefe", b"what do ya want for nothing?"),
1517            "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
1518        );
1519        // RFC 4231 test case 1: key = 0x0b * 20, data = "Hi There".
1520        assert_eq!(
1521            sha256::hmac_hex(&[0x0b; 20], b"Hi There"),
1522            "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
1523        );
1524    }
1525
1526    #[test]
1527    fn parse_sol_round_trips_writer() {
1528        // Writer is the inverse we must match exactly. Derive the banner
1529        // from the crate version so this fixture never goes stale on a
1530        // version bump (the round-trip is agnostic to the exact string).
1531        let message = format!(
1532            "POUNCE {}: Optimal Solution Found",
1533            env!("CARGO_PKG_VERSION")
1534        );
1535        let payload = SolutionFile {
1536            message: &message,
1537            x: &[1.0, 2.5, -0.5, 100.0],
1538            mult_g: &[0.1, -0.2],
1539            solve_result_num: 0,
1540            suffixes: &[],
1541        };
1542        let text = format_sol(&payload);
1543        let parsed = parse_sol(&text).expect("parse");
1544        assert_eq!(parsed.x.len(), 4);
1545        assert_eq!(parsed.lambda.len(), 2);
1546        assert!((parsed.x[1] - 2.5).abs() < 1e-15);
1547        assert!((parsed.x[3] - 100.0).abs() < 1e-12);
1548        // The primal round-trips as an identity, but the dual block does
1549        // NOT: `format_sol` negates pounce's internal multipliers into the
1550        // AMPL marginal convention (gh #271), and `parse_sol` reads the
1551        // file back verbatim. So a `mult_g` of +0.1 must come back as a
1552        // parsed dual of -0.1. Asserting identity here is what previously
1553        // let the sign defect pass unnoticed.
1554        assert!((parsed.lambda[0] + 0.1).abs() < 1e-15);
1555        assert!((parsed.lambda[1] - 0.2).abs() < 1e-15);
1556        assert_eq!(parsed.solve_result_num, Some(0));
1557    }
1558
1559    #[test]
1560    fn parse_sol_handles_no_duals() {
1561        let payload = SolutionFile {
1562            message: "msg",
1563            x: &[3.0, 4.0],
1564            mult_g: &[],
1565            solve_result_num: 200,
1566            suffixes: &[],
1567        };
1568        let text = format_sol(&payload);
1569        let parsed = parse_sol(&text).expect("parse");
1570        assert_eq!(parsed.x, vec![3.0, 4.0]);
1571        assert!(parsed.lambda.is_empty());
1572        assert_eq!(parsed.solve_result_num, Some(200));
1573    }
1574
1575    #[test]
1576    fn box_violation_basic() {
1577        // inside
1578        assert_eq!(box_violation(5.0, 0.0, 10.0), 0.0);
1579        // below lower
1580        assert!((box_violation(-2.0, 0.0, 10.0) - 2.0).abs() < 1e-15);
1581        // above upper
1582        assert!((box_violation(13.0, 0.0, 10.0) - 3.0).abs() < 1e-15);
1583        // one-sided (no upper)
1584        assert_eq!(box_violation(1e30, 0.0, NLP_UPPER_BOUND_INF), 0.0);
1585    }
1586
1587    #[test]
1588    fn box_violation_rejects_non_finite() {
1589        // Regression: a fabricated `.sol` carrying NaN must register an
1590        // infinite violation, not slip through as feasible. Before the
1591        // `is_finite` guard, `NaN.max(_).max(0.0)` collapsed to `0.0`
1592        // (f64::max drops NaN operands) and the checker reported VERIFIED.
1593        assert_eq!(box_violation(Number::NAN, 0.0, 10.0), Number::INFINITY);
1594        // ±∞ pinned at an unbounded variable is not a real point either.
1595        assert_eq!(
1596            box_violation(Number::INFINITY, 0.0, NLP_UPPER_BOUND_INF),
1597            Number::INFINITY
1598        );
1599        assert_eq!(
1600            box_violation(Number::NEG_INFINITY, NLP_LOWER_BOUND_INF, 10.0),
1601            Number::INFINITY
1602        );
1603    }
1604
1605    /// **gh #403.** `verify` exists to be the independent check on a `.sol`.
1606    /// A checker that under-reports is worse than its blast radius suggests.
1607    ///
1608    /// `is_finite_bound` was a *band* membership test —
1609    /// `b > NLP_LOWER_BOUND_INF && b < NLP_UPPER_BOUND_INF` — applied to `lo`
1610    /// and `hi` alike. A real upper bound of `-5e20` failed it, so `above`
1611    /// became `-inf` and the violation read `0.0`: **ACCEPTED for a `.sol` that
1612    /// violates a declared bound.**
1613    #[test]
1614    fn a_bound_past_the_opposite_sentinel_still_scores_a_violation() {
1615        // x <= -5e20, no lower bound. The point 0.0 violates it by 5e20.
1616        let v = box_violation(0.0, NLP_LOWER_BOUND_INF, -5.0e20);
1617        assert_eq!(
1618            v, 5.0e20,
1619            "0 is 5e20 above an upper bound of -5e20; scoring it 0.0 lets a \
1620             fabricated .sol past the feasibility gate"
1621        );
1622        // Mirror: x >= 5e20, no upper bound.
1623        assert_eq!(box_violation(0.0, 5.0e20, NLP_UPPER_BOUND_INF), 5.0e20);
1624        // A point that does satisfy the same bound still scores zero.
1625        assert_eq!(box_violation(-6.0e20, NLP_LOWER_BOUND_INF, -5.0e20), 0.0);
1626        // And the sentinels themselves still mean "no bound".
1627        assert_eq!(
1628            box_violation(1e30, NLP_LOWER_BOUND_INF, NLP_UPPER_BOUND_INF),
1629            0.0
1630        );
1631    }
1632
1633    // -----------------------------------------------------------------
1634    // gh #516 — the two complementarity quantities.
1635    // -----------------------------------------------------------------
1636
1637    /// The bound multipliers reach a `.sol` only as suffix blocks, so the
1638    /// parser has to pick them out of the trailing section — past `objno`
1639    /// and past whatever other suffixes the writer emitted.
1640    #[test]
1641    fn parse_sol_reads_the_bound_multiplier_suffixes() {
1642        use crate::nl_writer::{SolSuffix, SolSuffixTarget, SolSuffixValues};
1643        let payload = SolutionFile {
1644            message: "msg",
1645            x: &[1.0, -1.0, 0.0],
1646            mult_g: &[0.5],
1647            solve_result_num: 0,
1648            suffixes: &[
1649                // An unrelated block first: the walk must step over it.
1650                SolSuffix {
1651                    name: "sens_sol_state_1".to_string(),
1652                    target: SolSuffixTarget::Var,
1653                    values: SolSuffixValues::Real(vec![9.0, 9.0, 9.0]),
1654                },
1655                SolSuffix {
1656                    name: "ipopt_zL_out".to_string(),
1657                    target: SolSuffixTarget::Var,
1658                    values: SolSuffixValues::Real(vec![0.0, 2.0, 0.0]),
1659                },
1660                SolSuffix {
1661                    name: "ipopt_zU_out".to_string(),
1662                    target: SolSuffixTarget::Var,
1663                    values: SolSuffixValues::Real(vec![-4.0, 0.0, 0.0]),
1664                },
1665            ],
1666        };
1667        let parsed = parse_sol(&format_sol(&payload)).expect("parse");
1668        assert_eq!(parsed.solve_result_num, Some(0), "objno still parses");
1669        // Densified back to `n`: the writer sparse-trims zeros, so an absent
1670        // index means zero — not a short vector, and not "missing".
1671        assert_eq!(parsed.z_l, Some(vec![0.0, 2.0, 0.0]));
1672        assert_eq!(parsed.z_u, Some(vec![-4.0, 0.0, 0.0]));
1673    }
1674
1675    /// No suffixes → bound complementarity is *not checked*, and must stay
1676    /// `None` rather than collapse to a comfortable `0.0`.
1677    #[test]
1678    fn parse_sol_reports_absent_bound_multipliers_as_absent() {
1679        let payload = SolutionFile {
1680            message: "msg",
1681            x: &[1.0],
1682            mult_g: &[0.5],
1683            solve_result_num: 0,
1684            suffixes: &[],
1685        };
1686        let parsed = parse_sol(&format_sol(&payload)).expect("parse");
1687        assert!(parsed.z_l.is_none() && parsed.z_u.is_none());
1688    }
1689
1690    /// `min (x−3)² + (y+2)²  s.t.  x ≤ 1, y ≥ −1` — the model whose export
1691    /// convention is pinned in `main.rs` (gh #296): `ipopt_zL_out = +z_L`,
1692    /// `ipopt_zU_out = −z_U`, both equal to `∂f/∂x` at the bound.
1693    ///
1694    /// At the exact optimum every slack is zero, so bound complementarity is
1695    /// zero whichever sign convention the writer used — the check is on
1696    /// magnitudes. Off the optimum it is `|z| · slack`.
1697    #[test]
1698    fn bound_complementarity_is_z_times_slack_over_variables() {
1699        let x_l = [NLP_LOWER_BOUND_INF, -1.0];
1700        let x_u = [1.0, NLP_UPPER_BOUND_INF];
1701        // Exactly on both bounds: no slack anywhere.
1702        assert_eq!(
1703            bound_complementarity(&[1.0, -1.0], &x_l, &x_u, &[0.0, 2.0], &[-4.0, 0.0]),
1704            0.0
1705        );
1706        // Pull x off its upper bound by 1e-3 while keeping z_U: the product
1707        // is the residual, and the sign of the multiplier does not enter.
1708        let c = bound_complementarity(&[0.999, -1.0], &x_l, &x_u, &[0.0, 2.0], &[-4.0, 0.0]);
1709        assert!((c - 4.0e-3).abs() < 1e-12, "got {c}");
1710        let flipped = bound_complementarity(&[0.999, -1.0], &x_l, &x_u, &[0.0, 2.0], &[4.0, 0.0]);
1711        assert_eq!(c, flipped, "magnitudes only — no sign convention assumed");
1712        // A variable with no bound on the side in question contributes
1713        // nothing, however large its (meaningless) multiplier.
1714        assert_eq!(
1715            bound_complementarity(
1716                &[0.0],
1717                &[NLP_LOWER_BOUND_INF],
1718                &[NLP_UPPER_BOUND_INF],
1719                &[1e6],
1720                &[1e6]
1721            ),
1722            0.0
1723        );
1724    }
1725
1726    /// The exact residual uses the multipliers the `.sol` actually carries,
1727    /// so it sees what the bound-projected one projects away — the gh #495
1728    /// blind spot: a bound multiplier that is missing or wrong leaves the
1729    /// projected residual at `0.0`.
1730    #[test]
1731    fn exact_dual_infeasibility_sees_what_the_projection_hides() {
1732        // `min (x−3)² s.t. x ≤ 1`: x* = 1, ∇f = −4, so z_U = 4 and the
1733        // exported suffix is `ipopt_zU_out = −4`.
1734        let s = [-4.0];
1735        let x = [1.0];
1736        let x_l = [NLP_LOWER_BOUND_INF];
1737        let x_u = [1.0];
1738        assert_eq!(exact_dual_infeasibility(&s, &[0.0], &[-4.0], 1.0), 0.0);
1739
1740        // Projection: x sits on its upper bound, so a valid z_U absorbs the
1741        // whole negative gradient and the residual reads zero — with *no*
1742        // multiplier supplied at all.
1743        assert_eq!(bound_projected_residual(&s, &x, &x_l, &x_u, 1e-6), 0.0);
1744        // The exact check does not get to assume one exists.
1745        assert_eq!(exact_dual_infeasibility(&s, &[0.0], &[0.0], 1.0), 4.0);
1746        // Nor that it has the right sign.
1747        assert_eq!(exact_dual_infeasibility(&s, &[0.0], &[4.0], 1.0), 8.0);
1748    }
1749
1750    /// An interior-point answer stops `μ/z` short of an active bound, not on
1751    /// it. Numbers from PGLib case6468_rte: `x` 1.9e-8 inside an upper
1752    /// bound of 0.2189 with gradient −0.4399 is an active bound carrying
1753    /// `z_U = 0.44` at complementarity 8.4e-9 — not an interior variable
1754    /// with a residual of 0.44, which is what a gap-only activity test
1755    /// (1.2e-8 here) reported. The same gradient on a variable well inside
1756    /// its box is still a residual.
1757    #[test]
1758    fn a_barrier_stand_off_from_a_bound_is_still_active() {
1759        let x_l = [-0.1276];
1760        let x_u = [0.2189];
1761        let s = [-0.4399];
1762        let near = [0.2189 - 1.9e-8];
1763        assert_eq!(bound_projected_residual(&s, &near, &x_l, &x_u, 1e-6), 0.0);
1764        let inside = [0.05];
1765        assert_eq!(
1766            bound_projected_residual(&s, &inside, &x_l, &x_u, 1e-6),
1767            0.4399
1768        );
1769        // Only the bound on the matching side absorbs: a positive gradient
1770        // at the upper bound needs a negative z_U and stays a residual.
1771        assert_eq!(
1772            bound_projected_residual(&[0.4399], &near, &x_l, &x_u, 1e-6),
1773            0.4399
1774        );
1775    }
1776
1777    /// **gh #516.** Constraint complementarity (rows) and bound
1778    /// complementarity (variables) are different quantities at the same
1779    /// point, and can disagree by orders of magnitude. Printing either under
1780    /// a bare `complementarity residual` label invites the comparison that
1781    /// cost two people an afternoon in #505; this test pins the fact that
1782    /// makes the label matter.
1783    #[test]
1784    fn row_and_bound_complementarity_are_different_quantities() {
1785        // One inequality row `g ≥ 0`, slack 4.5e-2, multiplier 1 — a real
1786        // row-complementarity residual.
1787        let rows = row_complementarity(&[1.0], &[4.5e-2], &[0.0], &[NLP_UPPER_BOUND_INF]);
1788        assert!((rows - 4.5e-2).abs() < 1e-15);
1789        // The same point's variables sit hard on their bounds: bound
1790        // complementarity is eleven orders of magnitude smaller.
1791        let bounds = bound_complementarity(
1792            &[1.0],
1793            &[NLP_LOWER_BOUND_INF],
1794            &[1.0 + 1e-11],
1795            &[0.0],
1796            &[-1.0],
1797        );
1798        assert!(bounds < 1e-10, "got {bounds}");
1799        assert!(
1800            rows / bounds > 1e8,
1801            "the two must not be read as one number"
1802        );
1803    }
1804
1805    /// The same predicate sizes a row's magnitude for the scale-relative
1806    /// feasibility test. A row written at `5e20` must report that magnitude,
1807    /// not fall back to its evaluated value alone.
1808    #[test]
1809    fn row_magnitude_counts_a_bound_past_the_opposite_sentinel() {
1810        assert_eq!(
1811            row_magnitude(1.0, NLP_LOWER_BOUND_INF, -5.0e20),
1812            5.0e20,
1813            "the row's own upper bound is its magnitude"
1814        );
1815        assert_eq!(row_magnitude(1.0, 5.0e20, NLP_UPPER_BOUND_INF), 5.0e20);
1816        // Absent on both sides: only the evaluated value carries magnitude.
1817        assert_eq!(
1818            row_magnitude(3.0, NLP_LOWER_BOUND_INF, NLP_UPPER_BOUND_INF),
1819            3.0
1820        );
1821    }
1822}