Skip to main content

gam_solve/
progress_log.rs

1//! Stderr progress logger for the `gam` CLI and the `gamfit` Python bindings.
2//!
3//! Installs a global [`log`] backend that timestamps each record (elapsed since
4//! process start), strips terminal control / escape sequences, and writes to
5//! stderr under a write-lock so concurrent solver threads never interleave
6//! partial lines. This is the sole logger bootstrap for the CLI binary and the
7//! Python extension module.
8//!
9//! (Extracted from the former TUI `visualizer` module, which has been removed
10//! along with its `crossterm`/`ratatui` dependencies; only the stderr logging
11//! survives — the live chart / progress lanes were non-essential opt-in cruft.)
12
13use log::{LevelFilter, Log, Metadata, Record};
14use std::io::{self, Write};
15use std::sync::{Mutex, OnceLock};
16use std::time::{Duration, Instant};
17
18static LOGGER: ProgressLogger = ProgressLogger;
19static LOG_START: OnceLock<Instant> = OnceLock::new();
20static LOG_WRITE_LOCK: Mutex<()> = Mutex::new(());
21
22struct ProgressLogger;
23
24impl Log for ProgressLogger {
25    fn enabled(&self, metadata: &Metadata<'_>) -> bool {
26        metadata.level() <= log::max_level()
27    }
28
29    fn log(&self, record: &Record<'_>) {
30        if !self.enabled(record.metadata()) {
31            return;
32        }
33        let lines = format_log_record(record);
34        let log_lock_guard = LOG_WRITE_LOCK.lock().unwrap_or_else(|p| p.into_inner());
35        let mut stderr = io::stderr().lock();
36        for line in lines {
37            // stderr is gone (closed pipe, full disk): further lines cannot land
38            // either, and logging the failure would recurse into this `Log` impl,
39            // so stop writing this record rather than spin through every line.
40            if writeln!(stderr, "{line}").is_err() {
41                break;
42            }
43        }
44        drop(log_lock_guard);
45    }
46
47    fn flush(&self) {}
48}
49
50fn format_log_record(record: &Record<'_>) -> Vec<String> {
51    let elapsed = LOG_START.get_or_init(Instant::now).elapsed();
52    let prefix = format!("[{}]", human_elapsed(elapsed));
53    sanitize_log_message(&record.args().to_string())
54        .lines()
55        .map(|line| format!("{prefix} {line}"))
56        .collect()
57}
58
59fn sanitize_log_message(message: &str) -> String {
60    let mut sanitized = String::with_capacity(message.len());
61    let mut chars = message.chars().peekable();
62    while let Some(ch) = chars.next() {
63        match ch {
64            '\x1b' => {
65                strip_escape_sequence(&mut chars);
66            }
67            '\r' => sanitized.push('\n'),
68            '\n' | '\t' => sanitized.push(ch),
69            ch if ch.is_control() => {}
70            ch => sanitized.push(ch),
71        }
72    }
73    sanitized
74}
75
76fn strip_escape_sequence<I>(chars: &mut std::iter::Peekable<I>)
77where
78    I: Iterator<Item = char>,
79{
80    match chars.next() {
81        Some('[') => {
82            for seq_ch in chars.by_ref() {
83                if ('@'..='~').contains(&seq_ch) {
84                    break;
85                }
86            }
87        }
88        Some(']') => strip_string_escape(chars),
89        Some('P' | 'X' | '^' | '_') => strip_string_escape(chars),
90        Some(_) | None => {}
91    }
92}
93
94fn strip_string_escape<I>(chars: &mut std::iter::Peekable<I>)
95where
96    I: Iterator<Item = char>,
97{
98    while let Some(seq_ch) = chars.next() {
99        if seq_ch == '\x07' {
100            break;
101        }
102        if seq_ch == '\x1b' && chars.next_if_eq(&'\\').is_some() {
103            break;
104        }
105    }
106}
107
108fn human_elapsed(elapsed: Duration) -> String {
109    let total_secs = elapsed.as_secs();
110    let hours = total_secs / 3600;
111    let minutes = (total_secs / 60) % 60;
112    let seconds = total_secs % 60;
113    if hours > 0 {
114        format!("{hours}h {minutes:02}m {seconds:02}s")
115    } else if minutes > 0 {
116        format!("{minutes}m {seconds:02}s")
117    } else {
118        format!("{seconds}s")
119    }
120}
121
122/// Default verbosity when the user has not requested an explicit level.
123///
124/// A single ordinary fit (e.g. a 400-row `s(x)` P-spline) emits thousands of
125/// per-iteration `[OUTER ...]` / `[GAM ALO]` `info!`/`warn!` records. Writing
126/// them to stderr under a write-lock is not free — when stderr is a terminal
127/// or a pipe it is *measurable* fit overhead (#1689), and for the common case
128/// (a library call from Python that just wants the model back) the stream is
129/// pure noise. So the out-of-the-box level is `Warn`: genuine problems still
130/// surface, but the routine progress chatter is silent unless explicitly
131/// requested. Power users opt back in by calling [`set_log_level`] (e.g.
132/// `set_log_level("info")`) or [`log::set_max_level`] directly — verbosity is
133/// set through an explicit API, not a process-global env var.
134const DEFAULT_LOG_LEVEL: LevelFilter = LevelFilter::Warn;
135
136/// Parse one verbosity spelling into a [`LevelFilter`]. Case-insensitive,
137/// surrounding whitespace ignored. Returns `None` for anything unrecognized so
138/// the caller can fall through to the next source rather than guessing.
139fn parse_log_level(value: &str) -> Option<LevelFilter> {
140    match value.trim().to_ascii_lowercase().as_str() {
141        "off" | "none" | "silent" => Some(LevelFilter::Off),
142        "error" => Some(LevelFilter::Error),
143        "warn" | "warning" => Some(LevelFilter::Warn),
144        "info" => Some(LevelFilter::Info),
145        "debug" => Some(LevelFilter::Debug),
146        "trace" | "all" => Some(LevelFilter::Trace),
147        _ => None,
148    }
149}
150
151/// Map a caller-supplied verbosity spelling onto a [`LevelFilter`]. Wraps the
152/// internal `parse_log_level` for out-of-crate callers (the CLI `--log-level`
153/// flag, the Python `set_log_level` shim). Returns `None` for blank/unrecognized
154/// input so the caller decides the fallback rather than guessing here.
155pub fn parse_level_directive(raw: &str) -> Option<LevelFilter> {
156    parse_log_level(raw)
157}
158
159/// Explicitly set the active log verbosity from a level spelling
160/// (`off|error|warn|info|debug|trace`, case-insensitive). This is the supported
161/// way to raise verbosity above the default — callers pass the level they want
162/// rather than relying on a process-global env var. Returns the [`LevelFilter`]
163/// actually installed (the default when `spelling` is unrecognized, so a typo never
164/// silently disables logging). A no-op-safe wrapper over [`log::set_max_level`].
165pub fn set_log_level(spelling: &str) -> LevelFilter {
166    let level = parse_log_level(spelling).unwrap_or(DEFAULT_LOG_LEVEL);
167    log::set_max_level(level);
168    level
169}
170
171pub fn init_logging() {
172    init_logging_at(DEFAULT_LOG_LEVEL);
173}
174
175/// Install the stderr logger at an explicit verbosity. Idempotent in the sense
176/// that the first caller wins the global `log` backend registration; **every**
177/// call (re-)applies the requested max level, so an embedding can call
178/// `init_logging()` early and later raise the level via `init_logging_at` (e.g.
179/// the Python `set_log_level` shim) without losing the override. This is how a
180/// caller opts back into the verbose `Info`/`debug`/`trace` solver trace that
181/// the `Warn` default suppresses for performance (#1688).
182pub fn init_logging_at(level: LevelFilter) {
183    LOG_START.get_or_init(Instant::now);
184    // First caller wins the backend registration; an already-installed logger
185    // is fine — we still want to (re-)apply the requested level below, so do
186    // not gate `set_max_level` on the registration result.
187    let installed_backend = log::set_logger(&LOGGER).is_ok();
188    log::set_max_level(level);
189    if !installed_backend {
190        // Not an error, but it is the answer to "why does the trace not look
191        // like ours?": some other backend owns the global sink, and all this
192        // call did was move the level filter.
193        log::debug!(
194            "[log] a logging backend was already installed; re-applied max level {level} only"
195        );
196    }
197    // Log the GPU backend inventory once at startup so the "are GPUs being
198    // used?" answer is visible at the top of the log, before any solver
199    // dispatch site lazily checks for device support.
200    gam_gpu::log_backend_inventory_once();
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn default_level_is_warn_when_no_explicit_level() {
209        assert_eq!(DEFAULT_LOG_LEVEL, LevelFilter::Warn);
210        init_logging();
211        assert_eq!(log::max_level(), DEFAULT_LOG_LEVEL);
212    }
213
214    #[test]
215    fn set_log_level_installs_explicit_level_and_defaults_on_typo() {
216        // Explicit spelling installs that level and reports it back.
217        assert_eq!(set_log_level("debug"), LevelFilter::Debug);
218        assert_eq!(log::max_level(), LevelFilter::Debug);
219        // A typo never silently disables logging — it falls back to the default.
220        assert_eq!(set_log_level("verbose"), DEFAULT_LOG_LEVEL);
221        assert_eq!(log::max_level(), DEFAULT_LOG_LEVEL);
222        // Restore a quiet default so this process-global write cannot perturb
223        // sibling tests that observe the level.
224        log::set_max_level(DEFAULT_LOG_LEVEL);
225    }
226
227    #[test]
228    fn unrecognized_values_fall_back_to_default_not_off() {
229        // A typo must never silently disable logging or crank it to trace.
230        assert_eq!(set_log_level("verbose"), DEFAULT_LOG_LEVEL);
231        assert_eq!(set_log_level("yes"), DEFAULT_LOG_LEVEL);
232        log::set_max_level(DEFAULT_LOG_LEVEL);
233    }
234
235    #[test]
236    fn parse_level_directive_matches_internal_parser() {
237        // The public out-of-crate entry point must behave exactly like the
238        // internal parser the env-precedence helper uses.
239        for spelling in [
240            "off", "error", "warn", "info", "debug", "trace", "", "garbage",
241        ] {
242            assert_eq!(parse_level_directive(spelling), parse_log_level(spelling));
243        }
244    }
245
246    #[test]
247    fn parsing_is_case_and_whitespace_insensitive() {
248        assert_eq!(parse_log_level("  INFO "), Some(LevelFilter::Info));
249        assert_eq!(parse_log_level("Warn"), Some(LevelFilter::Warn));
250        assert_eq!(parse_log_level("TRACE"), Some(LevelFilter::Trace));
251        assert_eq!(parse_log_level("off"), Some(LevelFilter::Off));
252        assert_eq!(parse_log_level("warning"), Some(LevelFilter::Warn));
253        assert_eq!(parse_log_level("silent"), Some(LevelFilter::Off));
254        assert_eq!(parse_log_level(""), None);
255    }
256}