probabilistic_machines 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,445 @@
1
+ //! Quantile estimation over a stream, in clusters instead of samples.
2
+
3
+ use alloc::vec::Vec;
4
+
5
+ use crate::{CodecError, TAG_TDIGEST, read8};
6
+
7
+ const HEADER_LEN: usize = 31;
8
+ const CENTROID_LEN: usize = 16;
9
+
10
+ /// Clusters the digest may hold per unit of compression.
11
+ const CLUSTERS_PER_COMPRESSION: usize = 10;
12
+
13
+ /// How far a total summed one point at a time may sit from the sum of the
14
+ /// clusters those points were folded into, relative to the total itself. Both
15
+ /// sums round once per addition, so a stream of fractional weights long enough
16
+ /// to exhaust this bound is one no `f64` total describes anyway.
17
+ const WEIGHT_DRIFT: f64 = 1e-6;
18
+
19
+ /// A weighted point: the mean of the values that collapsed into it.
20
+ #[derive(Debug, Clone, Copy, PartialEq)]
21
+ pub struct Centroid {
22
+ pub mean: f64,
23
+ pub weight: f64,
24
+ }
25
+
26
+ /// Estimates quantiles from clusters whose weight is capped by how close they
27
+ /// sit to the tails.
28
+ ///
29
+ /// A cluster covering the quantile range around `q` holds at most
30
+ /// `4 * n * q * (1 - q) / compression` of the `n` points seen, so a quantile is
31
+ /// off by at most the width of one cluster there: about `1 / compression` of
32
+ /// the distribution in the middle, shrinking towards the extremes, where
33
+ /// clusters are single points and `quantile(0.0)` and `quantile(1.0)` are
34
+ /// exact.
35
+ ///
36
+ /// The cluster count grows with `compression * ln(n)`, not with `n`:
37
+ /// compression 100 holds 266 clusters at a thousand points and 809 at a
38
+ /// million. Below `10 * compression` points nothing is collapsed and the
39
+ /// quantiles are exact.
40
+ ///
41
+ /// ```
42
+ /// use probabilistic_machines::TDigest;
43
+ ///
44
+ /// let mut latency = TDigest::new(100);
45
+ /// for ms in 1..=10_000 {
46
+ /// latency.add(f64::from(ms));
47
+ /// }
48
+ ///
49
+ /// assert!((latency.quantile(0.5) - 5_000.0).abs() < 100.0);
50
+ /// assert!((latency.quantile(0.99) - 9_900.0).abs() < 50.0);
51
+ /// assert_eq!(latency.quantile(0.0), 1.0);
52
+ /// assert_eq!(latency.quantile(1.0), 10_000.0);
53
+ /// ```
54
+ #[derive(Debug, Clone, PartialEq)]
55
+ pub struct TDigest {
56
+ compression: u16,
57
+ centroids: Vec<Centroid>,
58
+ count: f64,
59
+ minimum: f64,
60
+ maximum: f64,
61
+ }
62
+
63
+ impl TDigest {
64
+ #[must_use]
65
+ pub const fn new(compression: u16) -> Self {
66
+ Self {
67
+ compression,
68
+ centroids: Vec::new(),
69
+ count: 0.0,
70
+ minimum: f64::INFINITY,
71
+ maximum: f64::NEG_INFINITY,
72
+ }
73
+ }
74
+
75
+ #[must_use]
76
+ pub const fn compression(&self) -> u16 {
77
+ self.compression
78
+ }
79
+
80
+ /// Total weight added, which for unweighted points is the item count.
81
+ #[must_use]
82
+ pub const fn count(&self) -> f64 {
83
+ self.count
84
+ }
85
+
86
+ /// Smallest value seen, [`f64::INFINITY`] when empty.
87
+ #[must_use]
88
+ pub const fn minimum(&self) -> f64 {
89
+ self.minimum
90
+ }
91
+
92
+ /// Largest value seen, [`f64::NEG_INFINITY`] when empty.
93
+ #[must_use]
94
+ pub const fn maximum(&self) -> f64 {
95
+ self.maximum
96
+ }
97
+
98
+ /// Clusters currently held, ordered by mean.
99
+ #[must_use]
100
+ pub fn centroids(&self) -> &[Centroid] {
101
+ &self.centroids
102
+ }
103
+
104
+ #[must_use]
105
+ pub const fn len(&self) -> usize {
106
+ self.centroids.len()
107
+ }
108
+
109
+ #[must_use]
110
+ pub const fn is_empty(&self) -> bool {
111
+ self.centroids.is_empty()
112
+ }
113
+
114
+ /// Adds one observation of `value`, under the same precondition as
115
+ /// [`add_weighted`](Self::add_weighted).
116
+ pub fn add(&mut self, value: f64) {
117
+ self.add_weighted(value, 1.0);
118
+ }
119
+
120
+ /// Adds a value standing for `weight` observations.
121
+ ///
122
+ /// `value` must be finite, and `weight` finite and positive: the quantiles
123
+ /// of a stream carrying anything else mean nothing, and the clusters it
124
+ /// leaves are not ones [`decode`](Self::decode) reads back.
125
+ pub fn add_weighted(&mut self, value: f64, weight: f64) {
126
+ let at = self
127
+ .centroids
128
+ .partition_point(|centroid| centroid.mean < value);
129
+ self.centroids.insert(
130
+ at,
131
+ Centroid {
132
+ mean: value,
133
+ weight,
134
+ },
135
+ );
136
+ self.count += weight;
137
+ if value < self.minimum {
138
+ self.minimum = value;
139
+ }
140
+ if value > self.maximum {
141
+ self.maximum = value;
142
+ }
143
+ if self.centroids.len() > self.capacity() {
144
+ self.compress();
145
+ }
146
+ }
147
+
148
+ /// Value at quantile `q`, [`f64::NAN`] when empty.
149
+ #[must_use]
150
+ pub fn quantile(&self, q: f64) -> f64 {
151
+ if self.centroids.is_empty() {
152
+ return f64::NAN;
153
+ }
154
+
155
+ let target = q * self.count;
156
+ let mut processed = 0.0;
157
+ let mut left_rank = 0.0;
158
+ let mut left_mean = self.minimum;
159
+
160
+ for centroid in &self.centroids {
161
+ let half = centroid.weight * 0.5;
162
+ let rank = processed + half;
163
+ if target < rank {
164
+ return lerp(target, left_rank, left_mean, rank, centroid.mean);
165
+ }
166
+ left_rank = rank;
167
+ left_mean = centroid.mean;
168
+ processed += centroid.weight;
169
+ }
170
+
171
+ lerp(target, left_rank, left_mean, self.count, self.maximum)
172
+ }
173
+
174
+ /// Fraction of the stream at or below `value`, [`f64::NAN`] when empty.
175
+ #[must_use]
176
+ pub fn cdf(&self, value: f64) -> f64 {
177
+ if self.centroids.is_empty() {
178
+ return f64::NAN;
179
+ }
180
+ if value < self.minimum {
181
+ return 0.0;
182
+ }
183
+ if value > self.maximum {
184
+ return 1.0;
185
+ }
186
+
187
+ let mut processed = 0.0;
188
+ let mut left_rank = 0.0;
189
+ let mut left_mean = self.minimum;
190
+
191
+ for centroid in &self.centroids {
192
+ let half = centroid.weight * 0.5;
193
+ let rank = processed + half;
194
+ if value < centroid.mean {
195
+ return lerp(value, left_mean, left_rank, centroid.mean, rank) / self.count;
196
+ }
197
+ left_rank = rank;
198
+ left_mean = centroid.mean;
199
+ processed += centroid.weight;
200
+ }
201
+
202
+ lerp(value, left_mean, left_rank, self.maximum, self.count) / self.count
203
+ }
204
+
205
+ /// Folds another digest in. The result is the same digest you would get by
206
+ /// feeding one instance both streams, up to the order clusters formed in.
207
+ pub fn merge(&mut self, other: &Self) {
208
+ self.centroids.extend_from_slice(&other.centroids);
209
+ self.centroids
210
+ .sort_unstable_by(|left, right| left.mean.total_cmp(&right.mean));
211
+ self.count += other.count;
212
+ if other.minimum < self.minimum {
213
+ self.minimum = other.minimum;
214
+ }
215
+ if other.maximum > self.maximum {
216
+ self.maximum = other.maximum;
217
+ }
218
+ self.compress();
219
+ }
220
+
221
+ /// Collapses adjacent clusters up to the weight their quantile allows.
222
+ pub fn compress(&mut self) {
223
+ let factor = 4.0 / f64::from(self.compression) * self.count;
224
+ let mut merged = Vec::with_capacity(self.centroids.len());
225
+ let mut processed = 0.0;
226
+ let mut open: Option<Centroid> = None;
227
+
228
+ for &next in &self.centroids {
229
+ let Some(current) = open else {
230
+ open = Some(next);
231
+ continue;
232
+ };
233
+ let combined = current.weight + next.weight;
234
+ let half = combined * 0.5;
235
+ let rank = (processed + half) / self.count;
236
+ if combined <= factor * rank * (1.0 - rank) {
237
+ open = Some(fold(current, next));
238
+ } else {
239
+ processed += current.weight;
240
+ merged.push(current);
241
+ open = Some(next);
242
+ }
243
+ }
244
+
245
+ merged.extend(open);
246
+ self.centroids = merged;
247
+
248
+ // A cluster holding nearly all the weight pins every later rank at 1.0,
249
+ // where the scale function above can merge nothing.
250
+ while self.centroids.len() > self.capacity() {
251
+ self.centroids = self
252
+ .centroids
253
+ .chunks(2)
254
+ .map(|pair| match pair {
255
+ [left, right] => fold(*left, *right),
256
+ _ => pair[0],
257
+ })
258
+ .collect();
259
+ }
260
+ }
261
+
262
+ /// Bytes [`encode`](Self::encode) needs, which grows with the cluster count.
263
+ #[must_use]
264
+ pub const fn encoded_len(&self) -> usize {
265
+ HEADER_LEN + self.centroids.len() * CENTROID_LEN
266
+ }
267
+
268
+ /// Writes the digest into `out`, returning the number of bytes written.
269
+ ///
270
+ /// The frame is a header of the tag, the compression, the cluster count,
271
+ /// the total weight and the two extremes, then a record of mean and weight
272
+ /// per cluster, every scalar little-endian.
273
+ ///
274
+ /// # Errors
275
+ ///
276
+ /// [`CodecError::Truncated`] when `out` is shorter than
277
+ /// [`encoded_len`](Self::encoded_len).
278
+ pub fn encode(&self, out: &mut [u8]) -> Result<usize, CodecError> {
279
+ let length = self.encoded_len();
280
+ let Some(frame) = out.get_mut(..length) else {
281
+ return Err(CodecError::Truncated);
282
+ };
283
+ self.write_frame(frame);
284
+ Ok(length)
285
+ }
286
+
287
+ /// The digest's frame, exactly [`encoded_len`](Self::encoded_len) bytes.
288
+ #[must_use]
289
+ pub fn encoded(&self) -> alloc::boxed::Box<[u8]> {
290
+ let mut frame = alloc::vec![0_u8; self.encoded_len()].into_boxed_slice();
291
+ self.write_frame(&mut frame);
292
+ frame
293
+ }
294
+
295
+ /// Fills a frame of exactly [`encoded_len`](Self::encoded_len) bytes.
296
+ fn write_frame(&self, frame: &mut [u8]) {
297
+ #[expect(
298
+ clippy::cast_possible_truncation,
299
+ reason = "capacity caps the cluster count at compression * CLUSTERS_PER_COMPRESSION"
300
+ )]
301
+ let clusters = self.centroids.len() as u32;
302
+ frame[0] = TAG_TDIGEST;
303
+ frame[1..3].copy_from_slice(&self.compression.to_le_bytes());
304
+ frame[3..7].copy_from_slice(&clusters.to_le_bytes());
305
+ frame[7..15].copy_from_slice(&self.count.to_le_bytes());
306
+ frame[15..23].copy_from_slice(&self.minimum.to_le_bytes());
307
+ frame[23..31].copy_from_slice(&self.maximum.to_le_bytes());
308
+
309
+ let (slots, _) = frame[HEADER_LEN..].as_chunks_mut::<CENTROID_LEN>();
310
+ for (slot, centroid) in slots.iter_mut().zip(&self.centroids) {
311
+ slot[..8].copy_from_slice(&centroid.mean.to_le_bytes());
312
+ slot[8..].copy_from_slice(&centroid.weight.to_le_bytes());
313
+ }
314
+ }
315
+
316
+ /// Reads a digest written by [`encode`](Self::encode).
317
+ ///
318
+ /// # Errors
319
+ ///
320
+ /// [`CodecError::Truncated`] when `bytes` is shorter than a header or than
321
+ /// the clusters that header counts,
322
+ /// [`CodecError::Tag`] when it holds another structure,
323
+ /// [`CodecError::Parameters`] when the clusters, the total weight and the
324
+ /// extremes contradict each other.
325
+ pub fn decode(bytes: &[u8]) -> Result<Self, CodecError> {
326
+ let Some(header) = bytes.get(..HEADER_LEN) else {
327
+ return Err(CodecError::Truncated);
328
+ };
329
+ if header[0] != TAG_TDIGEST {
330
+ return Err(CodecError::Tag);
331
+ }
332
+ let counted = u32::from_le_bytes([header[3], header[4], header[5], header[6]]);
333
+ let Ok(clusters) = usize::try_from(counted) else {
334
+ return Err(CodecError::Truncated);
335
+ };
336
+ if (bytes.len() - HEADER_LEN) / CENTROID_LEN < clusters {
337
+ return Err(CodecError::Truncated);
338
+ }
339
+ let body = &bytes[HEADER_LEN..HEADER_LEN + clusters * CENTROID_LEN];
340
+
341
+ let mut digest = Self::new(u16::from_le_bytes([header[1], header[2]]));
342
+ digest.count = f64::from_le_bytes(read8(&header[7..15]));
343
+ digest.minimum = f64::from_le_bytes(read8(&header[15..23]));
344
+ digest.maximum = f64::from_le_bytes(read8(&header[23..31]));
345
+ let (slots, _) = body.as_chunks::<CENTROID_LEN>();
346
+ digest.centroids = slots
347
+ .iter()
348
+ .map(|slot| Centroid {
349
+ mean: f64::from_le_bytes(read8(&slot[..8])),
350
+ weight: f64::from_le_bytes(read8(&slot[8..])),
351
+ })
352
+ .collect();
353
+ if digest.is_coherent() {
354
+ Ok(digest)
355
+ } else {
356
+ Err(CodecError::Parameters)
357
+ }
358
+ }
359
+
360
+ /// Whether the clusters carry the total weight and reach the extremes the
361
+ /// header claims, as every digest built by adding points does.
362
+ fn is_coherent(&self) -> bool {
363
+ if self.centroids.len() > self.capacity() {
364
+ return false;
365
+ }
366
+ let (Some(first), Some(last)) = (self.centroids.first(), self.centroids.last()) else {
367
+ return self.count == 0.0
368
+ && self.minimum == f64::INFINITY
369
+ && self.maximum == f64::NEG_INFINITY;
370
+ };
371
+ if !self.count.is_finite() || self.count <= 0.0 {
372
+ return false;
373
+ }
374
+ let weight: f64 = self.centroids.iter().map(|centroid| centroid.weight).sum();
375
+ weight.is_finite()
376
+ && self.centroids.iter().all(|centroid| {
377
+ centroid.mean.is_finite() && centroid.weight.is_finite() && centroid.weight > 0.0
378
+ })
379
+ && self
380
+ .centroids
381
+ .is_sorted_by(|left, right| left.mean <= right.mean)
382
+ && (weight - self.count).abs() <= self.count * WEIGHT_DRIFT
383
+ && self.minimum.is_finite()
384
+ && self.maximum.is_finite()
385
+ && self.minimum <= first.mean
386
+ && last.mean <= self.maximum
387
+ }
388
+
389
+ /// Clusters held before compression runs, and never below the one cluster
390
+ /// folding cannot reduce further.
391
+ fn capacity(&self) -> usize {
392
+ (usize::from(self.compression) * CLUSTERS_PER_COMPRESSION).max(1)
393
+ }
394
+ }
395
+
396
+ /// The weighted mean of two adjacent clusters.
397
+ ///
398
+ /// Interpolating at half scale keeps the span of two opposite-sign extremes
399
+ /// inside the finite range, and doubling a mean that lies between two finite
400
+ /// means cannot leave it.
401
+ // Clippy only suggests `mul_add` when `std` provides it; `core` has no
402
+ // `f64::mul_add`, so the suggestion would break the `no_std` build.
403
+ #[cfg_attr(
404
+ feature = "std",
405
+ expect(clippy::suboptimal_flops, reason = "mul_add is std-only")
406
+ )]
407
+ fn fold(left: Centroid, right: Centroid) -> Centroid {
408
+ let weight = left.weight + right.weight;
409
+ let low = left.mean * 0.5;
410
+ let high = right.mean * 0.5;
411
+ Centroid {
412
+ mean: ((high - low) * (right.weight / weight) + low) * 2.0,
413
+ weight,
414
+ }
415
+ }
416
+
417
+ /// Where `at` falls between two points, read off the line through them.
418
+ ///
419
+ /// A span of two opposite-sign extremes is wider than [`f64::MAX`], and an
420
+ /// infinite span made every finite `at` land on the low end. Such a span is
421
+ /// halved the way [`fold`] averages, which costs nothing while the halves stay
422
+ /// normal but rounds a subnormal span to zero, so the span is only halved when
423
+ /// it does not fit.
424
+ // Clippy only suggests `mul_add` when `std` provides it; `core` has no
425
+ // `f64::mul_add`, so the suggestion would break the `no_std` build.
426
+ #[cfg_attr(
427
+ feature = "std",
428
+ expect(clippy::suboptimal_flops, reason = "mul_add is std-only")
429
+ )]
430
+ fn lerp(at: f64, low_x: f64, low_y: f64, high_x: f64, high_y: f64) -> f64 {
431
+ if high_x <= low_x {
432
+ return high_y;
433
+ }
434
+ let span = high_x - low_x;
435
+ let fraction = if span.is_finite() {
436
+ (at - low_x) / span
437
+ } else {
438
+ let low = low_x * 0.5;
439
+ (at * 0.5 - low) / (high_x * 0.5 - low)
440
+ };
441
+ low_y * (1.0 - fraction) + high_y * fraction
442
+ }
443
+
444
+ #[cfg(test)]
445
+ mod tests;