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