Skip to main content

reifydb_value/value/percentile/
mod.rs

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