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#[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}