1use 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#[derive(Debug, Clone)]
75pub struct VerifyArgs {
76 pub nl: PathBuf,
77 pub sol: PathBuf,
78 pub feas_tol: Number,
80 pub opt_tol: Number,
82 pub json_output: Option<PathBuf>,
84 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
135pub fn run_from_argv(rest: &[String]) -> ExitCode {
137 let args = match parse_verify_argv(rest) {
138 Ok(Some(a)) => a,
139 Ok(None) => {
140 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#[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 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 pub objective: Option<Number>,
209 pub duals_present: bool,
210 pub stationarity: Option<Number>,
211 pub dual_sign: Option<i32>,
212 pub constraint_complementarity: Option<Number>,
216 pub bound_multipliers_present: bool,
218 pub bound_complementarity: Option<Number>,
223 pub stationarity_with_bound_multipliers: Option<Number>,
227 pub optimal: Option<bool>,
228 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
242pub(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
274pub(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 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 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 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 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 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 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 let feasible = !any_con_violated && !any_bound_violated;
464
465 let objective = tnlp.eval_f(&x, true);
467
468 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 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 if duals_present || m == 0 {
494 let lambda = &parsed.lambda;
495
496 let mut grad_f = vec![0.0; n];
498 tnlp.eval_grad_f(&x, true, &mut grad_f);
499
500 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 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 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 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
578fn 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
602fn bound_projected_residual(s: &[Number], x: &[Number], x_l: &[Number], x_u: &[Number]) -> Number {
611 let n = s.len();
612 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 (-s[j]).max(0.0)
627 } else if at_hi && !at_lo {
628 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
638fn 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
659fn 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
688fn 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 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; }
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#[derive(Debug)]
738struct ParsedSol {
739 x: Vec<Number>,
740 lambda: Vec<Number>,
741 solve_result_num: Option<i32>,
742 z_l: Option<Vec<Number>>,
744 z_u: Option<Vec<Number>>,
746}
747
748fn parse_sol(text: &str) -> Result<ParsedSol, String> {
754 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 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
828fn 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 if tablen != 0 {
867 break;
868 }
869 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
900fn 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 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 "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
1003pub const KEY_ENV: &str = "POUNCE_VERIFY_KEY";
1006
1007pub 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 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 "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 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
1156pub 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 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 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 pub fn hex(data: &[u8]) -> String {
1260 to_hex(&digest(data))
1261 }
1262
1263 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 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 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 assert_eq!(
1321 sha256::hmac_hex(b"Jefe", b"what do ya want for nothing?"),
1322 "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
1323 );
1324 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 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 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 assert_eq!(box_violation(5.0, 0.0, 10.0), 0.0);
1384 assert!((box_violation(-2.0, 0.0, 10.0) - 2.0).abs() < 1e-15);
1386 assert!((box_violation(13.0, 0.0, 10.0) - 3.0).abs() < 1e-15);
1388 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 assert_eq!(box_violation(Number::NAN, 0.0, 10.0), Number::INFINITY);
1399 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 #[test]
1419 fn a_bound_past_the_opposite_sentinel_still_scores_a_violation() {
1420 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 assert_eq!(box_violation(0.0, 5.0e20, NLP_UPPER_BOUND_INF), 5.0e20);
1429 assert_eq!(box_violation(-6.0e20, NLP_LOWER_BOUND_INF, -5.0e20), 0.0);
1431 assert_eq!(
1433 box_violation(1e30, NLP_LOWER_BOUND_INF, NLP_UPPER_BOUND_INF),
1434 0.0
1435 );
1436 }
1437
1438 #[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 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 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 #[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 #[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 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 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 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 #[test]
1536 fn exact_dual_infeasibility_sees_what_the_projection_hides() {
1537 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 assert_eq!(bound_projected_residual(&s, &x, &x_l, &x_u), 0.0);
1549 assert_eq!(exact_dual_infeasibility(&s, &[0.0], &[0.0]), 4.0);
1551 assert_eq!(exact_dual_infeasibility(&s, &[0.0], &[4.0]), 8.0);
1553 }
1554
1555 #[test]
1562 fn row_and_bound_complementarity_are_different_quantities() {
1563 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 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 #[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 assert_eq!(
1596 row_magnitude(3.0, NLP_LOWER_BOUND_INF, NLP_UPPER_BOUND_INF),
1597 3.0
1598 );
1599 }
1600}