Skip to main content

pounce_cli/
print.rs

1//! Ipopt-style banner / problem-stats / final-summary printing for
2//! the `pounce` CLI. Output is structured to match upstream Ipopt's
3//! console layout closely enough that anyone familiar with `ipopt`
4//! can spot at a glance whether POUNCE is converging similarly.
5
6use crate::counting_tnlp::CountingTnlp;
7use pounce_common::types::Number;
8use pounce_nlp::return_codes::ApplicationReturnStatus;
9use pounce_nlp::solve_statistics::SolveStatistics;
10use pounce_nlp::tnlp::{IndexStyle, NlpInfo, SparsityRequest, TNLP};
11use pounce_nlp::tnlp_adapter::{FixedVarTreatment, TNLPAdapter};
12use std::cell::RefCell;
13use std::rc::Rc;
14
15/// Same sentinel Ipopt uses for "no bound": ±1e19. Only referenced by the
16/// unit tests now that `collect_stats` derives bounds from the adapter
17/// classification rather than re-thresholding raw bounds itself.
18#[cfg(test)]
19const BOUND_INF: f64 = 1.0e19;
20
21#[derive(Debug, Clone, Copy)]
22pub struct ProblemStats {
23    pub n: i32,
24    pub m: i32,
25    pub nnz_jac_eq: i32,
26    pub nnz_jac_ineq: i32,
27    pub nnz_h_lag: i32,
28    pub var_lower_only: i32,
29    pub var_upper_only: i32,
30    pub var_both: i32,
31    pub var_free: i32,
32    pub n_eq: i32,
33    pub n_ineq: i32,
34    pub ineq_lower_only: i32,
35    pub ineq_upper_only: i32,
36    pub ineq_both: i32,
37}
38
39/// Gather everything the banner block needs, reported over the **reduced**
40/// problem that the algorithm actually solves — i.e. after
41/// `fixed_variable_treatment` removes fixed (`x_l == x_u`) variables under
42/// `make_parameter`. This mirrors Ipopt, whose banner is computed from the
43/// post-`IpTNLPAdapter` problem; computing it from the raw TNLP instead made
44/// pounce over-report variables and bucket fixed vars as "lower and upper
45/// bounds" (#140).
46///
47/// To stay byte-for-byte consistent with the solve, the counts are taken from
48/// a throwaway [`TNLPAdapter`] built with the same options — reusing the exact
49/// production classification (including the `make_parameter → relax_bounds`
50/// auto-switch). The Jacobian / Hessian nnz are read from the raw structure and
51/// filtered to drop entries in fixed-variable columns (the columns Ipopt
52/// removes). Returns `None` if any required TNLP call fails.
53pub fn collect_stats(
54    tnlp: &Rc<RefCell<dyn TNLP>>,
55    lo_inf: Number,
56    up_inf: Number,
57    fixed_treatment: FixedVarTreatment,
58) -> Option<ProblemStats> {
59    let adapter =
60        TNLPAdapter::new_with_options(Rc::clone(tnlp), lo_inf, up_inf, fixed_treatment).ok()?;
61    let cls = adapter.classification();
62    let info: NlpInfo = *adapter.nlp_info();
63    let n_full_x = cls.n_full_x as usize;
64    let m = info.m as usize;
65    let one_based = matches!(info.index_style, IndexStyle::Fortran);
66
67    // --- Variable bound buckets over the reduced (non-fixed) variable set.
68    // `x_l_map` / `x_u_map` hold positions in `x_var` that carry a finite
69    // lower / upper bound. A fixed var under `make_parameter` is absent from
70    // both (it was dropped from `x_var`); under `relax_bounds` it lands in
71    // both — matching Ipopt's banner in either mode.
72    let nv = cls.n_x_var() as usize;
73    let mut has_l = vec![false; nv];
74    let mut has_u = vec![false; nv];
75    for &p in &cls.x_l_map {
76        has_l[p as usize] = true;
77    }
78    for &p in &cls.x_u_map {
79        has_u[p as usize] = true;
80    }
81    let (mut var_lower_only, mut var_upper_only, mut var_both, mut var_free) = (0, 0, 0, 0);
82    for k in 0..nv {
83        match (has_l[k], has_u[k]) {
84            (true, true) => var_both += 1,
85            (true, false) => var_lower_only += 1,
86            (false, true) => var_upper_only += 1,
87            (false, false) => var_free += 1,
88        }
89    }
90
91    // --- Constraint counts / inequality bound buckets, straight from the
92    // classification (equality = `c_map`, inequality = `d_map`).
93    let n_eq = cls.n_c;
94    let n_ineq = cls.n_d;
95    let nd = cls.n_d as usize;
96    let mut has_dl = vec![false; nd];
97    let mut has_du = vec![false; nd];
98    for &p in &cls.d_l_map {
99        has_dl[p as usize] = true;
100    }
101    for &p in &cls.d_u_map {
102        has_du[p as usize] = true;
103    }
104    let (mut ineq_lower_only, mut ineq_upper_only, mut ineq_both) = (0, 0, 0);
105    for k in 0..nd {
106        match (has_dl[k], has_du[k]) {
107            (true, true) => ineq_both += 1,
108            (true, false) => ineq_lower_only += 1,
109            (false, true) => ineq_upper_only += 1,
110            // A "free" inequality has no finite bound on either side (e.g. an
111            // `.nl` range row left fully open). It is still counted in
112            // `n_ineq`, so bucket it under "both" or the printed breakdown
113            // won't sum to the total.
114            (false, false) => ineq_both += 1,
115        }
116    }
117
118    // Which raw rows are equality rows (for the Jacobian split).
119    let mut row_is_eq = vec![false; m];
120    for &r in &cls.c_map {
121        row_is_eq[r as usize] = true;
122    }
123
124    // --- Jacobian split: read the raw structure once, drop fixed-variable
125    // columns (the ones `make_parameter` removes), tally per-row.
126    let nnz_total = info.nnz_jac_g as usize;
127    let (mut nnz_jac_eq, mut nnz_jac_ineq) = (0, 0);
128    if nnz_total > 0 && m > 0 {
129        let mut irow = vec![0_i32; nnz_total];
130        let mut jcol = vec![0_i32; nnz_total];
131        let mut t = tnlp.borrow_mut();
132        if t.eval_jac_g(
133            None,
134            true,
135            SparsityRequest::Structure {
136                irow: &mut irow,
137                jcol: &mut jcol,
138            },
139        ) {
140            for k in 0..nnz_total {
141                let col = if one_based {
142                    (jcol[k] - 1) as usize
143                } else {
144                    jcol[k] as usize
145                };
146                // Skip nonzeros in fixed-variable columns: `full_to_var[col]`
147                // is `-1` for a dropped fixed var.
148                if col >= n_full_x || cls.full_to_var[col] < 0 {
149                    continue;
150                }
151                let row = if one_based {
152                    (irow[k] - 1) as usize
153                } else {
154                    irow[k] as usize
155                };
156                if row < m && row_is_eq[row] {
157                    nnz_jac_eq += 1;
158                } else {
159                    nnz_jac_ineq += 1;
160                }
161            }
162        }
163    }
164
165    // --- Hessian nnz over the reduced problem: drop any entry touching a
166    // fixed-variable row or column. If the TNLP supplies no Hessian
167    // (`eval_h` → false), fall back to the raw count.
168    let mut nnz_h_lag = info.nnz_h_lag;
169    let nnz_h = info.nnz_h_lag as usize;
170    if nnz_h > 0 {
171        let mut irow = vec![0_i32; nnz_h];
172        let mut jcol = vec![0_i32; nnz_h];
173        let mut t = tnlp.borrow_mut();
174        if t.eval_h(
175            None,
176            true,
177            1.0,
178            None,
179            true,
180            SparsityRequest::Structure {
181                irow: &mut irow,
182                jcol: &mut jcol,
183            },
184        ) {
185            let mut kept = 0_i32;
186            for k in 0..nnz_h {
187                let r = if one_based {
188                    (irow[k] - 1) as usize
189                } else {
190                    irow[k] as usize
191                };
192                let c = if one_based {
193                    (jcol[k] - 1) as usize
194                } else {
195                    jcol[k] as usize
196                };
197                if r < n_full_x
198                    && c < n_full_x
199                    && cls.full_to_var[r] >= 0
200                    && cls.full_to_var[c] >= 0
201                {
202                    kept += 1;
203                }
204            }
205            nnz_h_lag = kept;
206        }
207    }
208
209    Some(ProblemStats {
210        n: cls.n_x_var(),
211        m: info.m,
212        nnz_jac_eq,
213        nnz_jac_ineq,
214        nnz_h_lag,
215        var_lower_only,
216        var_upper_only,
217        var_both,
218        var_free,
219        n_eq,
220        n_ineq,
221        ineq_lower_only,
222        ineq_upper_only,
223        ineq_both,
224    })
225}
226
227/// POUNCE wordmark in block letters, printed above the copyright banner.
228const LOGO: [&str; 5] = [
229    "####    ###   #   #  #   #   ####  #####",
230    "#   #  #   #  #   #  ##  #  #      #    ",
231    "####   #   #  #   #  # # #  #      #### ",
232    "#      #   #  #   #  #  ##  #      #    ",
233    "#       ###    ###   #   #   ####  #####",
234];
235
236/// Width of the copyright banner's asterisk rules — wide enough to span
237/// the longest banner text line. The wordmark is centered against this,
238/// and a matching rule is printed above it.
239const BANNER_WIDTH: usize = 80;
240
241/// Print the branded POUNCE ASCII wordmark, mimicking the project logo.
242///
243/// Block letters get a top-lit **steel** sheen (light silver → dark
244/// steel down the rows); three diagonal **molten claw** slashes rake
245/// upper-right → lower-left, glowing bright gold at the top into deep
246/// red at the bottom — the brand logo's look. Emitted through
247/// `anstream::stdout()`, which strips the ANSI when stdout is redirected
248/// or `NO_COLOR` is set (non-TTY sinks get the plain text), with a
249/// 256-color downgrade on non-truecolor terminals. The metallic letters
250/// are tuned for a dark terminal background.
251pub fn print_logo() {
252    use std::io::Write as _;
253    let width = LOGO
254        .iter()
255        .map(|l| l.chars().count())
256        .max()
257        .unwrap_or(1)
258        .max(2);
259    let mut out = anstream::stdout();
260    // Leading rule matching the copyright banner's width, then a blank
261    // line, then the centered wordmark. The rule is left in the terminal's
262    // default color (like the banner's own rules) so it stays distinct on
263    // any background. `anstream` strips the styling when stdout isn't a TTY.
264    let _ = writeln!(out, "{}", "*".repeat(BANNER_WIDTH));
265    let _ = writeln!(out);
266    let pad = " ".repeat(BANNER_WIDTH.saturating_sub(width) / 2);
267    for row in logo_rows(true) {
268        let _ = writeln!(out, "{pad}{row}");
269    }
270    let _ = writeln!(out);
271}
272
273/// Render the POUNCE wordmark as styled rows (one `String` per line):
274/// steel-sheen letters with three molten claw slashes, in the project
275/// palette. Emits ANSI styling only when `color`; otherwise plain
276/// `#`/`/` block characters. Shared by the solve header ([`print_logo`])
277/// and the interactive debugger's open banner (rendered to stderr).
278pub fn logo_rows(color: bool) -> Vec<String> {
279    use pounce_common::style::{downgrade, truecolor_enabled, ALPHA_HOT, BRIGHT_YEL, TIGER_ORANGE};
280
281    fn lerp(a: u8, b: u8, t: f64) -> u8 {
282        (a as f64 + (b as f64 - a as f64) * t)
283            .round()
284            .clamp(0.0, 255.0) as u8
285    }
286    fn mix(a: anstyle::RgbColor, b: anstyle::RgbColor, t: f64) -> anstyle::RgbColor {
287        anstyle::RgbColor(lerp(a.0, b.0, t), lerp(a.1, b.1, t), lerp(a.2, b.2, t))
288    }
289    // Steel sheen (top-lit): light silver at the top row → dark steel at
290    // the bottom. Molten ramp: gold → tiger-orange → deep red top-to-bottom.
291    const STEEL_HI: anstyle::RgbColor = anstyle::RgbColor(0xd2, 0xd6, 0xdc);
292    const STEEL_LO: anstyle::RgbColor = anstyle::RgbColor(0x5c, 0x60, 0x68);
293
294    let rows = LOGO.len();
295    let width = LOGO
296        .iter()
297        .map(|l| l.chars().count())
298        .max()
299        .unwrap_or(1)
300        .max(2);
301    let vfrac = |r: usize| {
302        if rows <= 1 {
303            0.0
304        } else {
305            r as f64 / (rows - 1) as f64
306        }
307    };
308    // Molten color for a claw cell at row `r` (0 = top, hottest).
309    let molten = |r: usize| {
310        let t = vfrac(r);
311        if t < 0.5 {
312            mix(BRIGHT_YEL, TIGER_ORANGE, t / 0.5)
313        } else {
314            mix(TIGER_ORANGE, ALPHA_HOT, (t - 0.5) / 0.5)
315        }
316    };
317
318    let mut grid: Vec<Vec<Option<(char, anstyle::RgbColor)>>> = vec![vec![None; width]; rows];
319    for (r, line) in LOGO.iter().enumerate() {
320        let steel = mix(STEEL_HI, STEEL_LO, vfrac(r));
321        for (c, ch) in line.chars().enumerate() {
322            if ch != ' ' {
323                grid[r][c] = Some((ch, steel));
324            }
325        }
326    }
327    // Three parallel molten claw slashes, upper-right → lower-left (`/`).
328    for &start in &[width / 4, width / 4 + 6, width / 4 + 12] {
329        for r in 0..rows {
330            let c = start + (rows - 1 - r);
331            if c < width {
332                grid[r][c] = Some(('/', molten(r)));
333            }
334        }
335    }
336
337    let truecolor = truecolor_enabled();
338    grid.iter()
339        .map(|row| {
340            let mut rendered = String::new();
341            for cell in row {
342                match cell {
343                    Some((ch, rgb)) if color => {
344                        let style = anstyle::Style::new()
345                            .bold()
346                            .fg_color(Some(downgrade(*rgb, truecolor)));
347                        rendered.push_str(&format!(
348                            "{}{}{}",
349                            style.render(),
350                            ch,
351                            style.render_reset()
352                        ));
353                    }
354                    Some((ch, _)) => rendered.push(*ch),
355                    None => rendered.push(' '),
356                }
357            }
358            rendered.trim_end().to_string()
359        })
360        .collect()
361}
362
363pub fn print_banner(linear_solver: &str) {
364    use std::io::IsTerminal as _;
365
366    // OSC 8 hyperlink so supporting terminals make the URL clickable;
367    // only emitted to a TTY so redirected output stays plain text.
368    const URL: &str = "https://github.com/jkitchin/pounce";
369    let link = if std::io::stdout().is_terminal() {
370        format!("\x1b]8;;{URL}\x1b\\{URL}\x1b]8;;\x1b\\")
371    } else {
372        URL.to_string()
373    };
374
375    let rule = "*".repeat(BANNER_WIDTH);
376    println!("{rule}");
377    println!("This program contains POUNCE, a pure-Rust interior-point optimization solver");
378    println!("for nonlinear, conic, and global problems (its NLP core is ported from Ipopt).");
379    println!("Released under the Eclipse Public License (EPL) — drop-in compatible with Ipopt.");
380    println!("         For more information visit {link}");
381    println!("{rule}");
382    println!();
383    println!(
384        "This is POUNCE version {}, running with linear solver {}.",
385        env!("CARGO_PKG_VERSION"),
386        linear_solver
387    );
388    println!();
389}
390
391pub fn print_problem_stats(s: &ProblemStats) {
392    println!(
393        "Number of nonzeros in equality constraint Jacobian...: {:>8}",
394        s.nnz_jac_eq
395    );
396    println!(
397        "Number of nonzeros in inequality constraint Jacobian.: {:>8}",
398        s.nnz_jac_ineq
399    );
400    println!(
401        "Number of nonzeros in Lagrangian Hessian.............: {:>8}",
402        s.nnz_h_lag
403    );
404    println!();
405    println!(
406        "Total number of variables............................: {:>8}",
407        s.n
408    );
409    println!(
410        "                     variables with only lower bounds: {:>8}",
411        s.var_lower_only
412    );
413    println!(
414        "                variables with lower and upper bounds: {:>8}",
415        s.var_both
416    );
417    println!(
418        "                     variables with only upper bounds: {:>8}",
419        s.var_upper_only
420    );
421    println!(
422        "Total number of equality constraints.................: {:>8}",
423        s.n_eq
424    );
425    println!(
426        "Total number of inequality constraints...............: {:>8}",
427        s.n_ineq
428    );
429    println!(
430        "        inequality constraints with only lower bounds: {:>8}",
431        s.ineq_lower_only
432    );
433    println!(
434        "   inequality constraints with lower and upper bounds: {:>8}",
435        s.ineq_both
436    );
437    println!(
438        "        inequality constraints with only upper bounds: {:>8}",
439        s.ineq_upper_only
440    );
441    println!();
442}
443
444pub fn print_summary(
445    status: ApplicationReturnStatus,
446    stats: &SolveStatistics,
447    counters: &CountingTnlp,
448) {
449    println!();
450    println!();
451    println!("Number of Iterations....: {}", stats.iteration_count);
452    println!();
453    println!("                                   (scaled)                 (unscaled)");
454    let row = |label: &str, scaled: f64, unscaled: f64| {
455        println!(
456            "{label}:   {}    {}",
457            fmt_ipopt(scaled),
458            fmt_ipopt(unscaled)
459        );
460    };
461    row(
462        "Objective...............",
463        stats.final_scaled_objective,
464        stats.final_objective,
465    );
466    row(
467        "Dual infeasibility......",
468        stats.final_dual_inf,
469        stats.final_dual_inf,
470    );
471    row(
472        "Constraint violation....",
473        stats.final_constr_viol,
474        stats.final_constr_viol,
475    );
476    row("Variable bound violation", 0.0, 0.0);
477    row(
478        "Complementarity.........",
479        stats.final_compl,
480        stats.final_compl,
481    );
482    row(
483        "Overall NLP error.......",
484        stats.final_kkt_error,
485        stats.final_kkt_error,
486    );
487    println!();
488    println!();
489    println!(
490        "Number of objective function evaluations             = {}",
491        counters.n_obj.get()
492    );
493    println!(
494        "Number of objective gradient evaluations             = {}",
495        counters.n_grad_f.get()
496    );
497    println!(
498        "Number of equality constraint evaluations            = {}",
499        counters.n_g.get()
500    );
501    println!(
502        "Number of inequality constraint evaluations          = {}",
503        counters.n_g.get()
504    );
505    println!(
506        "Number of equality constraint Jacobian evaluations   = {}",
507        counters.n_jac_g.get()
508    );
509    println!(
510        "Number of inequality constraint Jacobian evaluations = {}",
511        counters.n_jac_g.get()
512    );
513    println!(
514        "Number of Lagrangian Hessian evaluations             = {}",
515        counters.n_h.get()
516    );
517    println!(
518        "Total seconds in POUNCE                              = {:.3}",
519        stats.total_wallclock_time_secs
520    );
521    println!();
522    println!("EXIT: {}", status_message(status));
523    println!();
524    println!(
525        "POUNCE {}: {}",
526        env!("CARGO_PKG_VERSION"),
527        status_message(status)
528    );
529}
530
531/// Emit an Ipopt-style end-of-run summary for the dedicated convex
532/// (LP / QP / conic) IPM path. That path otherwise prints only a compact
533/// one-line result, so the `Number of Iterations....:` and
534/// `Objective...............:` lines the general NLP path emits are missing.
535/// Downstream consumers that parse Ipopt's summary block — notably the
536/// benchmark harness's `extract_obj`/`extract_iters` in
537/// `benchmarks/scripts/run_nl_bench.sh` — then see a null objective and zero
538/// iterations even though the solve succeeded. This prints the same labelled
539/// lines (objective + KKT residual rows) so those consumers capture the real
540/// values. The convex solver reports a single (unscaled, user-sense) objective
541/// and residuals, so the "(scaled)"/"(unscaled)" columns carry the same value.
542pub fn print_convex_summary(
543    iterations: usize,
544    objective: f64,
545    primal_inf: f64,
546    dual_inf: f64,
547    complementarity: f64,
548    kkt_error: f64,
549) {
550    println!();
551    println!();
552    println!("Number of Iterations....: {iterations}");
553    println!();
554    println!("                                   (scaled)                 (unscaled)");
555    let row = |label: &str, v: f64| {
556        println!("{label}:   {}    {}", fmt_ipopt(v), fmt_ipopt(v));
557    };
558    row("Objective...............", objective);
559    row("Dual infeasibility......", dual_inf);
560    row("Constraint violation....", primal_inf);
561    row("Variable bound violation", 0.0);
562    row("Complementarity.........", complementarity);
563    row("Overall NLP error.......", kkt_error);
564    println!();
565}
566
567/// Format a number in Ipopt's scientific notation: 16-digit mantissa,
568/// signed 2-digit exponent (e.g. `3.7952009505566139e+03`). Rust's
569/// `{:.16e}` is close but emits a 1-digit exponent without leading
570/// sign, which makes side-by-side diffs against `ipopt` output messy.
571pub fn fmt_ipopt(v: f64) -> String {
572    if v.is_nan() {
573        return "nan".to_string();
574    }
575    if v.is_infinite() {
576        return if v > 0.0 { "inf".into() } else { "-inf".into() };
577    }
578    let s = format!("{:.16e}", v);
579    let Some(e_pos) = s.rfind('e') else {
580        return s;
581    };
582    let (mantissa, exp_part) = s.split_at(e_pos);
583    let exp_str = &exp_part[1..];
584    let (sign, digits) = if let Some(rest) = exp_str.strip_prefix('-') {
585        ('-', rest)
586    } else if let Some(rest) = exp_str.strip_prefix('+') {
587        ('+', rest)
588    } else {
589        ('+', exp_str)
590    };
591    let padded = if digits.len() < 2 {
592        format!("0{digits}")
593    } else {
594        digits.to_string()
595    };
596    format!("{mantissa}e{sign}{padded}")
597}
598
599pub fn status_message(s: ApplicationReturnStatus) -> &'static str {
600    match s {
601        ApplicationReturnStatus::SolveSucceeded => "Optimal Solution Found.",
602        ApplicationReturnStatus::SolvedToAcceptableLevel => "Solved To Acceptable Level.",
603        ApplicationReturnStatus::InfeasibleProblemDetected => {
604            "Converged to a point of local infeasibility. Problem may be infeasible."
605        }
606        ApplicationReturnStatus::SearchDirectionBecomesTooSmall => {
607            "Search Direction is becoming Too Small."
608        }
609        ApplicationReturnStatus::DivergingIterates => {
610            "Iterates diverging; problem might be unbounded."
611        }
612        ApplicationReturnStatus::UserRequestedStop => "Stopping optimization at user request.",
613        ApplicationReturnStatus::FeasiblePointFound => "Feasible Point Found.",
614        ApplicationReturnStatus::MaximumIterationsExceeded => {
615            "Maximum Number of Iterations Exceeded."
616        }
617        ApplicationReturnStatus::RestorationFailed => "Restoration Failed!",
618        ApplicationReturnStatus::ErrorInStepComputation => "Error in step computation.",
619        ApplicationReturnStatus::MaximumCpuTimeExceeded => "Maximum CPU time exceeded.",
620        ApplicationReturnStatus::MaximumWallTimeExceeded => "Maximum wallclock time exceeded.",
621        ApplicationReturnStatus::NotEnoughDegreesOfFreedom => "Not Enough Degrees of Freedom.",
622        ApplicationReturnStatus::InvalidProblemDefinition => "Invalid Problem Definition.",
623        ApplicationReturnStatus::InvalidOption => "Invalid Option.",
624        ApplicationReturnStatus::InvalidNumberDetected => {
625            "Invalid number in NLP function or derivative detected."
626        }
627        ApplicationReturnStatus::UnrecoverableException => "Unrecoverable Exception.",
628        ApplicationReturnStatus::NonIpoptExceptionThrown => "Exception of type generic.",
629        ApplicationReturnStatus::InsufficientMemory => "Insufficient memory.",
630        ApplicationReturnStatus::InternalError => "INTERNAL ERROR: Unknown SolverReturn value.",
631    }
632}
633
634#[cfg(test)]
635mod inequality_tally_tests {
636    //! Regression test for code review L26: the inequality bound-type
637    //! breakdown (`lower_only` / `both` / `upper_only`) must always sum to
638    //! `n_ineq`. A "free" inequality row (no finite bound on either side)
639    //! previously fell through to a no-op arm, so the breakdown summed to
640    //! *less* than the total whenever such a row was present.
641    use super::*;
642    use pounce_common::types::{Index, Number};
643    use pounce_nlp::tnlp::{BoundsInfo, IndexStyle, IpoptCq, IpoptData, Solution, StartingPoint};
644
645    /// Two free variables, three inequality rows of distinct bound types:
646    /// row 0 lower-only, row 1 both, row 2 *free* (the bug trigger). No
647    /// equality rows. The breakdown must sum to `n_ineq == 3`.
648    struct FreeIneqRow;
649    impl TNLP for FreeIneqRow {
650        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
651            Some(NlpInfo {
652                n: 2,
653                m: 3,
654                nnz_jac_g: 3,
655                nnz_h_lag: 0,
656                index_style: IndexStyle::C,
657            })
658        }
659        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
660            b.x_l.iter_mut().for_each(|v| *v = -BOUND_INF);
661            b.x_u.iter_mut().for_each(|v| *v = BOUND_INF);
662            // row 0: lower-only  [0, +inf)
663            b.g_l[0] = 0.0;
664            b.g_u[0] = BOUND_INF;
665            // row 1: both        [0, 1]
666            b.g_l[1] = 0.0;
667            b.g_u[1] = 1.0;
668            // row 2: free        (-inf, +inf) — the regression trigger
669            b.g_l[2] = -BOUND_INF;
670            b.g_u[2] = BOUND_INF;
671            true
672        }
673        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
674            sp.x.iter_mut().for_each(|v| *v = 0.0);
675            true
676        }
677        fn eval_f(&mut self, _x: &[Number], _new_x: bool) -> Option<Number> {
678            Some(0.0)
679        }
680        fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, grad_f: &mut [Number]) -> bool {
681            grad_f.iter_mut().for_each(|v| *v = 0.0);
682            true
683        }
684        fn eval_g(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
685            g.iter_mut().for_each(|v| *v = 0.0);
686            true
687        }
688        fn eval_jac_g(
689            &mut self,
690            _x: Option<&[Number]>,
691            _new_x: bool,
692            mode: SparsityRequest<'_>,
693        ) -> bool {
694            match mode {
695                SparsityRequest::Structure { irow, jcol } => {
696                    // one entry per row so the eq/ineq Jacobian split also
697                    // visits each row.
698                    irow.copy_from_slice(&[0, 1, 2]);
699                    jcol.copy_from_slice(&[0, 0, 0]);
700                }
701                SparsityRequest::Values { values } => {
702                    values.copy_from_slice(&[1.0, 1.0, 1.0]);
703                }
704            }
705            true
706        }
707        fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
708    }
709
710    #[test]
711    fn free_inequality_row_keeps_breakdown_summing_to_total() {
712        let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(FreeIneqRow));
713        let s = collect_stats(
714            &tnlp,
715            -BOUND_INF,
716            BOUND_INF,
717            FixedVarTreatment::MakeParameter,
718        )
719        .expect("collect_stats succeeds");
720
721        assert_eq!(s.n_eq, 0, "no equality rows");
722        assert_eq!(s.n_ineq, 3, "all three rows are inequalities");
723        // The headline invariant L26 flagged: the three printed buckets must
724        // account for every inequality row.
725        let bucket_sum: Index = s.ineq_lower_only + s.ineq_both + s.ineq_upper_only;
726        assert_eq!(
727            bucket_sum, s.n_ineq,
728            "ineq bound-type breakdown ({} lower + {} both + {} upper) must sum to n_ineq={}",
729            s.ineq_lower_only, s.ineq_both, s.ineq_upper_only, s.n_ineq
730        );
731        // The free row is bucketed under "both" alongside the genuine
732        // both-bounded row 1.
733        assert_eq!(s.ineq_lower_only, 1);
734        assert_eq!(s.ineq_upper_only, 0);
735        assert_eq!(s.ineq_both, 2);
736    }
737
738    /// #140 regression. Three variables, the middle one fixed
739    /// (`x_l == x_u`). Under the default `make_parameter` the banner must
740    /// report the *reduced* problem: the fixed var is dropped from the total,
741    /// is NOT bucketed as "lower and upper bounds", and its Jacobian column is
742    /// excluded from the nnz tally — matching Ipopt (and the problem the
743    /// algorithm actually solves). Previously the banner walked the raw TNLP
744    /// and over-reported all three.
745    struct OneFixedVar;
746    impl TNLP for OneFixedVar {
747        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
748            Some(NlpInfo {
749                n: 3,
750                m: 1,
751                nnz_jac_g: 3,
752                nnz_h_lag: 0,
753                index_style: IndexStyle::C,
754            })
755        }
756        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
757            // var 0: lower-only [0, +inf)   var 1: FIXED at 2   var 2: free
758            b.x_l[0] = 0.0;
759            b.x_u[0] = BOUND_INF;
760            b.x_l[1] = 2.0;
761            b.x_u[1] = 2.0;
762            b.x_l[2] = -BOUND_INF;
763            b.x_u[2] = BOUND_INF;
764            // one equality row (keeps n_x_var=2 >= n_c=1, so no relax switch)
765            b.g_l[0] = 0.0;
766            b.g_u[0] = 0.0;
767            true
768        }
769        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
770            sp.x.iter_mut().for_each(|v| *v = 0.0);
771            true
772        }
773        fn eval_f(&mut self, _x: &[Number], _new_x: bool) -> Option<Number> {
774            Some(0.0)
775        }
776        fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, grad_f: &mut [Number]) -> bool {
777            grad_f.iter_mut().for_each(|v| *v = 0.0);
778            true
779        }
780        fn eval_g(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
781            g.iter_mut().for_each(|v| *v = 0.0);
782            true
783        }
784        fn eval_jac_g(
785            &mut self,
786            _x: Option<&[Number]>,
787            _new_x: bool,
788            mode: SparsityRequest<'_>,
789        ) -> bool {
790            match mode {
791                // The equality row touches all three columns, including the
792                // fixed var (col 1) that must be filtered out.
793                SparsityRequest::Structure { irow, jcol } => {
794                    irow.copy_from_slice(&[0, 0, 0]);
795                    jcol.copy_from_slice(&[0, 1, 2]);
796                }
797                SparsityRequest::Values { values } => {
798                    values.copy_from_slice(&[1.0, 1.0, 1.0]);
799                }
800            }
801            true
802        }
803        fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
804    }
805
806    #[test]
807    fn make_parameter_banner_reports_reduced_problem() {
808        let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(OneFixedVar));
809        let s = collect_stats(
810            &tnlp,
811            -BOUND_INF,
812            BOUND_INF,
813            FixedVarTreatment::MakeParameter,
814        )
815        .expect("collect_stats succeeds");
816
817        // Fixed var removed: 3 raw vars → 2 optimized.
818        assert_eq!(s.n, 2, "fixed variable must be dropped from the total");
819        assert_eq!(s.var_both, 0, "fixed var must NOT count as lower-and-upper");
820        assert_eq!(s.var_lower_only, 1, "var 0 is lower-only");
821        assert_eq!(s.var_free, 1, "var 2 is free");
822        assert_eq!(s.var_upper_only, 0);
823        // The fixed column is excluded from the Jacobian nnz.
824        assert_eq!(s.n_eq, 1);
825        assert_eq!(
826            s.nnz_jac_eq, 2,
827            "fixed-var column dropped from the Jacobian"
828        );
829        assert_eq!(s.nnz_jac_ineq, 0);
830    }
831
832    #[test]
833    fn relax_bounds_banner_keeps_fixed_variable() {
834        // Under relax_bounds the fixed var stays in the optimization and is
835        // reported as a lower-and-upper-bounded variable — matching Ipopt.
836        let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(OneFixedVar));
837        let s = collect_stats(&tnlp, -BOUND_INF, BOUND_INF, FixedVarTreatment::RelaxBounds)
838            .expect("collect_stats succeeds");
839
840        assert_eq!(s.n, 3, "relax_bounds keeps the fixed variable");
841        assert_eq!(
842            s.var_both, 1,
843            "fixed var reported as lower-and-upper bounded"
844        );
845        assert_eq!(s.var_lower_only, 1);
846        assert_eq!(s.var_free, 1);
847        assert_eq!(s.nnz_jac_eq, 3, "all columns retained under relax_bounds");
848    }
849}