1use crate::error::{RillError, checked_increment, ensure_finite};
12use crate::persistence::ValidateState;
13use crate::traits::OnlineStatistic;
14
15const MARKER_COUNT: usize = 5;
16const MAX_TRACKED_QUANTILES: usize = 64;
17
18#[derive(Debug, Clone, PartialEq)]
20#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
21pub struct P2Quantile {
22 quantile: f64,
23 samples_seen: u64,
24 initial: Vec<f64>,
25 marker_heights: [f64; MARKER_COUNT],
26 marker_positions: [u64; MARKER_COUNT],
27 desired_positions: [f64; MARKER_COUNT],
28}
29
30impl P2Quantile {
31 pub fn new(quantile: f64) -> Result<Self, RillError> {
33 ensure_finite("quantile", quantile)?;
34 if quantile <= 0.0 || quantile >= 1.0 {
35 return Err(RillError::InvalidParameter {
36 name: "quantile",
37 value: quantile,
38 });
39 }
40 Ok(Self {
41 quantile,
42 samples_seen: 0,
43 initial: Vec::with_capacity(MARKER_COUNT),
44 marker_heights: [0.0; MARKER_COUNT],
45 marker_positions: [0; MARKER_COUNT],
46 desired_positions: [0.0; MARKER_COUNT],
47 })
48 }
49
50 pub const fn quantile(&self) -> f64 {
52 self.quantile
53 }
54
55 pub fn value(&self) -> Option<f64> {
61 if self.samples_seen == 0 {
62 return None;
63 }
64 if self.samples_seen < MARKER_COUNT as u64 {
65 let mut sorted = self.initial.clone();
66 sorted.sort_by(f64::total_cmp);
67 return Some(exact_linear_quantile(&sorted, self.quantile));
68 }
69 Some(self.marker_heights[2])
70 }
71
72 pub fn reset(&mut self) {
74 self.samples_seen = 0;
75 self.initial.clear();
76 self.marker_heights = [0.0; MARKER_COUNT];
77 self.marker_positions = [0; MARKER_COUNT];
78 self.desired_positions = [0.0; MARKER_COUNT];
79 }
80
81 fn desired_increments(&self) -> [f64; MARKER_COUNT] {
82 [
83 0.0,
84 self.quantile / 2.0,
85 self.quantile,
86 (1.0 + self.quantile) / 2.0,
87 1.0,
88 ]
89 }
90
91 fn update_inner(&mut self, value: f64) -> Result<(), RillError> {
92 ensure_finite("value", value)?;
93 let next_samples = checked_increment(self.samples_seen, "P2 samples_seen")?;
94 if self.samples_seen < MARKER_COUNT as u64 {
95 self.initial.push(value);
96 self.samples_seen = next_samples;
97 if self.samples_seen == MARKER_COUNT as u64 {
98 self.initial.sort_by(f64::total_cmp);
99 self.marker_heights.copy_from_slice(&self.initial);
100 self.marker_positions = [1, 2, 3, 4, 5];
101 self.desired_positions = [
102 1.0,
103 1.0 + 2.0 * self.quantile,
104 1.0 + 4.0 * self.quantile,
105 3.0 + 2.0 * self.quantile,
106 5.0,
107 ];
108 }
109 return Ok(());
110 }
111
112 let cell = if value < self.marker_heights[0] {
113 self.marker_heights[0] = value;
114 0
115 } else if value < self.marker_heights[1] {
116 0
117 } else if value < self.marker_heights[2] {
118 1
119 } else if value < self.marker_heights[3] {
120 2
121 } else if value <= self.marker_heights[4] {
122 3
123 } else {
124 self.marker_heights[4] = value;
125 3
126 };
127
128 for position in self.marker_positions.iter_mut().skip(cell + 1) {
129 *position = position
130 .checked_add(1)
131 .ok_or_else(|| RillError::InvalidState("P2 marker position overflow".to_owned()))?;
132 }
133 let increments = self.desired_increments();
134 for (desired, increment) in self.desired_positions.iter_mut().zip(increments) {
135 *desired += increment;
136 ensure_finite("P2 desired marker position", *desired)?;
137 }
138
139 for index in 1..(MARKER_COUNT - 1) {
140 let difference = self.desired_positions[index] - self.marker_positions[index] as f64;
141 let can_move_up = difference >= 1.0
142 && self.marker_positions[index + 1] - self.marker_positions[index] > 1;
143 let can_move_down = difference <= -1.0
144 && self.marker_positions[index] - self.marker_positions[index - 1] > 1;
145 if !can_move_up && !can_move_down {
146 continue;
147 }
148 let direction = if difference > 0.0 { 1_i8 } else { -1_i8 };
149 let candidate = self.parabolic_candidate(index, direction)?;
150 let next_height = if self.marker_heights[index - 1] < candidate
151 && candidate < self.marker_heights[index + 1]
152 {
153 candidate
154 } else {
155 self.linear_candidate(index, direction)?
156 };
157 ensure_finite("P2 marker height", next_height)?;
158 self.marker_heights[index] = next_height;
159 if direction > 0 {
160 self.marker_positions[index] += 1;
161 } else {
162 self.marker_positions[index] -= 1;
163 }
164 }
165 self.samples_seen = next_samples;
166 Ok(())
167 }
168
169 fn parabolic_candidate(&self, index: usize, direction: i8) -> Result<f64, RillError> {
170 let n_prev = self.marker_positions[index - 1] as f64;
171 let n = self.marker_positions[index] as f64;
172 let n_next = self.marker_positions[index + 1] as f64;
173 let q_prev = self.marker_heights[index - 1];
174 let q = self.marker_heights[index];
175 let q_next = self.marker_heights[index + 1];
176 let d = f64::from(direction);
177 let left = (n - n_prev + d) * (q_next - q) / (n_next - n);
178 let right = (n_next - n - d) * (q - q_prev) / (n - n_prev);
179 let candidate = q + d * (left + right) / (n_next - n_prev);
180 ensure_finite("P2 parabolic marker", candidate)?;
181 Ok(candidate)
182 }
183
184 fn linear_candidate(&self, index: usize, direction: i8) -> Result<f64, RillError> {
185 let adjacent = if direction > 0 { index + 1 } else { index - 1 };
186 let numerator = self.marker_heights[adjacent] - self.marker_heights[index];
187 let denominator =
188 self.marker_positions[adjacent] as f64 - self.marker_positions[index] as f64;
189 let candidate = self.marker_heights[index] + f64::from(direction) * numerator / denominator;
190 ensure_finite("P2 linear marker", candidate)?;
191 Ok(candidate)
192 }
193}
194
195impl OnlineStatistic for P2Quantile {
196 fn update(&mut self, value: f64) -> Result<(), RillError> {
197 let mut next = self.clone();
200 next.update_inner(value)?;
201 *self = next;
202 Ok(())
203 }
204
205 fn samples_seen(&self) -> u64 {
206 self.samples_seen
207 }
208
209 fn reset(&mut self) {
210 P2Quantile::reset(self);
211 }
212}
213
214impl ValidateState for P2Quantile {
215 fn validate_state(&self) -> Result<(), RillError> {
216 P2Quantile::new(self.quantile)?;
217 if self.samples_seen < MARKER_COUNT as u64 {
218 if self.initial.len() != self.samples_seen as usize {
219 return Err(RillError::InvalidState(
220 "P2 bootstrap length does not match samples_seen".to_owned(),
221 ));
222 }
223 for &value in &self.initial {
224 ensure_finite("P2 bootstrap value", value)?;
225 }
226 if self.marker_heights != [0.0; MARKER_COUNT]
227 || self.marker_positions != [0; MARKER_COUNT]
228 || self.desired_positions != [0.0; MARKER_COUNT]
229 {
230 return Err(RillError::InvalidState(
231 "P2 bootstrap state contains initialized markers".to_owned(),
232 ));
233 }
234 return Ok(());
235 }
236 if self.initial.len() != MARKER_COUNT {
237 return Err(RillError::InvalidState(
238 "P2 initialized state must retain exactly five bootstrap values".to_owned(),
239 ));
240 }
241 for &value in self.initial.iter().chain(&self.marker_heights) {
242 ensure_finite("P2 retained value", value)?;
243 }
244 for &value in &self.desired_positions {
245 ensure_finite("P2 desired position", value)?;
246 }
247 let observations_after_bootstrap = (self.samples_seen - MARKER_COUNT as u64) as f64;
248 let increments = self.desired_increments();
249 let initial_desired = [
250 1.0,
251 1.0 + 2.0 * self.quantile,
252 1.0 + 4.0 * self.quantile,
253 3.0 + 2.0 * self.quantile,
254 5.0,
255 ];
256 let desired_matches_count = self
257 .desired_positions
258 .iter()
259 .zip(initial_desired.iter().zip(increments))
260 .all(|(&actual, (&initial, increment))| {
261 let expected = initial + observations_after_bootstrap * increment;
262 (actual - expected).abs() <= 1e-10 * expected.abs().max(1.0)
263 });
264 if self.initial.windows(2).any(|pair| pair[0] > pair[1])
265 || self.marker_positions[0] != 1
266 || self.marker_positions[4] != self.samples_seen
267 || self
268 .marker_positions
269 .windows(2)
270 .any(|pair| pair[0] >= pair[1])
271 || self
272 .marker_positions
273 .iter()
274 .any(|&position| position > self.samples_seen)
275 || self.marker_heights.windows(2).any(|pair| pair[0] > pair[1])
276 || self
277 .desired_positions
278 .windows(2)
279 .any(|pair| pair[0] > pair[1])
280 || !desired_matches_count
281 {
282 return Err(RillError::InvalidState(
283 "P2 marker ordering or boundary positions are inconsistent".to_owned(),
284 ));
285 }
286 Ok(())
287 }
288}
289
290#[derive(Debug, Clone, PartialEq)]
292#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
293pub struct P2Quantiles {
294 estimators: Vec<P2Quantile>,
295}
296
297impl P2Quantiles {
298 pub fn new(quantiles: &[f64]) -> Result<Self, RillError> {
300 if quantiles.is_empty() || quantiles.len() > MAX_TRACKED_QUANTILES {
301 return Err(RillError::InvalidCapacity(quantiles.len()));
302 }
303 let mut estimators = Vec::with_capacity(quantiles.len());
304 for (index, &quantile) in quantiles.iter().enumerate() {
305 if quantiles[..index].contains(&quantile) {
306 return Err(RillError::InvalidState(
307 "P2Quantiles requires distinct quantiles".to_owned(),
308 ));
309 }
310 estimators.push(P2Quantile::new(quantile)?);
311 }
312 Ok(Self { estimators })
313 }
314
315 pub fn update(&mut self, value: f64) -> Result<(), RillError> {
317 let mut next = self.clone();
318 for estimator in &mut next.estimators {
319 estimator.update_inner(value)?;
320 }
321 *self = next;
322 Ok(())
323 }
324
325 pub fn values(&self) -> Vec<(f64, Option<f64>)> {
327 self.estimators
328 .iter()
329 .map(|estimator| (estimator.quantile(), estimator.value()))
330 .collect()
331 }
332
333 pub fn samples_seen(&self) -> u64 {
335 self.estimators[0].samples_seen()
336 }
337
338 pub fn reset(&mut self) {
340 for estimator in &mut self.estimators {
341 estimator.reset();
342 }
343 }
344}
345
346impl ValidateState for P2Quantiles {
347 fn validate_state(&self) -> Result<(), RillError> {
348 if self.estimators.is_empty() || self.estimators.len() > MAX_TRACKED_QUANTILES {
349 return Err(RillError::InvalidState(
350 "P2Quantiles estimator count is out of bounds".to_owned(),
351 ));
352 }
353 let samples = self.estimators[0].samples_seen();
354 for (index, estimator) in self.estimators.iter().enumerate() {
355 estimator.validate_state()?;
356 if estimator.samples_seen() != samples
357 || self.estimators[..index]
358 .iter()
359 .any(|previous| previous.quantile() == estimator.quantile())
360 {
361 return Err(RillError::InvalidState(
362 "P2Quantiles estimators are inconsistent".to_owned(),
363 ));
364 }
365 }
366 Ok(())
367 }
368}
369
370fn exact_linear_quantile(sorted: &[f64], quantile: f64) -> f64 {
371 let position = (sorted.len() - 1) as f64 * quantile;
372 let lower = position.floor() as usize;
373 let upper = position.ceil() as usize;
374 if lower == upper {
375 sorted[lower]
376 } else {
377 let weight = position - lower as f64;
378 sorted[lower] + weight * (sorted[upper] - sorted[lower])
379 }
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385 use proptest::prelude::*;
386 use rand::{Rng, SeedableRng};
387
388 #[test]
389 fn bootstrap_values_are_exact() {
390 let mut median = P2Quantile::new(0.5).unwrap();
391 for value in [3.0, 1.0, 2.0, 4.0] {
392 median.update(value).unwrap();
393 }
394 assert_eq!(median.value(), Some(2.5));
395 assert_eq!(median.samples_seen(), 4);
396 }
397
398 #[test]
399 fn monotonic_and_constant_streams() {
400 let mut median = P2Quantile::new(0.5).unwrap();
401 for value in 1..=1000 {
402 median.update(value as f64).unwrap();
403 }
404 assert!((median.value().unwrap() - 500.0).abs() <= 2.0);
405
406 let mut constant = P2Quantile::new(0.99).unwrap();
407 for _ in 0..10_000 {
408 constant.update(7.25).unwrap();
409 }
410 assert_eq!(constant.value(), Some(7.25));
411 }
412
413 #[test]
414 fn seeded_random_stream_matches_offline_rank() {
415 let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(42);
416 for quantile in [0.1, 0.5, 0.9, 0.99] {
417 let mut estimator = P2Quantile::new(quantile).unwrap();
418 let mut data = Vec::new();
419 for _ in 0..20_000 {
420 let value = rng.gen_range(-1000.0..1000.0);
421 estimator.update(value).unwrap();
422 data.push(value);
423 }
424 data.sort_by(f64::total_cmp);
425 let estimate = estimator.value().unwrap();
426 let rank = data.partition_point(|value| *value <= estimate) as f64 / data.len() as f64;
427 assert!(
428 (rank - quantile).abs() < 0.02,
429 "q={quantile}, estimated rank={rank}, value={estimate}"
430 );
431 }
432 }
433
434 #[test]
435 fn extreme_finite_values_and_non_finite_rejection_are_atomic() {
436 let mut estimator = P2Quantile::new(0.5).unwrap();
437 for value in [-1e150, -1e100, 0.0, 1e100, 1e150] {
438 estimator.update(value).unwrap();
439 }
440 assert!(estimator.value().unwrap().is_finite());
441 let before = estimator.clone();
442 assert!(estimator.update(f64::NAN).is_err());
443 assert_eq!(estimator, before);
444 }
445
446 #[test]
447 fn multiple_quantiles_are_bounded_resettable_and_validated() {
448 let mut quantiles = P2Quantiles::new(&[0.1, 0.5, 0.9]).unwrap();
449 for value in 0..1000 {
450 quantiles.update(value as f64).unwrap();
451 }
452 let values = quantiles.values();
453 assert!(values[0].1.unwrap() < values[1].1.unwrap());
454 assert!(values[1].1.unwrap() < values[2].1.unwrap());
455 quantiles.validate_state().unwrap();
456 quantiles.reset();
457 assert_eq!(quantiles.samples_seen(), 0);
458 assert!(quantiles.values().iter().all(|(_, value)| value.is_none()));
459 }
460
461 #[test]
462 fn validation_rejects_corrupt_marker_counts_and_positions() {
463 let mut estimator = P2Quantile::new(0.5).unwrap();
464 for value in 0..20 {
465 estimator.update(value as f64).unwrap();
466 }
467 let mut corrupt = estimator.clone();
468 corrupt.desired_positions[2] += 10.0;
469 assert!(corrupt.validate_state().is_err());
470 let mut corrupt = estimator.clone();
471 corrupt.marker_positions[4] -= 1;
472 assert!(corrupt.validate_state().is_err());
473 let mut corrupt = estimator;
474 corrupt.marker_heights[2] = corrupt.marker_heights[1] - 1.0;
475 assert!(corrupt.validate_state().is_err());
476 }
477
478 #[cfg(feature = "serde")]
479 #[test]
480 fn serde_roundtrip_preserves_future_continuity() {
481 let mut original = P2Quantile::new(0.75).unwrap();
482 for value in 0..100 {
483 original.update(value as f64).unwrap();
484 }
485 let json = serde_json::to_string(&original).unwrap();
486 let mut restored: P2Quantile = serde_json::from_str(&json).unwrap();
487 restored.validate_state().unwrap();
488 for value in 100..200 {
489 original.update(value as f64).unwrap();
490 restored.update(value as f64).unwrap();
491 assert_eq!(original, restored);
492 }
493 }
494
495 proptest! {
496 #[test]
497 fn property_estimate_stays_within_observed_range(
498 values in prop::collection::vec(-1e6_f64..1e6_f64, 1..500),
499 quantile in 0.01_f64..0.99_f64,
500 ) {
501 let mut estimator = P2Quantile::new(quantile).unwrap();
502 for &value in &values {
503 estimator.update(value).unwrap();
504 }
505 let estimate = estimator.value().unwrap();
506 let min = values.iter().copied().fold(f64::INFINITY, f64::min);
507 let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
508 prop_assert!(estimate >= min && estimate <= max);
509 prop_assert!(estimate.is_finite());
510 estimator.validate_state().unwrap();
511 }
512 }
513}