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 bounds at all — count it
88                // anyway under "both" to keep the totals consistent.
89                (false, false) => {}
90            }
91        }
92    }
93
94    // Jacobian split: read the structure once and tally per-row.
95    let nnz_total = info.nnz_jac_g as usize;
96    let (mut nnz_jac_eq, mut nnz_jac_ineq) = (0, 0);
97    if nnz_total > 0 && m > 0 {
98        let mut irow = vec![0_i32; nnz_total];
99        let mut jcol = vec![0_i32; nnz_total];
100        if t.eval_jac_g(
101            None,
102            true,
103            SparsityRequest::Structure {
104                irow: &mut irow,
105                jcol: &mut jcol,
106            },
107        ) {
108            let one_based = matches!(info.index_style, pounce_nlp::tnlp::IndexStyle::Fortran);
109            for &r in &irow {
110                let row = if one_based {
111                    (r - 1) as usize
112                } else {
113                    r as usize
114                };
115                if row < m && row_is_eq[row] {
116                    nnz_jac_eq += 1;
117                } else {
118                    nnz_jac_ineq += 1;
119                }
120            }
121        }
122    }
123
124    Some(ProblemStats {
125        n: info.n,
126        m: info.m,
127        nnz_jac_eq,
128        nnz_jac_ineq,
129        nnz_h_lag: info.nnz_h_lag,
130        var_lower_only,
131        var_upper_only,
132        var_both,
133        var_free,
134        n_eq,
135        n_ineq,
136        ineq_lower_only,
137        ineq_upper_only,
138        ineq_both,
139    })
140}
141
142/// POUNCE wordmark in block letters, printed above the copyright banner.
143const LOGO: [&str; 5] = [
144    "####    ###   #   #  #   #   ####  #####",
145    "#   #  #   #  #   #  ##  #  #      #    ",
146    "####   #   #  #   #  # # #  #      #### ",
147    "#      #   #  #   #  #  ##  #      #    ",
148    "#       ###    ###   #   #   ####  #####",
149];
150
151/// Width of the copyright banner's asterisk rules — wide enough to span
152/// the longest banner text line. The wordmark is centered against this,
153/// and a matching rule is printed above it.
154const BANNER_WIDTH: usize = 80;
155
156/// Print the branded POUNCE ASCII wordmark, mimicking the project logo.
157///
158/// Block letters get a top-lit **steel** sheen (light silver → dark
159/// steel down the rows); three diagonal **molten claw** slashes rake
160/// upper-right → lower-left, glowing bright gold at the top into deep
161/// red at the bottom — the brand logo's look. Emitted through
162/// `anstream::stdout()`, which strips the ANSI when stdout is redirected
163/// or `NO_COLOR` is set (non-TTY sinks get the plain text), with a
164/// 256-color downgrade on non-truecolor terminals. The metallic letters
165/// are tuned for a dark terminal background.
166pub fn print_logo() {
167    use std::io::Write as _;
168    let width = LOGO
169        .iter()
170        .map(|l| l.chars().count())
171        .max()
172        .unwrap_or(1)
173        .max(2);
174    let mut out = anstream::stdout();
175    // Leading rule matching the copyright banner's width, then a blank
176    // line, then the centered wordmark. The rule is left in the terminal's
177    // default color (like the banner's own rules) so it stays distinct on
178    // any background. `anstream` strips the styling when stdout isn't a TTY.
179    let _ = writeln!(out, "{}", "*".repeat(BANNER_WIDTH));
180    let _ = writeln!(out);
181    let pad = " ".repeat(BANNER_WIDTH.saturating_sub(width) / 2);
182    for row in logo_rows(true) {
183        let _ = writeln!(out, "{pad}{row}");
184    }
185    let _ = writeln!(out);
186}
187
188/// Render the POUNCE wordmark as styled rows (one `String` per line):
189/// steel-sheen letters with three molten claw slashes, in the project
190/// palette. Emits ANSI styling only when `color`; otherwise plain
191/// `#`/`/` block characters. Shared by the solve header ([`print_logo`])
192/// and the interactive debugger's open banner (rendered to stderr).
193pub fn logo_rows(color: bool) -> Vec<String> {
194    use pounce_common::style::{downgrade, truecolor_enabled, ALPHA_HOT, BRIGHT_YEL, TIGER_ORANGE};
195
196    fn lerp(a: u8, b: u8, t: f64) -> u8 {
197        (a as f64 + (b as f64 - a as f64) * t)
198            .round()
199            .clamp(0.0, 255.0) as u8
200    }
201    fn mix(a: anstyle::RgbColor, b: anstyle::RgbColor, t: f64) -> anstyle::RgbColor {
202        anstyle::RgbColor(lerp(a.0, b.0, t), lerp(a.1, b.1, t), lerp(a.2, b.2, t))
203    }
204    // Steel sheen (top-lit): light silver at the top row → dark steel at
205    // the bottom. Molten ramp: gold → tiger-orange → deep red top-to-bottom.
206    const STEEL_HI: anstyle::RgbColor = anstyle::RgbColor(0xd2, 0xd6, 0xdc);
207    const STEEL_LO: anstyle::RgbColor = anstyle::RgbColor(0x5c, 0x60, 0x68);
208
209    let rows = LOGO.len();
210    let width = LOGO
211        .iter()
212        .map(|l| l.chars().count())
213        .max()
214        .unwrap_or(1)
215        .max(2);
216    let vfrac = |r: usize| {
217        if rows <= 1 {
218            0.0
219        } else {
220            r as f64 / (rows - 1) as f64
221        }
222    };
223    let molten = |r: usize| {
224        let t = vfrac(r);
225        if t < 0.5 {
226            mix(BRIGHT_YEL, TIGER_ORANGE, t / 0.5)
227        } else {
228            mix(TIGER_ORANGE, ALPHA_HOT, (t - 0.5) / 0.5)
229        }
230    };
231
232    let mut grid: Vec<Vec<Option<(char, anstyle::RgbColor)>>> = vec![vec![None; width]; rows];
233    for (r, line) in LOGO.iter().enumerate() {
234        let steel = mix(STEEL_HI, STEEL_LO, vfrac(r));
235        for (c, ch) in line.chars().enumerate() {
236            if ch != ' ' {
237                grid[r][c] = Some((ch, steel));
238            }
239        }
240    }
241    // Three parallel molten claw slashes, upper-right → lower-left (`/`).
242    for &start in &[width / 4, width / 4 + 6, width / 4 + 12] {
243        for r in 0..rows {
244            let c = start + (rows - 1 - r);
245            if c < width {
246                grid[r][c] = Some(('/', molten(r)));
247            }
248        }
249    }
250
251    let truecolor = truecolor_enabled();
252    grid.iter()
253        .map(|row| {
254            let mut rendered = String::new();
255            for cell in row {
256                match cell {
257                    Some((ch, rgb)) if color => {
258                        let style = anstyle::Style::new()
259                            .bold()
260                            .fg_color(Some(downgrade(*rgb, truecolor)));
261                        rendered.push_str(&format!(
262                            "{}{}{}",
263                            style.render(),
264                            ch,
265                            style.render_reset()
266                        ));
267                    }
268                    Some((ch, _)) => rendered.push(*ch),
269                    None => rendered.push(' '),
270                }
271            }
272            rendered.trim_end().to_string()
273        })
274        .collect()
275}
276
277pub fn print_banner(linear_solver: &str) {
278    use std::io::IsTerminal as _;
279
280    // OSC 8 hyperlink so supporting terminals make the URL clickable;
281    // only emitted to a TTY so redirected output stays plain text.
282    const URL: &str = "https://github.com/jkitchin/pounce";
283    let link = if std::io::stdout().is_terminal() {
284        format!("\x1b]8;;{URL}\x1b\\{URL}\x1b]8;;\x1b\\")
285    } else {
286        URL.to_string()
287    };
288
289    let rule = "*".repeat(BANNER_WIDTH);
290    println!("{rule}");
291    println!("This program contains POUNCE, a Rust port of Ipopt for nonlinear optimization.");
292    println!("Released under the Eclipse Public License (EPL) — drop-in compatible with Ipopt.");
293    println!("         For more information visit {link}");
294    println!("{rule}");
295    println!();
296    println!(
297        "This is POUNCE version {}, running with linear solver {}.",
298        env!("CARGO_PKG_VERSION"),
299        linear_solver
300    );
301    println!();
302}
303
304pub fn print_problem_stats(s: &ProblemStats) {
305    println!(
306        "Number of nonzeros in equality constraint Jacobian...: {:>8}",
307        s.nnz_jac_eq
308    );
309    println!(
310        "Number of nonzeros in inequality constraint Jacobian.: {:>8}",
311        s.nnz_jac_ineq
312    );
313    println!(
314        "Number of nonzeros in Lagrangian Hessian.............: {:>8}",
315        s.nnz_h_lag
316    );
317    println!();
318    println!(
319        "Total number of variables............................: {:>8}",
320        s.n
321    );
322    println!(
323        "                     variables with only lower bounds: {:>8}",
324        s.var_lower_only
325    );
326    println!(
327        "                variables with lower and upper bounds: {:>8}",
328        s.var_both
329    );
330    println!(
331        "                     variables with only upper bounds: {:>8}",
332        s.var_upper_only
333    );
334    println!(
335        "Total number of equality constraints.................: {:>8}",
336        s.n_eq
337    );
338    println!(
339        "Total number of inequality constraints...............: {:>8}",
340        s.n_ineq
341    );
342    println!(
343        "        inequality constraints with only lower bounds: {:>8}",
344        s.ineq_lower_only
345    );
346    println!(
347        "   inequality constraints with lower and upper bounds: {:>8}",
348        s.ineq_both
349    );
350    println!(
351        "        inequality constraints with only upper bounds: {:>8}",
352        s.ineq_upper_only
353    );
354    println!();
355}
356
357pub fn print_summary(
358    status: ApplicationReturnStatus,
359    stats: &SolveStatistics,
360    counters: &CountingTnlp,
361) {
362    println!();
363    println!();
364    println!("Number of Iterations....: {}", stats.iteration_count);
365    println!();
366    println!("                                   (scaled)                 (unscaled)");
367    let row = |label: &str, scaled: f64, unscaled: f64| {
368        println!(
369            "{label}:   {}    {}",
370            fmt_ipopt(scaled),
371            fmt_ipopt(unscaled)
372        );
373    };
374    row(
375        "Objective...............",
376        stats.final_scaled_objective,
377        stats.final_objective,
378    );
379    row(
380        "Dual infeasibility......",
381        stats.final_dual_inf,
382        stats.final_dual_inf,
383    );
384    row(
385        "Constraint violation....",
386        stats.final_constr_viol,
387        stats.final_constr_viol,
388    );
389    row("Variable bound violation", 0.0, 0.0);
390    row(
391        "Complementarity.........",
392        stats.final_compl,
393        stats.final_compl,
394    );
395    row(
396        "Overall NLP error.......",
397        stats.final_kkt_error,
398        stats.final_kkt_error,
399    );
400    println!();
401    println!();
402    println!(
403        "Number of objective function evaluations             = {}",
404        counters.n_obj.get()
405    );
406    println!(
407        "Number of objective gradient evaluations             = {}",
408        counters.n_grad_f.get()
409    );
410    println!(
411        "Number of equality constraint evaluations            = {}",
412        counters.n_g.get()
413    );
414    println!(
415        "Number of inequality constraint evaluations          = {}",
416        counters.n_g.get()
417    );
418    println!(
419        "Number of equality constraint Jacobian evaluations   = {}",
420        counters.n_jac_g.get()
421    );
422    println!(
423        "Number of inequality constraint Jacobian evaluations = {}",
424        counters.n_jac_g.get()
425    );
426    println!(
427        "Number of Lagrangian Hessian evaluations             = {}",
428        counters.n_h.get()
429    );
430    println!(
431        "Total seconds in POUNCE                              = {:.3}",
432        stats.total_wallclock_time_secs
433    );
434    println!();
435    println!("EXIT: {}", status_message(status));
436    println!();
437    println!(
438        "POUNCE {}: {}",
439        env!("CARGO_PKG_VERSION"),
440        status_message(status)
441    );
442}
443
444/// Format a number in Ipopt's scientific notation: 16-digit mantissa,
445/// signed 2-digit exponent (e.g. `3.7952009505566139e+03`). Rust's
446/// `{:.16e}` is close but emits a 1-digit exponent without leading
447/// sign, which makes side-by-side diffs against `ipopt` output messy.
448pub fn fmt_ipopt(v: f64) -> String {
449    if v.is_nan() {
450        return "nan".to_string();
451    }
452    if v.is_infinite() {
453        return if v > 0.0 { "inf".into() } else { "-inf".into() };
454    }
455    let s = format!("{:.16e}", v);
456    let Some(e_pos) = s.rfind('e') else {
457        return s;
458    };
459    let (mantissa, exp_part) = s.split_at(e_pos);
460    let exp_str = &exp_part[1..];
461    let (sign, digits) = if let Some(rest) = exp_str.strip_prefix('-') {
462        ('-', rest)
463    } else if let Some(rest) = exp_str.strip_prefix('+') {
464        ('+', rest)
465    } else {
466        ('+', exp_str)
467    };
468    let padded = if digits.len() < 2 {
469        format!("0{digits}")
470    } else {
471        digits.to_string()
472    };
473    format!("{mantissa}e{sign}{padded}")
474}
475
476pub fn status_message(s: ApplicationReturnStatus) -> &'static str {
477    match s {
478        ApplicationReturnStatus::SolveSucceeded => "Optimal Solution Found.",
479        ApplicationReturnStatus::SolvedToAcceptableLevel => "Solved To Acceptable Level.",
480        ApplicationReturnStatus::InfeasibleProblemDetected => {
481            "Converged to a point of local infeasibility. Problem may be infeasible."
482        }
483        ApplicationReturnStatus::SearchDirectionBecomesTooSmall => {
484            "Search Direction is becoming Too Small."
485        }
486        ApplicationReturnStatus::DivergingIterates => {
487            "Iterates diverging; problem might be unbounded."
488        }
489        ApplicationReturnStatus::UserRequestedStop => "Stopping optimization at user request.",
490        ApplicationReturnStatus::FeasiblePointFound => "Feasible Point Found.",
491        ApplicationReturnStatus::MaximumIterationsExceeded => {
492            "Maximum Number of Iterations Exceeded."
493        }
494        ApplicationReturnStatus::RestorationFailed => "Restoration Failed!",
495        ApplicationReturnStatus::ErrorInStepComputation => "Error in step computation.",
496        ApplicationReturnStatus::MaximumCpuTimeExceeded => "Maximum CPU time exceeded.",
497        ApplicationReturnStatus::MaximumWallTimeExceeded => "Maximum wallclock time exceeded.",
498        ApplicationReturnStatus::NotEnoughDegreesOfFreedom => "Not Enough Degrees of Freedom.",
499        ApplicationReturnStatus::InvalidProblemDefinition => "Invalid Problem Definition.",
500        ApplicationReturnStatus::InvalidOption => "Invalid Option.",
501        ApplicationReturnStatus::InvalidNumberDetected => {
502            "Invalid number in NLP function or derivative detected."
503        }
504        ApplicationReturnStatus::UnrecoverableException => "Unrecoverable Exception.",
505        ApplicationReturnStatus::NonIpoptExceptionThrown => "Exception of type generic.",
506        ApplicationReturnStatus::InsufficientMemory => "Insufficient memory.",
507        ApplicationReturnStatus::InternalError => "INTERNAL ERROR: Unknown SolverReturn value.",
508    }
509}