vor 0.2.1

Cross-platform performance instrumentation with an in-app egui panel and live system and GPU metrics.
Documentation
use std::collections::BTreeMap;
use std::sync::{Mutex, OnceLock};

/// Per-frame values (drained each frame) plus a persistent name to unit
/// registry (set once via [`record_metric_unit`]).
struct State {
    values: BTreeMap<String, f64>,
    units: BTreeMap<String, String>,
}

static METRICS: OnceLock<Mutex<State>> = OnceLock::new();

fn state() -> &'static Mutex<State> {
    METRICS.get_or_init(|| {
        Mutex::new(State {
            values: BTreeMap::new(),
            units: BTreeMap::new(),
        })
    })
}

/// Record the latest value of a named scalar for the current frame.
///
/// The headless-friendly, generics-free counterpart to a live-panel
/// `Metric<R>`: values land in a process-global table that the
/// recorder snapshots once per [`frame_mark`](crate::frame_mark). Good
/// for loss / lr / tokens-per-sec / batch size.
///
/// Latest write wins within a frame; the table is drained (cleared)
/// each `frame_mark`, so a metric is plotted only on frames where it
/// was recorded. Cheap and callable from any thread. To label the row
/// with a unit, call [`record_metric_unit`] once.
pub fn record_metric(name: &str, value: f64) {
    state().lock().unwrap().values.insert(name.to_owned(), value);
}

/// Attach a unit (e.g. `"ms"`, `"tok/s"`) to a named metric's plotted
/// row, once. The recorder reads it when it first declares the column,
/// so call this before the metric's first [`record_metric`] (e.g. at
/// startup). Metrics with no registered unit are unitless.
pub fn record_metric_unit(name: &str, unit: &str) {
    state()
        .lock()
        .unwrap()
        .units
        .insert(name.to_owned(), unit.to_owned());
}

/// One frame's reading of a named scalar, drained by the recorder.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) struct DrainedMetric {
    pub name: String,
    pub value: f64,
    pub unit: String,
}

/// Read + reset this frame's values, each paired with its registered
/// unit. Called once per frame by the recorder so the next frame's
/// values start empty (units persist). Order is by name (`BTreeMap`),
/// so a capture's column layout is deterministic.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn drain_metrics() -> Vec<DrainedMetric> {
    let mut guard = state().lock().unwrap();
    let State { values, units } = &mut *guard;
    std::mem::take(values)
        .into_iter()
        .map(|(name, value)| {
            let unit = units.get(&name).cloned().unwrap_or_default();
            DrainedMetric { name, value, unit }
        })
        .collect()
}

/// Serializes tests that touch the process-global metric table so a
/// concurrent drain (e.g. the recorder's) can't steal entries mid-test.
#[cfg(test)]
pub(crate) static TEST_GUARD: Mutex<()> = Mutex::new(());

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn record_metric_latest_wins_then_drains_with_units() {
        let _guard = TEST_GUARD.lock().unwrap();
        record_metric_unit("loss", "nats");
        record_metric("loss", 1.5);
        record_metric("lr", 3e-4);
        record_metric("loss", 1.2);
        let drained = drain_metrics();
        let view: Vec<_> = drained
            .iter()
            .map(|m| (m.name.as_str(), m.value, m.unit.as_str()))
            .collect();
        assert_eq!(view, vec![("loss", 1.2, "nats"), ("lr", 3e-4, "")]);
        assert!(drain_metrics().is_empty());
    }
}