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 set no environment override.
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 (`std::env::var`
129/// is banned crate-wide; see [`resolve_log_level`]).
130const DEFAULT_LOG_LEVEL: LevelFilter = LevelFilter::Warn;
131
132/// Resolve the active log level.
133///
134/// Reading verbosity from the environment (`GAM_LOG` / `RUST_LOG`) is NOT
135/// permitted: `std::env::var` is banned crate-wide (build.rs substring scanner,
136/// `feedback_no_env_vars` policy), because process-global env reads are a hidden
137/// input that makes a library call's behavior depend on ambient state the caller
138/// never passed. An override path landed transiently in #1696 and broke the
139/// build; the supported way to change verbosity is the explicit
140/// [`set_log_level`] entry point (or [`log::set_max_level`] directly), not an
141/// env var. The out-of-the-box level stays [`DEFAULT_LOG_LEVEL`] (`Warn`): a
142/// plain library call from Python gets quiet logs and pays no per-record stderr
143/// cost (#1689), while genuine problems still surface.
144fn resolve_log_level() -> LevelFilter {
145 // Reading env vars in non-test src is banned by build.rs (no env-var gates),
146 // so the active level is the compile-time default. The precedence helper is
147 // retained (and unit-tested) for callers that pass explicit overrides.
148 log_level_from_overrides(None, None)
149}
150
151/// Parse one verbosity spelling into a [`LevelFilter`]. Case-insensitive,
152/// surrounding whitespace ignored. Returns `None` for anything unrecognized so
153/// the caller can fall through to the next source rather than guessing.
154fn parse_log_level(value: &str) -> Option<LevelFilter> {
155 match value.trim().to_ascii_lowercase().as_str() {
156 "off" | "none" | "silent" => Some(LevelFilter::Off),
157 "error" => Some(LevelFilter::Error),
158 "warn" | "warning" => Some(LevelFilter::Warn),
159 "info" => Some(LevelFilter::Info),
160 "debug" => Some(LevelFilter::Debug),
161 "trace" | "all" => Some(LevelFilter::Trace),
162 _ => None,
163 }
164}
165
166/// Pure resolution of the active level from up to two explicitly-supplied
167/// override spellings, with the primary source winning over the fallback and an
168/// unset/unrecognized value falling through to [`DEFAULT_LOG_LEVEL`]. Kept pure
169/// (no env, no globals) so the precedence rules are unit-testable and so the
170/// public [`set_log_level`] entry point can hand it caller-provided strings.
171fn log_level_from_overrides(primary: Option<&str>, fallback: Option<&str>) -> LevelFilter {
172 primary
173 .and_then(parse_log_level)
174 .or_else(|| fallback.and_then(parse_log_level))
175 .unwrap_or(DEFAULT_LOG_LEVEL)
176}
177
178/// Map a caller-supplied verbosity spelling onto a [`LevelFilter`]. Wraps the
179/// internal [`parse_log_level`] for out-of-crate callers (the CLI `--log-level`
180/// flag, the Python `set_log_level` shim). Returns `None` for blank/unrecognized
181/// input so the caller decides the fallback rather than guessing here.
182pub fn parse_level_directive(raw: &str) -> Option<LevelFilter> {
183 parse_log_level(raw)
184}
185
186/// The quiet out-of-the-box verbosity, exposed so callers that want the
187/// documented default after an explicit-level path can name it instead of
188/// hardcoding `Warn`.
189pub const fn default_log_level() -> LevelFilter {
190 DEFAULT_LOG_LEVEL
191}
192
193/// Explicitly set the active log verbosity from a level spelling
194/// (`off|error|warn|info|debug|trace`, case-insensitive). This is the supported
195/// way to raise verbosity above the default — callers pass the level they want
196/// rather than relying on a process-global env var (`std::env::var` is banned
197/// crate-wide; see [`resolve_log_level`]). Returns the [`LevelFilter`] actually
198/// installed (the default when `spelling` is unrecognized, so a typo never
199/// silently disables logging). A no-op-safe wrapper over [`log::set_max_level`].
200pub fn set_log_level(spelling: &str) -> LevelFilter {
201 let level = log_level_from_overrides(Some(spelling), None);
202 log::set_max_level(level);
203 level
204}
205
206pub fn init_logging() {
207 init_logging_at(resolve_log_level());
208}
209
210/// Install the stderr logger at an explicit verbosity. Idempotent in the sense
211/// that the first caller wins the global `log` backend registration; **every**
212/// call (re-)applies the requested max level, so an embedding can call
213/// `init_logging()` early and later raise the level via `init_logging_at` (e.g.
214/// the Python `set_log_level` shim) without losing the override. This is how a
215/// caller opts back into the verbose `Info`/`debug`/`trace` solver trace that
216/// the `Warn` default suppresses for performance (#1688).
217pub fn init_logging_at(level: LevelFilter) {
218 LOG_START.get_or_init(Instant::now);
219 // First caller wins the backend registration; an already-installed logger
220 // is fine — we still want to (re-)apply the requested level below, so do
221 // not gate `set_max_level` on the registration result.
222 if log::set_logger(&LOGGER).is_err() {
223 // backend already installed by an earlier call — fall through.
224 }
225 log::set_max_level(level);
226 // Log the GPU backend inventory once at startup so the "are GPUs being
227 // used?" answer is visible at the top of the log, before any solver
228 // dispatch site lazily checks for device support.
229 gam_gpu::log_backend_inventory_once();
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 #[test]
237 fn default_level_is_warn_when_no_overrides() {
238 assert_eq!(log_level_from_overrides(None, None), LevelFilter::Warn);
239 assert_eq!(DEFAULT_LOG_LEVEL, LevelFilter::Warn);
240 }
241
242 #[test]
243 fn primary_override_takes_precedence_over_fallback() {
244 assert_eq!(
245 log_level_from_overrides(Some("info"), Some("trace")),
246 LevelFilter::Info
247 );
248 assert_eq!(
249 log_level_from_overrides(Some("off"), Some("debug")),
250 LevelFilter::Off
251 );
252 }
253
254 #[test]
255 fn fallback_used_when_primary_absent_or_unrecognized() {
256 assert_eq!(
257 log_level_from_overrides(None, Some("debug")),
258 LevelFilter::Debug
259 );
260 // Primary present but garbage → fall through to the fallback.
261 assert_eq!(
262 log_level_from_overrides(Some("loud"), Some("trace")),
263 LevelFilter::Trace
264 );
265 }
266
267 #[test]
268 fn set_log_level_installs_explicit_level_and_defaults_on_typo() {
269 // Explicit spelling installs that level and reports it back.
270 assert_eq!(set_log_level("debug"), LevelFilter::Debug);
271 assert_eq!(log::max_level(), LevelFilter::Debug);
272 // A typo never silently disables logging — it falls back to the default.
273 assert_eq!(set_log_level("verbose"), DEFAULT_LOG_LEVEL);
274 assert_eq!(log::max_level(), DEFAULT_LOG_LEVEL);
275 // Restore a quiet default so this process-global write cannot perturb
276 // sibling tests that observe the level.
277 log::set_max_level(DEFAULT_LOG_LEVEL);
278 }
279
280 #[test]
281 fn unrecognized_values_fall_back_to_default_not_off() {
282 // A typo must never silently disable logging or crank it to trace.
283 assert_eq!(
284 log_level_from_overrides(Some("verbose"), None),
285 DEFAULT_LOG_LEVEL
286 );
287 assert_eq!(
288 log_level_from_overrides(Some("yes"), Some("loud")),
289 DEFAULT_LOG_LEVEL
290 );
291 }
292
293 #[test]
294 fn parse_level_directive_matches_internal_parser() {
295 // The public out-of-crate entry point must behave exactly like the
296 // internal parser the env-precedence helper uses.
297 for spelling in ["off", "error", "warn", "info", "debug", "trace", "", "garbage"] {
298 assert_eq!(parse_level_directive(spelling), parse_log_level(spelling));
299 }
300 }
301
302 #[test]
303 fn default_log_level_accessor_matches_const() {
304 assert_eq!(default_log_level(), DEFAULT_LOG_LEVEL);
305 assert_eq!(default_log_level(), LevelFilter::Warn);
306 }
307
308 #[test]
309 fn parsing_is_case_and_whitespace_insensitive() {
310 assert_eq!(parse_log_level(" INFO "), Some(LevelFilter::Info));
311 assert_eq!(parse_log_level("Warn"), Some(LevelFilter::Warn));
312 assert_eq!(parse_log_level("TRACE"), Some(LevelFilter::Trace));
313 assert_eq!(parse_log_level("off"), Some(LevelFilter::Off));
314 assert_eq!(parse_log_level("warning"), Some(LevelFilter::Warn));
315 assert_eq!(parse_log_level("silent"), Some(LevelFilter::Off));
316 assert_eq!(parse_log_level(""), None);
317 }
318}