khora_control/
metrics.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Efficient storage for rolling telemetry metrics.
16
17use khora_core::telemetry::MetricId;
18use std::collections::HashMap;
19
20/// A fixed-size circular buffer for storing numerical samples.
21#[derive(Debug, Clone)]
22pub struct RingBuffer<T, const N: usize> {
23    data: [T; N],
24    index: usize,
25    count: usize,
26}
27
28impl<T: Default + Copy, const N: usize> Default for RingBuffer<T, N> {
29    fn default() -> Self {
30        Self::new()
31    }
32}
33
34impl<T: Default + Copy, const N: usize> RingBuffer<T, N> {
35    /// Creates a new, empty ring buffer.
36    pub fn new() -> Self {
37        Self {
38            data: [T::default(); N],
39            index: 0,
40            count: 0,
41        }
42    }
43
44    /// Pushes a new value into the buffer, overwriting the oldest if full.
45    pub fn push(&mut self, value: T) {
46        self.data[self.index] = value;
47        self.index = (self.index + 1) % N;
48        if self.count < N {
49            self.count += 1;
50        }
51    }
52
53    /// Returns the number of elements currently in the buffer.
54    pub fn count(&self) -> usize {
55        self.count
56    }
57
58    /// Returns an iterator over the values in chronological order (oldest to newest).
59    pub fn iter(&self) -> impl Iterator<Item = &T> {
60        let (left, right) = self.data.split_at(self.index);
61        if self.count < N {
62            // Buffer not full: only use values up to the current index
63            right[N - self.index..]
64                .iter()
65                .chain(left[..self.index].iter())
66        } else {
67            // Buffer full: start from the current index (the oldest value)
68            right.iter().chain(left.iter())
69        }
70    }
71}
72
73impl<const N: usize> RingBuffer<f32, N> {
74    /// Calculates the arithmetic mean of the values in the buffer.
75    pub fn average(&self) -> f32 {
76        if self.count == 0 {
77            return 0.0;
78        }
79        self.iter().sum::<f32>() / self.count as f32
80    }
81
82    /// Calculates the trend (slope) based on a simple linear regression or
83    /// just the difference between first and last half.
84    /// Returns positive if increasing, negative if decreasing.
85    pub fn trend(&self) -> f32 {
86        if self.count < 2 {
87            return 0.0;
88        }
89        let half = self.count / 2;
90        let first_half_avg: f32 = self.iter().take(half).sum::<f32>() / half as f32;
91        let last_half_avg: f32 = self.iter().skip(self.count - half).sum::<f32>() / half as f32;
92        last_half_avg - first_half_avg
93    }
94
95    /// Calculates the variance (spread) of the values in the buffer.
96    ///
97    /// Used for stutter detection: high variance in frame times indicates inconsistency.
98    pub fn variance(&self) -> f32 {
99        if self.count < 2 {
100            return 0.0;
101        }
102        let avg = self.average();
103        let sum_sq: f32 = self.iter().map(|v| (v - avg) * (v - avg)).sum();
104        sum_sq / self.count as f32
105    }
106
107    /// Returns the minimum value in the buffer, or `f32::MAX` if empty.
108    pub fn min(&self) -> f32 {
109        if self.count == 0 {
110            return f32::MAX;
111        }
112        self.iter().copied().fold(f32::MAX, f32::min)
113    }
114
115    /// Returns the maximum value in the buffer, or `f32::MIN` if empty.
116    pub fn max(&self) -> f32 {
117        if self.count == 0 {
118            return f32::MIN;
119        }
120        self.iter().copied().fold(f32::MIN, f32::max)
121    }
122}
123
124/// Central store for all incoming metrics, organized by ID.
125#[derive(Debug, Default)]
126pub struct MetricStore {
127    // For now we use a simple HashMap.
128    // In the future, we might want to use a more dense representation if many metrics exist.
129    buffers: HashMap<MetricId, RingBuffer<f32, 120>>, // Stores last 120 samples (e.g. 2s at 60Hz)
130}
131
132impl MetricStore {
133    /// Creates a new empty metric store.
134    pub fn new() -> Self {
135        Self::default()
136    }
137
138    /// Pushes a new sample for the given metric.
139    pub fn push(&mut self, id: MetricId, value: f32) {
140        self.buffers.entry(id).or_default().push(value);
141    }
142
143    /// Returns the average value for a metric, or 0.0 if not found.
144    pub fn get_average(&self, id: &MetricId) -> f32 {
145        self.buffers.get(id).map(|b| b.average()).unwrap_or(0.0)
146    }
147
148    /// Returns the trend for a metric, or 0.0 if not found.
149    pub fn get_trend(&self, id: &MetricId) -> f32 {
150        self.buffers.get(id).map(|b| b.trend()).unwrap_or(0.0)
151    }
152
153    /// Returns the variance for a metric, or 0.0 if not found.
154    ///
155    /// High variance in frame times is a strong stutter indicator.
156    pub fn get_variance(&self, id: &MetricId) -> f32 {
157        self.buffers.get(id).map(|b| b.variance()).unwrap_or(0.0)
158    }
159
160    /// Returns the maximum value for a metric, or 0.0 if not found.
161    pub fn get_max(&self, id: &MetricId) -> f32 {
162        self.buffers.get(id).map(|b| b.max()).unwrap_or(f32::MIN)
163    }
164
165    /// Returns the minimum value for a metric, or 0.0 if not found.
166    pub fn get_min(&self, id: &MetricId) -> f32 {
167        self.buffers.get(id).map(|b| b.min()).unwrap_or(f32::MAX)
168    }
169
170    /// Returns the sample count for a metric, or 0 if not found.
171    pub fn get_sample_count(&self, id: &MetricId) -> usize {
172        self.buffers.get(id).map(|b| b.count()).unwrap_or(0)
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn test_ring_buffer_push_and_iter() {
182        let mut rb = RingBuffer::<f32, 3>::new();
183        rb.push(1.0);
184        rb.push(2.0);
185        rb.push(3.0);
186        rb.push(4.0); // Overwrites 1.0
187
188        let values: Vec<f32> = rb.iter().copied().collect();
189        assert_eq!(values, vec![2.0, 3.0, 4.0]);
190        assert_eq!(rb.count(), 3);
191    }
192
193    #[test]
194    fn test_ring_buffer_average() {
195        let mut rb = RingBuffer::<f32, 4>::new();
196        rb.push(10.0);
197        rb.push(20.0);
198        assert_eq!(rb.average(), 15.0);
199    }
200
201    #[test]
202    fn test_ring_buffer_trend() {
203        let mut rb = RingBuffer::<f32, 4>::new();
204        rb.push(1.0);
205        rb.push(1.1);
206        rb.push(2.0);
207        rb.push(2.1);
208        // first half: (1.0 + 1.1) / 2 = 1.05
209        // second half: (2.0 + 2.1) / 2 = 2.05
210        // trend: 2.05 - 1.05 = 1.0
211        assert!((rb.trend() - 1.0).abs() < 0.001);
212    }
213
214    #[test]
215    fn test_ring_buffer_variance() {
216        let mut rb = RingBuffer::<f32, 4>::new();
217        rb.push(10.0);
218        rb.push(10.0);
219        rb.push(10.0);
220        rb.push(10.0);
221        assert_eq!(rb.variance(), 0.0); // Perfectly stable
222
223        let mut rb2 = RingBuffer::<f32, 4>::new();
224        rb2.push(5.0);
225        rb2.push(15.0);
226        rb2.push(5.0);
227        rb2.push(15.0);
228        // avg = 10.0, variance = ((5-10)^2 + (15-10)^2 + (5-10)^2 + (15-10)^2) / 4 = 25.0
229        assert!((rb2.variance() - 25.0).abs() < 0.001);
230    }
231
232    #[test]
233    fn test_ring_buffer_min_max() {
234        let mut rb = RingBuffer::<f32, 4>::new();
235        rb.push(3.0);
236        rb.push(1.0);
237        rb.push(4.0);
238        rb.push(1.5);
239        assert_eq!(rb.min(), 1.0);
240        assert_eq!(rb.max(), 4.0);
241    }
242
243    #[test]
244    fn test_ring_buffer_empty() {
245        let rb = RingBuffer::<f32, 4>::new();
246        assert_eq!(rb.average(), 0.0);
247        assert_eq!(rb.trend(), 0.0);
248        assert_eq!(rb.variance(), 0.0);
249        assert_eq!(rb.count(), 0);
250    }
251
252    #[test]
253    fn test_metric_store_variance_and_extremes() {
254        let mut store = MetricStore::new();
255        let id = MetricId::new("test", "values");
256        store.push(id.clone(), 5.0);
257        store.push(id.clone(), 15.0);
258        store.push(id.clone(), 5.0);
259        store.push(id.clone(), 15.0);
260
261        assert!((store.get_variance(&id) - 25.0).abs() < 0.01);
262        assert_eq!(store.get_min(&id), 5.0);
263        assert_eq!(store.get_max(&id), 15.0);
264        assert_eq!(store.get_sample_count(&id), 4);
265    }
266}