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
458pub fn print_summary(
459    status: ApplicationReturnStatus,
460    stats: &SolveStatistics,
461    counters: &EvalCounts,
462) {
463    println!();
464    println!();
465    println!("Number of Iterations....: {}", stats.iteration_count);
466    println!();
467    println!("                                   (scaled)                 (unscaled)");
468    let row = |label: &str, scaled: f64, unscaled: f64| {
469        println!(
470            "{label}:   {}    {}",
471            fmt_ipopt(scaled),
472            fmt_ipopt(unscaled)
473        );
474    };
475    row(
476        "Objective...............",
477        stats.final_scaled_objective,
478        stats.final_objective,
479    );
480    // The residual rows carry genuinely different values in the two columns
481    // whenever `nlp_scaling` is active, and printing the scaled number twice
482    // hid exactly the discrepancy that matters: on gh #200's `quartc` the
483    // objective row correctly showed `2.49e-06` / `2.49e+02` while dual
484    // infeasibility read `8.38e-09` in *both* columns — when the unscaled value
485    // is `0.84`, eight orders above it. A user auditing a suspicious
486    // certificate was shown a report that agreed with the certificate. The
487    // unscaled statistics were already computed and already surfaced through
488    // the Python bindings; only the console dropped them.
489    row(
490        "Dual infeasibility......",
491        stats.final_dual_inf,
492        stats.final_unscaled_dual_inf,
493    );
494    row(
495        "Constraint violation....",
496        stats.final_constr_viol,
497        stats.final_unscaled_constr_viol,
498    );
499    row("Variable bound violation", 0.0, 0.0);
500    row(
501        "Complementarity.........",
502        stats.final_compl,
503        stats.final_unscaled_compl,
504    );
505    row(
506        "Overall NLP error.......",
507        stats.final_kkt_error,
508        stats.final_unscaled_kkt_error,
509    );
510    // gh #528. The strict gate judges the primal term against what each row
511    // can actually represent in floating point, so on a model whose constraint
512    // values run to `~1e8` the raw error above can sit above `tol` beside an
513    // `EXIT: Optimal Solution Found`. Print the number that was tested, but
514    // only when it differs — every `O(1)` model, and every run with the floor
515    // switched off, keeps the summary block byte-identical to upstream's.
516    if stats.final_kkt_error_above_noise.is_finite()
517        && stats.final_kkt_error.is_finite()
518        && stats.final_kkt_error_above_noise != stats.final_kkt_error
519    {
520        println!(
521            "  ...above the per-row floating-point noise floor:   {}",
522            fmt_ipopt(stats.final_kkt_error_above_noise),
523        );
524        println!(
525            "  (the strict convergence test judges this value; the residual \
526             below it is finer than the row's own arithmetic can resolve. \
527             Set primal_noise_floor_kappa = 0 to disable.)"
528        );
529    }
530    println!();
531    println!();
532    println!(
533        "Number of objective function evaluations             = {}",
534        counters.n_obj
535    );
536    println!(
537        "Number of objective gradient evaluations             = {}",
538        counters.n_grad_f
539    );
540    println!(
541        "Number of equality constraint evaluations            = {}",
542        counters.n_g
543    );
544    println!(
545        "Number of inequality constraint evaluations          = {}",
546        counters.n_g
547    );
548    println!(
549        "Number of equality constraint Jacobian evaluations   = {}",
550        counters.n_jac_g
551    );
552    println!(
553        "Number of inequality constraint Jacobian evaluations = {}",
554        counters.n_jac_g
555    );
556    println!(
557        "Number of Lagrangian Hessian evaluations             = {}",
558        counters.n_h
559    );
560    println!(
561        "Total seconds in POUNCE                              = {:.3}",
562        stats.total_wallclock_time_secs
563    );
564    println!();
565    println!("EXIT: {}", status_message(status));
566    println!();
567    println!(
568        "POUNCE {}: {}",
569        env!("CARGO_PKG_VERSION"),
570        status_message(status)
571    );
572}
573
574/// Emit an Ipopt-style end-of-run summary for the dedicated convex
575/// (LP / QP / conic) IPM path. That path otherwise prints only a compact
576/// one-line result, so the `Number of Iterations....:` and
577/// `Objective...............:` lines the general NLP path emits are missing.
578/// Downstream consumers that parse Ipopt's summary block — notably the
579/// benchmark harness's `extract_obj`/`extract_iters` in
580/// `benchmarks/scripts/run_nl_bench.sh` — then see a null objective and zero
581/// iterations even though the solve succeeded. This prints the same labelled
582/// lines (objective + KKT residual rows) so those consumers capture the real
583/// values. The convex solver reports a single (unscaled, user-sense) objective
584/// and residuals, so the "(scaled)"/"(unscaled)" columns carry the same value.
585pub fn print_convex_summary(
586    iterations: usize,
587    objective: f64,
588    primal_inf: f64,
589    dual_inf: f64,
590    complementarity: f64,
591    kkt_error: f64,
592) {
593    println!();
594    println!();
595    println!("Number of Iterations....: {iterations}");
596    println!();
597    println!("                                   (scaled)                 (unscaled)");
598    let row = |label: &str, v: f64| {
599        println!("{label}:   {}    {}", fmt_ipopt(v), fmt_ipopt(v));
600    };
601    row("Objective...............", objective);
602    row("Dual infeasibility......", dual_inf);
603    row("Constraint violation....", primal_inf);
604    row("Variable bound violation", 0.0);
605    row("Complementarity.........", complementarity);
606    row("Overall NLP error.......", kkt_error);
607    println!();
608}
609
610/// Format a number in Ipopt's scientific notation: 16-digit mantissa,
611/// signed 2-digit exponent (e.g. `3.7952009505566139e+03`). Rust's
612/// `{:.16e}` is close but emits a 1-digit exponent without leading
613/// sign, which makes side-by-side diffs against `ipopt` output messy.
614pub fn fmt_ipopt(v: f64) -> String {
615    if v.is_nan() {
616        return "nan".to_string();
617    }
618    if v.is_infinite() {
619        return if v > 0.0 { "inf".into() } else { "-inf".into() };
620    }
621    let s = format!("{:.16e}", v);
622    let Some(e_pos) = s.rfind('e') else {
623        return s;
624    };
625    let (mantissa, exp_part) = s.split_at(e_pos);
626    let exp_str = &exp_part[1..];
627    let (sign, digits) = if let Some(rest) = exp_str.strip_prefix('-') {
628        ('-', rest)
629    } else if let Some(rest) = exp_str.strip_prefix('+') {
630        ('+', rest)
631    } else {
632        ('+', exp_str)
633    };
634    let padded = if digits.len() < 2 {
635        format!("0{digits}")
636    } else {
637        digits.to_string()
638    };
639    format!("{mantissa}e{sign}{padded}")
640}
641
642pub fn status_message(s: ApplicationReturnStatus) -> &'static str {
643    match s {
644        ApplicationReturnStatus::SolveSucceeded => "Optimal Solution Found.",
645        ApplicationReturnStatus::SolvedToAcceptableLevel => "Solved To Acceptable Level.",
646        ApplicationReturnStatus::InfeasibleProblemDetected => {
647            "Converged to a point of local infeasibility. Problem may be infeasible."
648        }
649        ApplicationReturnStatus::SearchDirectionBecomesTooSmall => {
650            "Search Direction is becoming Too Small."
651        }
652        ApplicationReturnStatus::DivergingIterates => {
653            "Iterates diverging; problem might be unbounded."
654        }
655        ApplicationReturnStatus::UserRequestedStop => "Stopping optimization at user request.",
656        ApplicationReturnStatus::FeasiblePointFound => "Feasible Point Found.",
657        ApplicationReturnStatus::MaximumIterationsExceeded => {
658            "Maximum Number of Iterations Exceeded."
659        }
660        ApplicationReturnStatus::RestorationFailed => "Restoration Failed!",
661        ApplicationReturnStatus::ErrorInStepComputation => "Error in step computation.",
662        ApplicationReturnStatus::MaximumCpuTimeExceeded => "Maximum CPU time exceeded.",
663        ApplicationReturnStatus::MaximumWallTimeExceeded => "Maximum wallclock time exceeded.",
664        ApplicationReturnStatus::NotEnoughDegreesOfFreedom => "Not Enough Degrees of Freedom.",
665        ApplicationReturnStatus::InvalidProblemDefinition => "Invalid Problem Definition.",
666        ApplicationReturnStatus::InvalidOption => "Invalid Option.",
667        ApplicationReturnStatus::InvalidNumberDetected => {
668            "Invalid number in NLP function or derivative detected."
669        }
670        ApplicationReturnStatus::UnrecoverableException => "Unrecoverable Exception.",
671        ApplicationReturnStatus::NonIpoptExceptionThrown => "Exception of type generic.",
672        ApplicationReturnStatus::InsufficientMemory => "Insufficient memory.",
673        ApplicationReturnStatus::InternalError => "INTERNAL ERROR: Unknown SolverReturn value.",
674    }
675}
676
677#[cfg(test)]
678mod inequality_tally_tests {
679    //! Regression test for code review L26: the inequality bound-type
680    //! breakdown (`lower_only` / `both` / `upper_only`) must always sum to
681    //! `n_ineq`. A "free" inequality row (no finite bound on either side)
682    //! previously fell through to a no-op arm, so the breakdown summed to
683    //! *less* than the total whenever such a row was present.
684    use super::*;
685    use pounce_common::types::{Index, Number};
686    use pounce_nlp::tnlp::{BoundsInfo, IndexStyle, IpoptCq, IpoptData, Solution, StartingPoint};
687
688    /// Two free variables, three inequality rows of distinct bound types:
689    /// row 0 lower-only, row 1 both, row 2 *free* (the bug trigger). No
690    /// equality rows. The breakdown must sum to `n_ineq == 3`.
691    struct FreeIneqRow;
692    impl TNLP for FreeIneqRow {
693        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
694            Some(NlpInfo {
695                n: 2,
696                m: 3,
697                nnz_jac_g: 3,
698                nnz_h_lag: 0,
699                index_style: IndexStyle::C,
700            })
701        }
702        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
703            b.x_l.iter_mut().for_each(|v| *v = -BOUND_INF);
704            b.x_u.iter_mut().for_each(|v| *v = BOUND_INF);
705            // row 0: lower-only  [0, +inf)
706            b.g_l[0] = 0.0;
707            b.g_u[0] = BOUND_INF;
708            // row 1: both        [0, 1]
709            b.g_l[1] = 0.0;
710            b.g_u[1] = 1.0;
711            // row 2: free        (-inf, +inf) — the regression trigger
712            b.g_l[2] = -BOUND_INF;
713            b.g_u[2] = BOUND_INF;
714            true
715        }
716        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
717            sp.x.iter_mut().for_each(|v| *v = 0.0);
718            true
719        }
720        fn eval_f(&mut self, _x: &[Number], _new_x: bool) -> Option<Number> {
721            Some(0.0)
722        }
723        fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, grad_f: &mut [Number]) -> bool {
724            grad_f.iter_mut().for_each(|v| *v = 0.0);
725            true
726        }
727        fn eval_g(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
728            g.iter_mut().for_each(|v| *v = 0.0);
729            true
730        }
731        fn eval_jac_g(
732            &mut self,
733            _x: Option<&[Number]>,
734            _new_x: bool,
735            mode: SparsityRequest<'_>,
736        ) -> bool {
737            match mode {
738                SparsityRequest::Structure { irow, jcol } => {
739                    // one entry per row so the eq/ineq Jacobian split also
740                    // visits each row.
741                    irow.copy_from_slice(&[0, 1, 2]);
742                    jcol.copy_from_slice(&[0, 0, 0]);
743                }
744                SparsityRequest::Values { values } => {
745                    values.copy_from_slice(&[1.0, 1.0, 1.0]);
746                }
747            }
748            true
749        }
750        fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
751    }
752
753    #[test]
754    fn free_inequality_row_keeps_breakdown_summing_to_total() {
755        let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(FreeIneqRow));
756        let s = collect_stats(
757            &tnlp,
758            -BOUND_INF,
759            BOUND_INF,
760            FixedVarTreatment::MakeParameter,
761        )
762        .expect("collect_stats succeeds");
763
764        assert_eq!(s.n_eq, 0, "no equality rows");
765        assert_eq!(s.n_ineq, 3, "all three rows are inequalities");
766        // The headline invariant L26 flagged: the three printed buckets must
767        // account for every inequality row.
768        let bucket_sum: Index = s.ineq_lower_only + s.ineq_both + s.ineq_upper_only;
769        assert_eq!(
770            bucket_sum, s.n_ineq,
771            "ineq bound-type breakdown ({} lower + {} both + {} upper) must sum to n_ineq={}",
772            s.ineq_lower_only, s.ineq_both, s.ineq_upper_only, s.n_ineq
773        );
774        // The free row is bucketed under "both" alongside the genuine
775        // both-bounded row 1.
776        assert_eq!(s.ineq_lower_only, 1);
777        assert_eq!(s.ineq_upper_only, 0);
778        assert_eq!(s.ineq_both, 2);
779    }
780
781    /// #140 regression. Three variables, the middle one fixed
782    /// (`x_l == x_u`). Under the default `make_parameter` the banner must
783    /// report the *reduced* problem: the fixed var is dropped from the total,
784    /// is NOT bucketed as "lower and upper bounds", and its Jacobian column is
785    /// excluded from the nnz tally — matching Ipopt (and the problem the
786    /// algorithm actually solves). Previously the banner walked the raw TNLP
787    /// and over-reported all three.
788    struct OneFixedVar;
789    impl TNLP for OneFixedVar {
790        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
791            Some(NlpInfo {
792                n: 3,
793                m: 1,
794                nnz_jac_g: 3,
795                nnz_h_lag: 0,
796                index_style: IndexStyle::C,
797            })
798        }
799        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
800            // var 0: lower-only [0, +inf)   var 1: FIXED at 2   var 2: free
801            b.x_l[0] = 0.0;
802            b.x_u[0] = BOUND_INF;
803            b.x_l[1] = 2.0;
804            b.x_u[1] = 2.0;
805            b.x_l[2] = -BOUND_INF;
806            b.x_u[2] = BOUND_INF;
807            // one equality row (keeps n_x_var=2 >= n_c=1, so no relax switch)
808            b.g_l[0] = 0.0;
809            b.g_u[0] = 0.0;
810            true
811        }
812        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
813            sp.x.iter_mut().for_each(|v| *v = 0.0);
814            true
815        }
816        fn eval_f(&mut self, _x: &[Number], _new_x: bool) -> Option<Number> {
817            Some(0.0)
818        }
819        fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, grad_f: &mut [Number]) -> bool {
820            grad_f.iter_mut().for_each(|v| *v = 0.0);
821            true
822        }
823        fn eval_g(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
824            g.iter_mut().for_each(|v| *v = 0.0);
825            true
826        }
827        fn eval_jac_g(
828            &mut self,
829            _x: Option<&[Number]>,
830            _new_x: bool,
831            mode: SparsityRequest<'_>,
832        ) -> bool {
833            match mode {
834                // The equality row touches all three columns, including the
835                // fixed var (col 1) that must be filtered out.
836                SparsityRequest::Structure { irow, jcol } => {
837                    irow.copy_from_slice(&[0, 0, 0]);
838                    jcol.copy_from_slice(&[0, 1, 2]);
839                }
840                SparsityRequest::Values { values } => {
841                    values.copy_from_slice(&[1.0, 1.0, 1.0]);
842                }
843            }
844            true
845        }
846        fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
847    }
848
849    #[test]
850    fn make_parameter_banner_reports_reduced_problem() {
851        let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(OneFixedVar));
852        let s = collect_stats(
853            &tnlp,
854            -BOUND_INF,
855            BOUND_INF,
856            FixedVarTreatment::MakeParameter,
857        )
858        .expect("collect_stats succeeds");
859
860        // Fixed var removed: 3 raw vars → 2 optimized.
861        assert_eq!(s.n, 2, "fixed variable must be dropped from the total");
862        assert_eq!(s.var_both, 0, "fixed var must NOT count as lower-and-upper");
863        assert_eq!(s.var_lower_only, 1, "var 0 is lower-only");
864        assert_eq!(s.var_free, 1, "var 2 is free");
865        assert_eq!(s.var_upper_only, 0);
866        // The fixed column is excluded from the Jacobian nnz.
867        assert_eq!(s.n_eq, 1);
868        assert_eq!(
869            s.nnz_jac_eq, 2,
870            "fixed-var column dropped from the Jacobian"
871        );
872        assert_eq!(s.nnz_jac_ineq, 0);
873    }
874
875    #[test]
876    fn relax_bounds_banner_keeps_fixed_variable() {
877        // Under relax_bounds the fixed var stays in the optimization and is
878        // reported as a lower-and-upper-bounded variable — matching Ipopt.
879        let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(OneFixedVar));
880        let s = collect_stats(&tnlp, -BOUND_INF, BOUND_INF, FixedVarTreatment::RelaxBounds)
881            .expect("collect_stats succeeds");
882
883        assert_eq!(s.n, 3, "relax_bounds keeps the fixed variable");
884        assert_eq!(
885            s.var_both, 1,
886            "fixed var reported as lower-and-upper bounded"
887        );
888        assert_eq!(s.var_lower_only, 1);
889        assert_eq!(s.var_free, 1);
890        assert_eq!(s.nnz_jac_eq, 3, "all columns retained under relax_bounds");
891    }
892}