Skip to main content

wm_selfmodel/
metrics.rs

1//! Metric tracking — per-subsystem performance metrics with ring buffer history.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::collections::VecDeque;
6
7/// Kinds of metrics tracked by the self-model.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum MetricKind {
11    /// CPU load fraction (0.0 = idle, 1.0 = saturated).
12    CpuLoad,
13    /// Memory pressure fraction (0.0 = plenty, 1.0 = critical).
14    MemoryPressure,
15    /// Dispatch latency in milliseconds.
16    Latency,
17    /// Citta coherence score (0.0–1.0).
18    Coherence,
19    /// Tool error rate fraction (0.0 = no errors, 1.0 = all errors).
20    ErrorRate,
21    /// Disk I/O rate fraction (0.0 = idle, 1.0 = saturated).
22    DiskIo,
23    /// Swap usage fraction (0.0 = none, 1.0 = full).
24    SwapUsage,
25    // ── Cognitive metrics (Imagination Engine) ──
26    /// Imagination quality score (0.0–1.0) — scenario evaluation score.
27    ImaginationQuality,
28    /// Research output rate (0.0–1.0) — hypotheses generated per cycle.
29    ResearchOutput,
30    /// Scenario confidence (0.0–1.0) — MC rollout positive fraction.
31    ScenarioConfidence,
32    /// Simulation variance (0.0–1.0) — MC std dev (lower is better).
33    SimulationVariance,
34    /// Conformal empirical coverage (0.0–1.0) — fraction of prediction
35    /// sets/intervals that contained the truth in the latest monitor run.
36    /// Drift monitoring: sustained drops below the target (1 − α) mean the
37    /// calibration is stale and should be re-fit.
38    ConformalCoverage,
39    /// Average Brier score of resolved predictions (lower is better).
40    /// Rising Brier = deteriorating calibration honesty.
41    BrierScore,
42}
43
44impl MetricKind {
45    /// Human-readable name.
46    #[must_use]
47    pub const fn as_str(self) -> &'static str {
48        match self {
49            Self::CpuLoad => "cpu_load",
50            Self::MemoryPressure => "memory_pressure",
51            Self::Latency => "latency",
52            Self::Coherence => "coherence",
53            Self::ErrorRate => "error_rate",
54            Self::DiskIo => "disk_io",
55            Self::SwapUsage => "swap_usage",
56            Self::ImaginationQuality => "imagination_quality",
57            Self::ResearchOutput => "research_output",
58            Self::ScenarioConfidence => "scenario_confidence",
59            Self::SimulationVariance => "simulation_variance",
60            Self::ConformalCoverage => "conformal_coverage",
61            Self::BrierScore => "brier_score",
62        }
63    }
64
65    /// All metric kinds.
66    #[must_use]
67    pub const fn all() -> &'static [Self] {
68        &[
69            Self::CpuLoad,
70            Self::MemoryPressure,
71            Self::Latency,
72            Self::Coherence,
73            Self::ErrorRate,
74            Self::DiskIo,
75            Self::SwapUsage,
76            Self::ImaginationQuality,
77            Self::ResearchOutput,
78            Self::ScenarioConfidence,
79            Self::SimulationVariance,
80            Self::ConformalCoverage,
81            Self::BrierScore,
82        ]
83    }
84
85    /// Whether higher values are better (true) or worse (false).
86    #[must_use]
87    pub const fn higher_is_better(self) -> bool {
88        matches!(
89            self,
90            Self::Coherence
91                | Self::ImaginationQuality
92                | Self::ResearchOutput
93                | Self::ScenarioConfidence
94                | Self::ConformalCoverage
95        )
96    }
97
98    /// Default warning threshold for this metric.
99    #[must_use]
100    pub const fn default_warning(self) -> f32 {
101        match self {
102            Self::CpuLoad | Self::MemoryPressure | Self::SwapUsage | Self::DiskIo => 0.7,
103            Self::Latency => 10.0,
104            Self::Coherence => 0.3, // Below this is bad
105            Self::ErrorRate => 0.1,
106            Self::ImaginationQuality => 0.4,
107            Self::ResearchOutput => 0.3,
108            Self::ScenarioConfidence => 0.4,
109            Self::SimulationVariance => 0.3, // Above this is bad
110            // Conservative defaults — a 90%-target calibration at 85% coverage
111            // already signals drift. Users can add tighter rules per alpha.
112            Self::ConformalCoverage => 0.85,
113            // Brier < 0.15 = "good calibration" (v26 threshold); 0.3 is
114            // clearly degraded (0.3 ≈ always predicting 0.5's mean error).
115            Self::BrierScore => 0.15,
116        }
117    }
118
119    /// Default critical threshold for this metric.
120    #[must_use]
121    pub const fn default_critical(self) -> f32 {
122        match self {
123            Self::CpuLoad | Self::MemoryPressure | Self::SwapUsage | Self::DiskIo => 0.9,
124            Self::Latency => 50.0,
125            Self::Coherence => 0.1,
126            Self::ErrorRate => 0.3,
127            Self::ImaginationQuality => 0.2,
128            Self::ResearchOutput => 0.1,
129            Self::ScenarioConfidence => 0.2,
130            Self::SimulationVariance => 0.5,
131            Self::ConformalCoverage => 0.8,
132            Self::BrierScore => 0.3,
133        }
134    }
135}
136
137/// A single metric sample at a point in time.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct MetricSample {
140    /// Which metric.
141    pub kind: MetricKind,
142    /// Sampled value.
143    pub value: f32,
144    /// When the sample was taken.
145    pub timestamp: DateTime<Utc>,
146}
147
148/// Per-metric ring buffer history tracker.
149pub struct MetricTracker {
150    /// Per-metric history (ring buffer).
151    history: Vec<VecDeque<MetricSample>>,
152    /// Maximum samples per metric.
153    capacity: usize,
154}
155
156impl MetricTracker {
157    /// Create a new metric tracker with the given capacity per metric.
158    #[must_use]
159    pub fn new(capacity: usize) -> Self {
160        let mut history = Vec::with_capacity(MetricKind::all().len());
161        for _ in MetricKind::all() {
162            history.push(VecDeque::with_capacity(capacity));
163        }
164        Self { history, capacity }
165    }
166
167    /// Get the index for a metric kind.
168    fn index(kind: MetricKind) -> usize {
169        MetricKind::all()
170            .iter()
171            .position(|k| *k == kind)
172            .unwrap_or(0)
173    }
174
175    /// Record a metric sample.
176    pub fn record(&mut self, sample: MetricSample) {
177        let idx = Self::index(sample.kind);
178        let buf = &mut self.history[idx];
179        if buf.len() >= self.capacity {
180            buf.pop_front();
181        }
182        buf.push_back(sample);
183    }
184
185    /// Get the history for a metric kind (oldest first).
186    #[must_use]
187    pub fn history(&self, kind: MetricKind) -> Option<&VecDeque<MetricSample>> {
188        let buf = &self.history[Self::index(kind)];
189        if buf.is_empty() { None } else { Some(buf) }
190    }
191
192    /// Get the most recent sample for a metric kind.
193    #[must_use]
194    pub fn latest(&self, kind: MetricKind) -> Option<&MetricSample> {
195        self.history(kind).and_then(|h| h.back())
196    }
197
198    /// Get the number of samples for a metric kind.
199    #[must_use]
200    pub fn sample_count(&self, kind: MetricKind) -> usize {
201        self.history(kind)
202            .map_or(0, std::collections::VecDeque::len)
203    }
204
205    /// Iterate over metric kinds that have at least one sample.
206    pub fn tracked_kinds(&self) -> impl Iterator<Item = MetricKind> + '_ {
207        MetricKind::all()
208            .iter()
209            .copied()
210            .filter(|kind| self.sample_count(*kind) > 0)
211    }
212
213    /// Number of metrics with at least one sample.
214    #[must_use]
215    pub fn tracked_count(&self) -> usize {
216        self.tracked_kinds().count()
217    }
218
219    /// Compute the EWMA (exponentially weighted moving average) for a metric.
220    #[must_use]
221    pub fn ewma(&self, kind: MetricKind, alpha: f32) -> Option<f32> {
222        let history = self.history(kind)?;
223        if history.is_empty() {
224            return None;
225        }
226
227        let alpha = alpha.clamp(0.0, 1.0);
228        let mut ewma = history.front().unwrap().value;
229        for sample in history.iter().skip(1) {
230            ewma = alpha.mul_add(sample.value, (1.0 - alpha) * ewma);
231        }
232        Some(ewma)
233    }
234
235    /// Compute the linear slope (rate of change) for a metric.
236    /// Returns the slope per sample (positive = increasing).
237    #[must_use]
238    pub fn slope(&self, kind: MetricKind) -> Option<f32> {
239        let history = self.history(kind)?;
240        let n = history.len();
241        if n < 2 {
242            return None;
243        }
244
245        // Simple linear regression: slope = (y_n - y_1) / (n - 1)
246        let first = history.front().unwrap().value;
247        let last = history.back().unwrap().value;
248        Some((last - first) / (n as f32 - 1.0))
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn metric_kind_as_str() {
258        assert_eq!(MetricKind::CpuLoad.as_str(), "cpu_load");
259        assert_eq!(MetricKind::Coherence.as_str(), "coherence");
260        assert_eq!(MetricKind::ErrorRate.as_str(), "error_rate");
261    }
262
263    #[test]
264    fn metric_kind_higher_is_better() {
265        assert!(MetricKind::Coherence.higher_is_better());
266        assert!(!MetricKind::CpuLoad.higher_is_better());
267        assert!(!MetricKind::ErrorRate.higher_is_better());
268    }
269
270    #[test]
271    fn metric_kind_default_thresholds() {
272        assert_eq!(MetricKind::CpuLoad.default_warning(), 0.7);
273        assert_eq!(MetricKind::CpuLoad.default_critical(), 0.9);
274        assert_eq!(MetricKind::Coherence.default_warning(), 0.3);
275        assert_eq!(MetricKind::Coherence.default_critical(), 0.1);
276    }
277
278    #[test]
279    fn metric_tracker_record_and_history() {
280        let mut tracker = MetricTracker::new(100);
281        tracker.record(MetricSample {
282            kind: MetricKind::CpuLoad,
283            value: 0.3,
284            timestamp: Utc::now(),
285        });
286        tracker.record(MetricSample {
287            kind: MetricKind::CpuLoad,
288            value: 0.5,
289            timestamp: Utc::now(),
290        });
291
292        let hist = tracker.history(MetricKind::CpuLoad).unwrap();
293        assert_eq!(hist.len(), 2);
294        assert_eq!(tracker.sample_count(MetricKind::CpuLoad), 2);
295    }
296
297    #[test]
298    fn metric_tracker_empty_returns_none() {
299        let tracker = MetricTracker::new(100);
300        assert!(tracker.history(MetricKind::CpuLoad).is_none());
301        assert!(tracker.latest(MetricKind::CpuLoad).is_none());
302    }
303
304    #[test]
305    fn metric_tracker_latest() {
306        let mut tracker = MetricTracker::new(100);
307        tracker.record(MetricSample {
308            kind: MetricKind::CpuLoad,
309            value: 0.3,
310            timestamp: Utc::now(),
311        });
312        tracker.record(MetricSample {
313            kind: MetricKind::CpuLoad,
314            value: 0.7,
315            timestamp: Utc::now(),
316        });
317        let latest = tracker.latest(MetricKind::CpuLoad).unwrap();
318        assert!((latest.value - 0.7).abs() < 0.001);
319    }
320
321    #[test]
322    fn metric_tracker_ring_buffer_capacity() {
323        let mut tracker = MetricTracker::new(3);
324        for v in [0.1, 0.2, 0.3, 0.4, 0.5] {
325            tracker.record(MetricSample {
326                kind: MetricKind::CpuLoad,
327                value: v,
328                timestamp: Utc::now(),
329            });
330        }
331        let hist = tracker.history(MetricKind::CpuLoad).unwrap();
332        assert_eq!(hist.len(), 3);
333        // Oldest should be 0.3 (first two evicted)
334        assert!((hist.front().unwrap().value - 0.3).abs() < 0.001);
335        // Newest should be 0.5
336        assert!((hist.back().unwrap().value - 0.5).abs() < 0.001);
337    }
338
339    #[test]
340    fn metric_tracker_tracked_kinds() {
341        let mut tracker = MetricTracker::new(100);
342        tracker.record(MetricSample {
343            kind: MetricKind::CpuLoad,
344            value: 0.3,
345            timestamp: Utc::now(),
346        });
347        tracker.record(MetricSample {
348            kind: MetricKind::MemoryPressure,
349            value: 0.2,
350            timestamp: Utc::now(),
351        });
352
353        let kinds: Vec<_> = tracker.tracked_kinds().collect();
354        assert_eq!(kinds.len(), 2);
355        assert!(kinds.contains(&MetricKind::CpuLoad));
356        assert!(kinds.contains(&MetricKind::MemoryPressure));
357        assert_eq!(tracker.tracked_count(), 2);
358    }
359
360    #[test]
361    fn metric_tracker_ewma() {
362        let mut tracker = MetricTracker::new(100);
363        for v in [0.1, 0.2, 0.3, 0.4, 0.5] {
364            tracker.record(MetricSample {
365                kind: MetricKind::CpuLoad,
366                value: v,
367                timestamp: Utc::now(),
368            });
369        }
370        let ewma = tracker.ewma(MetricKind::CpuLoad, 0.3).unwrap();
371        // EWMA should be between first and last
372        assert!(ewma > 0.1 && ewma < 0.5);
373    }
374
375    #[test]
376    fn metric_tracker_ewma_alpha_clamped() {
377        let mut tracker = MetricTracker::new(100);
378        tracker.record(MetricSample {
379            kind: MetricKind::CpuLoad,
380            value: 0.5,
381            timestamp: Utc::now(),
382        });
383        tracker.record(MetricSample {
384            kind: MetricKind::CpuLoad,
385            value: 0.9,
386            timestamp: Utc::now(),
387        });
388        // Alpha > 1.0 should be clamped to 1.0 (just take latest)
389        let ewma = tracker.ewma(MetricKind::CpuLoad, 2.0).unwrap();
390        assert!((ewma - 0.9).abs() < 0.001);
391    }
392
393    #[test]
394    fn metric_tracker_ewma_empty() {
395        let tracker = MetricTracker::new(100);
396        assert!(tracker.ewma(MetricKind::CpuLoad, 0.3).is_none());
397    }
398
399    #[test]
400    fn metric_tracker_slope_increasing() {
401        let mut tracker = MetricTracker::new(100);
402        for v in [0.1, 0.2, 0.3, 0.4, 0.5] {
403            tracker.record(MetricSample {
404                kind: MetricKind::CpuLoad,
405                value: v,
406                timestamp: Utc::now(),
407            });
408        }
409        let slope = tracker.slope(MetricKind::CpuLoad).unwrap();
410        assert!(slope > 0.0);
411        assert!((slope - 0.1).abs() < 0.001);
412    }
413
414    #[test]
415    fn metric_tracker_slope_decreasing() {
416        let mut tracker = MetricTracker::new(100);
417        for v in [0.5, 0.4, 0.3, 0.2, 0.1] {
418            tracker.record(MetricSample {
419                kind: MetricKind::CpuLoad,
420                value: v,
421                timestamp: Utc::now(),
422            });
423        }
424        let slope = tracker.slope(MetricKind::CpuLoad).unwrap();
425        assert!(slope < 0.0);
426    }
427
428    #[test]
429    fn metric_tracker_slope_insufficient_data() {
430        let mut tracker = MetricTracker::new(100);
431        tracker.record(MetricSample {
432            kind: MetricKind::CpuLoad,
433            value: 0.3,
434            timestamp: Utc::now(),
435        });
436        assert!(tracker.slope(MetricKind::CpuLoad).is_none());
437    }
438
439    #[test]
440    fn metric_sample_serialization() {
441        let sample = MetricSample {
442            kind: MetricKind::CpuLoad,
443            value: 0.42,
444            timestamp: Utc::now(),
445        };
446        let json = serde_json::to_string(&sample).unwrap();
447        let back: MetricSample = serde_json::from_str(&json).unwrap();
448        assert_eq!(back.kind, MetricKind::CpuLoad);
449        assert!((back.value - 0.42).abs() < 0.001);
450    }
451
452    #[test]
453    fn metric_kind_all_count() {
454        assert_eq!(MetricKind::all().len(), 13);
455    }
456}