pounce_solve_report/console.rs
1//! Ipopt-style banner / problem-stats / final-summary printing,
2//! shared by the `pounce` CLI (which re-exports this module as
3//! `print`) and the Python bindings (`pounce.print_banner`,
4//! `Problem.print_problem_stats`, `Solver.print_summary`). Output is
5//! structured to match upstream Ipopt's console layout closely enough
6//! that anyone familiar with `ipopt` can spot at a glance whether
7//! POUNCE is converging similarly.
8
9use pounce_common::types::Number;
10use pounce_nlp::return_codes::ApplicationReturnStatus;
11use pounce_nlp::solve_statistics::SolveStatistics;
12use pounce_nlp::tnlp::{IndexStyle, NlpInfo, SparsityRequest, TNLP};
13use pounce_nlp::tnlp_adapter::{FixedVarTreatment, TNLPAdapter};
14use std::cell::RefCell;
15use std::rc::Rc;
16
17/// Same sentinel Ipopt uses for "no bound": ±1e19. Only referenced by the
18/// unit tests now that `collect_stats` derives bounds from the adapter
19/// classification rather than re-thresholding raw bounds itself.
20#[cfg(test)]
21const BOUND_INF: f64 = 1.0e19;
22
23#[derive(Debug, Clone, Copy)]
24pub struct ProblemStats {
25 pub n: i32,
26 pub m: i32,
27 pub nnz_jac_eq: i32,
28 pub nnz_jac_ineq: i32,
29 pub nnz_h_lag: i32,
30 pub var_lower_only: i32,
31 pub var_upper_only: i32,
32 pub var_both: i32,
33 pub var_free: i32,
34 pub n_eq: i32,
35 pub n_ineq: i32,
36 pub ineq_lower_only: i32,
37 pub ineq_upper_only: i32,
38 pub ineq_both: i32,
39}
40
41/// Gather everything the banner block needs, reported over the **reduced**
42/// problem that the algorithm actually solves — i.e. after
43/// `fixed_variable_treatment` removes fixed (`x_l == x_u`) variables under
44/// `make_parameter`. This mirrors Ipopt, whose banner is computed from the
45/// post-`IpTNLPAdapter` problem; computing it from the raw TNLP instead made
46/// pounce over-report variables and bucket fixed vars as "lower and upper
47/// bounds" (#140).
48///
49/// To stay byte-for-byte consistent with the solve, the counts are taken from
50/// a throwaway [`TNLPAdapter`] built with the same options — reusing the exact
51/// production classification (including the `make_parameter → relax_bounds`
52/// auto-switch). The Jacobian / Hessian nnz are read from the raw structure and
53/// filtered to drop entries in fixed-variable columns (the columns Ipopt
54/// removes). Returns `None` if any required TNLP call fails.
55pub fn collect_stats(
56 tnlp: &Rc<RefCell<dyn TNLP>>,
57 lo_inf: Number,
58 up_inf: Number,
59 fixed_treatment: FixedVarTreatment,
60) -> Option<ProblemStats> {
61 let adapter =
62 TNLPAdapter::new_with_options(Rc::clone(tnlp), lo_inf, up_inf, fixed_treatment).ok()?;
63 let cls = adapter.classification();
64 let info: NlpInfo = *adapter.nlp_info();
65 let n_full_x = cls.n_full_x as usize;
66 let m = info.m as usize;
67 let one_based = matches!(info.index_style, IndexStyle::Fortran);
68
69 // --- Variable bound buckets over the reduced (non-fixed) variable set.
70 // `x_l_map` / `x_u_map` hold positions in `x_var` that carry a finite
71 // lower / upper bound. A fixed var under `make_parameter` is absent from
72 // both (it was dropped from `x_var`); under `relax_bounds` it lands in
73 // both — matching Ipopt's banner in either mode.
74 let nv = cls.n_x_var() as usize;
75 let mut has_l = vec![false; nv];
76 let mut has_u = vec![false; nv];
77 for &p in &cls.x_l_map {
78 has_l[p as usize] = true;
79 }
80 for &p in &cls.x_u_map {
81 has_u[p as usize] = true;
82 }
83 let (mut var_lower_only, mut var_upper_only, mut var_both, mut var_free) = (0, 0, 0, 0);
84 for k in 0..nv {
85 match (has_l[k], has_u[k]) {
86 (true, true) => var_both += 1,
87 (true, false) => var_lower_only += 1,
88 (false, true) => var_upper_only += 1,
89 (false, false) => var_free += 1,
90 }
91 }
92
93 // --- Constraint counts / inequality bound buckets, straight from the
94 // classification (equality = `c_map`, inequality = `d_map`).
95 let n_eq = cls.n_c;
96 let n_ineq = cls.n_d;
97 let nd = cls.n_d as usize;
98 let mut has_dl = vec![false; nd];
99 let mut has_du = vec![false; nd];
100 for &p in &cls.d_l_map {
101 has_dl[p as usize] = true;
102 }
103 for &p in &cls.d_u_map {
104 has_du[p as usize] = true;
105 }
106 let (mut ineq_lower_only, mut ineq_upper_only, mut ineq_both) = (0, 0, 0);
107 for k in 0..nd {
108 match (has_dl[k], has_du[k]) {
109 (true, true) => ineq_both += 1,
110 (true, false) => ineq_lower_only += 1,
111 (false, true) => ineq_upper_only += 1,
112 // A "free" inequality has no finite bound on either side (e.g. an
113 // `.nl` range row left fully open). It is still counted in
114 // `n_ineq`, so bucket it under "both" or the printed breakdown
115 // won't sum to the total.
116 (false, false) => ineq_both += 1,
117 }
118 }
119
120 // Which raw rows are equality rows (for the Jacobian split).
121 let mut row_is_eq = vec![false; m];
122 for &r in &cls.c_map {
123 row_is_eq[r as usize] = true;
124 }
125
126 // --- Jacobian split: read the raw structure once, drop fixed-variable
127 // columns (the ones `make_parameter` removes), tally per-row.
128 let nnz_total = info.nnz_jac_g as usize;
129 let (mut nnz_jac_eq, mut nnz_jac_ineq) = (0, 0);
130 if nnz_total > 0 && m > 0 {
131 let mut irow = vec![0_i32; nnz_total];
132 let mut jcol = vec![0_i32; nnz_total];
133 let mut t = tnlp.borrow_mut();
134 if t.eval_jac_g(
135 None,
136 true,
137 SparsityRequest::Structure {
138 irow: &mut irow,
139 jcol: &mut jcol,
140 },
141 ) {
142 for k in 0..nnz_total {
143 let col = if one_based {
144 (jcol[k] - 1) as usize
145 } else {
146 jcol[k] as usize
147 };
148 // Skip nonzeros in fixed-variable columns: `full_to_var[col]`
149 // is `-1` for a dropped fixed var.
150 if col >= n_full_x || cls.full_to_var[col] < 0 {
151 continue;
152 }
153 let row = if one_based {
154 (irow[k] - 1) as usize
155 } else {
156 irow[k] as usize
157 };
158 if row < m && row_is_eq[row] {
159 nnz_jac_eq += 1;
160 } else {
161 nnz_jac_ineq += 1;
162 }
163 }
164 }
165 }
166
167 // --- Hessian nnz over the reduced problem: drop any entry touching a
168 // fixed-variable row or column. If the TNLP supplies no Hessian
169 // (`eval_h` → false), fall back to the raw count.
170 let mut nnz_h_lag = info.nnz_h_lag;
171 let nnz_h = info.nnz_h_lag as usize;
172 if nnz_h > 0 {
173 let mut irow = vec![0_i32; nnz_h];
174 let mut jcol = vec![0_i32; nnz_h];
175 let mut t = tnlp.borrow_mut();
176 if t.eval_h(
177 None,
178 true,
179 1.0,
180 None,
181 true,
182 SparsityRequest::Structure {
183 irow: &mut irow,
184 jcol: &mut jcol,
185 },
186 ) {
187 let mut kept = 0_i32;
188 for k in 0..nnz_h {
189 let r = if one_based {
190 (irow[k] - 1) as usize
191 } else {
192 irow[k] as usize
193 };
194 let c = if one_based {
195 (jcol[k] - 1) as usize
196 } else {
197 jcol[k] as usize
198 };
199 if r < n_full_x
200 && c < n_full_x
201 && cls.full_to_var[r] >= 0
202 && cls.full_to_var[c] >= 0
203 {
204 kept += 1;
205 }
206 }
207 nnz_h_lag = kept;
208 }
209 }
210
211 Some(ProblemStats {
212 n: cls.n_x_var(),
213 m: info.m,
214 nnz_jac_eq,
215 nnz_jac_ineq,
216 nnz_h_lag,
217 var_lower_only,
218 var_upper_only,
219 var_both,
220 var_free,
221 n_eq,
222 n_ineq,
223 ineq_lower_only,
224 ineq_upper_only,
225 ineq_both,
226 })
227}
228
229/// POUNCE wordmark in block letters, printed above the copyright banner.
230const LOGO: [&str; 5] = [
231 "#### ### # # # # #### #####",
232 "# # # # # # ## # # # ",
233 "#### # # # # # # # # #### ",
234 "# # # # # # ## # # ",
235 "# ### ### # # #### #####",
236];
237
238/// Width of the copyright banner's asterisk rules — wide enough to span
239/// the longest banner text line. The wordmark is centered against this,
240/// and a matching rule is printed above it.
241const BANNER_WIDTH: usize = 80;
242
243/// Print the branded POUNCE ASCII wordmark, mimicking the project logo.
244///
245/// Block letters get a top-lit **steel** sheen (light silver → dark
246/// steel down the rows); three diagonal **molten claw** slashes rake
247/// upper-right → lower-left, glowing bright gold at the top into deep
248/// red at the bottom — the brand logo's look. Emitted through
249/// `anstream::stdout()`, which strips the ANSI when stdout is redirected
250/// or `NO_COLOR` is set (non-TTY sinks get the plain text), with a
251/// 256-color downgrade on non-truecolor terminals. The metallic letters
252/// are tuned for a dark terminal background.
253pub fn print_logo() {
254 use std::io::Write as _;
255 let width = LOGO
256 .iter()
257 .map(|l| l.chars().count())
258 .max()
259 .unwrap_or(1)
260 .max(2);
261 let mut out = anstream::stdout();
262 // Leading rule matching the copyright banner's width, then a blank
263 // line, then the centered wordmark. The rule is left in the terminal's
264 // default color (like the banner's own rules) so it stays distinct on
265 // any background. `anstream` strips the styling when stdout isn't a TTY.
266 let _ = writeln!(out, "{}", "*".repeat(BANNER_WIDTH));
267 let _ = writeln!(out);
268 let pad = " ".repeat(BANNER_WIDTH.saturating_sub(width) / 2);
269 for row in logo_rows(true) {
270 let _ = writeln!(out, "{pad}{row}");
271 }
272 let _ = writeln!(out);
273}
274
275/// Render the POUNCE wordmark as styled rows (one `String` per line):
276/// steel-sheen letters with three molten claw slashes, in the project
277/// palette. Emits ANSI styling only when `color`; otherwise plain
278/// `#`/`/` block characters. Shared by the solve header ([`print_logo`])
279/// and the interactive debugger's open banner (rendered to stderr).
280pub fn logo_rows(color: bool) -> Vec<String> {
281 use pounce_common::style::{ALPHA_HOT, BRIGHT_YEL, TIGER_ORANGE, downgrade, truecolor_enabled};
282
283 fn lerp(a: u8, b: u8, t: f64) -> u8 {
284 (a as f64 + (b as f64 - a as f64) * t)
285 .round()
286 .clamp(0.0, 255.0) as u8
287 }
288 fn mix(a: anstyle::RgbColor, b: anstyle::RgbColor, t: f64) -> anstyle::RgbColor {
289 anstyle::RgbColor(lerp(a.0, b.0, t), lerp(a.1, b.1, t), lerp(a.2, b.2, t))
290 }
291 // Steel sheen (top-lit): light silver at the top row → dark steel at
292 // the bottom. Molten ramp: gold → tiger-orange → deep red top-to-bottom.
293 const STEEL_HI: anstyle::RgbColor = anstyle::RgbColor(0xd2, 0xd6, 0xdc);
294 const STEEL_LO: anstyle::RgbColor = anstyle::RgbColor(0x5c, 0x60, 0x68);
295
296 let rows = LOGO.len();
297 let width = LOGO
298 .iter()
299 .map(|l| l.chars().count())
300 .max()
301 .unwrap_or(1)
302 .max(2);
303 let vfrac = |r: usize| {
304 if rows <= 1 {
305 0.0
306 } else {
307 r as f64 / (rows - 1) as f64
308 }
309 };
310 // Molten color for a claw cell at row `r` (0 = top, hottest).
311 let molten = |r: usize| {
312 let t = vfrac(r);
313 if t < 0.5 {
314 mix(BRIGHT_YEL, TIGER_ORANGE, t / 0.5)
315 } else {
316 mix(TIGER_ORANGE, ALPHA_HOT, (t - 0.5) / 0.5)
317 }
318 };
319
320 let mut grid: Vec<Vec<Option<(char, anstyle::RgbColor)>>> = vec![vec![None; width]; rows];
321 for (r, line) in LOGO.iter().enumerate() {
322 let steel = mix(STEEL_HI, STEEL_LO, vfrac(r));
323 for (c, ch) in line.chars().enumerate() {
324 if ch != ' ' {
325 grid[r][c] = Some((ch, steel));
326 }
327 }
328 }
329 // Three parallel molten claw slashes, upper-right → lower-left (`/`).
330 for &start in &[width / 4, width / 4 + 6, width / 4 + 12] {
331 for r in 0..rows {
332 let c = start + (rows - 1 - r);
333 if c < width {
334 grid[r][c] = Some(('/', molten(r)));
335 }
336 }
337 }
338
339 let truecolor = truecolor_enabled();
340 grid.iter()
341 .map(|row| {
342 let mut rendered = String::new();
343 for cell in row {
344 match cell {
345 Some((ch, rgb)) if color => {
346 let style = anstyle::Style::new()
347 .bold()
348 .fg_color(Some(downgrade(*rgb, truecolor)));
349 rendered.push_str(&format!(
350 "{}{}{}",
351 style.render(),
352 ch,
353 style.render_reset()
354 ));
355 }
356 Some((ch, _)) => rendered.push(*ch),
357 None => rendered.push(' '),
358 }
359 }
360 rendered.trim_end().to_string()
361 })
362 .collect()
363}
364
365pub fn print_banner(linear_solver: &str) {
366 use std::io::IsTerminal as _;
367
368 // OSC 8 hyperlink so supporting terminals make the URL clickable;
369 // only emitted to a TTY so redirected output stays plain text.
370 const URL: &str = "https://github.com/jkitchin/pounce";
371 let link = if std::io::stdout().is_terminal() {
372 format!("\x1b]8;;{URL}\x1b\\{URL}\x1b]8;;\x1b\\")
373 } else {
374 URL.to_string()
375 };
376
377 let rule = "*".repeat(BANNER_WIDTH);
378 println!("{rule}");
379 println!("This program contains POUNCE, a pure-Rust interior-point optimization solver");
380 println!("for nonlinear, conic, and global problems (its NLP core is ported from Ipopt).");
381 println!("Released under the Eclipse Public License (EPL) — drop-in compatible with Ipopt.");
382 println!(" For more information visit {link}");
383 println!("{rule}");
384 println!();
385 println!(
386 "This is POUNCE version {}, running with linear solver {}.",
387 env!("CARGO_PKG_VERSION"),
388 linear_solver
389 );
390 println!();
391}
392
393pub fn print_problem_stats(s: &ProblemStats) {
394 println!(
395 "Number of nonzeros in equality constraint Jacobian...: {:>8}",
396 s.nnz_jac_eq
397 );
398 println!(
399 "Number of nonzeros in inequality constraint Jacobian.: {:>8}",
400 s.nnz_jac_ineq
401 );
402 println!(
403 "Number of nonzeros in Lagrangian Hessian.............: {:>8}",
404 s.nnz_h_lag
405 );
406 println!();
407 println!(
408 "Total number of variables............................: {:>8}",
409 s.n
410 );
411 println!(
412 " variables with only lower bounds: {:>8}",
413 s.var_lower_only
414 );
415 println!(
416 " variables with lower and upper bounds: {:>8}",
417 s.var_both
418 );
419 println!(
420 " variables with only upper bounds: {:>8}",
421 s.var_upper_only
422 );
423 println!(
424 "Total number of equality constraints.................: {:>8}",
425 s.n_eq
426 );
427 println!(
428 "Total number of inequality constraints...............: {:>8}",
429 s.n_ineq
430 );
431 println!(
432 " inequality constraints with only lower bounds: {:>8}",
433 s.ineq_lower_only
434 );
435 println!(
436 " inequality constraints with lower and upper bounds: {:>8}",
437 s.ineq_both
438 );
439 println!(
440 " inequality constraints with only upper bounds: {:>8}",
441 s.ineq_upper_only
442 );
443 println!();
444}
445
446/// Evaluation-callback tallies for the end-of-run summary, decoupled
447/// from any particular TNLP wrapper so both the CLI's `CountingTnlp`
448/// and the Python bindings' callback problem can supply them.
449#[derive(Debug, Clone, Copy, Default)]
450pub struct EvalCounts {
451 pub n_obj: u64,
452 pub n_grad_f: u64,
453 pub n_g: u64,
454 pub n_jac_g: u64,
455 pub n_h: u64,
456}
457
458/// Emit the "the point is outside the model you wrote" warning, in bold red.
459///
460/// This is the one line in the summary block that reports a *defect in the
461/// answer* rather than a residual of the solve, and it is easy to read past in
462/// a wall of sixteen-digit residuals — which is the whole failure mode it
463/// exists to catch: on the LISWET/YAO family a `1e-8` widening moves the
464/// optimum by 25% under `EXIT: Optimal Solution Found`, and the only trace in
465/// the log is a number nobody looks at.
466///
467/// Written through `anstream::stdout()`, which strips the ANSI when stdout is
468/// not a TTY or `NO_COLOR` is set — so redirected logs, the benchmark
469/// harness's scrapes and every `assert!(stdout.contains(...))` in the test
470/// suite see the plain text, byte-for-byte as before. It is deliberately kept
471/// *out* of the upstream-compatible residual table above: that block is
472/// diffed against `ipopt`'s own output, and this line has no counterpart
473/// there to diff against.
474fn print_declared_violation(value: Number) {
475 use std::io::Write as _;
476 let style = anstyle::Style::new()
477 .bold()
478 .fg_color(Some(anstyle::AnsiColor::Red.into()));
479 let mut out = anstream::stdout();
480 let _ = writeln!(out);
481 let _ = writeln!(
482 out,
483 "{}Violation of the model as declared (before the \
484 bound_relax_factor widening): {}{}",
485 style.render(),
486 fmt_ipopt(value),
487 style.render_reset()
488 );
489}
490
491pub fn print_summary(
492 status: ApplicationReturnStatus,
493 stats: &SolveStatistics,
494 counters: &EvalCounts,
495) {
496 println!();
497 println!();
498 println!("Number of Iterations....: {}", stats.iteration_count);
499 println!();
500 println!(" (scaled) (unscaled)");
501 let row = |label: &str, scaled: f64, unscaled: f64| {
502 println!(
503 "{label}: {} {}",
504 fmt_ipopt(scaled),
505 fmt_ipopt(unscaled)
506 );
507 };
508 row(
509 "Objective...............",
510 stats.final_scaled_objective,
511 stats.final_objective,
512 );
513 // The residual rows carry genuinely different values in the two columns
514 // whenever `nlp_scaling` is active, and printing the scaled number twice
515 // hid exactly the discrepancy that matters: on gh #200's `quartc` the
516 // objective row correctly showed `2.49e-06` / `2.49e+02` while dual
517 // infeasibility read `8.38e-09` in *both* columns — when the unscaled value
518 // is `0.84`, eight orders above it. A user auditing a suspicious
519 // certificate was shown a report that agreed with the certificate. The
520 // unscaled statistics were already computed and already surfaced through
521 // the Python bindings; only the console dropped them.
522 row(
523 "Dual infeasibility......",
524 stats.final_dual_inf,
525 stats.final_unscaled_dual_inf,
526 );
527 row(
528 "Constraint violation....",
529 stats.final_constr_viol,
530 stats.final_unscaled_constr_viol,
531 );
532 // Was a hardcoded `0.0` until gh#900: the row asserted a measurement the
533 // solver never took, and it read `0.00e+00` on exactly the solves where it
534 // matters — a `bound_relax_factor`-widened run whose point sits outside
535 // the box the caller wrote. Variable bounds carry no scaling (POUNCE
536 // scales the objective and the constraint rows only), so the one number is
537 // correct in both columns rather than being duplicated for want of a
538 // second.
539 row(
540 "Variable bound violation",
541 stats.final_declared_box_viol,
542 stats.final_declared_box_viol,
543 );
544 row(
545 "Complementarity.........",
546 stats.final_compl,
547 stats.final_unscaled_compl,
548 );
549 row(
550 "Overall NLP error.......",
551 stats.final_kkt_error,
552 stats.final_unscaled_kkt_error,
553 );
554 // gh #528. The strict gate judges the primal term against what each row
555 // can actually represent in floating point, so on a model whose constraint
556 // values run to `~1e8` the raw error above can sit above `tol` beside an
557 // `EXIT: Optimal Solution Found`. Print the number that was tested, but
558 // only when it differs — every `O(1)` model, and every run with the floor
559 // switched off, keeps the summary block byte-identical to upstream's.
560 if stats.final_kkt_error_above_noise.is_finite()
561 && stats.final_kkt_error.is_finite()
562 && stats.final_kkt_error_above_noise != stats.final_kkt_error
563 {
564 println!(
565 " ...above the per-row floating-point noise floor: {}",
566 fmt_ipopt(stats.final_kkt_error_above_noise),
567 );
568 println!(
569 " (the strict convergence test judges this value; the residual \
570 below it is finer than the row's own arithmetic can resolve. \
571 Set primal_noise_floor_kappa = 0 to disable.)"
572 );
573 }
574 // How far outside the model AS DECLARED the returned point sits. This arm
575 // applies the `bound_relax_factor` widening by design -- a feasible-iterate
576 // log-barrier needs `x` strictly inside its bounds -- so the residuals
577 // above are honest residuals of the WIDENED model, and on a row-degenerate
578 // one the two differ by orders: netlib `wood1p` prints `1.71e-14` above at
579 // a point `9.84e-09` outside the declared model. Printed only when a
580 // widening was applied and it actually moved the number, so the block
581 // stays byte-identical to upstream's on everything else.
582 if stats.final_declared_constr_viol.is_finite()
583 && stats.final_declared_constr_viol > stats.final_constr_viol * 10.0
584 && stats.final_declared_constr_viol > 0.0
585 {
586 print_declared_violation(stats.final_declared_constr_viol);
587 }
588 println!();
589 println!();
590 println!(
591 "Number of objective function evaluations = {}",
592 counters.n_obj
593 );
594 println!(
595 "Number of objective gradient evaluations = {}",
596 counters.n_grad_f
597 );
598 println!(
599 "Number of equality constraint evaluations = {}",
600 counters.n_g
601 );
602 println!(
603 "Number of inequality constraint evaluations = {}",
604 counters.n_g
605 );
606 println!(
607 "Number of equality constraint Jacobian evaluations = {}",
608 counters.n_jac_g
609 );
610 println!(
611 "Number of inequality constraint Jacobian evaluations = {}",
612 counters.n_jac_g
613 );
614 println!(
615 "Number of Lagrangian Hessian evaluations = {}",
616 counters.n_h
617 );
618 // gh #819. `Number of Iterations` above is the index of the last row the
619 // iteration table printed, `r` rows included — Ipopt's rule, and now
620 // POUNCE's on every exit path. That total is the right headline, but it
621 // hides the split, and the split is what tells a reader whether an
622 // eleven-second solve was eleven seconds of Newton steps or a restoration
623 // grind. Printed only when restoration actually ran, so a summary from a
624 // solve that never entered it stays byte-identical to upstream's.
625 if stats.restoration_inner_iters > 0 || stats.restoration_calls > 0 {
626 println!(
627 "Number of restoration iterations = {} (in {} call{})",
628 stats.restoration_inner_iters,
629 stats.restoration_calls,
630 if stats.restoration_calls == 1 {
631 ""
632 } else {
633 "s"
634 },
635 );
636 }
637 // gh#857. Printed only when the linear solver actually escalated, so a
638 // summary from a solve that never did stays byte-identical to
639 // upstream's — the same rule the restoration split above follows.
640 // Worth a line of its own because an escalation is not visible
641 // anywhere else in a default-verbosity run: it reroutes the rest of
642 // the solve (FERAL's ladder changes which pivots are taken and never
643 // steps back down), and until this line a reader comparing two runs
644 // had no way to tell that apart from an ordinary trajectory
645 // difference.
646 if stats.quality_escalations > 0 {
647 println!(
648 "Number of linear solver quality escalations = {}",
649 stats.quality_escalations,
650 );
651 }
652 // gh#884. Same rule: printed only when it happened, so a summary from
653 // a solve that never saw the signature stays byte-identical to
654 // upstream's. Worth its own line for the same reason as the
655 // escalation count — a promoted retry reports the *retry's* iteration
656 // count and residuals, so without this the second solve leaves no
657 // trace at all, and a reader comparing two runs would see a
658 // trajectory that came from nowhere.
659 //
660 // This line reports the **signature**, never the retry's verdict, and
661 // that is forced rather than chosen: this block runs once per attempt,
662 // from inside the solve, while the promotion is decided after the last
663 // attempt returns. `dual_divergence_retry_promoted` therefore reads
664 // `false` here even on the run that promotes, and an earlier draft that
665 // printed it produced a summary contradicting the JSON report beside
666 // it. `run_with_dual_divergence_retry` prints the verdict, being the
667 // only place that knows it.
668 //
669 // Two further narrowings. No number is printed: the flag is sticky
670 // across the attempts of one solve, so on a promoted run it is `true`
671 // while *this* attempt's residuals are clean, and a residual beside the
672 // word "detected" would read as its evidence while belonging to the
673 // other attempt. And it is suppressed on `Solve_Succeeded`, because
674 // passing *through* a biactive runaway and recovering is routine on an
675 // MPCC lowering — `mpcc_qpec_small_biactive.nl` sets the flag at
676 // default options and then converges in 29 iterations. A warning over a
677 // correct answer is noise.
678 if stats.dual_divergence_signature && !matches!(status, ApplicationReturnStatus::SolveSucceeded)
679 {
680 println!("Biactive dual divergence (gh#884) = detected");
681 }
682 println!(
683 "Total seconds in POUNCE = {:.3}",
684 stats.total_wallclock_time_secs
685 );
686 println!();
687 println!("EXIT: {}", status_message(status));
688 println!();
689 println!(
690 "POUNCE {}: {}",
691 env!("CARGO_PKG_VERSION"),
692 status_message(status)
693 );
694}
695
696/// Emit an Ipopt-style end-of-run summary for the dedicated convex
697/// (LP / QP / conic) IPM path. That path otherwise prints only a compact
698/// one-line result, so the `Number of Iterations....:` and
699/// `Objective...............:` lines the general NLP path emits are missing.
700/// Downstream consumers that parse Ipopt's summary block — notably the
701/// benchmark harness's `extract_obj`/`extract_iters` in
702/// `benchmarks/scripts/run_nl_bench.sh` — then see a null objective and zero
703/// iterations even though the solve succeeded. This prints the same labelled
704/// lines (objective + KKT residual rows) so those consumers capture the real
705/// values. The convex solver reports a single (unscaled, user-sense) objective
706/// and residuals, so the "(scaled)"/"(unscaled)" columns carry the same value.
707pub fn print_convex_summary(
708 iterations: usize,
709 objective: f64,
710 primal_inf: f64,
711 dual_inf: f64,
712 complementarity: f64,
713 kkt_error: f64,
714 // How far the returned `x` sits outside the **declared** variable box —
715 // `QpResiduals::bound_violation` measured against a re-extraction at
716 // `BoundRelax::NONE`, falling back to the solved model's own box when no
717 // widening was applied (the two coincide there). Ipopt's `Variable bound
718 // violation` line, which this arm printed as a hardcoded `0.0` until
719 // gh#900.
720 bound_violation: f64,
721 // How far outside the model AS DECLARED the point sits, when the solve
722 // applied the `bound_relax_factor` widening and the two differ. The
723 // residuals above measure the widened model the solver was handed — the
724 // model its convergence test is about — so without this line a reader
725 // takes `Constraint violation....: 8.68e-13` for their own model's
726 // feasibility when the point is `4.99e-06` outside a declared row
727 // (netlib `afiro`, gh #744/#745). `None` when no widening applied.
728 declared_primal_inf: Option<f64>,
729) {
730 println!();
731 println!();
732 println!("Number of Iterations....: {iterations}");
733 println!();
734 println!(" (scaled) (unscaled)");
735 let row = |label: &str, v: f64| {
736 println!("{label}: {} {}", fmt_ipopt(v), fmt_ipopt(v));
737 };
738 row("Objective...............", objective);
739 row("Dual infeasibility......", dual_inf);
740 row("Constraint violation....", primal_inf);
741 row("Variable bound violation", bound_violation);
742 row("Complementarity.........", complementarity);
743 row("Overall NLP error.......", kkt_error);
744 // Only when the widening actually moved the number: on the vast majority
745 // of models the point satisfies the declared rows to the same order and
746 // an extra line would be noise.
747 if let Some(d) = declared_primal_inf {
748 if d > primal_inf * 10.0 && d > 0.0 {
749 print_declared_violation(d);
750 }
751 }
752 println!();
753}
754
755/// The end-of-run verdict block for the convex path: wall-clock total,
756/// `EXIT:` banner, and the `POUNCE <version>:` line — the same three lines,
757/// in the same order and spelling, that [`print_summary`] ends the NLP
758/// path's log with.
759///
760/// gh #767: the convex log stopped after the residual block, with no `EXIT:`
761/// banner and no terminal status of any kind. `benchmarks/scripts/run_nl_bench.sh`
762/// already compensates with a ladder of convex-specific stdout scrapes, so
763/// every *other* consumer of the CLI had to reimplement that ladder to learn
764/// how a convex-routed solve ended. The phrases come from [`status_message`],
765/// the table the NLP path prints from, so a consumer that recognises Ipopt's
766/// end-of-run vocabulary needs no convex-specific case at all.
767///
768/// The caller supplies the status already mapped onto the NLP-side
769/// [`ApplicationReturnStatus`] (the CLI's `qp_status_to_ars`), for the same
770/// reason: one status vocabulary across both engines.
771pub fn print_convex_end(status: ApplicationReturnStatus, total_seconds: f64) {
772 println!();
773 println!("Total seconds in POUNCE = {total_seconds:.3}");
774 println!();
775 println!("EXIT: {}", status_message(status));
776 println!();
777 println!(
778 "POUNCE {}: {}",
779 env!("CARGO_PKG_VERSION"),
780 status_message(status)
781 );
782}
783
784/// Format a number in Ipopt's scientific notation: 16-digit mantissa,
785/// signed 2-digit exponent (e.g. `3.7952009505566139e+03`). Rust's
786/// `{:.16e}` is close but emits a 1-digit exponent without leading
787/// sign, which makes side-by-side diffs against `ipopt` output messy.
788pub fn fmt_ipopt(v: f64) -> String {
789 if v.is_nan() {
790 return "nan".to_string();
791 }
792 if v.is_infinite() {
793 return if v > 0.0 { "inf".into() } else { "-inf".into() };
794 }
795 let s = format!("{:.16e}", v);
796 let Some(e_pos) = s.rfind('e') else {
797 return s;
798 };
799 let (mantissa, exp_part) = s.split_at(e_pos);
800 let exp_str = &exp_part[1..];
801 let (sign, digits) = if let Some(rest) = exp_str.strip_prefix('-') {
802 ('-', rest)
803 } else if let Some(rest) = exp_str.strip_prefix('+') {
804 ('+', rest)
805 } else {
806 ('+', exp_str)
807 };
808 let padded = if digits.len() < 2 {
809 format!("0{digits}")
810 } else {
811 digits.to_string()
812 };
813 format!("{mantissa}e{sign}{padded}")
814}
815
816pub fn status_message(s: ApplicationReturnStatus) -> &'static str {
817 match s {
818 ApplicationReturnStatus::SolveSucceeded => "Optimal Solution Found.",
819 ApplicationReturnStatus::SolvedToAcceptableLevel => "Solved To Acceptable Level.",
820 ApplicationReturnStatus::InfeasibleProblemDetected => {
821 "Converged to a point of local infeasibility. Problem may be infeasible."
822 }
823 ApplicationReturnStatus::SearchDirectionBecomesTooSmall => {
824 "Search Direction is becoming Too Small."
825 }
826 ApplicationReturnStatus::DivergingIterates => {
827 "Iterates diverging; problem might be unbounded."
828 }
829 ApplicationReturnStatus::UserRequestedStop => "Stopping optimization at user request.",
830 ApplicationReturnStatus::FeasiblePointFound => "Feasible Point Found.",
831 ApplicationReturnStatus::MaximumIterationsExceeded => {
832 "Maximum Number of Iterations Exceeded."
833 }
834 ApplicationReturnStatus::RestorationFailed => "Restoration Failed!",
835 ApplicationReturnStatus::ErrorInStepComputation => "Error in step computation.",
836 ApplicationReturnStatus::MaximumCpuTimeExceeded => "Maximum CPU time exceeded.",
837 ApplicationReturnStatus::MaximumWallTimeExceeded => "Maximum wallclock time exceeded.",
838 ApplicationReturnStatus::NotEnoughDegreesOfFreedom => "Not Enough Degrees of Freedom.",
839 ApplicationReturnStatus::InvalidProblemDefinition => "Invalid Problem Definition.",
840 ApplicationReturnStatus::InvalidOption => "Invalid Option.",
841 ApplicationReturnStatus::InvalidNumberDetected => {
842 "Invalid number in NLP function or derivative detected."
843 }
844 ApplicationReturnStatus::UnrecoverableException => "Unrecoverable Exception.",
845 ApplicationReturnStatus::NonIpoptExceptionThrown => "Exception of type generic.",
846 ApplicationReturnStatus::InsufficientMemory => "Insufficient memory.",
847 ApplicationReturnStatus::InternalError => "INTERNAL ERROR: Unknown SolverReturn value.",
848 }
849}
850
851#[cfg(test)]
852mod inequality_tally_tests {
853 //! Regression test for code review L26: the inequality bound-type
854 //! breakdown (`lower_only` / `both` / `upper_only`) must always sum to
855 //! `n_ineq`. A "free" inequality row (no finite bound on either side)
856 //! previously fell through to a no-op arm, so the breakdown summed to
857 //! *less* than the total whenever such a row was present.
858 use super::*;
859 use pounce_common::types::{Index, Number};
860 use pounce_nlp::tnlp::{BoundsInfo, IndexStyle, IpoptCq, IpoptData, Solution, StartingPoint};
861
862 /// Two free variables, three inequality rows of distinct bound types:
863 /// row 0 lower-only, row 1 both, row 2 *free* (the bug trigger). No
864 /// equality rows. The breakdown must sum to `n_ineq == 3`.
865 struct FreeIneqRow;
866 impl TNLP for FreeIneqRow {
867 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
868 Some(NlpInfo {
869 n: 2,
870 m: 3,
871 nnz_jac_g: 3,
872 nnz_h_lag: 0,
873 index_style: IndexStyle::C,
874 })
875 }
876 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
877 b.x_l.iter_mut().for_each(|v| *v = -BOUND_INF);
878 b.x_u.iter_mut().for_each(|v| *v = BOUND_INF);
879 // row 0: lower-only [0, +inf)
880 b.g_l[0] = 0.0;
881 b.g_u[0] = BOUND_INF;
882 // row 1: both [0, 1]
883 b.g_l[1] = 0.0;
884 b.g_u[1] = 1.0;
885 // row 2: free (-inf, +inf) — the regression trigger
886 b.g_l[2] = -BOUND_INF;
887 b.g_u[2] = BOUND_INF;
888 true
889 }
890 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
891 sp.x.iter_mut().for_each(|v| *v = 0.0);
892 true
893 }
894 fn eval_f(&mut self, _x: &[Number], _new_x: bool) -> Option<Number> {
895 Some(0.0)
896 }
897 fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, grad_f: &mut [Number]) -> bool {
898 grad_f.iter_mut().for_each(|v| *v = 0.0);
899 true
900 }
901 fn eval_g(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
902 g.iter_mut().for_each(|v| *v = 0.0);
903 true
904 }
905 fn eval_jac_g(
906 &mut self,
907 _x: Option<&[Number]>,
908 _new_x: bool,
909 mode: SparsityRequest<'_>,
910 ) -> bool {
911 match mode {
912 SparsityRequest::Structure { irow, jcol } => {
913 // one entry per row so the eq/ineq Jacobian split also
914 // visits each row.
915 irow.copy_from_slice(&[0, 1, 2]);
916 jcol.copy_from_slice(&[0, 0, 0]);
917 }
918 SparsityRequest::Values { values } => {
919 values.copy_from_slice(&[1.0, 1.0, 1.0]);
920 }
921 }
922 true
923 }
924 fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
925 }
926
927 #[test]
928 fn free_inequality_row_keeps_breakdown_summing_to_total() {
929 let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(FreeIneqRow));
930 let s = collect_stats(
931 &tnlp,
932 -BOUND_INF,
933 BOUND_INF,
934 FixedVarTreatment::MakeParameter,
935 )
936 .expect("collect_stats succeeds");
937
938 assert_eq!(s.n_eq, 0, "no equality rows");
939 assert_eq!(s.n_ineq, 3, "all three rows are inequalities");
940 // The headline invariant L26 flagged: the three printed buckets must
941 // account for every inequality row.
942 let bucket_sum: Index = s.ineq_lower_only + s.ineq_both + s.ineq_upper_only;
943 assert_eq!(
944 bucket_sum, s.n_ineq,
945 "ineq bound-type breakdown ({} lower + {} both + {} upper) must sum to n_ineq={}",
946 s.ineq_lower_only, s.ineq_both, s.ineq_upper_only, s.n_ineq
947 );
948 // The free row is bucketed under "both" alongside the genuine
949 // both-bounded row 1.
950 assert_eq!(s.ineq_lower_only, 1);
951 assert_eq!(s.ineq_upper_only, 0);
952 assert_eq!(s.ineq_both, 2);
953 }
954
955 /// #140 regression. Three variables, the middle one fixed
956 /// (`x_l == x_u`). Under the default `make_parameter` the banner must
957 /// report the *reduced* problem: the fixed var is dropped from the total,
958 /// is NOT bucketed as "lower and upper bounds", and its Jacobian column is
959 /// excluded from the nnz tally — matching Ipopt (and the problem the
960 /// algorithm actually solves). Previously the banner walked the raw TNLP
961 /// and over-reported all three.
962 struct OneFixedVar;
963 impl TNLP for OneFixedVar {
964 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
965 Some(NlpInfo {
966 n: 3,
967 m: 1,
968 nnz_jac_g: 3,
969 nnz_h_lag: 0,
970 index_style: IndexStyle::C,
971 })
972 }
973 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
974 // var 0: lower-only [0, +inf) var 1: FIXED at 2 var 2: free
975 b.x_l[0] = 0.0;
976 b.x_u[0] = BOUND_INF;
977 b.x_l[1] = 2.0;
978 b.x_u[1] = 2.0;
979 b.x_l[2] = -BOUND_INF;
980 b.x_u[2] = BOUND_INF;
981 // one equality row (keeps n_x_var=2 >= n_c=1, so no relax switch)
982 b.g_l[0] = 0.0;
983 b.g_u[0] = 0.0;
984 true
985 }
986 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
987 sp.x.iter_mut().for_each(|v| *v = 0.0);
988 true
989 }
990 fn eval_f(&mut self, _x: &[Number], _new_x: bool) -> Option<Number> {
991 Some(0.0)
992 }
993 fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, grad_f: &mut [Number]) -> bool {
994 grad_f.iter_mut().for_each(|v| *v = 0.0);
995 true
996 }
997 fn eval_g(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
998 g.iter_mut().for_each(|v| *v = 0.0);
999 true
1000 }
1001 fn eval_jac_g(
1002 &mut self,
1003 _x: Option<&[Number]>,
1004 _new_x: bool,
1005 mode: SparsityRequest<'_>,
1006 ) -> bool {
1007 match mode {
1008 // The equality row touches all three columns, including the
1009 // fixed var (col 1) that must be filtered out.
1010 SparsityRequest::Structure { irow, jcol } => {
1011 irow.copy_from_slice(&[0, 0, 0]);
1012 jcol.copy_from_slice(&[0, 1, 2]);
1013 }
1014 SparsityRequest::Values { values } => {
1015 values.copy_from_slice(&[1.0, 1.0, 1.0]);
1016 }
1017 }
1018 true
1019 }
1020 fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
1021 }
1022
1023 #[test]
1024 fn make_parameter_banner_reports_reduced_problem() {
1025 let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(OneFixedVar));
1026 let s = collect_stats(
1027 &tnlp,
1028 -BOUND_INF,
1029 BOUND_INF,
1030 FixedVarTreatment::MakeParameter,
1031 )
1032 .expect("collect_stats succeeds");
1033
1034 // Fixed var removed: 3 raw vars → 2 optimized.
1035 assert_eq!(s.n, 2, "fixed variable must be dropped from the total");
1036 assert_eq!(s.var_both, 0, "fixed var must NOT count as lower-and-upper");
1037 assert_eq!(s.var_lower_only, 1, "var 0 is lower-only");
1038 assert_eq!(s.var_free, 1, "var 2 is free");
1039 assert_eq!(s.var_upper_only, 0);
1040 // The fixed column is excluded from the Jacobian nnz.
1041 assert_eq!(s.n_eq, 1);
1042 assert_eq!(
1043 s.nnz_jac_eq, 2,
1044 "fixed-var column dropped from the Jacobian"
1045 );
1046 assert_eq!(s.nnz_jac_ineq, 0);
1047 }
1048
1049 #[test]
1050 fn relax_bounds_banner_keeps_fixed_variable() {
1051 // Under relax_bounds the fixed var stays in the optimization and is
1052 // reported as a lower-and-upper-bounded variable — matching Ipopt.
1053 let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(OneFixedVar));
1054 let s = collect_stats(&tnlp, -BOUND_INF, BOUND_INF, FixedVarTreatment::RelaxBounds)
1055 .expect("collect_stats succeeds");
1056
1057 assert_eq!(s.n, 3, "relax_bounds keeps the fixed variable");
1058 assert_eq!(
1059 s.var_both, 1,
1060 "fixed var reported as lower-and-upper bounded"
1061 );
1062 assert_eq!(s.var_lower_only, 1);
1063 assert_eq!(s.var_free, 1);
1064 assert_eq!(s.nnz_jac_eq, 3, "all columns retained under relax_bounds");
1065 }
1066}