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_nlp::return_codes::ApplicationReturnStatus;
8use pounce_nlp::solve_statistics::SolveStatistics;
9use pounce_nlp::tnlp::{BoundsInfo, NlpInfo, SparsityRequest, TNLP};
10use std::cell::RefCell;
11use std::rc::Rc;
12
13/// Same sentinel Ipopt uses for "no bound": ±1e19. Matched exactly so
14/// the per-bound-type tallies agree with `ipopt`'s own output on
15/// problems whose bounds were authored against that convention.
16const BOUND_INF: f64 = 1.0e19;
17
18#[derive(Debug, Clone, Copy)]
19pub struct ProblemStats {
20    pub n: i32,
21    pub m: i32,
22    pub nnz_jac_eq: i32,
23    pub nnz_jac_ineq: i32,
24    pub nnz_h_lag: i32,
25    pub var_lower_only: i32,
26    pub var_upper_only: i32,
27    pub var_both: i32,
28    pub var_free: i32,
29    pub n_eq: i32,
30    pub n_ineq: i32,
31    pub ineq_lower_only: i32,
32    pub ineq_upper_only: i32,
33    pub ineq_both: i32,
34}
35
36/// Walk the TNLP once to gather everything the banner block needs:
37/// `NlpInfo`, the four bound vectors, and the Jacobian row indices.
38/// Returns `None` if any of the required TNLP calls fails.
39pub fn collect_stats(tnlp: &Rc<RefCell<dyn TNLP>>) -> Option<ProblemStats> {
40    let mut t = tnlp.borrow_mut();
41    let info: NlpInfo = t.get_nlp_info()?;
42    let n = info.n as usize;
43    let m = info.m as usize;
44    let mut x_l = vec![0.0; n];
45    let mut x_u = vec![0.0; n];
46    let mut g_l = vec![0.0; m];
47    let mut g_u = vec![0.0; m];
48    if !t.get_bounds_info(BoundsInfo {
49        x_l: &mut x_l,
50        x_u: &mut x_u,
51        g_l: &mut g_l,
52        g_u: &mut g_u,
53    }) {
54        return None;
55    }
56
57    // Variable bound classification.
58    let (mut var_lower_only, mut var_upper_only, mut var_both, mut var_free) = (0, 0, 0, 0);
59    for i in 0..n {
60        let has_l = x_l[i] > -BOUND_INF;
61        let has_u = x_u[i] < BOUND_INF;
62        match (has_l, has_u) {
63            (true, true) => var_both += 1,
64            (true, false) => var_lower_only += 1,
65            (false, true) => var_upper_only += 1,
66            (false, false) => var_free += 1,
67        }
68    }
69
70    // Constraint classification (equality vs inequality, and the
71    // inequality bound type).
72    let (mut n_eq, mut n_ineq) = (0, 0);
73    let (mut ineq_lower_only, mut ineq_upper_only, mut ineq_both) = (0, 0, 0);
74    let mut row_is_eq = vec![false; m];
75    for i in 0..m {
76        if (g_l[i] - g_u[i]).abs() < 1e-12 && g_l[i].abs() < BOUND_INF {
77            n_eq += 1;
78            row_is_eq[i] = true;
79        } else {
80            n_ineq += 1;
81            let has_l = g_l[i] > -BOUND_INF;
82            let has_u = g_u[i] < BOUND_INF;
83            match (has_l, has_u) {
84                (true, true) => ineq_both += 1,
85                (true, false) => ineq_lower_only += 1,
86                (false, true) => ineq_upper_only += 1,
87                // A "free" inequality has no finite bound on either side
88                // (e.g. an `.nl` range row left fully open). It is already
89                // counted in `n_ineq`, so it must land in one of the
90                // per-bound-type buckets or the printed breakdown won't sum
91                // to the total. Bucket it under "both", matching the comment's
92                // long-standing intent (it previously fell through to `{}`,
93                // leaving `lower_only + both + upper_only < n_ineq`).
94                (false, false) => ineq_both += 1,
95            }
96        }
97    }
98
99    // Jacobian split: read the structure once and tally per-row.
100    let nnz_total = info.nnz_jac_g as usize;
101    let (mut nnz_jac_eq, mut nnz_jac_ineq) = (0, 0);
102    if nnz_total > 0 && m > 0 {
103        let mut irow = vec![0_i32; nnz_total];
104        let mut jcol = vec![0_i32; nnz_total];
105        if t.eval_jac_g(
106            None,
107            true,
108            SparsityRequest::Structure {
109                irow: &mut irow,
110                jcol: &mut jcol,
111            },
112        ) {
113            let one_based = matches!(info.index_style, pounce_nlp::tnlp::IndexStyle::Fortran);
114            for &r in &irow {
115                let row = if one_based {
116                    (r - 1) as usize
117                } else {
118                    r as usize
119                };
120                if row < m && row_is_eq[row] {
121                    nnz_jac_eq += 1;
122                } else {
123                    nnz_jac_ineq += 1;
124                }
125            }
126        }
127    }
128
129    Some(ProblemStats {
130        n: info.n,
131        m: info.m,
132        nnz_jac_eq,
133        nnz_jac_ineq,
134        nnz_h_lag: info.nnz_h_lag,
135        var_lower_only,
136        var_upper_only,
137        var_both,
138        var_free,
139        n_eq,
140        n_ineq,
141        ineq_lower_only,
142        ineq_upper_only,
143        ineq_both,
144    })
145}
146
147/// POUNCE wordmark in block letters, printed above the copyright banner.
148const LOGO: [&str; 5] = [
149    "####    ###   #   #  #   #   ####  #####",
150    "#   #  #   #  #   #  ##  #  #      #    ",
151    "####   #   #  #   #  # # #  #      #### ",
152    "#      #   #  #   #  #  ##  #      #    ",
153    "#       ###    ###   #   #   ####  #####",
154];
155
156/// Width of the copyright banner's asterisk rules — wide enough to span
157/// the longest banner text line. The wordmark is centered against this,
158/// and a matching rule is printed above it.
159const BANNER_WIDTH: usize = 80;
160
161/// Print the branded POUNCE ASCII wordmark, mimicking the project logo.
162///
163/// Block letters get a top-lit **steel** sheen (light silver → dark
164/// steel down the rows); three diagonal **molten claw** slashes rake
165/// upper-right → lower-left, glowing bright gold at the top into deep
166/// red at the bottom — the brand logo's look. Emitted through
167/// `anstream::stdout()`, which strips the ANSI when stdout is redirected
168/// or `NO_COLOR` is set (non-TTY sinks get the plain text), with a
169/// 256-color downgrade on non-truecolor terminals. The metallic letters
170/// are tuned for a dark terminal background.
171pub fn print_logo() {
172    use std::io::Write as _;
173    let width = LOGO
174        .iter()
175        .map(|l| l.chars().count())
176        .max()
177        .unwrap_or(1)
178        .max(2);
179    let mut out = anstream::stdout();
180    // Leading rule matching the copyright banner's width, then a blank
181    // line, then the centered wordmark. The rule is left in the terminal's
182    // default color (like the banner's own rules) so it stays distinct on
183    // any background. `anstream` strips the styling when stdout isn't a TTY.
184    let _ = writeln!(out, "{}", "*".repeat(BANNER_WIDTH));
185    let _ = writeln!(out);
186    let pad = " ".repeat(BANNER_WIDTH.saturating_sub(width) / 2);
187    for row in logo_rows(true) {
188        let _ = writeln!(out, "{pad}{row}");
189    }
190    let _ = writeln!(out);
191}
192
193/// Render the POUNCE wordmark as styled rows (one `String` per line):
194/// steel-sheen letters with three molten claw slashes, in the project
195/// palette. Emits ANSI styling only when `color`; otherwise plain
196/// `#`/`/` block characters. Shared by the solve header ([`print_logo`])
197/// and the interactive debugger's open banner (rendered to stderr).
198pub fn logo_rows(color: bool) -> Vec<String> {
199    use pounce_common::style::{downgrade, truecolor_enabled, ALPHA_HOT, BRIGHT_YEL, TIGER_ORANGE};
200
201    fn lerp(a: u8, b: u8, t: f64) -> u8 {
202        (a as f64 + (b as f64 - a as f64) * t)
203            .round()
204            .clamp(0.0, 255.0) as u8
205    }
206    fn mix(a: anstyle::RgbColor, b: anstyle::RgbColor, t: f64) -> anstyle::RgbColor {
207        anstyle::RgbColor(lerp(a.0, b.0, t), lerp(a.1, b.1, t), lerp(a.2, b.2, t))
208    }
209    // Steel sheen (top-lit): light silver at the top row → dark steel at
210    // the bottom. Molten ramp: gold → tiger-orange → deep red top-to-bottom.
211    const STEEL_HI: anstyle::RgbColor = anstyle::RgbColor(0xd2, 0xd6, 0xdc);
212    const STEEL_LO: anstyle::RgbColor = anstyle::RgbColor(0x5c, 0x60, 0x68);
213
214    let rows = LOGO.len();
215    let width = LOGO
216        .iter()
217        .map(|l| l.chars().count())
218        .max()
219        .unwrap_or(1)
220        .max(2);
221    let vfrac = |r: usize| {
222        if rows <= 1 {
223            0.0
224        } else {
225            r as f64 / (rows - 1) as f64
226        }
227    };
228    // Molten color for a claw cell at row `r` (0 = top, hottest).
229    let molten = |r: usize| {
230        let t = vfrac(r);
231        if t < 0.5 {
232            mix(BRIGHT_YEL, TIGER_ORANGE, t / 0.5)
233        } else {
234            mix(TIGER_ORANGE, ALPHA_HOT, (t - 0.5) / 0.5)
235        }
236    };
237
238    let mut grid: Vec<Vec<Option<(char, anstyle::RgbColor)>>> = vec![vec![None; width]; rows];
239    for (r, line) in LOGO.iter().enumerate() {
240        let steel = mix(STEEL_HI, STEEL_LO, vfrac(r));
241        for (c, ch) in line.chars().enumerate() {
242            if ch != ' ' {
243                grid[r][c] = Some((ch, steel));
244            }
245        }
246    }
247    // Three parallel molten claw slashes, upper-right → lower-left (`/`).
248    for &start in &[width / 4, width / 4 + 6, width / 4 + 12] {
249        for r in 0..rows {
250            let c = start + (rows - 1 - r);
251            if c < width {
252                grid[r][c] = Some(('/', molten(r)));
253            }
254        }
255    }
256
257    let truecolor = truecolor_enabled();
258    grid.iter()
259        .map(|row| {
260            let mut rendered = String::new();
261            for cell in row {
262                match cell {
263                    Some((ch, rgb)) if color => {
264                        let style = anstyle::Style::new()
265                            .bold()
266                            .fg_color(Some(downgrade(*rgb, truecolor)));
267                        rendered.push_str(&format!(
268                            "{}{}{}",
269                            style.render(),
270                            ch,
271                            style.render_reset()
272                        ));
273                    }
274                    Some((ch, _)) => rendered.push(*ch),
275                    None => rendered.push(' '),
276                }
277            }
278            rendered.trim_end().to_string()
279        })
280        .collect()
281}
282
283pub fn print_banner(linear_solver: &str) {
284    use std::io::IsTerminal as _;
285
286    // OSC 8 hyperlink so supporting terminals make the URL clickable;
287    // only emitted to a TTY so redirected output stays plain text.
288    const URL: &str = "https://github.com/jkitchin/pounce";
289    let link = if std::io::stdout().is_terminal() {
290        format!("\x1b]8;;{URL}\x1b\\{URL}\x1b]8;;\x1b\\")
291    } else {
292        URL.to_string()
293    };
294
295    let rule = "*".repeat(BANNER_WIDTH);
296    println!("{rule}");
297    println!("This program contains POUNCE, a pure-Rust interior-point optimization solver");
298    println!("for nonlinear, conic, and global problems (its NLP core is ported from Ipopt).");
299    println!("Released under the Eclipse Public License (EPL) — drop-in compatible with Ipopt.");
300    println!("         For more information visit {link}");
301    println!("{rule}");
302    println!();
303    println!(
304        "This is POUNCE version {}, running with linear solver {}.",
305        env!("CARGO_PKG_VERSION"),
306        linear_solver
307    );
308    println!();
309}
310
311pub fn print_problem_stats(s: &ProblemStats) {
312    println!(
313        "Number of nonzeros in equality constraint Jacobian...: {:>8}",
314        s.nnz_jac_eq
315    );
316    println!(
317        "Number of nonzeros in inequality constraint Jacobian.: {:>8}",
318        s.nnz_jac_ineq
319    );
320    println!(
321        "Number of nonzeros in Lagrangian Hessian.............: {:>8}",
322        s.nnz_h_lag
323    );
324    println!();
325    println!(
326        "Total number of variables............................: {:>8}",
327        s.n
328    );
329    println!(
330        "                     variables with only lower bounds: {:>8}",
331        s.var_lower_only
332    );
333    println!(
334        "                variables with lower and upper bounds: {:>8}",
335        s.var_both
336    );
337    println!(
338        "                     variables with only upper bounds: {:>8}",
339        s.var_upper_only
340    );
341    println!(
342        "Total number of equality constraints.................: {:>8}",
343        s.n_eq
344    );
345    println!(
346        "Total number of inequality constraints...............: {:>8}",
347        s.n_ineq
348    );
349    println!(
350        "        inequality constraints with only lower bounds: {:>8}",
351        s.ineq_lower_only
352    );
353    println!(
354        "   inequality constraints with lower and upper bounds: {:>8}",
355        s.ineq_both
356    );
357    println!(
358        "        inequality constraints with only upper bounds: {:>8}",
359        s.ineq_upper_only
360    );
361    println!();
362}
363
364pub fn print_summary(
365    status: ApplicationReturnStatus,
366    stats: &SolveStatistics,
367    counters: &CountingTnlp,
368) {
369    println!();
370    println!();
371    println!("Number of Iterations....: {}", stats.iteration_count);
372    println!();
373    println!("                                   (scaled)                 (unscaled)");
374    let row = |label: &str, scaled: f64, unscaled: f64| {
375        println!(
376            "{label}:   {}    {}",
377            fmt_ipopt(scaled),
378            fmt_ipopt(unscaled)
379        );
380    };
381    row(
382        "Objective...............",
383        stats.final_scaled_objective,
384        stats.final_objective,
385    );
386    row(
387        "Dual infeasibility......",
388        stats.final_dual_inf,
389        stats.final_dual_inf,
390    );
391    row(
392        "Constraint violation....",
393        stats.final_constr_viol,
394        stats.final_constr_viol,
395    );
396    row("Variable bound violation", 0.0, 0.0);
397    row(
398        "Complementarity.........",
399        stats.final_compl,
400        stats.final_compl,
401    );
402    row(
403        "Overall NLP error.......",
404        stats.final_kkt_error,
405        stats.final_kkt_error,
406    );
407    println!();
408    println!();
409    println!(
410        "Number of objective function evaluations             = {}",
411        counters.n_obj.get()
412    );
413    println!(
414        "Number of objective gradient evaluations             = {}",
415        counters.n_grad_f.get()
416    );
417    println!(
418        "Number of equality constraint evaluations            = {}",
419        counters.n_g.get()
420    );
421    println!(
422        "Number of inequality constraint evaluations          = {}",
423        counters.n_g.get()
424    );
425    println!(
426        "Number of equality constraint Jacobian evaluations   = {}",
427        counters.n_jac_g.get()
428    );
429    println!(
430        "Number of inequality constraint Jacobian evaluations = {}",
431        counters.n_jac_g.get()
432    );
433    println!(
434        "Number of Lagrangian Hessian evaluations             = {}",
435        counters.n_h.get()
436    );
437    println!(
438        "Total seconds in POUNCE                              = {:.3}",
439        stats.total_wallclock_time_secs
440    );
441    println!();
442    println!("EXIT: {}", status_message(status));
443    println!();
444    println!(
445        "POUNCE {}: {}",
446        env!("CARGO_PKG_VERSION"),
447        status_message(status)
448    );
449}
450
451/// Emit an Ipopt-style end-of-run summary for the dedicated convex
452/// (LP / QP / conic) IPM path. That path otherwise prints only a compact
453/// one-line result, so the `Number of Iterations....:` and
454/// `Objective...............:` lines the general NLP path emits are missing.
455/// Downstream consumers that parse Ipopt's summary block — notably the
456/// benchmark harness's `extract_obj`/`extract_iters` in
457/// `benchmarks/scripts/run_nl_bench.sh` — then see a null objective and zero
458/// iterations even though the solve succeeded. This prints the same labelled
459/// lines (objective + KKT residual rows) so those consumers capture the real
460/// values. The convex solver reports a single (unscaled, user-sense) objective
461/// and residuals, so the "(scaled)"/"(unscaled)" columns carry the same value.
462pub fn print_convex_summary(
463    iterations: usize,
464    objective: f64,
465    primal_inf: f64,
466    dual_inf: f64,
467    complementarity: f64,
468    kkt_error: f64,
469) {
470    println!();
471    println!();
472    println!("Number of Iterations....: {iterations}");
473    println!();
474    println!("                                   (scaled)                 (unscaled)");
475    let row = |label: &str, v: f64| {
476        println!("{label}:   {}    {}", fmt_ipopt(v), fmt_ipopt(v));
477    };
478    row("Objective...............", objective);
479    row("Dual infeasibility......", dual_inf);
480    row("Constraint violation....", primal_inf);
481    row("Variable bound violation", 0.0);
482    row("Complementarity.........", complementarity);
483    row("Overall NLP error.......", kkt_error);
484    println!();
485}
486
487/// Format a number in Ipopt's scientific notation: 16-digit mantissa,
488/// signed 2-digit exponent (e.g. `3.7952009505566139e+03`). Rust's
489/// `{:.16e}` is close but emits a 1-digit exponent without leading
490/// sign, which makes side-by-side diffs against `ipopt` output messy.
491pub fn fmt_ipopt(v: f64) -> String {
492    if v.is_nan() {
493        return "nan".to_string();
494    }
495    if v.is_infinite() {
496        return if v > 0.0 { "inf".into() } else { "-inf".into() };
497    }
498    let s = format!("{:.16e}", v);
499    let Some(e_pos) = s.rfind('e') else {
500        return s;
501    };
502    let (mantissa, exp_part) = s.split_at(e_pos);
503    let exp_str = &exp_part[1..];
504    let (sign, digits) = if let Some(rest) = exp_str.strip_prefix('-') {
505        ('-', rest)
506    } else if let Some(rest) = exp_str.strip_prefix('+') {
507        ('+', rest)
508    } else {
509        ('+', exp_str)
510    };
511    let padded = if digits.len() < 2 {
512        format!("0{digits}")
513    } else {
514        digits.to_string()
515    };
516    format!("{mantissa}e{sign}{padded}")
517}
518
519pub fn status_message(s: ApplicationReturnStatus) -> &'static str {
520    match s {
521        ApplicationReturnStatus::SolveSucceeded => "Optimal Solution Found.",
522        ApplicationReturnStatus::SolvedToAcceptableLevel => "Solved To Acceptable Level.",
523        ApplicationReturnStatus::InfeasibleProblemDetected => {
524            "Converged to a point of local infeasibility. Problem may be infeasible."
525        }
526        ApplicationReturnStatus::SearchDirectionBecomesTooSmall => {
527            "Search Direction is becoming Too Small."
528        }
529        ApplicationReturnStatus::DivergingIterates => {
530            "Iterates diverging; problem might be unbounded."
531        }
532        ApplicationReturnStatus::UserRequestedStop => "Stopping optimization at user request.",
533        ApplicationReturnStatus::FeasiblePointFound => "Feasible Point Found.",
534        ApplicationReturnStatus::MaximumIterationsExceeded => {
535            "Maximum Number of Iterations Exceeded."
536        }
537        ApplicationReturnStatus::RestorationFailed => "Restoration Failed!",
538        ApplicationReturnStatus::ErrorInStepComputation => "Error in step computation.",
539        ApplicationReturnStatus::MaximumCpuTimeExceeded => "Maximum CPU time exceeded.",
540        ApplicationReturnStatus::MaximumWallTimeExceeded => "Maximum wallclock time exceeded.",
541        ApplicationReturnStatus::NotEnoughDegreesOfFreedom => "Not Enough Degrees of Freedom.",
542        ApplicationReturnStatus::InvalidProblemDefinition => "Invalid Problem Definition.",
543        ApplicationReturnStatus::InvalidOption => "Invalid Option.",
544        ApplicationReturnStatus::InvalidNumberDetected => {
545            "Invalid number in NLP function or derivative detected."
546        }
547        ApplicationReturnStatus::UnrecoverableException => "Unrecoverable Exception.",
548        ApplicationReturnStatus::NonIpoptExceptionThrown => "Exception of type generic.",
549        ApplicationReturnStatus::InsufficientMemory => "Insufficient memory.",
550        ApplicationReturnStatus::InternalError => "INTERNAL ERROR: Unknown SolverReturn value.",
551    }
552}
553
554#[cfg(test)]
555mod inequality_tally_tests {
556    //! Regression test for code review L26: the inequality bound-type
557    //! breakdown (`lower_only` / `both` / `upper_only`) must always sum to
558    //! `n_ineq`. A "free" inequality row (no finite bound on either side)
559    //! previously fell through to a no-op arm, so the breakdown summed to
560    //! *less* than the total whenever such a row was present.
561    use super::*;
562    use pounce_common::types::{Index, Number};
563    use pounce_nlp::tnlp::{IndexStyle, IpoptCq, IpoptData, Solution, StartingPoint};
564
565    /// Two free variables, three inequality rows of distinct bound types:
566    /// row 0 lower-only, row 1 both, row 2 *free* (the bug trigger). No
567    /// equality rows. The breakdown must sum to `n_ineq == 3`.
568    struct FreeIneqRow;
569    impl TNLP for FreeIneqRow {
570        fn get_nlp_info(&mut self) -> Option<NlpInfo> {
571            Some(NlpInfo {
572                n: 2,
573                m: 3,
574                nnz_jac_g: 3,
575                nnz_h_lag: 0,
576                index_style: IndexStyle::C,
577            })
578        }
579        fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
580            b.x_l.iter_mut().for_each(|v| *v = -BOUND_INF);
581            b.x_u.iter_mut().for_each(|v| *v = BOUND_INF);
582            // row 0: lower-only  [0, +inf)
583            b.g_l[0] = 0.0;
584            b.g_u[0] = BOUND_INF;
585            // row 1: both        [0, 1]
586            b.g_l[1] = 0.0;
587            b.g_u[1] = 1.0;
588            // row 2: free        (-inf, +inf) — the regression trigger
589            b.g_l[2] = -BOUND_INF;
590            b.g_u[2] = BOUND_INF;
591            true
592        }
593        fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
594            sp.x.iter_mut().for_each(|v| *v = 0.0);
595            true
596        }
597        fn eval_f(&mut self, _x: &[Number], _new_x: bool) -> Option<Number> {
598            Some(0.0)
599        }
600        fn eval_grad_f(&mut self, _x: &[Number], _new_x: bool, grad_f: &mut [Number]) -> bool {
601            grad_f.iter_mut().for_each(|v| *v = 0.0);
602            true
603        }
604        fn eval_g(&mut self, _x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
605            g.iter_mut().for_each(|v| *v = 0.0);
606            true
607        }
608        fn eval_jac_g(
609            &mut self,
610            _x: Option<&[Number]>,
611            _new_x: bool,
612            mode: SparsityRequest<'_>,
613        ) -> bool {
614            match mode {
615                SparsityRequest::Structure { irow, jcol } => {
616                    // one entry per row so the eq/ineq Jacobian split also
617                    // visits each row.
618                    irow.copy_from_slice(&[0, 1, 2]);
619                    jcol.copy_from_slice(&[0, 0, 0]);
620                }
621                SparsityRequest::Values { values } => {
622                    values.copy_from_slice(&[1.0, 1.0, 1.0]);
623                }
624            }
625            true
626        }
627        fn finalize_solution(&mut self, _sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {}
628    }
629
630    #[test]
631    fn free_inequality_row_keeps_breakdown_summing_to_total() {
632        let tnlp: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(FreeIneqRow));
633        let s = collect_stats(&tnlp).expect("collect_stats succeeds");
634
635        assert_eq!(s.n_eq, 0, "no equality rows");
636        assert_eq!(s.n_ineq, 3, "all three rows are inequalities");
637        // The headline invariant L26 flagged: the three printed buckets must
638        // account for every inequality row.
639        let bucket_sum: Index = s.ineq_lower_only + s.ineq_both + s.ineq_upper_only;
640        assert_eq!(
641            bucket_sum, s.n_ineq,
642            "ineq bound-type breakdown ({} lower + {} both + {} upper) must sum to n_ineq={}",
643            s.ineq_lower_only, s.ineq_both, s.ineq_upper_only, s.n_ineq
644        );
645        // The free row is bucketed under "both" alongside the genuine
646        // both-bounded row 1.
647        assert_eq!(s.ineq_lower_only, 1);
648        assert_eq!(s.ineq_upper_only, 0);
649        assert_eq!(s.ineq_both, 2);
650    }
651}