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
43use crate::nl_reader;
44use pounce_common::types::{Number, NLP_LOWER_BOUND_INF, NLP_UPPER_BOUND_INF};
45use pounce_nlp::tnlp::{BoundsInfo, IndexStyle, SparsityRequest, TNLP};
46use std::path::PathBuf;
47use std::process::ExitCode;
48
49/// Parsed `verify` subcommand arguments.
50#[derive(Debug, Clone)]
51pub struct VerifyArgs {
52    pub nl: PathBuf,
53    pub sol: PathBuf,
54    /// Max `|violation|` of any constraint or bound still called feasible.
55    pub feas_tol: Number,
56    /// Max stationarity residual still called first-order optimal.
57    pub opt_tol: Number,
58    /// `--json-output PATH` — write the machine-readable receipt to PATH.
59    pub json_output: Option<PathBuf>,
60    /// `--require-optimal` — also gate the exit code on the stationarity
61    /// residual (needs duals in the `.sol`).
62    pub require_optimal: bool,
63}
64
65impl Default for VerifyArgs {
66    fn default() -> Self {
67        VerifyArgs {
68            nl: PathBuf::new(),
69            sol: PathBuf::new(),
70            feas_tol: 1e-6,
71            opt_tol: 1e-6,
72            json_output: None,
73            require_optimal: false,
74        }
75    }
76}
77
78const USAGE: &str = "\
79Usage: pounce verify <problem.nl> <claim.sol> [OPTIONS]
80
81Independently check that the solution in <claim.sol> satisfies the
82constraints and bounds of the canonical problem <problem.nl>. Re-derives
83feasibility from the model itself — it does not trust the .sol's status
84line or rerun the solver.
85
86Arguments:
87  <problem.nl>            canonical AMPL .nl problem (the source of truth)
88  <claim.sol>            claimed AMPL .sol solution to check
89
90Options:
91  --feas-tol <t>         feasibility tolerance (default 1e-6)
92  --opt-tol <t>          stationarity tolerance (default 1e-6)
93  --require-optimal      also fail if the KKT stationarity residual
94                         exceeds --opt-tol (needs duals in the .sol)
95  --json-output <path>   write a JSON verification receipt to <path>
96  -h, --help             print this message
97
98Exit code: 0 = verified feasible, 20 = violation exceeds tolerance,
992 = usage/IO error.";
100
101/// Entry point dispatched from `main` when argv[1] == "verify".
102pub fn run_from_argv(rest: &[String]) -> ExitCode {
103    let args = match parse_verify_argv(rest) {
104        Ok(Some(a)) => a,
105        Ok(None) => {
106            // help was requested
107            println!("{USAGE}");
108            return ExitCode::SUCCESS;
109        }
110        Err(msg) => {
111            eprintln!("pounce verify: {msg}");
112            eprintln!("{USAGE}");
113            return ExitCode::from(2);
114        }
115    };
116    run(&args)
117}
118
119fn parse_verify_argv(rest: &[String]) -> Result<Option<VerifyArgs>, String> {
120    let mut a = VerifyArgs::default();
121    let mut positionals: Vec<PathBuf> = Vec::new();
122    let mut it = rest.iter();
123    while let Some(arg) = it.next() {
124        match arg.as_str() {
125            "-h" | "--help" => return Ok(None),
126            "--feas-tol" => {
127                let v = it.next().ok_or("--feas-tol requires a value")?;
128                a.feas_tol = v.parse().map_err(|e| format!("--feas-tol: {e}"))?;
129            }
130            "--opt-tol" => {
131                let v = it.next().ok_or("--opt-tol requires a value")?;
132                a.opt_tol = v.parse().map_err(|e| format!("--opt-tol: {e}"))?;
133            }
134            "--require-optimal" => a.require_optimal = true,
135            "--json-output" => {
136                let v = it.next().ok_or("--json-output requires a value")?;
137                a.json_output = Some(PathBuf::from(v));
138            }
139            other if other.starts_with('-') => {
140                return Err(format!("unknown flag `{other}`"));
141            }
142            _ => positionals.push(PathBuf::from(arg)),
143        }
144    }
145    match positionals.len() {
146        0 | 1 => Err("expected two positional arguments: <problem.nl> <claim.sol>".to_string()),
147        2 => {
148            a.nl = positionals[0].clone();
149            a.sol = positionals[1].clone();
150            Ok(Some(a))
151        }
152        n => Err(format!("expected 2 positional arguments, got {n}")),
153    }
154}
155
156/// The fully-evaluated verification result. Serialized to the JSON
157/// receipt and rendered to the console.
158#[derive(Debug)]
159pub struct VerifyOutcome {
160    pub n_vars: usize,
161    pub n_cons: usize,
162    pub nl_sha256: String,
163    pub sol_sha256: String,
164    pub solve_result_num: Option<i32>,
165    pub feas_tol: Number,
166    pub opt_tol: Number,
167    // feasibility
168    pub max_con_violation: Number,
169    pub worst_con: Option<RowReport>,
170    pub max_bound_violation: Number,
171    pub worst_bound: Option<RowReport>,
172    pub feasible: bool,
173    // optimality (only when duals supplied)
174    pub objective: Option<Number>,
175    pub duals_present: bool,
176    pub stationarity: Option<Number>,
177    pub dual_sign: Option<i32>,
178    pub complementarity: Option<Number>,
179    pub optimal: Option<bool>,
180    // final
181    pub verified: bool,
182}
183
184#[derive(Debug, Clone)]
185pub struct RowReport {
186    pub index: usize,
187    pub name: String,
188    pub value: Number,
189    pub lo: Number,
190    pub hi: Number,
191    pub violation: Number,
192}
193
194fn is_finite_bound(b: Number) -> bool {
195    b > NLP_LOWER_BOUND_INF && b < NLP_UPPER_BOUND_INF
196}
197
198/// `g_l ≤ v ≤ g_u` violation: how far `v` is outside the box, 0 if inside.
199///
200/// A non-finite `v` (NaN or ±∞) is treated as an infinite violation, never
201/// as feasible: `NaN`-laden arithmetic would otherwise collapse to `0.0`
202/// through `f64::max` (which drops NaN operands) and let a fabricated `.sol`
203/// slip past the feasibility gate — the exact threat this checker defends
204/// against. An unbounded variable pinned at ±∞ is likewise not a real point.
205fn box_violation(v: Number, lo: Number, hi: Number) -> Number {
206    if !v.is_finite() {
207        return Number::INFINITY;
208    }
209    let below = if is_finite_bound(lo) {
210        lo - v
211    } else {
212        Number::NEG_INFINITY
213    };
214    let above = if is_finite_bound(hi) {
215        v - hi
216    } else {
217        Number::NEG_INFINITY
218    };
219    below.max(above).max(0.0)
220}
221
222pub fn run(args: &VerifyArgs) -> ExitCode {
223    let outcome = match evaluate(args) {
224        Ok(o) => o,
225        Err(msg) => {
226            eprintln!("pounce verify: {msg}");
227            return ExitCode::from(2);
228        }
229    };
230    print_report(args, &outcome);
231
232    if let Some(path) = &args.json_output {
233        let json = receipt_json(args, &outcome);
234        if let Err(e) = std::fs::write(path, json.as_bytes()) {
235            eprintln!(
236                "pounce verify: failed to write receipt {}: {e}",
237                path.display()
238            );
239            return ExitCode::from(2);
240        }
241        let signed = std::env::var(KEY_ENV)
242            .map(|k| !k.is_empty())
243            .unwrap_or(false);
244        println!(
245            "  receipt: {}{}",
246            path.display(),
247            if signed {
248                "  (signed: HMAC-SHA256)"
249            } else {
250                ""
251            }
252        );
253    }
254
255    if outcome.verified {
256        ExitCode::SUCCESS
257    } else {
258        ExitCode::from(20)
259    }
260}
261
262fn evaluate(args: &VerifyArgs) -> Result<VerifyOutcome, String> {
263    // --- read + hash the two inputs (content-address the receipt) ---
264    let nl_bytes =
265        std::fs::read(&args.nl).map_err(|e| format!("cannot read {}: {e}", args.nl.display()))?;
266    let sol_bytes =
267        std::fs::read(&args.sol).map_err(|e| format!("cannot read {}: {e}", args.sol.display()))?;
268    let nl_sha256 = sha256::hex(&nl_bytes);
269    let sol_sha256 = sha256::hex(&sol_bytes);
270
271    // --- canonical problem ---
272    let prob = nl_reader::read_nl_file(&args.nl)?;
273    let n = prob.n;
274    let m = prob.m;
275    let con_names = prob.con_names.clone();
276    let var_names = prob.var_names.clone();
277    let mut tnlp = nl_reader::NlTnlp::new(prob);
278
279    let info = tnlp
280        .get_nlp_info()
281        .ok_or("get_nlp_info failed on the .nl")?;
282    let nnz = info.nnz_jac_g.max(0) as usize;
283    let fortran = matches!(info.index_style, IndexStyle::Fortran);
284
285    // --- claimed solution ---
286    let sol_text = String::from_utf8_lossy(&sol_bytes);
287    let parsed = parse_sol(&sol_text)?;
288    if parsed.x.len() != n {
289        return Err(format!(
290            "solution has {} primal values but the problem has {n} variables \
291             (is this the right .sol for this .nl?)",
292            parsed.x.len()
293        ));
294    }
295    let x = parsed.x;
296    let duals_present = !parsed.lambda.is_empty();
297    if duals_present && parsed.lambda.len() != m {
298        return Err(format!(
299            "solution carries {} dual values but the problem has {m} constraints",
300            parsed.lambda.len()
301        ));
302    }
303
304    // --- bounds ---
305    let mut x_l = vec![0.0; n];
306    let mut x_u = vec![0.0; n];
307    let mut g_l = vec![0.0; m];
308    let mut g_u = vec![0.0; m];
309    if !tnlp.get_bounds_info(BoundsInfo {
310        x_l: &mut x_l,
311        x_u: &mut x_u,
312        g_l: &mut g_l,
313        g_u: &mut g_u,
314    }) {
315        return Err("get_bounds_info failed".to_string());
316    }
317
318    // --- bound feasibility ---
319    let mut max_bound_violation = 0.0_f64;
320    let mut worst_bound: Option<RowReport> = None;
321    for j in 0..n {
322        let viol = box_violation(x[j], x_l[j], x_u[j]);
323        if viol > max_bound_violation {
324            max_bound_violation = viol;
325            worst_bound = Some(RowReport {
326                index: j,
327                name: name_at(&var_names, j, 'x'),
328                value: x[j],
329                lo: x_l[j],
330                hi: x_u[j],
331                violation: viol,
332            });
333        }
334    }
335
336    // --- constraint feasibility ---
337    let mut g = vec![0.0; m];
338    if !tnlp.eval_g(&x, true, &mut g) {
339        return Err("eval_g failed at the claimed solution".to_string());
340    }
341    let mut max_con_violation = 0.0_f64;
342    let mut worst_con: Option<RowReport> = None;
343    for i in 0..m {
344        let viol = box_violation(g[i], g_l[i], g_u[i]);
345        if viol > max_con_violation {
346            max_con_violation = viol;
347            worst_con = Some(RowReport {
348                index: i,
349                name: name_at(&con_names, i, 'c'),
350                value: g[i],
351                lo: g_l[i],
352                hi: g_u[i],
353                violation: viol,
354            });
355        }
356    }
357
358    let feasible = max_con_violation <= args.feas_tol && max_bound_violation <= args.feas_tol;
359
360    // --- objective ---
361    let objective = tnlp.eval_f(&x, true);
362
363    // --- first-order / KKT stationarity (only when duals are supplied) ---
364    let mut stationarity = None;
365    let mut dual_sign = None;
366    let mut complementarity = None;
367    let mut optimal = None;
368    if duals_present {
369        let lambda = &parsed.lambda;
370
371        // ∇f(x*)
372        let mut grad_f = vec![0.0; n];
373        tnlp.eval_grad_f(&x, true, &mut grad_f);
374
375        // Jacobian triplets (structure then values).
376        let mut irow = vec![0i32; nnz];
377        let mut jcol = vec![0i32; nnz];
378        tnlp.eval_jac_g(
379            Some(&x),
380            true,
381            SparsityRequest::Structure {
382                irow: &mut irow,
383                jcol: &mut jcol,
384            },
385        );
386        let mut jval = vec![0.0; nnz];
387        tnlp.eval_jac_g(
388            Some(&x),
389            true,
390            SparsityRequest::Values { values: &mut jval },
391        );
392
393        // AMPL's dual sign convention can flip relative to ours; rather
394        // than guess, compute the bound-projected stationarity residual
395        // for both signs and keep the better one. A genuine KKT point is
396        // stationary for exactly one of them; we report which.
397        let (resid_pos, _comp_pos) = stationarity_residual(
398            1.0, &grad_f, &irow, &jcol, &jval, fortran, lambda, &x, &x_l, &x_u,
399        );
400        let (resid_neg, _comp_neg) = stationarity_residual(
401            -1.0, &grad_f, &irow, &jcol, &jval, fortran, lambda, &x, &x_l, &x_u,
402        );
403        let (best_resid, sign) = if resid_pos <= resid_neg {
404            (resid_pos, 1)
405        } else {
406            (resid_neg, -1)
407        };
408        stationarity = Some(best_resid);
409        dual_sign = Some(sign);
410        complementarity = Some(constraint_complementarity(lambda, &g, &g_l, &g_u));
411        optimal = Some(best_resid <= args.opt_tol);
412    }
413
414    // Verified = feasible (always required) AND, if --require-optimal,
415    // also first-order optimal.
416    let verified = feasible && (!args.require_optimal || optimal.unwrap_or(false));
417
418    Ok(VerifyOutcome {
419        n_vars: n,
420        n_cons: m,
421        nl_sha256,
422        sol_sha256,
423        solve_result_num: parsed.solve_result_num,
424        feas_tol: args.feas_tol,
425        opt_tol: args.opt_tol,
426        max_con_violation,
427        worst_con,
428        max_bound_violation,
429        worst_bound,
430        feasible,
431        objective,
432        duals_present,
433        stationarity,
434        dual_sign,
435        complementarity,
436        optimal,
437        verified,
438    })
439}
440
441/// Bound-projected stationarity (a.k.a. "dual infeasibility"):
442/// `s = ∇f + sign·Jᵀλ`, then for each variable the part of `s` that a
443/// valid sign-constrained bound multiplier `z_L, z_U ≥ 0` cannot absorb.
444/// Returns `(‖projected s‖∞, _)`.
445#[allow(clippy::too_many_arguments)]
446fn stationarity_residual(
447    sign: Number,
448    grad_f: &[Number],
449    irow: &[i32],
450    jcol: &[i32],
451    jval: &[Number],
452    fortran: bool,
453    lambda: &[Number],
454    x: &[Number],
455    x_l: &[Number],
456    x_u: &[Number],
457) -> (Number, Number) {
458    let n = grad_f.len();
459    let off = if fortran { 1 } else { 0 };
460    let mut s = grad_f.to_vec();
461    for k in 0..jval.len() {
462        let row = (irow[k] as usize).wrapping_sub(off);
463        let col = (jcol[k] as usize).wrapping_sub(off);
464        if row < lambda.len() && col < n {
465            s[col] += sign * jval[k] * lambda[row];
466        }
467    }
468    // Activity tolerance for "x_j sits on a bound."
469    let mut dual_inf = 0.0_f64;
470    for j in 0..n {
471        let at_lo = is_finite_bound(x_l[j]) && (x[j] - x_l[j]).abs() <= 1e-8 * (1.0 + x_l[j].abs());
472        let at_hi = is_finite_bound(x_u[j]) && (x_u[j] - x[j]).abs() <= 1e-8 * (1.0 + x_u[j].abs());
473        let fixed =
474            is_finite_bound(x_l[j]) && is_finite_bound(x_u[j]) && (x_u[j] - x_l[j]).abs() <= 1e-12;
475        let r = if fixed {
476            0.0
477        } else if at_lo && !at_hi {
478            // need z_L = s_j ≥ 0; leftover is the negative part.
479            (-s[j]).max(0.0)
480        } else if at_hi && !at_lo {
481            // need z_U = -s_j ≥ 0; leftover is the positive part.
482            s[j].max(0.0)
483        } else {
484            s[j].abs()
485        };
486        dual_inf = dual_inf.max(r);
487    }
488    (dual_inf, 0.0)
489}
490
491/// `max_i |λ_i| · dist(g_i, active side)` over constraints with a finite
492/// range — a constraint with a nonzero multiplier should be active.
493/// Equalities (`g_l == g_u`) contribute 0. Best-effort, informational.
494fn constraint_complementarity(
495    lambda: &[Number],
496    g: &[Number],
497    g_l: &[Number],
498    g_u: &[Number],
499) -> Number {
500    let mut comp = 0.0_f64;
501    for i in 0..lambda.len() {
502        if (g_u[i] - g_l[i]).abs() <= 1e-12 {
503            continue; // equality: multiplier is free, no complementarity
504        }
505        let dl = if is_finite_bound(g_l[i]) {
506            (g[i] - g_l[i]).abs()
507        } else {
508            Number::INFINITY
509        };
510        let du = if is_finite_bound(g_u[i]) {
511            (g_u[i] - g[i]).abs()
512        } else {
513            Number::INFINITY
514        };
515        let dist = dl.min(du);
516        if dist.is_finite() {
517            comp = comp.max(lambda[i].abs() * dist);
518        }
519    }
520    comp
521}
522
523fn name_at(names: &[String], i: usize, kind: char) -> String {
524    match names.get(i) {
525        Some(s) if !s.is_empty() => s.clone(),
526        _ => format!("{kind}[{i}]"),
527    }
528}
529
530// ---------------------------------------------------------------------------
531// AMPL .sol parser (the inverse of `crate::nl_writer`).
532// ---------------------------------------------------------------------------
533
534#[derive(Debug)]
535struct ParsedSol {
536    x: Vec<Number>,
537    lambda: Vec<Number>,
538    solve_result_num: Option<i32>,
539}
540
541/// Parse the ASCII AMPL `.sol` form pounce writes: a free-text banner, a
542/// blank line, `Options`, an option count + that many option words, the
543/// four-integer count block `<n_dual> <m> <n_primal> <n>`, then the dual
544/// block followed by the primal block, then an optional `objno` line.
545fn parse_sol(text: &str) -> Result<ParsedSol, String> {
546    // Find the "Options" delimiter line, then tokenize everything after it.
547    let mut after_options = None;
548    for (i, line) in text.lines().enumerate() {
549        if line.trim() == "Options" {
550            after_options = Some(i);
551            break;
552        }
553    }
554    let start = after_options.ok_or("malformed .sol: no `Options` section found")?;
555    let tail: String = text.lines().skip(start + 1).collect::<Vec<_>>().join(" ");
556    let mut toks = tail.split_whitespace();
557
558    let nopts: usize = toks
559        .next()
560        .ok_or("malformed .sol: missing option count")?
561        .parse()
562        .map_err(|e| format!("malformed .sol: bad option count: {e}"))?;
563    for _ in 0..nopts {
564        toks.next()
565            .ok_or("malformed .sol: truncated option words")?;
566    }
567
568    let next_usize = |toks: &mut std::str::SplitWhitespace, what: &str| -> Result<usize, String> {
569        toks.next()
570            .ok_or_else(|| format!("malformed .sol: missing {what}"))?
571            .parse::<usize>()
572            .map_err(|e| format!("malformed .sol: bad {what}: {e}"))
573    };
574    let n_dual = next_usize(&mut toks, "dual count")?;
575    let _m = next_usize(&mut toks, "constraint count")?;
576    let n_primal = next_usize(&mut toks, "primal count")?;
577    let _n = next_usize(&mut toks, "variable count")?;
578
579    let mut lambda = Vec::with_capacity(n_dual);
580    for k in 0..n_dual {
581        let t = toks
582            .next()
583            .ok_or_else(|| format!("malformed .sol: truncated dual block at {k}"))?;
584        lambda.push(
585            t.parse::<Number>()
586                .map_err(|e| format!("malformed .sol: bad dual {k}: {e}"))?,
587        );
588    }
589    let mut x = Vec::with_capacity(n_primal);
590    for k in 0..n_primal {
591        let t = toks
592            .next()
593            .ok_or_else(|| format!("malformed .sol: truncated primal block at {k}"))?;
594        x.push(
595            t.parse::<Number>()
596                .map_err(|e| format!("malformed .sol: bad primal {k}: {e}"))?,
597        );
598    }
599
600    // Optional `objno <objno> <solve_result_num>`.
601    let mut solve_result_num = None;
602    let rest: Vec<&str> = toks.collect();
603    if let Some(p) = rest.iter().position(|&t| t == "objno") {
604        if let Some(code) = rest.get(p + 2) {
605            solve_result_num = code.parse::<i32>().ok();
606        }
607    }
608
609    Ok(ParsedSol {
610        x,
611        lambda,
612        solve_result_num,
613    })
614}
615
616// ---------------------------------------------------------------------------
617// Console + JSON rendering.
618// ---------------------------------------------------------------------------
619
620fn print_report(args: &VerifyArgs, o: &VerifyOutcome) {
621    println!("pounce verify — independent solution check");
622    println!(
623        "  problem : {}  ({} vars, {} cons)",
624        args.nl.display(),
625        o.n_vars,
626        o.n_cons
627    );
628    println!("            sha256:{}", o.nl_sha256);
629    println!("  solution: {}", args.sol.display());
630    println!("            sha256:{}", o.sol_sha256);
631    if let Some(srn) = o.solve_result_num {
632        println!("  claimed solve_result_num: {srn}");
633    }
634    println!();
635    println!("  feasibility (tol {:.1e}):", o.feas_tol);
636    print_row(
637        "max constraint violation",
638        o.max_con_violation,
639        &o.worst_con,
640    );
641    print_row(
642        "max bound violation     ",
643        o.max_bound_violation,
644        &o.worst_bound,
645    );
646    if let Some(obj) = o.objective {
647        println!("  objective at x*: {obj:.10e}");
648    }
649    if o.duals_present {
650        println!();
651        println!("  optimality (tol {:.1e}, duals supplied):", o.opt_tol);
652        if let Some(s) = o.stationarity {
653            let sign = o.dual_sign.unwrap_or(1);
654            println!("    KKT stationarity residual: {s:.3e}  (dual sign {sign:+})");
655        }
656        if let Some(c) = o.complementarity {
657            println!("    complementarity residual : {c:.3e}");
658        }
659    } else {
660        println!();
661        println!("  optimality: not checked (.sol carried no duals)");
662    }
663    println!();
664    let verdict = if o.verified {
665        "VERIFIED — solution is feasible for the canonical problem".to_string()
666    } else if !o.feasible {
667        "REJECTED — solution VIOLATES the canonical constraints".to_string()
668    } else if !o.duals_present {
669        // Feasible, --require-optimal was asked for, but optimality could not
670        // be checked at all because the .sol carried no duals — say so rather
671        // than implying we found it non-optimal.
672        "REJECTED — feasible, but --require-optimal needs duals and the .sol \
673         carried none"
674            .to_string()
675    } else {
676        "REJECTED — feasible but not first-order optimal (--require-optimal)".to_string()
677    };
678    println!("  VERDICT: {verdict}");
679}
680
681fn print_row(label: &str, v: Number, worst: &Option<RowReport>) {
682    match worst {
683        Some(r) => println!(
684            "    {label}: {v:.3e}  at {} (value {:.6e}, bounds [{:.6e}, {:.6e}])",
685            r.name, r.value, r.lo, r.hi
686        ),
687        None => println!("    {label}: {v:.3e}"),
688    }
689}
690
691/// Environment variable holding the HMAC key. When set (non-empty) and a
692/// `--json-output` receipt is requested, the receipt is signed.
693pub const KEY_ENV: &str = "POUNCE_VERIFY_KEY";
694
695/// The exact byte string that gets HMAC-signed. Deliberately **float-free**
696/// — only hex hashes, integer counts, and the verdict — so any language
697/// reproduces it byte-for-byte (no float-formatting parity problems between
698/// Rust and a Python/JS consumer). One `key=value` per line, fixed order,
699/// trailing newline. The consumer re-derives this from the receipt fields,
700/// recomputes `HMAC-SHA256(key, preimage)`, and compares to `signature`.
701/// Documented in `docs/src/verify.md`.
702///
703/// The signed fields are exactly the security-critical bindings: *which*
704/// problem (`nl_sha256`), *which* solution (`sol_sha256`), the problem
705/// dimensions, and the verdict. The numeric violations in the receipt are
706/// supporting evidence; trust flows from the hashes + `verified` flag.
707pub fn signing_preimage(o: &VerifyOutcome) -> String {
708    format!(
709        "pounce-verify-receipt/v1\n\
710         verify_version=1\n\
711         nl_sha256={}\n\
712         sol_sha256={}\n\
713         n_vars={}\n\
714         n_cons={}\n\
715         feasible={}\n\
716         verified={}\n\
717         verdict={}\n",
718        o.nl_sha256,
719        o.sol_sha256,
720        o.n_vars,
721        o.n_cons,
722        o.feasible,
723        o.verified,
724        if o.verified { "VERIFIED" } else { "REJECTED" },
725    )
726}
727
728fn receipt_json(args: &VerifyArgs, o: &VerifyOutcome) -> String {
729    use serde_json::json;
730    let worst_con = o.worst_con.as_ref().map(row_json);
731    let worst_bound = o.worst_bound.as_ref().map(row_json);
732    let optimality = if o.duals_present {
733        json!({
734            "available": true,
735            "objective": o.objective,
736            "stationarity_residual": o.stationarity,
737            "dual_sign": o.dual_sign,
738            "complementarity_residual": o.complementarity,
739            "optimal": o.optimal,
740            "note": "bound-projected stationarity (dual infeasibility) using the .sol's \
741                     constraint duals; bound multipliers inferred from activity. Sign chosen \
742                     to match the supplied dual convention. Feasibility is the rigorous gate."
743        })
744    } else {
745        json!({ "available": false })
746    };
747    let mut receipt = json!({
748        "pounce_verify_version": 1,
749        "solver": format!("pounce {}", env!("CARGO_PKG_VERSION")),
750        "problem": {
751            "path": args.nl.display().to_string(),
752            "sha256": o.nl_sha256,
753            "n_vars": o.n_vars,
754            "n_cons": o.n_cons,
755        },
756        "solution": {
757            "path": args.sol.display().to_string(),
758            "sha256": o.sol_sha256,
759            "claimed_solve_result_num": o.solve_result_num,
760            "duals_present": o.duals_present,
761        },
762        "tolerances": { "feasibility": o.feas_tol, "optimality": o.opt_tol },
763        "feasibility": {
764            "max_constraint_violation": o.max_con_violation,
765            "worst_constraint": worst_con,
766            "max_bound_violation": o.max_bound_violation,
767            "worst_bound": worst_bound,
768            "feasible": o.feasible,
769        },
770        "optimality": optimality,
771        "verdict": if o.verified { "VERIFIED" } else { "REJECTED" },
772        "verified": o.verified,
773    });
774
775    // Sign the receipt when a key is present. The signature covers the
776    // float-free `signing_preimage`, NOT the pretty JSON, so a consumer in
777    // any language can recompute it without matching float formatting.
778    if let Ok(key) = std::env::var(KEY_ENV) {
779        if !key.is_empty() {
780            if let Some(obj) = receipt.as_object_mut() {
781                let sig = sha256::hmac_hex(key.as_bytes(), signing_preimage(o).as_bytes());
782                obj.insert("signature_alg".into(), json!("HMAC-SHA256"));
783                obj.insert(
784                    "signed_fields".into(),
785                    json!([
786                        "verify_version",
787                        "nl_sha256",
788                        "sol_sha256",
789                        "n_vars",
790                        "n_cons",
791                        "feasible",
792                        "verified",
793                        "verdict"
794                    ]),
795                );
796                obj.insert("signature".into(), json!(sig));
797            }
798        }
799    }
800
801    serde_json::to_string_pretty(&receipt).unwrap_or_else(|_| "{}".to_string())
802}
803
804fn row_json(r: &RowReport) -> serde_json::Value {
805    serde_json::json!({
806        "index": r.index,
807        "name": r.name,
808        "value": r.value,
809        "lower": r.lo,
810        "upper": r.hi,
811        "violation": r.violation,
812    })
813}
814
815// ---------------------------------------------------------------------------
816// Self-contained SHA-256 (FIPS 180-4) — content-addresses the receipt's
817// inputs with zero new dependencies, matching the crate's hand-rolled,
818// dependency-light style. Known-answer tested below.
819// ---------------------------------------------------------------------------
820
821pub mod sha256 {
822    const K: [u32; 64] = [
823        0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
824        0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
825        0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
826        0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
827        0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
828        0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
829        0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
830        0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
831        0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
832        0xc67178f2,
833    ];
834
835    /// Raw 32-byte SHA-256 digest.
836    pub fn digest(data: &[u8]) -> [u8; 32] {
837        let mut h: [u32; 8] = [
838            0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
839            0x5be0cd19,
840        ];
841
842        // Pad: message || 0x80 || 0x00... || 64-bit big-endian bit length.
843        let bit_len = (data.len() as u64).wrapping_mul(8);
844        let mut msg = data.to_vec();
845        msg.push(0x80);
846        while msg.len() % 64 != 56 {
847            msg.push(0);
848        }
849        msg.extend_from_slice(&bit_len.to_be_bytes());
850
851        let mut w = [0u32; 64];
852        for chunk in msg.chunks_exact(64) {
853            for i in 0..16 {
854                w[i] = u32::from_be_bytes([
855                    chunk[4 * i],
856                    chunk[4 * i + 1],
857                    chunk[4 * i + 2],
858                    chunk[4 * i + 3],
859                ]);
860            }
861            for i in 16..64 {
862                let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
863                let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
864                w[i] = w[i - 16]
865                    .wrapping_add(s0)
866                    .wrapping_add(w[i - 7])
867                    .wrapping_add(s1);
868            }
869
870            let (mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh) =
871                (h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7]);
872            for i in 0..64 {
873                let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
874                let ch = (e & f) ^ ((!e) & g);
875                let t1 = hh
876                    .wrapping_add(s1)
877                    .wrapping_add(ch)
878                    .wrapping_add(K[i])
879                    .wrapping_add(w[i]);
880                let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
881                let maj = (a & b) ^ (a & c) ^ (b & c);
882                let t2 = s0.wrapping_add(maj);
883                hh = g;
884                g = f;
885                f = e;
886                e = d.wrapping_add(t1);
887                d = c;
888                c = b;
889                b = a;
890                a = t1.wrapping_add(t2);
891            }
892            h[0] = h[0].wrapping_add(a);
893            h[1] = h[1].wrapping_add(b);
894            h[2] = h[2].wrapping_add(c);
895            h[3] = h[3].wrapping_add(d);
896            h[4] = h[4].wrapping_add(e);
897            h[5] = h[5].wrapping_add(f);
898            h[6] = h[6].wrapping_add(g);
899            h[7] = h[7].wrapping_add(hh);
900        }
901
902        let mut out = [0u8; 32];
903        for (i, word) in h.iter().enumerate() {
904            out[4 * i..4 * i + 4].copy_from_slice(&word.to_be_bytes());
905        }
906        out
907    }
908
909    fn to_hex(bytes: &[u8]) -> String {
910        let mut out = String::with_capacity(bytes.len() * 2);
911        for b in bytes {
912            out.push_str(&format!("{b:02x}"));
913        }
914        out
915    }
916
917    /// Lowercase-hex SHA-256 of `data`.
918    pub fn hex(data: &[u8]) -> String {
919        to_hex(&digest(data))
920    }
921
922    /// HMAC-SHA256(key, msg) per RFC 2104, raw 32 bytes.
923    pub fn hmac(key: &[u8], msg: &[u8]) -> [u8; 32] {
924        const BLOCK: usize = 64;
925        let mut k = [0u8; BLOCK];
926        if key.len() > BLOCK {
927            k[..32].copy_from_slice(&digest(key));
928        } else {
929            k[..key.len()].copy_from_slice(key);
930        }
931        let mut ipad = [0x36u8; BLOCK];
932        let mut opad = [0x5cu8; BLOCK];
933        for i in 0..BLOCK {
934            ipad[i] ^= k[i];
935            opad[i] ^= k[i];
936        }
937        let mut inner = Vec::with_capacity(BLOCK + msg.len());
938        inner.extend_from_slice(&ipad);
939        inner.extend_from_slice(msg);
940        let inner_digest = digest(&inner);
941        let mut outer = Vec::with_capacity(BLOCK + 32);
942        outer.extend_from_slice(&opad);
943        outer.extend_from_slice(&inner_digest);
944        digest(&outer)
945    }
946
947    /// HMAC-SHA256 as lowercase hex.
948    pub fn hmac_hex(key: &[u8], msg: &[u8]) -> String {
949        to_hex(&hmac(key, msg))
950    }
951}
952
953#[cfg(test)]
954mod tests {
955    use super::*;
956    use crate::nl_writer::{format_sol, SolutionFile};
957
958    #[test]
959    fn sha256_known_answers() {
960        // FIPS 180-4 test vectors.
961        assert_eq!(
962            sha256::hex(b""),
963            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
964        );
965        assert_eq!(
966            sha256::hex(b"abc"),
967            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
968        );
969        assert_eq!(
970            sha256::hex(b"The quick brown fox jumps over the lazy dog"),
971            "d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592"
972        );
973    }
974
975    #[test]
976    fn hmac_sha256_known_answers() {
977        // RFC 4231 test case 2.
978        assert_eq!(
979            sha256::hmac_hex(b"Jefe", b"what do ya want for nothing?"),
980            "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
981        );
982        // RFC 4231 test case 1: key = 0x0b * 20, data = "Hi There".
983        assert_eq!(
984            sha256::hmac_hex(&[0x0b; 20], b"Hi There"),
985            "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
986        );
987    }
988
989    #[test]
990    fn parse_sol_round_trips_writer() {
991        // Writer is the inverse we must match exactly. Derive the banner
992        // from the crate version so this fixture never goes stale on a
993        // version bump (the round-trip is agnostic to the exact string).
994        let message = format!(
995            "POUNCE {}: Optimal Solution Found",
996            env!("CARGO_PKG_VERSION")
997        );
998        let payload = SolutionFile {
999            message: &message,
1000            x: &[1.0, 2.5, -0.5, 100.0],
1001            lambda: &[0.1, -0.2],
1002            solve_result_num: 0,
1003            suffixes: &[],
1004        };
1005        let text = format_sol(&payload);
1006        let parsed = parse_sol(&text).expect("parse");
1007        assert_eq!(parsed.x.len(), 4);
1008        assert_eq!(parsed.lambda.len(), 2);
1009        assert!((parsed.x[1] - 2.5).abs() < 1e-15);
1010        assert!((parsed.x[3] - 100.0).abs() < 1e-12);
1011        assert!((parsed.lambda[0] - 0.1).abs() < 1e-15);
1012        assert_eq!(parsed.solve_result_num, Some(0));
1013    }
1014
1015    #[test]
1016    fn parse_sol_handles_no_duals() {
1017        let payload = SolutionFile {
1018            message: "msg",
1019            x: &[3.0, 4.0],
1020            lambda: &[],
1021            solve_result_num: 200,
1022            suffixes: &[],
1023        };
1024        let text = format_sol(&payload);
1025        let parsed = parse_sol(&text).expect("parse");
1026        assert_eq!(parsed.x, vec![3.0, 4.0]);
1027        assert!(parsed.lambda.is_empty());
1028        assert_eq!(parsed.solve_result_num, Some(200));
1029    }
1030
1031    #[test]
1032    fn box_violation_basic() {
1033        // inside
1034        assert_eq!(box_violation(5.0, 0.0, 10.0), 0.0);
1035        // below lower
1036        assert!((box_violation(-2.0, 0.0, 10.0) - 2.0).abs() < 1e-15);
1037        // above upper
1038        assert!((box_violation(13.0, 0.0, 10.0) - 3.0).abs() < 1e-15);
1039        // one-sided (no upper)
1040        assert_eq!(box_violation(1e30, 0.0, NLP_UPPER_BOUND_INF), 0.0);
1041    }
1042
1043    #[test]
1044    fn box_violation_rejects_non_finite() {
1045        // Regression: a fabricated `.sol` carrying NaN must register an
1046        // infinite violation, not slip through as feasible. Before the
1047        // `is_finite` guard, `NaN.max(_).max(0.0)` collapsed to `0.0`
1048        // (f64::max drops NaN operands) and the checker reported VERIFIED.
1049        assert_eq!(box_violation(Number::NAN, 0.0, 10.0), Number::INFINITY);
1050        // ±∞ pinned at an unbounded variable is not a real point either.
1051        assert_eq!(
1052            box_violation(Number::INFINITY, 0.0, NLP_UPPER_BOUND_INF),
1053            Number::INFINITY
1054        );
1055        assert_eq!(
1056            box_violation(Number::NEG_INFINITY, NLP_LOWER_BOUND_INF, 10.0),
1057            Number::INFINITY
1058        );
1059    }
1060}