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