1use crate::nl_reader::{self, DiscreteCensus};
67use pounce_common::tolerance::is_negligible;
68use pounce_common::types::{Number, lower_bound_present, upper_bound_present};
69use pounce_nlp::tnlp::{BoundsInfo, IndexStyle, SparsityRequest, TNLP};
70use std::path::PathBuf;
71use std::process::ExitCode;
72
73#[derive(Debug, Clone)]
75pub struct VerifyArgs {
76 pub nl: PathBuf,
77 pub sol: PathBuf,
78 pub feas_tol: Number,
85 pub abs_feas_tol: Option<Number>,
88 pub opt_tol: Number,
90 pub json_output: Option<PathBuf>,
92 pub require_optimal: bool,
95}
96
97impl Default for VerifyArgs {
98 fn default() -> Self {
99 VerifyArgs {
100 nl: PathBuf::new(),
101 sol: PathBuf::new(),
102 feas_tol: 1e-6,
103 abs_feas_tol: None,
104 opt_tol: 1e-6,
105 json_output: None,
106 require_optimal: false,
107 }
108 }
109}
110
111const USAGE: &str = "\
112Usage: pounce verify <problem.nl> <claim.sol> [OPTIONS]
113
114Independently check that the solution in <claim.sol> satisfies the
115constraints and bounds of the canonical problem <problem.nl>. Re-derives
116feasibility from the model itself — it does not trust the .sol's status
117line or rerun the solver.
118
119Arguments:
120 <problem.nl> canonical AMPL .nl problem (the source of truth)
121 <claim.sol> claimed AMPL .sol solution to check
122
123Options:
124 --feas-tol <t> feasibility tolerance (default 1e-6). RELATIVE per
125 row: a row passes when |violation| <= t*max(1,|row|),
126 so a violation of 1.0 on a row of size 2e6 passes at
127 1e-6. Use --abs-feas-tol for an absolute test.
128 --abs-feas-tol <t> judge every row by the absolute test |violation| <= t
129 (replaces the relative --feas-tol test)
130 --opt-tol <t> stationarity tolerance (default 1e-6)
131 --require-optimal also fail if the KKT stationarity residual
132 exceeds --opt-tol (needs duals in the .sol)
133 --json-output <path> write a JSON verification receipt to <path>
134 -h, --help print this message
135
136Complementarity: two different residuals carry that name, and they can
137differ by many orders of magnitude at the same point.
138 * constraint complementarity (rows, |lambda|*slack) is computed from the
139 .sol's constraint duals and is always reported alongside stationarity.
140 * bound complementarity (vars, |z|*slack) is the quantity Ipopt prints as
141 `Complementarity`. It needs the bound multipliers, which reach a .sol
142 only as the `ipopt_zL_out` / `ipopt_zU_out` suffixes; without them it is
143 reported as `not checked`, never as a number.
144Do not compare the row quantity against a solver's `Complementarity` line.
145
146Exit code: 0 = verified feasible, 20 = violation exceeds tolerance,
1472 = usage/IO error.";
148
149pub fn run_from_argv(rest: &[String]) -> ExitCode {
151 let args = match parse_verify_argv(rest) {
152 Ok(Some(a)) => a,
153 Ok(None) => {
154 println!("{USAGE}");
156 return ExitCode::SUCCESS;
157 }
158 Err(msg) => {
159 eprintln!("pounce verify: {msg}");
160 eprintln!("{USAGE}");
161 return ExitCode::from(2);
162 }
163 };
164 run(&args)
165}
166
167fn parse_verify_argv(rest: &[String]) -> Result<Option<VerifyArgs>, String> {
168 let mut a = VerifyArgs::default();
169 let mut positionals: Vec<PathBuf> = Vec::new();
170 let mut it = rest.iter();
171 while let Some(arg) = it.next() {
172 match arg.as_str() {
173 "-h" | "--help" => return Ok(None),
174 "--feas-tol" => {
175 let v = it.next().ok_or("--feas-tol requires a value")?;
176 a.feas_tol = v.parse().map_err(|e| format!("--feas-tol: {e}"))?;
177 }
178 "--abs-feas-tol" => {
179 let v = it.next().ok_or("--abs-feas-tol requires a value")?;
180 a.abs_feas_tol = Some(v.parse().map_err(|e| format!("--abs-feas-tol: {e}"))?);
181 }
182 "--opt-tol" => {
183 let v = it.next().ok_or("--opt-tol requires a value")?;
184 a.opt_tol = v.parse().map_err(|e| format!("--opt-tol: {e}"))?;
185 }
186 "--require-optimal" => a.require_optimal = true,
187 "--json-output" => {
188 let v = it.next().ok_or("--json-output requires a value")?;
189 a.json_output = Some(PathBuf::from(v));
190 }
191 other if other.starts_with('-') => {
192 return Err(format!("unknown flag `{other}`"));
193 }
194 _ => positionals.push(PathBuf::from(arg)),
195 }
196 }
197 match positionals.len() {
198 0 | 1 => Err("expected two positional arguments: <problem.nl> <claim.sol>".to_string()),
199 2 => {
200 a.nl = positionals[0].clone();
201 a.sol = positionals[1].clone();
202 Ok(Some(a))
203 }
204 n => Err(format!("expected 2 positional arguments, got {n}")),
205 }
206}
207
208#[derive(Debug)]
211pub struct VerifyOutcome {
212 pub n_vars: usize,
213 pub n_cons: usize,
214 pub nl_sha256: String,
215 pub sol_sha256: String,
216 pub solve_result_num: Option<i32>,
217 pub feas_tol: Number,
218 pub opt_tol: Number,
219 pub max_con_violation: Number,
221 pub worst_con: Option<RowReport>,
222 pub max_bound_violation: Number,
223 pub worst_bound: Option<RowReport>,
224 pub feasible: bool,
225 pub objective: Option<Number>,
227 pub duals_present: bool,
228 pub stationarity: Option<Number>,
229 pub dual_sign: Option<i32>,
230 pub constraint_complementarity: Option<Number>,
234 pub bound_multipliers_present: bool,
236 pub bound_complementarity: Option<Number>,
241 pub stationarity_with_bound_multipliers: Option<Number>,
245 pub optimal: Option<bool>,
246 pub discrete: Option<DiscreteCensus>,
248 pub integrality: Option<(Number, usize)>,
253 pub abs_feas_tol: Option<Number>,
255 pub verified: bool,
257}
258
259#[derive(Debug, Clone)]
260pub struct RowReport {
261 pub index: usize,
262 pub name: String,
263 pub value: Number,
264 pub lo: Number,
265 pub hi: Number,
266 pub violation: Number,
267}
268
269pub(crate) fn row_magnitude(value: Number, lo: Number, hi: Number) -> Number {
291 let mut m = if value.is_finite() { value.abs() } else { 0.0 };
292 if lower_bound_present(lo) {
293 m = m.max(lo.abs());
294 }
295 if upper_bound_present(hi) {
296 m = m.max(hi.abs());
297 }
298 m
299}
300
301pub(crate) fn row_is_violated(viol: Number, magnitude: Number, feas_tol: Number) -> bool {
321 if !viol.is_finite() {
322 return true;
323 }
324 !is_negligible(viol, magnitude, feas_tol)
325}
326
327fn judge_row(args: &VerifyArgs, viol: Number, magnitude: Number) -> bool {
330 match args.abs_feas_tol {
331 Some(t) => !viol.is_finite() || viol > t,
332 None => row_is_violated(viol, magnitude, args.feas_tol),
333 }
334}
335
336pub fn integrality_notice(c: Option<DiscreteCensus>) -> Option<String> {
339 let c = c.filter(|c| c.total() > 0)?;
340 Some(format!(
341 "the .nl declares {} discrete variable(s) ({} binary and {} integer appearing \
342 linearly, {} appearing nonlinearly); pounce is a continuous solver and solved \
343 the RELAXATION, so the result is a bound on the MIP optimum, not an \
344 integer-feasible solution",
345 c.total(),
346 c.binary,
347 c.integer,
348 c.nonlinear_discrete()
349 ))
350}
351
352pub(crate) fn box_violation(v: Number, lo: Number, hi: Number) -> Number {
353 if !v.is_finite() {
354 return Number::INFINITY;
355 }
356 let below = if lower_bound_present(lo) {
357 lo - v
358 } else {
359 Number::NEG_INFINITY
360 };
361 let above = if upper_bound_present(hi) {
362 v - hi
363 } else {
364 Number::NEG_INFINITY
365 };
366 below.max(above).max(0.0)
367}
368
369pub fn run(args: &VerifyArgs) -> ExitCode {
370 let outcome = match evaluate(args) {
371 Ok(o) => o,
372 Err(msg) => {
373 eprintln!("pounce verify: {msg}");
374 return ExitCode::from(2);
375 }
376 };
377 print_report(args, &outcome);
378
379 if let Some(path) = &args.json_output {
380 let json = receipt_json(args, &outcome);
381 if let Err(e) = std::fs::write(path, json.as_bytes()) {
382 eprintln!(
383 "pounce verify: failed to write receipt {}: {e}",
384 path.display()
385 );
386 return ExitCode::from(2);
387 }
388 let signed = std::env::var(KEY_ENV)
389 .map(|k| !k.is_empty())
390 .unwrap_or(false);
391 println!(
392 " receipt: {}{}",
393 path.display(),
394 if signed {
395 " (signed: HMAC-SHA256)"
396 } else {
397 ""
398 }
399 );
400 }
401
402 if outcome.verified {
403 ExitCode::SUCCESS
404 } else {
405 ExitCode::from(20)
406 }
407}
408
409fn evaluate(args: &VerifyArgs) -> Result<VerifyOutcome, String> {
410 let nl_bytes =
412 std::fs::read(&args.nl).map_err(|e| format!("cannot read {}: {e}", args.nl.display()))?;
413 let sol_bytes =
414 std::fs::read(&args.sol).map_err(|e| format!("cannot read {}: {e}", args.sol.display()))?;
415 let nl_sha256 = sha256::hex(&nl_bytes);
416 let sol_sha256 = sha256::hex(&sol_bytes);
417
418 let prob = nl_reader::read_nl_file(&args.nl)?;
420 if !prob.complementarity.is_empty() {
421 return Err(format!(
424 "{} declares {} complementarity pair(s), which verify does not check; refusing \
425 rather than verifying the relaxation",
426 args.nl.display(),
427 prob.complementarity.len()
428 )
429 .into());
430 }
431 let n = prob.n;
432 let m = prob.m;
433 let con_names = prob.con_names.clone();
434 let var_names = prob.var_names.clone();
435 let prob_discrete = prob.n_discrete;
436 let prob_nl_counts = prob.nl_counts;
437 let obj_sigma: Number = if prob.minimize { 1.0 } else { -1.0 };
441 let mut tnlp = nl_reader::NlTnlp::try_new(prob)?;
442
443 let info = tnlp
444 .get_nlp_info()
445 .ok_or("get_nlp_info failed on the .nl")?;
446 let nnz = info.nnz_jac_g.max(0) as usize;
447 let fortran = matches!(info.index_style, IndexStyle::Fortran);
448
449 let sol_text = String::from_utf8_lossy(&sol_bytes);
451 let parsed = parse_sol(&sol_text)?;
452 if parsed.x.len() != n {
453 return Err(format!(
454 "solution has {} primal values but the problem has {n} variables \
455 (is this the right .sol for this .nl?)",
456 parsed.x.len()
457 ));
458 }
459 let x = parsed.x;
460 let duals_present = !parsed.lambda.is_empty();
461 if duals_present && parsed.lambda.len() != m {
462 return Err(format!(
463 "solution carries {} dual values but the problem has {m} constraints",
464 parsed.lambda.len()
465 ));
466 }
467
468 let mut x_l = vec![0.0; n];
470 let mut x_u = vec![0.0; n];
471 let mut g_l = vec![0.0; m];
472 let mut g_u = vec![0.0; m];
473 if !tnlp.get_bounds_info(BoundsInfo {
474 x_l: &mut x_l,
475 x_u: &mut x_u,
476 g_l: &mut g_l,
477 g_u: &mut g_u,
478 }) {
479 return Err("get_bounds_info failed".to_string());
480 }
481
482 let mut max_bound_violation = 0.0_f64;
484 let mut worst_bound: Option<RowReport> = None;
485 let mut any_bound_violated = false;
486 for j in 0..n {
487 let viol = box_violation(x[j], x_l[j], x_u[j]);
488 if judge_row(args, viol, row_magnitude(x[j], x_l[j], x_u[j])) {
489 any_bound_violated = true;
490 }
491 if viol > max_bound_violation {
492 max_bound_violation = viol;
493 worst_bound = Some(RowReport {
494 index: j,
495 name: name_at(&var_names, j, 'x'),
496 value: x[j],
497 lo: x_l[j],
498 hi: x_u[j],
499 violation: viol,
500 });
501 }
502 }
503
504 let mut g = vec![0.0; m];
506 if !tnlp.eval_g(&x, true, &mut g) {
507 return Err("eval_g failed at the claimed solution".to_string());
508 }
509 let mut max_con_violation = 0.0_f64;
510 let mut worst_con: Option<RowReport> = None;
511 let mut any_con_violated = false;
512 for i in 0..m {
513 let viol = box_violation(g[i], g_l[i], g_u[i]);
514 if judge_row(args, viol, row_magnitude(g[i], g_l[i], g_u[i])) {
515 any_con_violated = true;
516 }
517 if viol > max_con_violation {
518 max_con_violation = viol;
519 worst_con = Some(RowReport {
520 index: i,
521 name: name_at(&con_names, i, 'c'),
522 value: g[i],
523 lo: g_l[i],
524 hi: g_u[i],
525 violation: viol,
526 });
527 }
528 }
529
530 let discrete = prob_discrete;
541 let integrality = discrete
542 .filter(|d| d.total() > 0)
543 .and_then(|d| d.integer_columns(n, prob_nl_counts))
544 .filter(|cols| !cols.is_empty())
545 .map(|cols| {
546 let mut worst = (0.0_f64, cols[0]);
547 for j in cols {
548 let xj = x[j];
549 let dist = if xj.is_finite() {
550 (xj - xj.round()).abs()
551 } else {
552 Number::INFINITY
553 };
554 if dist > worst.0 {
555 worst = (dist, j);
556 }
557 }
558 worst
559 });
560 let integrality_ok =
561 integrality.is_none_or(|(d, _)| d <= args.abs_feas_tol.unwrap_or(args.feas_tol));
562 let feasible = !any_con_violated && !any_bound_violated && integrality_ok;
563
564 let objective = tnlp.eval_f(&x, true);
566
567 let bound_multipliers_present = parsed.z_l.is_some() || parsed.z_u.is_some();
575 let z_l_suf = parsed.z_l.clone().unwrap_or_else(|| vec![0.0; n]);
576 let z_u_suf = parsed.z_u.clone().unwrap_or_else(|| vec![0.0; n]);
577 let bound_complementarity = if bound_multipliers_present {
578 Some(bound_complementarity(&x, &x_l, &x_u, &z_l_suf, &z_u_suf))
579 } else {
580 None
581 };
582
583 let mut stationarity = None;
585 let mut dual_sign = None;
586 let mut constraint_complementarity = None;
587 let mut stationarity_with_bound_multipliers = None;
588 let mut optimal = None;
589 if duals_present || m == 0 {
593 let lambda = &parsed.lambda;
594
595 let mut grad_f = vec![0.0; n];
597 tnlp.eval_grad_f(&x, true, &mut grad_f);
598
599 let mut irow = vec![0i32; nnz];
601 let mut jcol = vec![0i32; nnz];
602 tnlp.eval_jac_g(
603 Some(&x),
604 true,
605 SparsityRequest::Structure {
606 irow: &mut irow,
607 jcol: &mut jcol,
608 },
609 );
610 let mut jval = vec![0.0; nnz];
611 tnlp.eval_jac_g(
612 Some(&x),
613 true,
614 SparsityRequest::Values { values: &mut jval },
615 );
616
617 let s_pos = lagrangian_gradient(1.0, &grad_f, &irow, &jcol, &jval, fortran, lambda);
622 let s_neg = lagrangian_gradient(-1.0, &grad_f, &irow, &jcol, &jval, fortran, lambda);
623 let resid_pos = bound_projected_residual(&s_pos, &x, &x_l, &x_u, args.opt_tol);
624 let resid_neg = bound_projected_residual(&s_neg, &x, &x_l, &x_u, args.opt_tol);
625 let (best_resid, sign, s) = if resid_pos <= resid_neg {
626 (resid_pos, 1, &s_pos)
627 } else {
628 (resid_neg, -1, &s_neg)
629 };
630 stationarity = Some(best_resid);
631 dual_sign = Some(sign);
632 constraint_complementarity = Some(row_complementarity(lambda, &g, &g_l, &g_u));
633
634 if bound_multipliers_present {
640 stationarity_with_bound_multipliers =
641 Some(exact_dual_infeasibility(s, &z_l_suf, &z_u_suf, obj_sigma));
642 }
643 let gate = stationarity_with_bound_multipliers.unwrap_or(best_resid);
644 optimal = Some(gate <= args.opt_tol);
645 }
646
647 let verified = feasible && (!args.require_optimal || optimal.unwrap_or(false));
650
651 Ok(VerifyOutcome {
652 n_vars: n,
653 n_cons: m,
654 nl_sha256,
655 sol_sha256,
656 solve_result_num: parsed.solve_result_num,
657 feas_tol: args.feas_tol,
658 opt_tol: args.opt_tol,
659 max_con_violation,
660 worst_con,
661 max_bound_violation,
662 worst_bound,
663 feasible,
664 objective,
665 duals_present,
666 stationarity,
667 dual_sign,
668 constraint_complementarity,
669 bound_multipliers_present,
670 bound_complementarity,
671 stationarity_with_bound_multipliers,
672 optimal,
673 discrete,
674 integrality,
675 abs_feas_tol: args.abs_feas_tol,
676 verified,
677 })
678}
679
680fn lagrangian_gradient(
683 sign: Number,
684 grad_f: &[Number],
685 irow: &[i32],
686 jcol: &[i32],
687 jval: &[Number],
688 fortran: bool,
689 lambda: &[Number],
690) -> Vec<Number> {
691 let n = grad_f.len();
692 let off = if fortran { 1 } else { 0 };
693 let mut s = grad_f.to_vec();
694 for k in 0..jval.len() {
695 let row = (irow[k] as usize).wrapping_sub(off);
696 let col = (jcol[k] as usize).wrapping_sub(off);
697 if row < lambda.len() && col < n {
698 s[col] += sign * jval[k] * lambda[row];
699 }
700 }
701 s
702}
703
704fn bound_projected_residual(
726 s: &[Number],
727 x: &[Number],
728 x_l: &[Number],
729 x_u: &[Number],
730 compl_tol: Number,
731) -> Number {
732 let n = s.len();
733 let mut dual_inf = 0.0_f64;
734 for j in 0..n {
735 let fixed = lower_bound_present(x_l[j])
736 && upper_bound_present(x_u[j])
737 && (x_u[j] - x_l[j]).abs() <= 1e-12;
738 if fixed {
739 continue;
740 }
741 let absorbable = |present: bool, bound: Number, gap: Number, z: Number| {
742 present && (gap.abs() <= 1e-8 * (1.0 + bound.abs()) || z * gap.max(0.0) <= compl_tol)
743 };
744 let r = if s[j] > 0.0 {
745 if absorbable(lower_bound_present(x_l[j]), x_l[j], x[j] - x_l[j], s[j]) {
746 0.0
747 } else {
748 s[j]
749 }
750 } else if absorbable(upper_bound_present(x_u[j]), x_u[j], x_u[j] - x[j], -s[j]) {
751 0.0
752 } else {
753 -s[j]
754 };
755 dual_inf = dual_inf.max(r);
756 }
757 dual_inf
758}
759
760fn exact_dual_infeasibility(
773 s: &[Number],
774 z_l_suf: &[Number],
775 z_u_suf: &[Number],
776 obj_sigma: Number,
777) -> Number {
778 let mut dual_inf = 0.0_f64;
779 for (j, &s_j) in s.iter().enumerate() {
780 let z = obj_sigma
781 * (z_l_suf.get(j).copied().unwrap_or(0.0) + z_u_suf.get(j).copied().unwrap_or(0.0));
782 dual_inf = dual_inf.max((s_j - z).abs());
783 }
784 dual_inf
785}
786
787fn bound_complementarity(
796 x: &[Number],
797 x_l: &[Number],
798 x_u: &[Number],
799 z_l_suf: &[Number],
800 z_u_suf: &[Number],
801) -> Number {
802 let mut comp = 0.0_f64;
803 for j in 0..x.len() {
804 if lower_bound_present(x_l[j]) {
805 let z = z_l_suf.get(j).copied().unwrap_or(0.0);
806 comp = comp.max((z * (x[j] - x_l[j])).abs());
807 }
808 if upper_bound_present(x_u[j]) {
809 let z = z_u_suf.get(j).copied().unwrap_or(0.0);
810 comp = comp.max((z * (x_u[j] - x[j])).abs());
811 }
812 }
813 comp
814}
815
816fn row_complementarity(lambda: &[Number], g: &[Number], g_l: &[Number], g_u: &[Number]) -> Number {
825 let mut comp = 0.0_f64;
826 for i in 0..lambda.len() {
827 if lower_bound_present(g_l[i])
831 && upper_bound_present(g_u[i])
832 && (g_u[i] - g_l[i]).abs() <= 1e-12
833 {
834 continue; }
836 let dl = if lower_bound_present(g_l[i]) {
837 (g[i] - g_l[i]).abs()
838 } else {
839 Number::INFINITY
840 };
841 let du = if upper_bound_present(g_u[i]) {
842 (g_u[i] - g[i]).abs()
843 } else {
844 Number::INFINITY
845 };
846 let dist = dl.min(du);
847 if dist.is_finite() {
848 comp = comp.max(lambda[i].abs() * dist);
849 }
850 }
851 comp
852}
853
854pub(crate) fn name_at(names: &[String], i: usize, kind: char) -> String {
855 match names.get(i) {
856 Some(s) if !s.is_empty() => s.clone(),
857 _ => format!("{kind}[{i}]"),
858 }
859}
860
861#[derive(Debug)]
866pub(crate) struct ParsedSol {
867 pub(crate) x: Vec<Number>,
868 pub(crate) lambda: Vec<Number>,
869 pub(crate) solve_result_num: Option<i32>,
870 pub(crate) z_l: Option<Vec<Number>>,
872 pub(crate) z_u: Option<Vec<Number>>,
874}
875
876pub(crate) fn parse_sol(text: &str) -> Result<ParsedSol, String> {
882 let mut after_options = None;
884 for (i, line) in text.lines().enumerate() {
885 if line.trim() == "Options" {
886 after_options = Some(i);
887 break;
888 }
889 }
890 let start = after_options.ok_or("malformed .sol: no `Options` section found")?;
891 let tail: String = text.lines().skip(start + 1).collect::<Vec<_>>().join(" ");
892 let mut toks = tail.split_whitespace();
893
894 let nopts: usize = toks
895 .next()
896 .ok_or("malformed .sol: missing option count")?
897 .parse()
898 .map_err(|e| format!("malformed .sol: bad option count: {e}"))?;
899 for _ in 0..nopts {
900 toks.next()
901 .ok_or("malformed .sol: truncated option words")?;
902 }
903
904 let next_usize = |toks: &mut std::str::SplitWhitespace, what: &str| -> Result<usize, String> {
905 toks.next()
906 .ok_or_else(|| format!("malformed .sol: missing {what}"))?
907 .parse::<usize>()
908 .map_err(|e| format!("malformed .sol: bad {what}: {e}"))
909 };
910 let n_dual = next_usize(&mut toks, "dual count")?;
911 let _m = next_usize(&mut toks, "constraint count")?;
912 let n_primal = next_usize(&mut toks, "primal count")?;
913 let _n = next_usize(&mut toks, "variable count")?;
914
915 let mut lambda = Vec::with_capacity(n_dual);
916 for k in 0..n_dual {
917 let t = toks
918 .next()
919 .ok_or_else(|| format!("malformed .sol: truncated dual block at {k}"))?;
920 lambda.push(
921 t.parse::<Number>()
922 .map_err(|e| format!("malformed .sol: bad dual {k}: {e}"))?,
923 );
924 }
925 let mut x = Vec::with_capacity(n_primal);
926 for k in 0..n_primal {
927 let t = toks
928 .next()
929 .ok_or_else(|| format!("malformed .sol: truncated primal block at {k}"))?;
930 x.push(
931 t.parse::<Number>()
932 .map_err(|e| format!("malformed .sol: bad primal {k}: {e}"))?,
933 );
934 }
935
936 let rest: Vec<&str> = toks.collect();
939 let (solve_result_num, var_suffixes) = parse_sol_tail(&rest, n_primal);
940 let suffix = |name: &str| -> Option<Vec<Number>> {
941 var_suffixes
942 .iter()
943 .find(|(n, _)| n == name)
944 .map(|(_, v)| v.clone())
945 };
946
947 Ok(ParsedSol {
948 x,
949 lambda,
950 solve_result_num,
951 z_l: suffix("ipopt_zL_out"),
952 z_u: suffix("ipopt_zU_out"),
953 })
954}
955
956fn parse_sol_tail(rest: &[&str], n: usize) -> (Option<i32>, Vec<(String, Vec<Number>)>) {
971 let mut solve_result_num = None;
972 let mut out: Vec<(String, Vec<Number>)> = Vec::new();
973 let mut i = 0;
974 while i < rest.len() {
975 match rest[i] {
976 "objno" => {
977 solve_result_num = rest.get(i + 2).and_then(|t| t.parse::<i32>().ok());
978 i += 3;
979 }
980 "suffix" => {
981 let int_at = |k: usize| rest.get(i + k).and_then(|t| t.parse::<i64>().ok());
982 let (Some(kind), Some(nvalues), Some(tablen)) = (int_at(1), int_at(2), int_at(4))
983 else {
984 break;
985 };
986 let (Some(name), true) = (rest.get(i + 6), nvalues >= 0) else {
987 break;
988 };
989 let name = (*name).to_string();
990 i += 7;
991 if tablen != 0 {
995 break;
996 }
997 let want = (kind & 0x3) == 0 && (kind & 0x4) != 0;
1000 let mut dense = vec![0.0; n];
1001 let mut complete = true;
1002 for _ in 0..nvalues as usize {
1003 let (Some(it), Some(vt)) = (rest.get(i), rest.get(i + 1)) else {
1004 complete = false;
1005 break;
1006 };
1007 if let (true, Ok(idx), Ok(v)) =
1008 (want, it.parse::<usize>(), vt.parse::<Number>())
1009 && idx < n
1010 {
1011 dense[idx] = v;
1012 }
1013 i += 2;
1014 }
1015 if !complete {
1016 break;
1017 }
1018 if want {
1019 out.push((name, dense));
1020 }
1021 }
1022 _ => i += 1,
1023 }
1024 }
1025 (solve_result_num, out)
1026}
1027
1028fn print_report(args: &VerifyArgs, o: &VerifyOutcome) {
1033 println!("pounce verify — independent solution check");
1034 println!(
1035 " problem : {} ({} vars, {} cons)",
1036 args.nl.display(),
1037 o.n_vars,
1038 o.n_cons
1039 );
1040 println!(" sha256:{}", o.nl_sha256);
1041 println!(" solution: {}", args.sol.display());
1042 println!(" sha256:{}", o.sol_sha256);
1043 if let Some(srn) = o.solve_result_num {
1044 println!(" claimed solve_result_num: {srn}");
1045 }
1046 println!();
1047 match o.abs_feas_tol {
1048 Some(t) => println!(" feasibility (absolute tol {t:.1e}):"),
1049 None => println!(
1050 " feasibility (tol {:.1e}, relative per row: |viol| <= tol*max(1,|row|)):",
1051 o.feas_tol
1052 ),
1053 }
1054 print_row(
1055 "max constraint violation",
1056 o.max_con_violation,
1057 &o.worst_con,
1058 );
1059 print_row(
1060 "max bound violation ",
1061 o.max_bound_violation,
1062 &o.worst_bound,
1063 );
1064 match (o.discrete.filter(|d| d.total() > 0), o.integrality) {
1065 (Some(d), Some((v, j))) => println!(
1066 " integrality ({} discrete columns: {} binary + {} integer linear, {} nonlinear): \
1067 max distance to an integer {v:.3e} at x[{j}]",
1068 d.total(),
1069 d.binary,
1070 d.integer,
1071 d.nonlinear_discrete()
1072 ),
1073 (Some(d), None) => println!(
1074 " integrality: NOT CHECKED — the .nl declares {} discrete variable(s) ({} appearing \
1075 nonlinearly) whose columns cannot be identified from the header",
1076 d.total(),
1077 d.nonlinear_discrete()
1078 ),
1079 (None, _) => {}
1080 }
1081 if let Some(obj) = o.objective {
1082 println!(" objective at x*: {obj:.10e}");
1083 }
1084 if o.stationarity.is_some() || o.bound_multipliers_present {
1085 let source = match (o.duals_present, o.bound_multipliers_present) {
1086 (true, true) => "duals + bound multipliers supplied",
1087 (true, false) => "duals supplied",
1088 (false, true) => "bound multipliers supplied",
1089 (false, false) => "no rows, so no duals to supply",
1090 };
1091 println!();
1092 println!(" optimality (tol {:.1e}, {source}):", o.opt_tol);
1093 if let Some(s) = o.stationarity {
1094 let sign = o.dual_sign.unwrap_or(1);
1095 println!(
1096 " KKT stationarity residual (bound-projected) : {s:.3e} (dual sign {sign:+})"
1097 );
1098 }
1099 if let Some(s) = o.stationarity_with_bound_multipliers {
1100 println!(" dual infeasibility (with z_L/z_U suffixes) : {s:.3e}");
1101 }
1102 if let Some(c) = o.constraint_complementarity {
1106 println!(" constraint complementarity (rows, |λ|·slack) : {c:.3e}");
1107 }
1108 match o.bound_complementarity {
1109 Some(c) => println!(" bound complementarity (vars, |z|·slack) : {c:.3e}"),
1110 None => {
1111 println!(
1112 " bound complementarity (vars, |z|·slack) : not checked \
1113 — the .sol carries no"
1114 );
1115 println!(
1116 " ipopt_zL_out/ipopt_zU_out suffixes. This, not the row line \
1117 above, is the"
1118 );
1119 println!(" quantity a solver reports as `Complementarity`.");
1120 }
1121 }
1122 } else {
1123 println!();
1124 println!(" optimality: not checked (.sol carried no duals)");
1125 }
1126 println!();
1127 if o.verified && !args.require_optimal {
1128 if let Some(s) = o.stationarity_with_bound_multipliers {
1129 if s > o.opt_tol {
1130 println!(
1131 " CAVEAT: dual infeasibility {s:.3e} exceeds --opt-tol {:.1e}; VERIFIED here \
1132 means FEASIBLE only, not optimal (pass --require-optimal to gate on it).",
1133 o.opt_tol
1134 );
1135 }
1136 } else if let Some(s) = o.stationarity {
1137 if s > o.opt_tol {
1138 println!(
1139 " CAVEAT: KKT stationarity residual {s:.3e} exceeds --opt-tol {:.1e}; \
1140 VERIFIED here means FEASIBLE only, not optimal (pass --require-optimal to gate on it).",
1141 o.opt_tol
1142 );
1143 }
1144 }
1145 }
1146 let verdict = if o.verified {
1147 "VERIFIED — solution is feasible for the canonical problem".to_string()
1148 } else if !o.feasible
1149 && o.integrality
1150 .is_some_and(|(d, _)| d > o.abs_feas_tol.unwrap_or(o.feas_tol))
1151 && o.max_con_violation <= o.abs_feas_tol.unwrap_or(o.feas_tol)
1152 && o.max_bound_violation <= o.abs_feas_tol.unwrap_or(o.feas_tol)
1153 {
1154 "REJECTED — solution is not integer-feasible (declared integer columns are fractional)"
1155 .to_string()
1156 } else if !o.feasible {
1157 "REJECTED — solution VIOLATES the canonical constraints".to_string()
1158 } else if o.optimal.is_none() {
1159 "REJECTED — feasible, but --require-optimal needs duals and the .sol \
1163 carried none"
1164 .to_string()
1165 } else {
1166 "REJECTED — feasible but not first-order optimal (--require-optimal)".to_string()
1167 };
1168 println!(" VERDICT: {verdict}");
1169}
1170
1171fn print_row(label: &str, v: Number, worst: &Option<RowReport>) {
1172 match worst {
1173 Some(r) => println!(
1174 " {label}: {v:.3e} at {} (value {:.6e}, bounds [{:.6e}, {:.6e}])",
1175 r.name, r.value, r.lo, r.hi
1176 ),
1177 None => println!(" {label}: {v:.3e}"),
1178 }
1179}
1180
1181pub const KEY_ENV: &str = "POUNCE_VERIFY_KEY";
1184
1185pub fn signing_preimage(o: &VerifyOutcome) -> String {
1198 format!(
1199 "pounce-verify-receipt/v1\n\
1200 verify_version=1\n\
1201 nl_sha256={}\n\
1202 sol_sha256={}\n\
1203 n_vars={}\n\
1204 n_cons={}\n\
1205 feasible={}\n\
1206 verified={}\n\
1207 verdict={}\n",
1208 o.nl_sha256,
1209 o.sol_sha256,
1210 o.n_vars,
1211 o.n_cons,
1212 o.feasible,
1213 o.verified,
1214 if o.verified { "VERIFIED" } else { "REJECTED" },
1215 )
1216}
1217
1218fn receipt_json(args: &VerifyArgs, o: &VerifyOutcome) -> String {
1219 use serde_json::json;
1220 let worst_con = o.worst_con.as_ref().map(row_json);
1221 let worst_bound = o.worst_bound.as_ref().map(row_json);
1222 let optimality = if o.duals_present || o.bound_multipliers_present {
1223 let optimal = o.optimal.map(|opt| opt && o.feasible);
1232 json!({
1233 "available": true,
1234 "objective": o.objective,
1235 "stationarity_residual": o.stationarity,
1236 "dual_sign": o.dual_sign,
1237 "stationarity_residual_with_bound_multipliers":
1238 o.stationarity_with_bound_multipliers,
1239 "constraint_complementarity_residual": o.constraint_complementarity,
1240 "bound_complementarity_residual": o.bound_complementarity,
1241 "bound_multipliers_present": o.bound_multipliers_present,
1242 "complementarity_residual": o.constraint_complementarity,
1246 "optimal": optimal,
1247 "note": "`stationarity_residual` is the BOUND-PROJECTED dual infeasibility from \
1248 the .sol's constraint duals, with bound multipliers inferred from \
1249 activity; the sign is chosen to match the supplied dual convention. \
1250 `constraint_complementarity_residual` is max_i |lambda_i| * dist(g_i, \
1251 nearest finite side) over ROWS — it is NOT what a solver reports as \
1252 `Complementarity`. That is `bound_complementarity_residual`, \
1253 max_j max(|z_L*(x-x_L)|, |z_U*(x_U-x)|) over VARIABLES, available only \
1254 when the .sol carries the ipopt_zL_out/ipopt_zU_out suffixes (null \
1255 otherwise, meaning not checked — not zero). When those suffixes are \
1256 present, `stationarity_residual_with_bound_multipliers` is the exact, \
1257 unprojected residual and is what `--require-optimal` gates on. \
1258 `complementarity_residual` is a deprecated alias of \
1259 `constraint_complementarity_residual`. Feasibility is the rigorous \
1260 gate, and `optimal` is reported false for an infeasible point \
1261 regardless of its stationarity residual."
1262 })
1263 } else {
1264 json!({ "available": false })
1265 };
1266 let mut receipt = json!({
1267 "pounce_verify_version": 1,
1268 "solver": format!("pounce {}", env!("CARGO_PKG_VERSION")),
1269 "problem": {
1270 "path": args.nl.display().to_string(),
1271 "sha256": o.nl_sha256,
1272 "n_vars": o.n_vars,
1273 "n_cons": o.n_cons,
1274 },
1275 "solution": {
1276 "path": args.sol.display().to_string(),
1277 "sha256": o.sol_sha256,
1278 "claimed_solve_result_num": o.solve_result_num,
1279 "duals_present": o.duals_present,
1280 },
1281 "tolerances": {
1282 "feasibility": o.feas_tol,
1283 "absolute_feasibility": o.abs_feas_tol,
1284 "feasibility_mode": if o.abs_feas_tol.is_some() { "absolute" } else { "relative_per_row" },
1285 "optimality": o.opt_tol,
1286 },
1287 "integrality": match (o.discrete, o.integrality) {
1288 (Some(d), Some((v, j))) if d.total() > 0 => json!({
1289 "checked": true, "binary": d.binary, "integer": d.integer,
1290 "nonlinear": d.nonlinear_discrete(),
1291 "max_distance_to_integer": v, "worst_column": j,
1292 }),
1293 (Some(d), None) if d.total() > 0 => json!({
1294 "checked": false, "binary": d.binary, "integer": d.integer,
1295 "nonlinear": d.nonlinear_discrete(),
1296 }),
1297 _ => json!({ "checked": false, "binary": 0, "integer": 0, "nonlinear": 0 }),
1298 },
1299 "feasibility": {
1300 "max_constraint_violation": o.max_con_violation,
1301 "worst_constraint": worst_con,
1302 "max_bound_violation": o.max_bound_violation,
1303 "worst_bound": worst_bound,
1304 "feasible": o.feasible,
1305 },
1306 "optimality": optimality,
1307 "verdict": if o.verified { "VERIFIED" } else { "REJECTED" },
1308 "verified": o.verified,
1309 });
1310
1311 if let Ok(key) = std::env::var(KEY_ENV) {
1315 if !key.is_empty() {
1316 if let Some(obj) = receipt.as_object_mut() {
1317 let sig = sha256::hmac_hex(key.as_bytes(), signing_preimage(o).as_bytes());
1318 obj.insert("signature_alg".into(), json!("HMAC-SHA256"));
1319 obj.insert(
1320 "signed_fields".into(),
1321 json!([
1322 "verify_version",
1323 "nl_sha256",
1324 "sol_sha256",
1325 "n_vars",
1326 "n_cons",
1327 "feasible",
1328 "verified",
1329 "verdict"
1330 ]),
1331 );
1332 obj.insert("signature".into(), json!(sig));
1333 }
1334 }
1335 }
1336
1337 serde_json::to_string_pretty(&receipt).unwrap_or_else(|_| "{}".to_string())
1338}
1339
1340fn row_json(r: &RowReport) -> serde_json::Value {
1341 serde_json::json!({
1342 "index": r.index,
1343 "name": r.name,
1344 "value": r.value,
1345 "lower": r.lo,
1346 "upper": r.hi,
1347 "violation": r.violation,
1348 })
1349}
1350
1351pub mod sha256 {
1358 const K: [u32; 64] = [
1359 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
1360 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
1361 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
1362 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
1363 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
1364 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
1365 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
1366 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
1367 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
1368 0xc67178f2,
1369 ];
1370
1371 pub fn digest(data: &[u8]) -> [u8; 32] {
1373 let mut h: [u32; 8] = [
1374 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
1375 0x5be0cd19,
1376 ];
1377
1378 let bit_len = (data.len() as u64).wrapping_mul(8);
1380 let mut msg = data.to_vec();
1381 msg.push(0x80);
1382 while msg.len() % 64 != 56 {
1383 msg.push(0);
1384 }
1385 msg.extend_from_slice(&bit_len.to_be_bytes());
1386
1387 let mut w = [0u32; 64];
1388 for chunk in msg.chunks_exact(64) {
1389 for i in 0..16 {
1390 w[i] = u32::from_be_bytes([
1391 chunk[4 * i],
1392 chunk[4 * i + 1],
1393 chunk[4 * i + 2],
1394 chunk[4 * i + 3],
1395 ]);
1396 }
1397 for i in 16..64 {
1398 let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
1399 let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
1400 w[i] = w[i - 16]
1401 .wrapping_add(s0)
1402 .wrapping_add(w[i - 7])
1403 .wrapping_add(s1);
1404 }
1405
1406 let (mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh) =
1407 (h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7]);
1408 for i in 0..64 {
1409 let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
1410 let ch = (e & f) ^ ((!e) & g);
1411 let t1 = hh
1412 .wrapping_add(s1)
1413 .wrapping_add(ch)
1414 .wrapping_add(K[i])
1415 .wrapping_add(w[i]);
1416 let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
1417 let maj = (a & b) ^ (a & c) ^ (b & c);
1418 let t2 = s0.wrapping_add(maj);
1419 hh = g;
1420 g = f;
1421 f = e;
1422 e = d.wrapping_add(t1);
1423 d = c;
1424 c = b;
1425 b = a;
1426 a = t1.wrapping_add(t2);
1427 }
1428 h[0] = h[0].wrapping_add(a);
1429 h[1] = h[1].wrapping_add(b);
1430 h[2] = h[2].wrapping_add(c);
1431 h[3] = h[3].wrapping_add(d);
1432 h[4] = h[4].wrapping_add(e);
1433 h[5] = h[5].wrapping_add(f);
1434 h[6] = h[6].wrapping_add(g);
1435 h[7] = h[7].wrapping_add(hh);
1436 }
1437
1438 let mut out = [0u8; 32];
1439 for (i, word) in h.iter().enumerate() {
1440 out[4 * i..4 * i + 4].copy_from_slice(&word.to_be_bytes());
1441 }
1442 out
1443 }
1444
1445 fn to_hex(bytes: &[u8]) -> String {
1446 let mut out = String::with_capacity(bytes.len() * 2);
1447 for b in bytes {
1448 out.push_str(&format!("{b:02x}"));
1449 }
1450 out
1451 }
1452
1453 pub fn hex(data: &[u8]) -> String {
1455 to_hex(&digest(data))
1456 }
1457
1458 pub fn hmac(key: &[u8], msg: &[u8]) -> [u8; 32] {
1460 const BLOCK: usize = 64;
1461 let mut k = [0u8; BLOCK];
1462 if key.len() > BLOCK {
1463 k[..32].copy_from_slice(&digest(key));
1464 } else {
1465 k[..key.len()].copy_from_slice(key);
1466 }
1467 let mut ipad = [0x36u8; BLOCK];
1468 let mut opad = [0x5cu8; BLOCK];
1469 for i in 0..BLOCK {
1470 ipad[i] ^= k[i];
1471 opad[i] ^= k[i];
1472 }
1473 let mut inner = Vec::with_capacity(BLOCK + msg.len());
1474 inner.extend_from_slice(&ipad);
1475 inner.extend_from_slice(msg);
1476 let inner_digest = digest(&inner);
1477 let mut outer = Vec::with_capacity(BLOCK + 32);
1478 outer.extend_from_slice(&opad);
1479 outer.extend_from_slice(&inner_digest);
1480 digest(&outer)
1481 }
1482
1483 pub fn hmac_hex(key: &[u8], msg: &[u8]) -> String {
1485 to_hex(&hmac(key, msg))
1486 }
1487}
1488
1489#[cfg(test)]
1490mod tests {
1491 use super::*;
1492 use crate::nl_writer::{SolutionFile, format_sol};
1493 use pounce_common::types::{NLP_LOWER_BOUND_INF, NLP_UPPER_BOUND_INF};
1494
1495 #[test]
1496 fn sha256_known_answers() {
1497 assert_eq!(
1499 sha256::hex(b""),
1500 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1501 );
1502 assert_eq!(
1503 sha256::hex(b"abc"),
1504 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
1505 );
1506 assert_eq!(
1507 sha256::hex(b"The quick brown fox jumps over the lazy dog"),
1508 "d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592"
1509 );
1510 }
1511
1512 #[test]
1513 fn hmac_sha256_known_answers() {
1514 assert_eq!(
1516 sha256::hmac_hex(b"Jefe", b"what do ya want for nothing?"),
1517 "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
1518 );
1519 assert_eq!(
1521 sha256::hmac_hex(&[0x0b; 20], b"Hi There"),
1522 "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
1523 );
1524 }
1525
1526 #[test]
1527 fn parse_sol_round_trips_writer() {
1528 let message = format!(
1532 "POUNCE {}: Optimal Solution Found",
1533 env!("CARGO_PKG_VERSION")
1534 );
1535 let payload = SolutionFile {
1536 message: &message,
1537 x: &[1.0, 2.5, -0.5, 100.0],
1538 mult_g: &[0.1, -0.2],
1539 solve_result_num: 0,
1540 suffixes: &[],
1541 };
1542 let text = format_sol(&payload);
1543 let parsed = parse_sol(&text).expect("parse");
1544 assert_eq!(parsed.x.len(), 4);
1545 assert_eq!(parsed.lambda.len(), 2);
1546 assert!((parsed.x[1] - 2.5).abs() < 1e-15);
1547 assert!((parsed.x[3] - 100.0).abs() < 1e-12);
1548 assert!((parsed.lambda[0] + 0.1).abs() < 1e-15);
1555 assert!((parsed.lambda[1] - 0.2).abs() < 1e-15);
1556 assert_eq!(parsed.solve_result_num, Some(0));
1557 }
1558
1559 #[test]
1560 fn parse_sol_handles_no_duals() {
1561 let payload = SolutionFile {
1562 message: "msg",
1563 x: &[3.0, 4.0],
1564 mult_g: &[],
1565 solve_result_num: 200,
1566 suffixes: &[],
1567 };
1568 let text = format_sol(&payload);
1569 let parsed = parse_sol(&text).expect("parse");
1570 assert_eq!(parsed.x, vec![3.0, 4.0]);
1571 assert!(parsed.lambda.is_empty());
1572 assert_eq!(parsed.solve_result_num, Some(200));
1573 }
1574
1575 #[test]
1576 fn box_violation_basic() {
1577 assert_eq!(box_violation(5.0, 0.0, 10.0), 0.0);
1579 assert!((box_violation(-2.0, 0.0, 10.0) - 2.0).abs() < 1e-15);
1581 assert!((box_violation(13.0, 0.0, 10.0) - 3.0).abs() < 1e-15);
1583 assert_eq!(box_violation(1e30, 0.0, NLP_UPPER_BOUND_INF), 0.0);
1585 }
1586
1587 #[test]
1588 fn box_violation_rejects_non_finite() {
1589 assert_eq!(box_violation(Number::NAN, 0.0, 10.0), Number::INFINITY);
1594 assert_eq!(
1596 box_violation(Number::INFINITY, 0.0, NLP_UPPER_BOUND_INF),
1597 Number::INFINITY
1598 );
1599 assert_eq!(
1600 box_violation(Number::NEG_INFINITY, NLP_LOWER_BOUND_INF, 10.0),
1601 Number::INFINITY
1602 );
1603 }
1604
1605 #[test]
1614 fn a_bound_past_the_opposite_sentinel_still_scores_a_violation() {
1615 let v = box_violation(0.0, NLP_LOWER_BOUND_INF, -5.0e20);
1617 assert_eq!(
1618 v, 5.0e20,
1619 "0 is 5e20 above an upper bound of -5e20; scoring it 0.0 lets a \
1620 fabricated .sol past the feasibility gate"
1621 );
1622 assert_eq!(box_violation(0.0, 5.0e20, NLP_UPPER_BOUND_INF), 5.0e20);
1624 assert_eq!(box_violation(-6.0e20, NLP_LOWER_BOUND_INF, -5.0e20), 0.0);
1626 assert_eq!(
1628 box_violation(1e30, NLP_LOWER_BOUND_INF, NLP_UPPER_BOUND_INF),
1629 0.0
1630 );
1631 }
1632
1633 #[test]
1641 fn parse_sol_reads_the_bound_multiplier_suffixes() {
1642 use crate::nl_writer::{SolSuffix, SolSuffixTarget, SolSuffixValues};
1643 let payload = SolutionFile {
1644 message: "msg",
1645 x: &[1.0, -1.0, 0.0],
1646 mult_g: &[0.5],
1647 solve_result_num: 0,
1648 suffixes: &[
1649 SolSuffix {
1651 name: "sens_sol_state_1".to_string(),
1652 target: SolSuffixTarget::Var,
1653 values: SolSuffixValues::Real(vec![9.0, 9.0, 9.0]),
1654 },
1655 SolSuffix {
1656 name: "ipopt_zL_out".to_string(),
1657 target: SolSuffixTarget::Var,
1658 values: SolSuffixValues::Real(vec![0.0, 2.0, 0.0]),
1659 },
1660 SolSuffix {
1661 name: "ipopt_zU_out".to_string(),
1662 target: SolSuffixTarget::Var,
1663 values: SolSuffixValues::Real(vec![-4.0, 0.0, 0.0]),
1664 },
1665 ],
1666 };
1667 let parsed = parse_sol(&format_sol(&payload)).expect("parse");
1668 assert_eq!(parsed.solve_result_num, Some(0), "objno still parses");
1669 assert_eq!(parsed.z_l, Some(vec![0.0, 2.0, 0.0]));
1672 assert_eq!(parsed.z_u, Some(vec![-4.0, 0.0, 0.0]));
1673 }
1674
1675 #[test]
1678 fn parse_sol_reports_absent_bound_multipliers_as_absent() {
1679 let payload = SolutionFile {
1680 message: "msg",
1681 x: &[1.0],
1682 mult_g: &[0.5],
1683 solve_result_num: 0,
1684 suffixes: &[],
1685 };
1686 let parsed = parse_sol(&format_sol(&payload)).expect("parse");
1687 assert!(parsed.z_l.is_none() && parsed.z_u.is_none());
1688 }
1689
1690 #[test]
1698 fn bound_complementarity_is_z_times_slack_over_variables() {
1699 let x_l = [NLP_LOWER_BOUND_INF, -1.0];
1700 let x_u = [1.0, NLP_UPPER_BOUND_INF];
1701 assert_eq!(
1703 bound_complementarity(&[1.0, -1.0], &x_l, &x_u, &[0.0, 2.0], &[-4.0, 0.0]),
1704 0.0
1705 );
1706 let c = bound_complementarity(&[0.999, -1.0], &x_l, &x_u, &[0.0, 2.0], &[-4.0, 0.0]);
1709 assert!((c - 4.0e-3).abs() < 1e-12, "got {c}");
1710 let flipped = bound_complementarity(&[0.999, -1.0], &x_l, &x_u, &[0.0, 2.0], &[4.0, 0.0]);
1711 assert_eq!(c, flipped, "magnitudes only — no sign convention assumed");
1712 assert_eq!(
1715 bound_complementarity(
1716 &[0.0],
1717 &[NLP_LOWER_BOUND_INF],
1718 &[NLP_UPPER_BOUND_INF],
1719 &[1e6],
1720 &[1e6]
1721 ),
1722 0.0
1723 );
1724 }
1725
1726 #[test]
1731 fn exact_dual_infeasibility_sees_what_the_projection_hides() {
1732 let s = [-4.0];
1735 let x = [1.0];
1736 let x_l = [NLP_LOWER_BOUND_INF];
1737 let x_u = [1.0];
1738 assert_eq!(exact_dual_infeasibility(&s, &[0.0], &[-4.0], 1.0), 0.0);
1739
1740 assert_eq!(bound_projected_residual(&s, &x, &x_l, &x_u, 1e-6), 0.0);
1744 assert_eq!(exact_dual_infeasibility(&s, &[0.0], &[0.0], 1.0), 4.0);
1746 assert_eq!(exact_dual_infeasibility(&s, &[0.0], &[4.0], 1.0), 8.0);
1748 }
1749
1750 #[test]
1758 fn a_barrier_stand_off_from_a_bound_is_still_active() {
1759 let x_l = [-0.1276];
1760 let x_u = [0.2189];
1761 let s = [-0.4399];
1762 let near = [0.2189 - 1.9e-8];
1763 assert_eq!(bound_projected_residual(&s, &near, &x_l, &x_u, 1e-6), 0.0);
1764 let inside = [0.05];
1765 assert_eq!(
1766 bound_projected_residual(&s, &inside, &x_l, &x_u, 1e-6),
1767 0.4399
1768 );
1769 assert_eq!(
1772 bound_projected_residual(&[0.4399], &near, &x_l, &x_u, 1e-6),
1773 0.4399
1774 );
1775 }
1776
1777 #[test]
1784 fn row_and_bound_complementarity_are_different_quantities() {
1785 let rows = row_complementarity(&[1.0], &[4.5e-2], &[0.0], &[NLP_UPPER_BOUND_INF]);
1788 assert!((rows - 4.5e-2).abs() < 1e-15);
1789 let bounds = bound_complementarity(
1792 &[1.0],
1793 &[NLP_LOWER_BOUND_INF],
1794 &[1.0 + 1e-11],
1795 &[0.0],
1796 &[-1.0],
1797 );
1798 assert!(bounds < 1e-10, "got {bounds}");
1799 assert!(
1800 rows / bounds > 1e8,
1801 "the two must not be read as one number"
1802 );
1803 }
1804
1805 #[test]
1809 fn row_magnitude_counts_a_bound_past_the_opposite_sentinel() {
1810 assert_eq!(
1811 row_magnitude(1.0, NLP_LOWER_BOUND_INF, -5.0e20),
1812 5.0e20,
1813 "the row's own upper bound is its magnitude"
1814 );
1815 assert_eq!(row_magnitude(1.0, 5.0e20, NLP_UPPER_BOUND_INF), 5.0e20);
1816 assert_eq!(
1818 row_magnitude(3.0, NLP_LOWER_BOUND_INF, NLP_UPPER_BOUND_INF),
1819 3.0
1820 );
1821 }
1822}