Skip to main content

llm_optimizer_processor/aggregation/
percentile.rs

1use super::trait_::{Aggregator, ToF64};
2use anyhow::anyhow;
3use serde::{Deserialize, Serialize};
4
5/// Accumulator for percentile aggregation
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
7pub struct PercentileAccumulator {
8    values: Vec<f64>,
9    percentile: f64,
10}
11
12/// Percentile aggregator - computes percentiles (p50, p95, p99, etc.)
13///
14/// This aggregator computes the specified percentile of all values.
15/// Uses a simple but memory-efficient approach by storing all values.
16/// For very large datasets, consider using approximate percentile algorithms.
17///
18/// Common percentiles:
19/// - p50 (median): 50th percentile
20/// - p95: 95th percentile (useful for SLAs)
21/// - p99: 99th percentile (worst-case performance)
22///
23/// # Examples
24///
25/// ```
26/// use processor::aggregation::{Aggregator, PercentileAggregator};
27///
28/// // Calculate p95 latency
29/// let mut agg = PercentileAggregator::p95();
30/// for i in 1..=100 {
31///     agg.update(i as f64).unwrap();
32/// }
33///
34/// let p95 = agg.finalize().unwrap();
35/// assert!((p95 - 95.0).abs() < 1.0);
36/// ```
37#[derive(Debug, Clone)]
38pub struct PercentileAggregator<T> {
39    values: Vec<f64>,
40    percentile: f64,
41    _phantom: std::marker::PhantomData<T>,
42}
43
44impl<T: ToF64 + Clone> PercentileAggregator<T> {
45    /// Create a new percentile aggregator with a custom percentile (0.0 to 100.0)
46    pub fn new(percentile: f64) -> anyhow::Result<Self> {
47        if !(0.0..=100.0).contains(&percentile) {
48            return Err(anyhow!("Percentile must be between 0 and 100"));
49        }
50        Ok(Self {
51            values: Vec::new(),
52            percentile,
53            _phantom: std::marker::PhantomData,
54        })
55    }
56
57    /// Create a p50 (median) aggregator
58    pub fn p50() -> Self {
59        Self {
60            values: Vec::new(),
61            percentile: 50.0,
62            _phantom: std::marker::PhantomData,
63        }
64    }
65
66    /// Create a p95 aggregator
67    pub fn p95() -> Self {
68        Self {
69            values: Vec::new(),
70            percentile: 95.0,
71            _phantom: std::marker::PhantomData,
72        }
73    }
74
75    /// Create a p99 aggregator
76    pub fn p99() -> Self {
77        Self {
78            values: Vec::new(),
79            percentile: 99.0,
80            _phantom: std::marker::PhantomData,
81        }
82    }
83
84    /// Create a p99.9 aggregator
85    pub fn p999() -> Self {
86        Self {
87            values: Vec::new(),
88            percentile: 99.9,
89            _phantom: std::marker::PhantomData,
90        }
91    }
92
93    /// Calculate percentile from sorted values using linear interpolation
94    fn calculate_percentile(sorted_values: &[f64], percentile: f64) -> Option<f64> {
95        if sorted_values.is_empty() {
96            return None;
97        }
98
99        if sorted_values.len() == 1 {
100            return Some(sorted_values[0]);
101        }
102
103        let rank = (percentile / 100.0) * (sorted_values.len() - 1) as f64;
104        let lower_idx = rank.floor() as usize;
105        let upper_idx = rank.ceil() as usize;
106
107        if lower_idx == upper_idx {
108            Some(sorted_values[lower_idx])
109        } else {
110            let fraction = rank - lower_idx as f64;
111            Some(sorted_values[lower_idx] * (1.0 - fraction)
112                + sorted_values[upper_idx] * fraction)
113        }
114    }
115}
116
117impl<T: ToF64 + Clone> Default for PercentileAggregator<T> {
118    fn default() -> Self {
119        Self::p50()
120    }
121}
122
123impl<T: ToF64 + Clone + Send + Sync + std::fmt::Debug> Aggregator for PercentileAggregator<T> {
124    type Input = T;
125    type Output = f64;
126    type Accumulator = PercentileAccumulator;
127
128    fn new() -> Self {
129        Self::p50()
130    }
131
132    fn update(&mut self, value: <Self as Aggregator>::Input) -> anyhow::Result<()> {
133        self.values.push(value.to_f64());
134        Ok(())
135    }
136
137    fn finalize(&self) -> anyhow::Result<<Self as Aggregator>::Output> {
138        if self.values.is_empty() {
139            return Err(anyhow!("Cannot compute percentile of zero values"));
140        }
141
142        let mut sorted = self.values.clone();
143        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
144
145        Self::calculate_percentile(&sorted, self.percentile)
146            .ok_or_else(|| anyhow!("Failed to calculate percentile"))
147    }
148
149    fn accumulator(&self) -> <Self as Aggregator>::Accumulator {
150        PercentileAccumulator {
151            values: self.values.clone(),
152            percentile: self.percentile,
153        }
154    }
155
156    fn merge(&mut self, other: <Self as Aggregator>::Accumulator) -> anyhow::Result<()> {
157        if (self.percentile - other.percentile).abs() > f64::EPSILON {
158            return Err(anyhow!(
159                "Cannot merge percentile aggregators with different percentiles: {} vs {}",
160                self.percentile,
161                other.percentile
162            ));
163        }
164        self.values.extend(other.values);
165        Ok(())
166    }
167
168    fn reset(&mut self) {
169        self.values.clear();
170    }
171
172    fn count(&self) -> u64 {
173        self.values.len() as u64
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn test_percentile_p50() {
183        let mut agg = PercentileAggregator::<f64>::p50();
184        for i in 1..=100 {
185            agg.update(i as f64).unwrap();
186        }
187
188        let p50 = agg.finalize().unwrap();
189        assert!((p50 - 50.5).abs() < 0.1);
190    }
191
192    #[test]
193    fn test_percentile_p95() {
194        let mut agg = PercentileAggregator::<f64>::p95();
195        for i in 1..=100 {
196            agg.update(i as f64).unwrap();
197        }
198
199        let p95 = agg.finalize().unwrap();
200        assert!((p95 - 95.05).abs() < 0.1);
201    }
202
203    #[test]
204    fn test_percentile_p99() {
205        let mut agg = PercentileAggregator::<f64>::p99();
206        for i in 1..=100 {
207            agg.update(i as f64).unwrap();
208        }
209
210        let p99 = agg.finalize().unwrap();
211        assert!((p99 - 99.01).abs() < 0.1);
212    }
213
214    #[test]
215    fn test_percentile_custom() {
216        let mut agg = PercentileAggregator::<f64>::new(75.0).unwrap();
217        for i in 1..=100 {
218            agg.update(i as f64).unwrap();
219        }
220
221        let p75 = agg.finalize().unwrap();
222        assert!((p75 - 75.25).abs() < 0.5);
223    }
224
225    #[test]
226    fn test_percentile_batch() {
227        let mut agg = PercentileAggregator::<f64>::p50();
228        let values: Vec<f64> = (1..=100).map(|i| i as f64).collect();
229        agg.update_batch(&values).unwrap();
230
231        let p50 = agg.finalize().unwrap();
232        assert!((p50 - 50.5).abs() < 0.1);
233    }
234
235    #[test]
236    fn test_percentile_merge() {
237        let mut agg1 = PercentileAggregator::<f64>::p50();
238        for i in 1..=50 {
239            agg1.update(i as f64).unwrap();
240        }
241
242        let mut agg2 = PercentileAggregator::<f64>::p50();
243        for i in 51..=100 {
244            agg2.update(i as f64).unwrap();
245        }
246
247        let acc2 = agg2.accumulator();
248        agg1.merge(acc2).unwrap();
249
250        let p50 = agg1.finalize().unwrap();
251        assert!((p50 - 50.5).abs() < 0.1);
252        assert_eq!(agg1.count(), 100);
253    }
254
255    #[test]
256    fn test_percentile_merge_different_percentiles_fails() {
257        let mut agg1 = PercentileAggregator::<f64>::p50();
258        agg1.update(1.0).unwrap();
259
260        let mut agg2 = PercentileAggregator::<f64>::p95();
261        agg2.update(2.0).unwrap();
262
263        let acc2 = agg2.accumulator();
264        assert!(agg1.merge(acc2).is_err());
265    }
266
267    #[test]
268    fn test_percentile_reset() {
269        let mut agg = PercentileAggregator::<f64>::p50();
270        agg.update(1.0).unwrap();
271        agg.update(2.0).unwrap();
272
273        assert_eq!(agg.count(), 2);
274
275        agg.reset();
276        assert_eq!(agg.count(), 0);
277        assert!(agg.finalize().is_err());
278    }
279
280    #[test]
281    fn test_percentile_serialization() {
282        let mut agg = PercentileAggregator::<f64>::p95();
283        agg.update(10.0).unwrap();
284        agg.update(20.0).unwrap();
285
286        let acc = agg.accumulator();
287        let serialized = serde_json::to_string(&acc).unwrap();
288        let deserialized: PercentileAccumulator = serde_json::from_str(&serialized).unwrap();
289
290        assert_eq!(acc, deserialized);
291        assert_eq!(deserialized.percentile, 95.0);
292        assert_eq!(deserialized.values.len(), 2);
293    }
294
295    #[test]
296    fn test_percentile_empty() {
297        let agg = PercentileAggregator::<f64>::p50();
298        assert!(agg.finalize().is_err());
299    }
300
301    #[test]
302    fn test_percentile_single_value() {
303        let mut agg = PercentileAggregator::<f64>::p95();
304        agg.update(42.0).unwrap();
305
306        assert_eq!(agg.finalize().unwrap(), 42.0);
307    }
308
309    #[test]
310    fn test_percentile_invalid_value() {
311        assert!(PercentileAggregator::<f64>::new(-1.0).is_err());
312        assert!(PercentileAggregator::<f64>::new(101.0).is_err());
313    }
314
315    #[test]
316    fn test_percentile_edge_cases() {
317        // p0 should give minimum
318        let mut agg = PercentileAggregator::<f64>::new(0.0).unwrap();
319        agg.update_batch(&[10.0, 20.0, 30.0]).unwrap();
320        assert_eq!(agg.finalize().unwrap(), 10.0);
321
322        // p100 should give maximum
323        let mut agg = PercentileAggregator::<f64>::new(100.0).unwrap();
324        agg.update_batch(&[10.0, 20.0, 30.0]).unwrap();
325        assert_eq!(agg.finalize().unwrap(), 30.0);
326    }
327
328    #[test]
329    fn test_percentile_with_duplicates() {
330        let mut agg = PercentileAggregator::<f64>::p50();
331        agg.update_batch(&[1.0, 2.0, 2.0, 2.0, 3.0]).unwrap();
332
333        // Median of [1, 2, 2, 2, 3] should be 2.0
334        assert_eq!(agg.finalize().unwrap(), 2.0);
335    }
336
337    #[test]
338    fn test_percentile_unsorted_input() {
339        let mut agg = PercentileAggregator::<f64>::p50();
340        agg.update_batch(&[50.0, 10.0, 30.0, 20.0, 40.0]).unwrap();
341
342        // Median of [10, 20, 30, 40, 50] should be 30.0
343        assert_eq!(agg.finalize().unwrap(), 30.0);
344    }
345}