Skip to main content

proof_engine/debug/
metrics.rs

1//! Metrics collection and performance instrumentation.
2//!
3//! Provides counters, gauges, histograms, rolling rates, exponential moving
4//! averages, a Prometheus-compatible text exporter, and a performance dashboard
5//! that aggregates engine-level statistics into a formatted table.
6
7use std::collections::HashMap;
8use std::sync::{Arc, Mutex};
9use std::time::{SystemTime, UNIX_EPOCH};
10
11// ── helpers ───────────────────────────────────────────────────────────────────
12
13fn now_ms() -> u64 {
14    SystemTime::now()
15        .duration_since(UNIX_EPOCH)
16        .unwrap_or_default()
17        .as_millis() as u64
18}
19
20fn now_us() -> u64 {
21    SystemTime::now()
22        .duration_since(UNIX_EPOCH)
23        .unwrap_or_default()
24        .as_micros() as u64
25}
26
27// ── MetricKind ────────────────────────────────────────────────────────────────
28
29/// The kind of a metric, determining how its value is interpreted.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum MetricKind {
32    /// Monotonically increasing count.
33    Counter,
34    /// Current instantaneous value (can go up or down).
35    Gauge,
36    /// Distribution of observed values across configurable buckets.
37    Histogram,
38    /// Percentile summary over a sliding window.
39    Summary,
40}
41
42// ── MetricValue ───────────────────────────────────────────────────────────────
43
44/// The actual numeric value stored by a `Metric`.
45#[derive(Debug, Clone)]
46pub enum MetricValue {
47    /// Integer value used for counters and integer gauges.
48    Int(i64),
49    /// Floating-point value for gauges, rates, etc.
50    Float(f64),
51    /// Histogram distribution: (upper_bound, cumulative_count) pairs plus aggregate stats.
52    Histogram {
53        buckets: Vec<(f64, u64)>,
54        sum:     f64,
55        count:   u64,
56    },
57    /// Percentile summary.
58    Summary {
59        p50:   f64,
60        p90:   f64,
61        p95:   f64,
62        p99:   f64,
63        count: u64,
64    },
65}
66
67impl Default for MetricValue {
68    fn default() -> Self { MetricValue::Int(0) }
69}
70
71// ── Metric ────────────────────────────────────────────────────────────────────
72
73/// A single named metric with labels and a current value.
74#[derive(Debug, Clone)]
75pub struct Metric {
76    pub name:        String,
77    pub kind:        MetricKind,
78    pub value:       MetricValue,
79    pub labels:      HashMap<String, String>,
80    /// Unix millisecond timestamp of the last update.
81    pub last_update: u64,
82}
83
84impl Metric {
85    fn new(name: impl Into<String>, kind: MetricKind, labels: HashMap<String, String>) -> Self {
86        let value = match kind {
87            MetricKind::Counter   => MetricValue::Int(0),
88            MetricKind::Gauge     => MetricValue::Float(0.0),
89            MetricKind::Histogram => MetricValue::Histogram { buckets: Vec::new(), sum: 0.0, count: 0 },
90            MetricKind::Summary   => MetricValue::Summary { p50: 0.0, p90: 0.0, p95: 0.0, p99: 0.0, count: 0 },
91        };
92        Self { name: name.into(), kind, value, labels, last_update: now_ms() }
93    }
94}
95
96// ── MetricKey ─────────────────────────────────────────────────────────────────
97
98#[derive(Debug, Clone, PartialEq, Eq, Hash)]
99struct MetricKey {
100    name:        String,
101    sorted_labels: Vec<(String, String)>,
102}
103
104impl MetricKey {
105    fn new(name: &str, labels: &HashMap<String, String>) -> Self {
106        let mut sorted_labels: Vec<(String, String)> = labels
107            .iter()
108            .map(|(k, v)| (k.clone(), v.clone()))
109            .collect();
110        sorted_labels.sort_by(|a, b| a.0.cmp(&b.0));
111        Self { name: name.to_owned(), sorted_labels }
112    }
113}
114
115// ── HistogramBuckets ──────────────────────────────────────────────────────────
116
117/// Configurable histogram bucket boundaries with statistical helpers.
118#[derive(Debug, Clone)]
119pub struct HistogramBuckets {
120    /// Upper bounds of each bucket (must be sorted ascending).
121    boundaries: Vec<f64>,
122    /// Count of observations falling into each bucket (cumulative).
123    counts:     Vec<u64>,
124    /// All raw observed values (for exact percentile computation).
125    observations: Vec<f64>,
126    sum:         f64,
127    count:       u64,
128}
129
130impl HistogramBuckets {
131    /// Create buckets from explicit sorted upper bounds.
132    pub fn new(boundaries: Vec<f64>) -> Self {
133        let n = boundaries.len();
134        Self {
135            boundaries,
136            counts: vec![0; n],
137            observations: Vec::new(),
138            sum: 0.0,
139            count: 0,
140        }
141    }
142
143    /// Standard latency buckets in milliseconds: 1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000.
144    pub fn latency_ms() -> Self {
145        Self::new(vec![1.0, 5.0, 10.0, 25.0, 50.0, 100.0, 250.0, 500.0, 1000.0, 5000.0])
146    }
147
148    /// Exponential buckets: start * factor^i for i in 0..count.
149    pub fn exponential(start: f64, factor: f64, count: usize) -> Self {
150        let mut b = Vec::with_capacity(count);
151        let mut v = start;
152        for _ in 0..count {
153            b.push(v);
154            v *= factor;
155        }
156        Self::new(b)
157    }
158
159    /// Record an observed value.
160    pub fn observe(&mut self, value: f64) {
161        self.sum   += value;
162        self.count += 1;
163        self.observations.push(value);
164        for (i, &bound) in self.boundaries.iter().enumerate() {
165            if value <= bound {
166                self.counts[i] += 1;
167            }
168        }
169    }
170
171    /// Estimate the p-th percentile (0.0–1.0) via linear interpolation.
172    pub fn percentile(&self, p: f64) -> f64 {
173        if self.observations.is_empty() { return 0.0; }
174        let mut sorted = self.observations.clone();
175        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
176        let rank = p * (sorted.len() - 1) as f64;
177        let lo   = rank.floor() as usize;
178        let hi   = rank.ceil() as usize;
179        let frac = rank - lo as f64;
180        if lo == hi { return sorted[lo]; }
181        sorted[lo] * (1.0 - frac) + sorted[hi] * frac
182    }
183
184    /// Arithmetic mean of all observations.
185    pub fn mean(&self) -> f64 {
186        if self.count == 0 { return 0.0; }
187        self.sum / self.count as f64
188    }
189
190    /// Population standard deviation of all observations.
191    pub fn std_dev(&self) -> f64 {
192        if self.count < 2 { return 0.0; }
193        let mean = self.mean();
194        let var  = self.observations.iter()
195            .map(|&x| (x - mean).powi(2))
196            .sum::<f64>() / self.count as f64;
197        var.sqrt()
198    }
199
200    /// Total count of observations.
201    pub fn count(&self) -> u64 { self.count }
202
203    /// Sum of all observations.
204    pub fn sum(&self) -> f64 { self.sum }
205
206    /// Returns (upper_bound, cumulative_count) pairs for Prometheus exposition.
207    pub fn bucket_pairs(&self) -> Vec<(f64, u64)> {
208        self.boundaries.iter().cloned().zip(self.counts.iter().cloned()).collect()
209    }
210
211    /// Reset all observations.
212    pub fn reset(&mut self) {
213        self.counts      = vec![0; self.boundaries.len()];
214        self.observations.clear();
215        self.sum   = 0.0;
216        self.count = 0;
217    }
218}
219
220// ── InternalHistogram ─────────────────────────────────────────────────────────
221
222/// Internal storage for a histogram metric in the registry.
223#[derive(Debug, Clone)]
224struct InternalHistogram {
225    buckets: HistogramBuckets,
226}
227
228impl InternalHistogram {
229    fn new(boundaries: Vec<f64>) -> Self {
230        Self { buckets: HistogramBuckets::new(boundaries) }
231    }
232
233    fn observe(&mut self, value: f64) {
234        self.buckets.observe(value);
235    }
236
237    fn to_metric_value(&self) -> MetricValue {
238        MetricValue::Histogram {
239            buckets: self.buckets.bucket_pairs(),
240            sum:     self.buckets.sum(),
241            count:   self.buckets.count(),
242        }
243    }
244
245    fn to_summary_value(&self) -> MetricValue {
246        MetricValue::Summary {
247            p50:   self.buckets.percentile(0.50),
248            p90:   self.buckets.percentile(0.90),
249            p95:   self.buckets.percentile(0.95),
250            p99:   self.buckets.percentile(0.99),
251            count: self.buckets.count(),
252        }
253    }
254}
255
256// ── RegistryEntry ─────────────────────────────────────────────────────────────
257
258#[derive(Debug, Clone)]
259enum RegistryEntry {
260    Counter(i64),
261    Gauge(f64),
262    Histogram(InternalHistogram),
263}
264
265// ── MetricsRegistry ───────────────────────────────────────────────────────────
266
267/// Thread-safe registry for creating and updating metrics.
268///
269/// All operations are guarded by an internal `Mutex`, making the registry safe
270/// to share across threads via `Arc<MetricsRegistry>`.
271pub struct MetricsRegistry {
272    inner: Mutex<RegistryInner>,
273}
274
275#[derive(Debug, Default)]
276struct RegistryInner {
277    entries: HashMap<MetricKey, (MetricKind, RegistryEntry, HashMap<String, String>)>,
278    /// Default histogram buckets for new histogram metrics.
279    default_buckets: Vec<f64>,
280}
281
282impl RegistryInner {
283    fn new() -> Self {
284        Self {
285            entries: HashMap::new(),
286            default_buckets: vec![0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 5.0, 10.0],
287        }
288    }
289}
290
291impl MetricsRegistry {
292    /// Create a new empty registry.
293    pub fn new() -> Self {
294        Self { inner: Mutex::new(RegistryInner::new()) }
295    }
296
297    /// Create a registry wrapped in an `Arc` for sharing.
298    pub fn shared() -> Arc<Self> {
299        Arc::new(Self::new())
300    }
301
302    /// Increment a counter by `delta` (default 1).
303    pub fn counter(&self, name: &str, labels: HashMap<String, String>) -> i64 {
304        self.counter_by(name, labels, 1)
305    }
306
307    /// Increment a counter by a specific amount.
308    pub fn counter_by(&self, name: &str, labels: HashMap<String, String>, delta: i64) -> i64 {
309        let key = MetricKey::new(name, &labels);
310        let mut inner = self.inner.lock().unwrap();
311        let entry = inner.entries.entry(key).or_insert_with(|| {
312            (MetricKind::Counter, RegistryEntry::Counter(0), labels.clone())
313        });
314        if let RegistryEntry::Counter(ref mut v) = entry.1 {
315            *v += delta;
316            *v
317        } else {
318            0
319        }
320    }
321
322    /// Set a gauge to `value`.
323    pub fn gauge(&self, name: &str, labels: HashMap<String, String>, value: f64) {
324        let key = MetricKey::new(name, &labels);
325        let mut inner = self.inner.lock().unwrap();
326        let entry = inner.entries.entry(key).or_insert_with(|| {
327            (MetricKind::Gauge, RegistryEntry::Gauge(0.0), labels.clone())
328        });
329        if let RegistryEntry::Gauge(ref mut v) = entry.1 {
330            *v = value;
331        }
332    }
333
334    /// Add `delta` to a gauge.
335    pub fn gauge_add(&self, name: &str, labels: HashMap<String, String>, delta: f64) {
336        let key = MetricKey::new(name, &labels);
337        let mut inner = self.inner.lock().unwrap();
338        let entry = inner.entries.entry(key).or_insert_with(|| {
339            (MetricKind::Gauge, RegistryEntry::Gauge(0.0), labels.clone())
340        });
341        if let RegistryEntry::Gauge(ref mut v) = entry.1 {
342            *v += delta;
343        }
344    }
345
346    /// Record a histogram observation.
347    pub fn histogram_observe(&self, name: &str, labels: HashMap<String, String>, value: f64) {
348        let key = MetricKey::new(name, &labels);
349        let mut inner = self.inner.lock().unwrap();
350        let buckets = inner.default_buckets.clone();
351        let entry = inner.entries.entry(key).or_insert_with(|| {
352            (MetricKind::Histogram, RegistryEntry::Histogram(InternalHistogram::new(buckets)), labels.clone())
353        });
354        if let RegistryEntry::Histogram(ref mut h) = entry.1 {
355            h.observe(value);
356        }
357    }
358
359    /// Override the default bucket boundaries for future histogram metrics.
360    pub fn set_default_buckets(&self, boundaries: Vec<f64>) {
361        let mut inner = self.inner.lock().unwrap();
362        inner.default_buckets = boundaries;
363    }
364
365    /// Take a snapshot of all current metric values.
366    pub fn snapshot(&self) -> Vec<Metric> {
367        let inner = self.inner.lock().unwrap();
368        let ts = now_ms();
369        inner.entries.iter().map(|(key, (kind, entry, labels))| {
370            let value = match entry {
371                RegistryEntry::Counter(v) => MetricValue::Int(*v),
372                RegistryEntry::Gauge(v)   => MetricValue::Float(*v),
373                RegistryEntry::Histogram(h) => {
374                    match kind {
375                        MetricKind::Summary => h.to_summary_value(),
376                        _                   => h.to_metric_value(),
377                    }
378                }
379            };
380            Metric {
381                name:        key.name.clone(),
382                kind:        kind.clone(),
383                value,
384                labels:      labels.clone(),
385                last_update: ts,
386            }
387        }).collect()
388    }
389
390    /// Get the current counter value (returns 0 if not found).
391    pub fn get_counter(&self, name: &str, labels: &HashMap<String, String>) -> i64 {
392        let key = MetricKey::new(name, labels);
393        let inner = self.inner.lock().unwrap();
394        if let Some((_, RegistryEntry::Counter(v), _)) = inner.entries.get(&key) {
395            *v
396        } else {
397            0
398        }
399    }
400
401    /// Get the current gauge value (returns 0.0 if not found).
402    pub fn get_gauge(&self, name: &str, labels: &HashMap<String, String>) -> f64 {
403        let key = MetricKey::new(name, labels);
404        let inner = self.inner.lock().unwrap();
405        if let Some((_, RegistryEntry::Gauge(v), _)) = inner.entries.get(&key) {
406            *v
407        } else {
408            0.0
409        }
410    }
411
412    /// Reset all metrics (clear all entries).
413    pub fn reset(&self) {
414        let mut inner = self.inner.lock().unwrap();
415        inner.entries.clear();
416    }
417}
418
419impl Default for MetricsRegistry {
420    fn default() -> Self { Self::new() }
421}
422
423// ── RollingCounter ────────────────────────────────────────────────────────────
424
425/// A counter that tracks events in a fixed time window using a ring buffer.
426///
427/// `rate()` returns events per second observed within the window.
428pub struct RollingCounter {
429    /// Ring buffer of (timestamp_us, delta) pairs.
430    buffer:      Vec<(u64, u64)>,
431    head:        usize,
432    capacity:    usize,
433    window_us:   u64,
434    total:       u64,
435}
436
437impl RollingCounter {
438    /// Create a new rolling counter with the specified window in seconds.
439    pub fn new(window_secs: f64) -> Self {
440        let capacity = 4096;
441        Self {
442            buffer:    vec![(0, 0); capacity],
443            head:      0,
444            capacity,
445            window_us: (window_secs * 1_000_000.0) as u64,
446            total:     0,
447        }
448    }
449
450    /// Record that `delta` events have occurred right now.
451    pub fn record(&mut self, delta: u64) {
452        let ts = now_us();
453        self.buffer[self.head] = (ts, delta);
454        self.head = (self.head + 1) % self.capacity;
455        self.total += delta;
456    }
457
458    /// Increment by 1.
459    pub fn increment(&mut self) { self.record(1); }
460
461    /// Compute the event rate (events per second) within the rolling window.
462    pub fn rate(&self) -> f64 {
463        let now = now_us();
464        let cutoff = now.saturating_sub(self.window_us);
465        let events_in_window: u64 = self.buffer.iter()
466            .filter(|&&(ts, _)| ts >= cutoff && ts > 0)
467            .map(|&(_, delta)| delta)
468            .sum();
469        let window_secs = self.window_us as f64 / 1_000_000.0;
470        events_in_window as f64 / window_secs
471    }
472
473    /// Total events ever recorded (not windowed).
474    pub fn total(&self) -> u64 { self.total }
475
476    /// Events within the current window.
477    pub fn window_count(&self) -> u64 {
478        let now = now_us();
479        let cutoff = now.saturating_sub(self.window_us);
480        self.buffer.iter()
481            .filter(|&&(ts, _)| ts >= cutoff && ts > 0)
482            .map(|&(_, delta)| delta)
483            .sum()
484    }
485
486    /// Reset the counter.
487    pub fn reset(&mut self) {
488        for entry in &mut self.buffer { *entry = (0, 0); }
489        self.head  = 0;
490        self.total = 0;
491    }
492}
493
494// ── ExponentialMovingAverage ──────────────────────────────────────────────────
495
496/// Exponential moving average with configurable smoothing factor α.
497///
498/// EMA_n = α * value + (1 - α) * EMA_{n-1}.
499/// Smaller α → smoother but slower to respond.
500#[derive(Debug, Clone)]
501pub struct ExponentialMovingAverage {
502    alpha:       f64,
503    value:       f64,
504    initialized: bool,
505    sample_count: u64,
506}
507
508impl ExponentialMovingAverage {
509    /// Create a new EMA. `alpha` must be in (0, 1].
510    ///
511    /// A good default for frame times is α = 0.1 (90% weight on history).
512    pub fn new(alpha: f64) -> Self {
513        let alpha = alpha.clamp(1e-9, 1.0);
514        Self { alpha, value: 0.0, initialized: false, sample_count: 0 }
515    }
516
517    /// Create an EMA tuned to smooth over approximately `n` samples.
518    pub fn with_samples(n: f64) -> Self {
519        Self::new(2.0 / (n + 1.0))
520    }
521
522    /// Update with a new observation.
523    pub fn update(&mut self, value: f64) {
524        if !self.initialized {
525            self.value       = value;
526            self.initialized = true;
527        } else {
528            self.value = self.alpha * value + (1.0 - self.alpha) * self.value;
529        }
530        self.sample_count += 1;
531    }
532
533    /// Get the current EMA value.
534    pub fn get(&self) -> f64 { self.value }
535
536    /// Number of samples seen.
537    pub fn sample_count(&self) -> u64 { self.sample_count }
538
539    /// Reset the EMA to uninitialized state.
540    pub fn reset(&mut self) {
541        self.value       = 0.0;
542        self.initialized = false;
543        self.sample_count = 0;
544    }
545
546    /// Current smoothing factor.
547    pub fn alpha(&self) -> f64 { self.alpha }
548}
549
550// ── MetricsExporter ───────────────────────────────────────────────────────────
551
552/// Formats a snapshot of metrics as Prometheus text exposition format.
553///
554/// Each metric is rendered as:
555/// ```text
556/// # HELP name <empty>
557/// # TYPE name counter|gauge|histogram|summary
558/// name{label="value",...} <value> <timestamp_ms>
559/// ```
560pub struct MetricsExporter {
561    registry: Arc<MetricsRegistry>,
562}
563
564impl MetricsExporter {
565    pub fn new(registry: Arc<MetricsRegistry>) -> Self {
566        Self { registry }
567    }
568
569    /// Export all metrics in Prometheus text format.
570    pub fn export(&self) -> String {
571        let metrics = self.registry.snapshot();
572        let mut lines = Vec::new();
573
574        for m in &metrics {
575            let type_str = match m.kind {
576                MetricKind::Counter   => "counter",
577                MetricKind::Gauge     => "gauge",
578                MetricKind::Histogram => "histogram",
579                MetricKind::Summary   => "summary",
580            };
581            lines.push(format!("# HELP {} ", m.name));
582            lines.push(format!("# TYPE {} {}", m.name, type_str));
583
584            let label_str = Self::format_labels(&m.labels);
585
586            match &m.value {
587                MetricValue::Int(v) => {
588                    lines.push(format!("{}{} {} {}", m.name, label_str, v, m.last_update));
589                }
590                MetricValue::Float(v) => {
591                    lines.push(format!("{}{} {} {}", m.name, label_str, v, m.last_update));
592                }
593                MetricValue::Histogram { buckets, sum, count } => {
594                    for (bound, cnt) in buckets {
595                        let bucket_label = Self::format_labels_with_extra(&m.labels, "le", &bound.to_string());
596                        lines.push(format!("{}_bucket{} {} {}", m.name, bucket_label, cnt, m.last_update));
597                    }
598                    // +Inf bucket
599                    let inf_label = Self::format_labels_with_extra(&m.labels, "le", "+Inf");
600                    lines.push(format!("{}_bucket{} {} {}", m.name, inf_label, count, m.last_update));
601                    lines.push(format!("{}_sum{} {} {}", m.name, label_str, sum, m.last_update));
602                    lines.push(format!("{}_count{} {} {}", m.name, label_str, count, m.last_update));
603                }
604                MetricValue::Summary { p50, p90, p95, p99, count } => {
605                    let q50 = Self::format_labels_with_extra(&m.labels, "quantile", "0.5");
606                    let q90 = Self::format_labels_with_extra(&m.labels, "quantile", "0.9");
607                    let q95 = Self::format_labels_with_extra(&m.labels, "quantile", "0.95");
608                    let q99 = Self::format_labels_with_extra(&m.labels, "quantile", "0.99");
609                    lines.push(format!("{}{} {} {}", m.name, q50, p50, m.last_update));
610                    lines.push(format!("{}{} {} {}", m.name, q90, p90, m.last_update));
611                    lines.push(format!("{}{} {} {}", m.name, q95, p95, m.last_update));
612                    lines.push(format!("{}{} {} {}", m.name, q99, p99, m.last_update));
613                    lines.push(format!("{}_count{} {} {}", m.name, label_str, count, m.last_update));
614                }
615            }
616        }
617
618        lines.join("\n") + "\n"
619    }
620
621    fn format_labels(labels: &HashMap<String, String>) -> String {
622        if labels.is_empty() { return String::new(); }
623        let mut pairs: Vec<_> = labels.iter().collect();
624        pairs.sort_by_key(|(k, _)| k.as_str());
625        let inner: Vec<String> = pairs.iter().map(|(k, v)| format!("{}=\"{}\"", k, v)).collect();
626        format!("{{{}}}", inner.join(","))
627    }
628
629    fn format_labels_with_extra(labels: &HashMap<String, String>, key: &str, value: &str) -> String {
630        let mut pairs: Vec<_> = labels.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
631        pairs.push((key, value));
632        pairs.sort_by_key(|(k, _)| *k);
633        let inner: Vec<String> = pairs.iter().map(|(k, v)| format!("{}=\"{}\"", k, v)).collect();
634        format!("{{{}}}", inner.join(","))
635    }
636}
637
638// ── EngineSnapshot ────────────────────────────────────────────────────────────
639
640/// A snapshot of engine-level performance data passed to `PerformanceDashboard`.
641#[derive(Debug, Clone, Default)]
642pub struct EngineSnapshot {
643    pub fps:              f64,
644    pub frame_time_ms:    f64,
645    pub entity_count:     usize,
646    pub particle_count:   usize,
647    pub glyph_count:      usize,
648    /// Estimated heap usage in bytes.
649    pub memory_estimate:  usize,
650    /// Optional extra named values.
651    pub extras:           Vec<(String, String)>,
652}
653
654// ── PerformanceDashboard ──────────────────────────────────────────────────────
655
656/// Aggregates engine performance metrics and renders them as a formatted table
657/// with box-drawing characters.
658pub struct PerformanceDashboard {
659    ema_fps:        ExponentialMovingAverage,
660    ema_frame_ms:   ExponentialMovingAverage,
661    peak_fps:       f64,
662    min_fps:        f64,
663    peak_frame_ms:  f64,
664    last_snapshot:  EngineSnapshot,
665    frame_count:    u64,
666}
667
668impl PerformanceDashboard {
669    pub fn new() -> Self {
670        Self {
671            ema_fps:       ExponentialMovingAverage::new(0.1),
672            ema_frame_ms:  ExponentialMovingAverage::new(0.1),
673            peak_fps:      0.0,
674            min_fps:       f64::MAX,
675            peak_frame_ms: 0.0,
676            last_snapshot: EngineSnapshot::default(),
677            frame_count:   0,
678        }
679    }
680
681    /// Update with a new engine snapshot.
682    pub fn update(&mut self, snapshot: EngineSnapshot) {
683        self.ema_fps.update(snapshot.fps);
684        self.ema_frame_ms.update(snapshot.frame_time_ms);
685        if snapshot.fps > self.peak_fps { self.peak_fps = snapshot.fps; }
686        if snapshot.fps < self.min_fps  { self.min_fps  = snapshot.fps; }
687        if snapshot.frame_time_ms > self.peak_frame_ms { self.peak_frame_ms = snapshot.frame_time_ms; }
688        self.frame_count   += 1;
689        self.last_snapshot  = snapshot;
690    }
691
692    /// Format the dashboard as a box-drawing table string.
693    pub fn format_table(&self) -> String {
694        let s = &self.last_snapshot;
695        let rows: Vec<(&str, String)> = vec![
696            ("FPS (cur)",    format!("{:>7.1}", s.fps)),
697            ("FPS (avg)",    format!("{:>7.1}", self.ema_fps.get())),
698            ("FPS (peak)",   format!("{:>7.1}", self.peak_fps)),
699            ("FPS (min)",    format!("{:>7.1}", if self.min_fps == f64::MAX { 0.0 } else { self.min_fps })),
700            ("Frame ms",     format!("{:>7.2}", s.frame_time_ms)),
701            ("Frame ms avg", format!("{:>7.2}", self.ema_frame_ms.get())),
702            ("Frame ms pk",  format!("{:>7.2}", self.peak_frame_ms)),
703            ("Entities",     format!("{:>7}", s.entity_count)),
704            ("Particles",    format!("{:>7}", s.particle_count)),
705            ("Glyphs",       format!("{:>7}", s.glyph_count)),
706            ("Memory",       format!("{:>6.1}K", s.memory_estimate as f64 / 1024.0)),
707            ("Frames",       format!("{:>7}", self.frame_count)),
708        ];
709
710        // Compute column widths
711        let key_width = rows.iter().map(|(k, _)| k.len()).max().unwrap_or(10);
712        let val_width = rows.iter().map(|(_, v)| v.len()).max().unwrap_or(7);
713        let total_inner = key_width + 3 + val_width; // " │ "
714
715        let top    = format!("╔{}╗", "═".repeat(total_inner + 2));
716        let title  = format!("║ {:<width$} ║", "Performance Dashboard", width = total_inner);
717        let sep    = format!("╠{}╣", "═".repeat(total_inner + 2));
718        let bottom = format!("╚{}╝", "═".repeat(total_inner + 2));
719
720        let mut lines = vec![top, title, sep];
721
722        for (key, val) in &rows {
723            lines.push(format!("║ {:<kw$} │ {:<vw$} ║", key, val, kw = key_width, vw = val_width));
724        }
725
726        // Extra rows
727        for (key, val) in &s.extras {
728            lines.push(format!("║ {:<kw$} │ {:<vw$} ║", key, val, kw = key_width, vw = val_width));
729        }
730
731        lines.push(bottom);
732        lines.join("\n")
733    }
734
735    /// Format a compact single-line summary.
736    pub fn format_line(&self) -> String {
737        let s = &self.last_snapshot;
738        format!(
739            "FPS:{:.0} dt:{:.1}ms E:{} P:{} G:{} M:{:.0}K",
740            s.fps, s.frame_time_ms,
741            s.entity_count, s.particle_count, s.glyph_count,
742            s.memory_estimate as f64 / 1024.0,
743        )
744    }
745}
746
747impl Default for PerformanceDashboard {
748    fn default() -> Self { Self::new() }
749}
750
751// ── MemoryTracker ─────────────────────────────────────────────────────────────
752
753/// Tracks per-category memory allocations with explicit alloc/free calls.
754///
755/// This is not a general allocator hook; it records explicit calls from
756/// subsystems that want to track their approximate heap usage.
757pub struct MemoryTracker {
758    categories: HashMap<String, CategoryStats>,
759}
760
761#[derive(Debug, Clone, Default)]
762struct CategoryStats {
763    current: usize,
764    peak:    usize,
765    total_alloc: u64,
766    total_free:  u64,
767    alloc_count: u64,
768    free_count:  u64,
769}
770
771impl MemoryTracker {
772    pub fn new() -> Self {
773        Self { categories: HashMap::new() }
774    }
775
776    /// Record an allocation of `bytes` bytes in `category`.
777    pub fn alloc(&mut self, category: &str, bytes: usize) {
778        let s = self.categories.entry(category.to_owned()).or_default();
779        s.current     += bytes;
780        s.total_alloc += bytes as u64;
781        s.alloc_count += 1;
782        if s.current > s.peak { s.peak = s.current; }
783    }
784
785    /// Record a free of `bytes` bytes in `category`.
786    pub fn free(&mut self, category: &str, bytes: usize) {
787        let s = self.categories.entry(category.to_owned()).or_default();
788        s.current     = s.current.saturating_sub(bytes);
789        s.total_free += bytes as u64;
790        s.free_count += 1;
791    }
792
793    /// Total bytes currently tracked across all categories.
794    pub fn total(&self) -> usize {
795        self.categories.values().map(|s| s.current).sum()
796    }
797
798    /// Peak total bytes seen across all categories at any single point.
799    pub fn peak_total(&self) -> usize {
800        self.categories.values().map(|s| s.peak).sum()
801    }
802
803    /// Per-category report sorted by current usage (descending).
804    pub fn report_by_category(&self) -> Vec<(String, usize)> {
805        let mut rows: Vec<(String, usize)> = self.categories.iter()
806            .map(|(k, v)| (k.clone(), v.current))
807            .collect();
808        rows.sort_by(|a, b| b.1.cmp(&a.1));
809        rows
810    }
811
812    /// Detailed per-category report including peak and alloc counts.
813    pub fn detailed_report(&self) -> Vec<CategoryReport> {
814        let mut rows: Vec<CategoryReport> = self.categories.iter().map(|(k, v)| {
815            CategoryReport {
816                category:    k.clone(),
817                current:     v.current,
818                peak:        v.peak,
819                total_alloc: v.total_alloc,
820                total_free:  v.total_free,
821                alloc_count: v.alloc_count,
822                free_count:  v.free_count,
823            }
824        }).collect();
825        rows.sort_by(|a, b| b.current.cmp(&a.current));
826        rows
827    }
828
829    /// Format a human-readable report.
830    pub fn format_report(&self) -> String {
831        let mut lines = vec!["=== Memory Tracker ===".to_owned()];
832        lines.push(format!("Total: {} bytes  Peak: {} bytes", self.total(), self.peak_total()));
833        for (cat, bytes) in self.report_by_category() {
834            lines.push(format!("  {:24} {:>10} bytes", cat, bytes));
835        }
836        lines.join("\n")
837    }
838
839    /// Reset all tracking data.
840    pub fn reset(&mut self) {
841        self.categories.clear();
842    }
843
844    /// Reset a specific category.
845    pub fn reset_category(&mut self, category: &str) {
846        self.categories.remove(category);
847    }
848}
849
850/// Detailed per-category memory statistics.
851#[derive(Debug, Clone)]
852pub struct CategoryReport {
853    pub category:    String,
854    pub current:     usize,
855    pub peak:        usize,
856    pub total_alloc: u64,
857    pub total_free:  u64,
858    pub alloc_count: u64,
859    pub free_count:  u64,
860}
861
862impl Default for MemoryTracker {
863    fn default() -> Self { Self::new() }
864}
865
866// ── TimeSeries ────────────────────────────────────────────────────────────────
867
868/// A simple fixed-capacity ring buffer of (timestamp_ms, f64) samples.
869#[derive(Debug, Clone)]
870pub struct TimeSeries {
871    samples:  Vec<(u64, f64)>,
872    head:     usize,
873    capacity: usize,
874    count:    usize,
875}
876
877impl TimeSeries {
878    pub fn new(capacity: usize) -> Self {
879        Self {
880            samples:  vec![(0, 0.0); capacity.max(1)],
881            head:     0,
882            capacity: capacity.max(1),
883            count:    0,
884        }
885    }
886
887    /// Push a new sample with the current timestamp.
888    pub fn push(&mut self, value: f64) {
889        self.samples[self.head] = (now_ms(), value);
890        self.head  = (self.head + 1) % self.capacity;
891        self.count = (self.count + 1).min(self.capacity);
892    }
893
894    /// Push a sample with an explicit timestamp.
895    pub fn push_at(&mut self, ts_ms: u64, value: f64) {
896        self.samples[self.head] = (ts_ms, value);
897        self.head  = (self.head + 1) % self.capacity;
898        self.count = (self.count + 1).min(self.capacity);
899    }
900
901    /// Iterate over samples in chronological order.
902    pub fn iter(&self) -> impl Iterator<Item = (u64, f64)> + '_ {
903        let start = if self.count < self.capacity { 0 } else { self.head };
904        (0..self.count).map(move |i| self.samples[(start + i) % self.capacity])
905    }
906
907    /// Latest value, or 0.0 if empty.
908    pub fn latest(&self) -> f64 {
909        if self.count == 0 { return 0.0; }
910        let idx = if self.head == 0 { self.capacity - 1 } else { self.head - 1 };
911        self.samples[idx].1
912    }
913
914    pub fn len(&self) -> usize { self.count }
915    pub fn is_empty(&self) -> bool { self.count == 0 }
916}
917
918// ── AggregateStats ────────────────────────────────────────────────────────────
919
920/// Compute summary statistics over a slice of f64 values.
921#[derive(Debug, Clone)]
922pub struct AggregateStats {
923    pub min:    f64,
924    pub max:    f64,
925    pub mean:   f64,
926    pub std_dev: f64,
927    pub p50:    f64,
928    pub p95:    f64,
929    pub p99:    f64,
930    pub count:  usize,
931}
932
933impl AggregateStats {
934    pub fn compute(values: &[f64]) -> Option<Self> {
935        if values.is_empty() { return None; }
936        let count = values.len();
937        let min   = values.iter().cloned().fold(f64::INFINITY, f64::min);
938        let max   = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
939        let sum: f64 = values.iter().sum();
940        let mean  = sum / count as f64;
941        let var   = values.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / count as f64;
942        let std_dev = var.sqrt();
943
944        let mut sorted = values.to_vec();
945        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
946
947        let percentile = |p: f64| -> f64 {
948            let rank = p * (count - 1) as f64;
949            let lo = rank.floor() as usize;
950            let hi = rank.ceil() as usize;
951            let frac = rank - lo as f64;
952            if lo == hi { return sorted[lo]; }
953            sorted[lo] * (1.0 - frac) + sorted[hi] * frac
954        };
955
956        Some(Self { min, max, mean, std_dev, p50: percentile(0.5), p95: percentile(0.95), p99: percentile(0.99), count })
957    }
958}
959
960// ── tests ─────────────────────────────────────────────────────────────────────
961
962#[cfg(test)]
963mod tests {
964    use super::*;
965
966    #[test]
967    fn counter_increments() {
968        let reg = MetricsRegistry::new();
969        reg.counter("requests", HashMap::new());
970        reg.counter("requests", HashMap::new());
971        reg.counter("requests", HashMap::new());
972        assert_eq!(reg.get_counter("requests", &HashMap::new()), 3);
973    }
974
975    #[test]
976    fn counter_by_delta() {
977        let reg = MetricsRegistry::new();
978        reg.counter_by("bytes", HashMap::new(), 1024);
979        reg.counter_by("bytes", HashMap::new(), 512);
980        assert_eq!(reg.get_counter("bytes", &HashMap::new()), 1536);
981    }
982
983    #[test]
984    fn gauge_set_and_get() {
985        let reg = MetricsRegistry::new();
986        reg.gauge("temperature", HashMap::new(), 98.6);
987        assert!((reg.get_gauge("temperature", &HashMap::new()) - 98.6).abs() < 1e-9);
988    }
989
990    #[test]
991    fn gauge_add() {
992        let reg = MetricsRegistry::new();
993        reg.gauge("level", HashMap::new(), 10.0);
994        reg.gauge_add("level", HashMap::new(), 5.0);
995        assert!((reg.get_gauge("level", &HashMap::new()) - 15.0).abs() < 1e-9);
996    }
997
998    #[test]
999    fn snapshot_contains_all_metrics() {
1000        let reg = MetricsRegistry::new();
1001        reg.counter("c1", HashMap::new());
1002        reg.gauge("g1", HashMap::new(), 1.0);
1003        reg.histogram_observe("h1", HashMap::new(), 0.5);
1004        let snap = reg.snapshot();
1005        assert!(snap.len() >= 3);
1006    }
1007
1008    #[test]
1009    fn histogram_buckets_percentile() {
1010        let mut h = HistogramBuckets::latency_ms();
1011        for v in [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0] {
1012            h.observe(v);
1013        }
1014        let p50 = h.percentile(0.5);
1015        assert!(p50 >= 5.0 && p50 <= 6.0, "p50={}", p50);
1016        let p90 = h.percentile(0.9);
1017        assert!(p90 >= 9.0, "p90={}", p90);
1018    }
1019
1020    #[test]
1021    fn histogram_mean_and_std_dev() {
1022        let mut h = HistogramBuckets::new(vec![10.0, 100.0]);
1023        for v in [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0] {
1024            h.observe(v);
1025        }
1026        let mean = h.mean();
1027        assert!((mean - 5.0).abs() < 0.01, "mean={}", mean);
1028        let sd = h.std_dev();
1029        assert!(sd > 0.0, "std_dev should be positive");
1030    }
1031
1032    #[test]
1033    fn rolling_counter_rate() {
1034        let mut rc = RollingCounter::new(1.0);
1035        for _ in 0..100 { rc.increment(); }
1036        assert_eq!(rc.total(), 100);
1037        // Rate should be > 0 since events just happened
1038        assert!(rc.rate() > 0.0);
1039    }
1040
1041    #[test]
1042    fn ema_convergence() {
1043        let mut ema = ExponentialMovingAverage::new(0.5);
1044        // Feed many samples of 10.0; EMA should converge to 10.0
1045        for _ in 0..30 { ema.update(10.0); }
1046        assert!((ema.get() - 10.0).abs() < 0.01, "EMA={}", ema.get());
1047    }
1048
1049    #[test]
1050    fn ema_with_samples() {
1051        let mut ema = ExponentialMovingAverage::with_samples(10.0);
1052        for _ in 0..50 { ema.update(5.0); }
1053        assert!((ema.get() - 5.0).abs() < 0.01);
1054    }
1055
1056    #[test]
1057    fn memory_tracker_alloc_free() {
1058        let mut tracker = MemoryTracker::new();
1059        tracker.alloc("textures", 1024);
1060        tracker.alloc("textures", 2048);
1061        tracker.free("textures", 1024);
1062        assert_eq!(tracker.total(), 2048);
1063        let report = tracker.report_by_category();
1064        assert_eq!(report[0].0, "textures");
1065        assert_eq!(report[0].1, 2048);
1066    }
1067
1068    #[test]
1069    fn memory_tracker_peak() {
1070        let mut tracker = MemoryTracker::new();
1071        tracker.alloc("verts", 4096);
1072        tracker.alloc("verts", 4096);
1073        tracker.free("verts", 8192);
1074        assert_eq!(tracker.peak_total(), 8192);
1075        assert_eq!(tracker.total(), 0);
1076    }
1077
1078    #[test]
1079    fn performance_dashboard_update() {
1080        let mut dash = PerformanceDashboard::new();
1081        dash.update(EngineSnapshot {
1082            fps:             60.0,
1083            frame_time_ms:   16.7,
1084            entity_count:    100,
1085            particle_count:  500,
1086            glyph_count:     2000,
1087            memory_estimate: 1024 * 1024,
1088            extras:          vec![],
1089        });
1090        let table = dash.format_table();
1091        assert!(table.contains("60"), "table should contain fps=60");
1092        assert!(table.contains("╔"), "table should have box-drawing chars");
1093        assert!(table.contains("╚"), "table should have box-drawing chars");
1094    }
1095
1096    #[test]
1097    fn metrics_exporter_counter() {
1098        let reg = Arc::new(MetricsRegistry::new());
1099        reg.counter("http_requests", HashMap::new());
1100        let exporter = MetricsExporter::new(Arc::clone(&reg));
1101        let out = exporter.export();
1102        assert!(out.contains("http_requests"), "export should mention metric name");
1103        assert!(out.contains("# TYPE"), "should have type annotation");
1104    }
1105
1106    #[test]
1107    fn aggregate_stats() {
1108        let vals = vec![1.0, 2.0, 3.0, 4.0, 5.0];
1109        let stats = AggregateStats::compute(&vals).unwrap();
1110        assert_eq!(stats.mean, 3.0);
1111        assert_eq!(stats.min, 1.0);
1112        assert_eq!(stats.max, 5.0);
1113    }
1114
1115    #[test]
1116    fn time_series_ring_buffer() {
1117        let mut ts = TimeSeries::new(5);
1118        for i in 0..8u64 { ts.push(i as f64); }
1119        assert_eq!(ts.len(), 5);
1120        assert_eq!(ts.latest(), 7.0);
1121    }
1122
1123    #[test]
1124    fn metrics_with_labels() {
1125        let reg = MetricsRegistry::new();
1126        let mut labels_a = HashMap::new();
1127        labels_a.insert("method".to_owned(), "GET".to_owned());
1128        let mut labels_b = HashMap::new();
1129        labels_b.insert("method".to_owned(), "POST".to_owned());
1130        reg.counter("requests", labels_a.clone());
1131        reg.counter("requests", labels_a.clone());
1132        reg.counter("requests", labels_b.clone());
1133        assert_eq!(reg.get_counter("requests", &labels_a), 2);
1134        assert_eq!(reg.get_counter("requests", &labels_b), 1);
1135    }
1136}