Skip to main content

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