metrale_telemetry/
run_metrics.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: The run mailbox: process-global counters read by the
4//! `/metrics` handler and the TUI, which hold no handle to the scheduler's
5//! context, and [`reset_for_new_run`], which starts a new run's accounting.
6//!
7//! `MetraleCudaBackend::new` calls [`reset_for_new_run`] before its first
8//! kernel lookup, so the kernel audit lists only that backend's lookups.
9//!
10//! Owner: telemetry.
11//! Invariants: the prefix-cache counters never decrease; a run's own counts
12//! are the difference from the snapshot `reset_for_new_run` takes.
13
14use std::sync::atomic::{AtomicU32, AtomicU64, AtomicUsize, Ordering};
15use std::sync::{LazyLock, Mutex};
16
17/// 2026-09-26: The process's run mailbox.
18#[derive(Debug, Default)]
19pub struct RunMetrics {
20    pub cache_hits: AtomicU64,
21    pub cache_misses: AtomicU64,
22    pub cache_hit_tokens: AtomicU64,
23
24    /// 2026-09-26: Most recent per-token entropy, as f32 bits.
25    pub last_entropy: AtomicU32,
26    pub low_entropy_tokens: AtomicU64,
27    pub total_sampled_tokens: AtomicU64,
28
29    /// 2026-09-26: The free-memory baseline `set_baseline_free_bytes` records
30    /// after the backend is built. KV sizing uses it only when neither
31    /// `METRALE_KV_EXTERNAL_RESERVE_GB` nor the allocation ledger applies: it
32    /// measures this process's use as `baseline - free_now` and charges the
33    /// smaller of that and the known footprint (`factory/build/kv_budget.rs`).
34    /// `0` means unset.
35    pub baseline_free_bytes: AtomicUsize,
36    /// 2026-09-26: `(module, func, loaded, dispatch site)` for every lookup
37    /// [`crate::kernel_audit::record`] received this run. The site is the
38    /// caller's `Location`, carried through `#[track_caller]` on
39    /// `GpuBackend::kernel`.
40    pub kernel_audit: Mutex<Vec<(String, String, bool, &'static std::panic::Location<'static>)>>,
41
42    // 2026-09-26: The prefix-cache counters at the start of this run.
43    // `/metrics` exports the counters as `metrale_prefix_cache_*_total`, type
44    // counter, so they are never reset; `cache_counts_this_run` subtracts
45    // these instead.
46    run_base_cache_hits: AtomicU64,
47    run_base_cache_misses: AtomicU64,
48    run_base_cache_hit_tokens: AtomicU64,
49}
50
51static METRICS: LazyLock<RunMetrics> = LazyLock::new(RunMetrics::default);
52
53/// 2026-09-26: Read the mailbox.
54pub fn metrics() -> &'static RunMetrics {
55    &METRICS
56}
57
58/// 2026-09-26: Begin a new run's accounting: snapshot the prefix-cache
59/// counters as this run's baseline, zero the entropy counters, the last
60/// entropy and the free-memory baseline, clear the kernel audit (unless its
61/// lock is poisoned) and unseal it.
62pub fn reset_for_new_run() {
63    let m = metrics();
64    for (counter, base) in [
65        (&m.cache_hits, &m.run_base_cache_hits),
66        (&m.cache_misses, &m.run_base_cache_misses),
67        (&m.cache_hit_tokens, &m.run_base_cache_hit_tokens),
68    ] {
69        base.store(counter.load(Ordering::Relaxed), Ordering::Relaxed);
70    }
71    for c in [&m.low_entropy_tokens, &m.total_sampled_tokens] {
72        c.store(0, Ordering::Relaxed);
73    }
74    m.baseline_free_bytes.store(0, Ordering::Relaxed);
75    m.last_entropy.store(0, Ordering::Relaxed);
76    if let Ok(mut v) = m.kernel_audit.lock() {
77        v.clear();
78    }
79    // 2026-09-26: The incoming model's lookups run before its own gate seals;
80    // under the outgoing model's seal a failed one would count as late.
81    crate::kernel_audit::unseal();
82}
83
84/// 2026-09-26: Prefix-cache `(hits, misses, hit tokens)` since the last
85/// [`reset_for_new_run`]. The TUI shows these; `/metrics` exports the
86/// cumulative counters.
87pub fn cache_counts_this_run() -> (u64, u64, u64) {
88    let m = metrics();
89    let sub = |c: &AtomicU64, b: &AtomicU64| {
90        c.load(Ordering::Relaxed)
91            .saturating_sub(b.load(Ordering::Relaxed))
92    };
93    (
94        sub(&m.cache_hits, &m.run_base_cache_hits),
95        sub(&m.cache_misses, &m.run_base_cache_misses),
96        sub(&m.cache_hit_tokens, &m.run_base_cache_hit_tokens),
97    )
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    /// 2026-09-26: After a reset the exported counter has not gone back, and
105    /// the run-relative count starts near 0. The checks are bounds, because
106    /// another test in this binary records into the same global mailbox
107    /// concurrently.
108    #[test]
109    fn a_new_run_starts_from_the_bottom() {
110        const RUN: u64 = 10_000;
111        for _ in 0..RUN {
112            crate::prefix_cache::record_cache_hit(1);
113        }
114        let cumulative = crate::prefix_cache::cache_hit_count();
115        assert!(cumulative >= RUN, "the run accumulated");
116
117        reset_for_new_run();
118
119        assert!(
120            crate::prefix_cache::cache_hit_count() >= cumulative,
121            "the exported counter must never go backwards"
122        );
123        let (hits, _, tokens) = cache_counts_this_run();
124        assert!(
125            hits < RUN / 10,
126            "but the next run does not inherit the previous run's hits"
127        );
128        assert!(tokens < RUN / 10);
129    }
130}
131
132#[cfg(test)]
133mod swap_counter_tests {
134    use super::*;
135
136    /// 2026-09-26: A lower bound against a value read at the start: other tests
137    /// in this binary can only add to the same global counter concurrently.
138    #[test]
139    fn a_new_run_does_not_move_the_counters_prometheus_exports() {
140        let m = metrics();
141        let before = m.cache_hits.load(Ordering::Relaxed);
142        m.cache_hits.fetch_add(7, Ordering::Relaxed);
143
144        reset_for_new_run();
145
146        assert!(
147            m.cache_hits.load(Ordering::Relaxed) >= before + 7,
148            "the cumulative counter must not go backwards across a swap"
149        );
150    }
151}