Skip to main content

reifydb_value/value/percentile/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use serde::{Deserialize, Serialize};
5
6use crate::value::ordered_f64::OrderedF64;
7
8#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
9pub struct Centroid {
10	mean: OrderedF64,
11	weight: OrderedF64,
12}
13
14impl Centroid {
15	pub fn new(mean: OrderedF64, weight: OrderedF64) -> Self {
16		Self {
17			mean,
18			weight,
19		}
20	}
21
22	pub fn mean(&self) -> OrderedF64 {
23		self.mean
24	}
25
26	pub fn weight(&self) -> OrderedF64 {
27		self.weight
28	}
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct Percentiles {
33	centroids: Vec<Centroid>,
34	max_size: usize,
35	sum: OrderedF64,
36	count: OrderedF64,
37	min: Option<OrderedF64>,
38	max: Option<OrderedF64>,
39}
40
41impl Percentiles {
42	pub fn empty(max_size: usize) -> Self {
43		Self {
44			centroids: Vec::new(),
45			max_size,
46			sum: OrderedF64::zero(),
47			count: OrderedF64::zero(),
48			min: None,
49			max: None,
50		}
51	}
52
53	pub fn new(
54		centroids: Vec<Centroid>,
55		sum: OrderedF64,
56		count: OrderedF64,
57		min: Option<OrderedF64>,
58		max: Option<OrderedF64>,
59		max_size: usize,
60	) -> Self {
61		Self {
62			centroids,
63			max_size,
64			sum,
65			count,
66			min,
67			max,
68		}
69	}
70
71	pub fn centroids(&self) -> &[Centroid] {
72		&self.centroids
73	}
74
75	pub fn max_size(&self) -> usize {
76		self.max_size
77	}
78
79	pub fn sum(&self) -> OrderedF64 {
80		self.sum
81	}
82
83	pub fn count(&self) -> OrderedF64 {
84		self.count
85	}
86
87	pub fn min(&self) -> Option<OrderedF64> {
88		self.min
89	}
90
91	pub fn max(&self) -> Option<OrderedF64> {
92		self.max
93	}
94
95	pub fn is_empty(&self) -> bool {
96		self.centroids.is_empty()
97	}
98
99	pub fn mean(&self) -> Option<OrderedF64> {
100		let count = self.count.value();
101		if count > 0.0 {
102			OrderedF64::try_from(self.sum.value() / count).ok()
103		} else {
104			None
105		}
106	}
107}
108
109impl Default for Percentiles {
110	fn default() -> Self {
111		Self::empty(DEFAULT_MAX_SIZE)
112	}
113}
114
115pub const DEFAULT_MAX_SIZE: usize = 100;
116
117#[cfg(test)]
118mod tests {
119	use postcard::{from_bytes, to_allocvec};
120
121	use crate::value::{
122		ordered_f64::OrderedF64,
123		percentile::{Centroid, Percentiles},
124	};
125
126	fn f(v: f64) -> OrderedF64 {
127		OrderedF64::try_from(v).expect("finite test constant")
128	}
129
130	fn sample() -> Percentiles {
131		Percentiles::new(
132			vec![Centroid::new(f(1.0), f(2.0)), Centroid::new(f(5.5), f(3.0))],
133			f(19.5),
134			f(5.0),
135			Some(f(1.0)),
136			Some(f(5.5)),
137			100,
138		)
139	}
140
141	#[test]
142	fn centroids_are_readable() {
143		// This accessor is the entire reason the type exists: tdigest::TDigest keeps its
144		// centroids private, which forces callers to persist it as an opaque blob.
145		let p = sample();
146		let centroids = p.centroids();
147
148		assert_eq!(centroids.len(), 2);
149		assert_eq!(centroids[0].mean(), f(1.0));
150		assert_eq!(centroids[0].weight(), f(2.0));
151		assert_eq!(centroids[1].mean(), f(5.5));
152		assert_eq!(centroids[1].weight(), f(3.0));
153	}
154
155	#[test]
156	fn round_trips_through_the_persisted_form() {
157		// The digest persists structurally, so every field including the centroids must survive.
158		let p = sample();
159
160		let bytes = to_allocvec(&p).expect("encode");
161		let restored: Percentiles = from_bytes(&bytes).expect("decode");
162
163		assert_eq!(restored, p);
164	}
165
166	#[test]
167	fn an_empty_digest_reports_absent_bounds_not_a_sentinel() {
168		// tdigest encodes "no observations" as NaN min/max. OrderedF64 rejects NaN, so
169		// empty must be represented as an absent value; a 0.0 sentinel would be
170		// indistinguishable from a genuine observation of zero.
171		let p = Percentiles::empty(100);
172
173		assert!(p.is_empty());
174		assert_eq!(p.min(), None);
175		assert_eq!(p.max(), None);
176		assert_eq!(p.count(), OrderedF64::zero());
177		assert_eq!(p.sum(), OrderedF64::zero());
178		assert_eq!(p.mean(), None, "mean of no observations is absent, not NaN or zero");
179	}
180
181	#[test]
182	fn an_empty_digest_round_trips() {
183		// Every bucket starts empty, so a decode failure here would break the first write
184		// to every new group rather than showing up under load.
185		let p = Percentiles::empty(64);
186
187		let bytes = to_allocvec(&p).expect("encode");
188		let restored: Percentiles = from_bytes(&bytes).expect("decode");
189
190		assert_eq!(restored, p);
191		assert_eq!(restored.max_size(), 64, "max_size must survive; it bounds later merges");
192	}
193
194	#[test]
195	fn mean_divides_sum_by_count() {
196		let p = sample();
197		assert_eq!(p.mean(), Some(f(19.5 / 5.0)));
198	}
199
200	#[test]
201	fn truncated_bytes_are_rejected() {
202		// The centroid length prefix must outrun the buffer, never yield a garbage digest.
203		let p = sample();
204		let bytes = to_allocvec(&p).expect("encode");
205		let truncated = &bytes[..bytes.len() / 2];
206
207		assert!(from_bytes::<Percentiles>(truncated).is_err());
208	}
209}