chronix_core/histogram.rs
1//! Native histograms — a whole distribution as one sample.
2//!
3//! A classic Prometheus histogram is a *family* of series: `foo_bucket` once
4//! per boundary, plus `foo_sum` and `foo_count`, with the boundaries fixed by
5//! whoever instrumented the code. Getting a useful quantile means guessing the
6//! right boundaries in advance, and getting them wrong means re-instrumenting
7//! and losing the history. A **native histogram** is one sample carrying the
8//! whole distribution, with buckets that are implied by a *schema* rather than
9//! enumerated — so the resolution is a number, the buckets are sparse, and
10//! nothing has to be guessed up front.
11//!
12//! Prometheus made them stable in **v3.8**. They arrive over remote write with
13//! `send_native_histograms`, and OTLP carries the same idea as an exponential
14//! histogram. A scrape configured for them reaches a database that cannot
15//! store them as data that is simply lost, which is why this type exists.
16//!
17//! # The model
18//!
19//! This follows the Prometheus specification exactly, because the whole point
20//! is to be the thing a Prometheus writes to:
21//!
22//! - **`schema`** fixes the resolution. For `-4 ..= 8`, bucket boundaries are
23//! powers of `2^(2^-schema)`: each bucket's upper bound is the previous
24//! one's times that factor, so schema *n* has twice the resolution of *n*−1.
25//! [`CUSTOM_BUCKETS_SCHEMA`] (`-53`) means the boundaries are listed
26//! explicitly in [`custom_values`](Histogram::custom_values) — which is what
27//! a *classic* histogram converts into, so it is the bridge rather than an
28//! extra.
29//! - **The zero bucket** counts observations in the closed interval
30//! `[-zero_threshold, +zero_threshold]`. It exists because an exponential
31//! scheme has no bucket for zero and infinitely many just above it.
32//! - **Positive and negative buckets** are sparse: only the indices that were
33//! observed are stored.
34//! - **`count`** and **`sum`** are the totals, and `sum` may be any float
35//! including `NaN` — a `NaN` observation poisons it, which is a fact
36//! [`quantile`](Histogram::quantile) has to know about.
37//!
38//! # Two decisions worth knowing about
39//!
40//! **Buckets hold absolute counts, not deltas, and are `(index, count)` pairs
41//! rather than spans.** The wire format carries spans plus deltas because that
42//! is a compression trick; `chronix-encoding` does better than that with pco,
43//! and re-deriving the absolutes on every quantile would be work done for
44//! nothing. Spans also have a normalisation question in them — a span may name
45//! a bucket whose count is zero — and two representations of one value is how
46//! the canonical series form went wrong once already. There is exactly one
47//! in-memory form: **sorted by index, no zero counts**, which
48//! [`canonicalise`](Histogram::canonicalise) establishes and
49//! [`validate`](Histogram::validate) requires.
50//!
51//! **Counts are `f64`.** Prometheus distinguishes integer histograms (a
52//! scrape) from float histograms (a recording rule's output, which arrives
53//! over remote write just the same), and supporting only the first would
54//! refuse ordinary traffic. One representation covers both, and the exactness
55//! that matters is *checked* rather than assumed: [`validate`](Histogram::validate)
56//! refuses a count above [`MAX_EXACT_COUNT`] — 2⁵³, where `f64` stops
57//! representing consecutive integers — so an integer histogram is exact by
58//! construction or refused, never silently rounded.
59
60use serde::{Deserialize, Serialize};
61
62/// The schema value meaning "the boundaries are listed, not computed".
63///
64/// Prometheus calls these NHCB — native histograms with custom buckets — and
65/// uses them to carry a classic `le`-bucketed histogram in the native shape.
66pub const CUSTOM_BUCKETS_SCHEMA: i8 = -53;
67
68/// Coarsest standard exponential schema: each bucket is 2¹⁶ times the last.
69pub const MIN_SCHEMA: i8 = -4;
70
71/// Finest standard exponential schema: 256 buckets per power of two.
72pub const MAX_SCHEMA: i8 = 8;
73
74/// The largest count `f64` represents exactly.
75///
76/// Above 2⁵³ consecutive integers are no longer distinguishable, so a count
77/// beyond it could not be stored and read back unchanged. Refused rather than
78/// rounded.
79pub const MAX_EXACT_COUNT: f64 = 9_007_199_254_740_992.0; // 2^53
80
81/// What is known about counter resets between this sample and the previous.
82///
83/// A histogram is usually a counter: buckets only grow. A reset — a process
84/// restart — has to be visible to `rate()`, and the scraper often knows
85/// without the database having to guess.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
87#[non_exhaustive]
88pub enum ResetHint {
89 /// Nothing is known; a reader must detect resets by comparing samples.
90 #[default]
91 Unknown,
92 /// This is the first histogram after a counter reset.
93 Reset,
94 /// There was no reset between this sample and the previous one.
95 NoReset,
96 /// A gauge histogram: counter resets do not apply.
97 Gauge,
98}
99
100/// One populated bucket.
101#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
102pub struct Bucket {
103 /// The bucket's index in the schema's implied sequence.
104 ///
105 /// May be negative: index 0 is the bucket ending at 1.
106 pub index: i32,
107 /// Observations that fell in it. Never zero in a canonical histogram, and
108 /// never negative.
109 pub count: f64,
110}
111
112/// Everything that can be wrong with a histogram.
113#[derive(Debug, Clone, PartialEq, thiserror::Error)]
114#[non_exhaustive]
115pub enum HistogramError {
116 /// The schema is outside `-4 ..= 8` and is not [`CUSTOM_BUCKETS_SCHEMA`].
117 #[error(
118 "histogram schema {0} is not a valid resolution: expected {MIN_SCHEMA}..={MAX_SCHEMA} \
119 for exponential buckets, or {CUSTOM_BUCKETS_SCHEMA} for custom boundaries"
120 )]
121 InvalidSchema(i8),
122
123 /// A count was negative, or not finite.
124 #[error("histogram {what} is {value}, which is not a count")]
125 InvalidCount {
126 /// Which count — `count`, `zero_count`, or a bucket's.
127 what: &'static str,
128 /// The offending value.
129 value: f64,
130 },
131
132 /// A count exceeded what `f64` represents exactly.
133 #[error(
134 "histogram {what} is {value}, above 2^53 — beyond that an f64 cannot \
135 distinguish consecutive integers, so the value could not be read back unchanged"
136 )]
137 CountNotExact {
138 /// Which count overflowed the exact range.
139 what: &'static str,
140 /// The offending value.
141 value: f64,
142 },
143
144 /// Buckets were not sorted, or an index appeared twice.
145 #[error("histogram {side} buckets are not strictly ascending by index (at {index})")]
146 UnorderedBuckets {
147 /// `"positive"` or `"negative"`.
148 side: &'static str,
149 /// The index at which the order broke.
150 index: i32,
151 },
152
153 /// `zero_threshold` was negative or not finite.
154 #[error("histogram zero_threshold is {0}, which cannot bound an interval around zero")]
155 InvalidZeroThreshold(f64),
156
157 /// Custom boundaries were supplied for an exponential schema, or omitted
158 /// for [`CUSTOM_BUCKETS_SCHEMA`].
159 #[error("{0}")]
160 CustomValues(&'static str),
161
162 /// The bucket counts add up to more than `count`.
163 #[error(
164 "histogram buckets total {observed} but count is {declared}: a histogram \
165 whose parts exceed its whole cannot be interpolated"
166 )]
167 CountMismatch {
168 /// What the buckets and zero bucket add up to.
169 observed: f64,
170 /// What `count` claims.
171 declared: f64,
172 },
173}
174
175/// `2^k`, built from the IEEE-754 exponent field rather than computed.
176///
177/// Exact for every `k` a power of two can represent, and — unlike `exp2`,
178/// `powi` or `powf` — specified to the last bit, so two machines agree.
179/// Saturates to `0.0` and `f64::INFINITY` outside the representable range.
180fn exp2i(k: i32) -> f64 {
181 const MAX_NORMAL_EXP: i32 = 1023;
182 const MIN_NORMAL_EXP: i32 = -1022;
183 /// Below this, `2^k` is not representable even as a subnormal.
184 const MIN_SUBNORMAL_EXP: i32 = -1074;
185
186 if k > MAX_NORMAL_EXP {
187 return f64::INFINITY;
188 }
189 if k < MIN_SUBNORMAL_EXP {
190 return 0.0;
191 }
192 if k >= MIN_NORMAL_EXP {
193 // Biased exponent, zero mantissa.
194 #[allow(clippy::cast_sign_loss)] // k + 1023 is in 1..=2046 here
195 return f64::from_bits(((k + 1023) as u64) << 52);
196 }
197 // Subnormal: the single set mantissa bit carries the exponent.
198 #[allow(clippy::cast_sign_loss)] // k - MIN_SUBNORMAL_EXP is in 0..=51 here
199 f64::from_bits(1u64 << ((k - MIN_SUBNORMAL_EXP) as u32))
200}
201
202/// `2^(1/2^n)` — `n` successive square roots of two.
203///
204/// `sqrt` is the one root operation IEEE 754 requires to be correctly
205/// rounded, so this is reproducible everywhere. `powf` is not, which is the
206/// whole reason this function exists.
207fn root_of_two(n: u32) -> f64 {
208 let mut v = 2.0_f64;
209 for _ in 0..n {
210 v = v.sqrt();
211 }
212 v
213}
214
215/// `2^(r / 2^s)` for `0 <= r < 2^s`, without a transcendental.
216///
217/// `r / 2^s` is a sum of distinct negative powers of two — one per set bit of
218/// `r` — so the result is the product of the corresponding roots of two.
219/// Multiplied low bit first, so the order (and therefore the rounding) is
220/// fixed rather than incidental.
221fn frac_power_of_two(r: u32, s: u32) -> f64 {
222 let mut acc = 1.0_f64;
223 for bit in 0..s {
224 if r & (1 << bit) != 0 {
225 // Bit `bit` of `r` contributes 2^bit / 2^s = 1 / 2^(s - bit).
226 acc *= root_of_two(s - bit);
227 }
228 }
229 acc
230}
231
232/// A distribution captured as one sample.
233///
234/// See the [module documentation](self) for the model and the two
235/// representation decisions.
236#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
237#[non_exhaustive]
238pub struct Histogram {
239 /// Bucket resolution — see [`CUSTOM_BUCKETS_SCHEMA`], [`MIN_SCHEMA`], [`MAX_SCHEMA`].
240 pub schema: i8,
241 /// Half-width of the zero bucket, which covers `[-t, +t]` inclusive.
242 pub zero_threshold: f64,
243 /// Observations in the zero bucket.
244 pub zero_count: f64,
245 /// Total observations, including the zero bucket.
246 pub count: f64,
247 /// Sum of all observations. May be `NaN` if any observation was.
248 pub sum: f64,
249 /// Buckets for positive observations, ascending by index, none zero.
250 pub positive: Vec<Bucket>,
251 /// Buckets for negative observations, ascending by index, none zero.
252 ///
253 /// Index *i* covers `[-base^i, -base^(i-1))` — the mirror of the positive
254 /// side, so larger indices are *more* negative.
255 pub negative: Vec<Bucket>,
256 /// Upper-inclusive boundaries, when `schema == CUSTOM_BUCKETS_SCHEMA`.
257 ///
258 /// Ascending. Bucket *i* of `positive` covers
259 /// `(custom_values[i-1], custom_values[i]]`, with `-Inf` below the first
260 /// and `+Inf` above the last.
261 pub custom_values: Vec<f64>,
262 /// What is known about counter resets before this sample.
263 pub reset_hint: ResetHint,
264}
265
266impl Default for Histogram {
267 fn default() -> Self {
268 Self::empty(0)
269 }
270}
271
272impl Histogram {
273 /// An empty histogram at the given schema.
274 #[must_use]
275 pub fn empty(schema: i8) -> Self {
276 Self {
277 schema,
278 zero_threshold: 0.0,
279 zero_count: 0.0,
280 count: 0.0,
281 sum: 0.0,
282 positive: Vec::new(),
283 negative: Vec::new(),
284 custom_values: Vec::new(),
285 reset_hint: ResetHint::Unknown,
286 }
287 }
288
289 /// Whether the boundaries are listed rather than computed.
290 #[must_use]
291 pub const fn has_custom_buckets(&self) -> bool {
292 self.schema == CUSTOM_BUCKETS_SCHEMA
293 }
294
295 /// The growth factor between consecutive exponential bucket bounds.
296 ///
297 /// `2^(2^-schema)`. Meaningless for [`CUSTOM_BUCKETS_SCHEMA`].
298 #[must_use]
299 pub fn base(schema: i8) -> f64 {
300 if schema <= 0 {
301 // 2^(2^|schema|) — an exact power of two.
302 exp2i(1i32 << (-i32::from(schema)).min(30))
303 } else {
304 // 2^(1/2^schema) — `schema` correctly-rounded square roots of 2.
305 root_of_two(u32::from(schema.unsigned_abs()))
306 }
307 }
308
309 /// The index of the bucket an observation falls in.
310 ///
311 /// From the specification's boundary rule — bucket *i* covers
312 /// `(base^(i-1), base^i]` — inverted: `i = ⌈log₂(v) · 2^schema⌉`.
313 ///
314 /// `value` must be finite and strictly positive; callers route zero and
315 /// near-zero observations to the zero bucket first.
316 #[must_use]
317 pub fn index_for(schema: i8, value: f64) -> i32 {
318 debug_assert!(value > 0.0 && value.is_finite());
319 let scaled = value.log2() * (2.0_f64).powi(i32::from(schema));
320 // `ceil` is the rule, but a value that *is* a bucket bound must land
321 // in the bucket it closes, and `log2` does not return exact halves:
322 // `log2(√2)` comes back a hair above 0.5, so schema 1 scales it to
323 // 1.0000000000000002 and `ceil` gives 2 — one bucket too high, for
324 // every observation that is exactly a bound. Since latencies in
325 // seconds are mostly exact powers of two, that is most of them.
326 //
327 // So: snap to the nearest integer when we are within rounding error
328 // of one, and take the ceiling otherwise. The tolerance is relative,
329 // because `scaled` grows with the index.
330 let nearest = scaled.round();
331 let tolerance = 1e-12 * scaled.abs().max(1.0);
332 let i = if (scaled - nearest).abs() <= tolerance {
333 nearest
334 } else {
335 scaled.ceil()
336 };
337 i as i32
338 }
339
340 /// The `(lower, upper)` bounds of a bucket, lower exclusive, upper inclusive.
341 ///
342 /// For the negative side, pass `negative = true`: index *i* then covers
343 /// `[-base^i, -base^(i-1))`.
344 #[must_use]
345 pub fn bucket_bounds(&self, index: i32, negative: bool) -> (f64, f64) {
346 if self.has_custom_buckets() {
347 let n = self.custom_values.len();
348 let i = index as usize;
349 let lower = if i == 0 {
350 f64::NEG_INFINITY
351 } else {
352 self.custom_values
353 .get(i - 1)
354 .copied()
355 .unwrap_or(f64::NEG_INFINITY)
356 };
357 let upper = if i >= n {
358 f64::INFINITY
359 } else {
360 self.custom_values.get(i).copied().unwrap_or(f64::INFINITY)
361 };
362 return (lower, upper);
363 }
364 let upper = Self::bound(self.schema, index);
365 let lower = Self::bound(self.schema, index - 1);
366 if negative {
367 (-upper, -lower)
368 } else {
369 (lower, upper)
370 }
371 }
372
373 /// The upper bound of bucket `index`: `2^(index / 2^schema)`.
374 ///
375 /// Computed **without a transcendental**, which is the point. `powf` and
376 /// `powi` are not correctly rounded and their last bit is not specified by
377 /// IEEE 754 or by Rust, so the same stored histogram could report
378 /// different boundaries on two machines — and a bucket boundary is a
379 /// storage-format semantic, not a display detail. It showed up as
380 /// `bucket_bounds(0)` returning `0.4999999999999999` instead of `0.5` at
381 /// schema 0, where the answer is an exact power of two.
382 ///
383 /// Instead, `index` is split as `index = q · 2^schema + r`, giving
384 /// `2^q · 2^(r / 2^schema)`. The first factor is built straight from the
385 /// IEEE exponent field, and the second from repeated `sqrt` — the one
386 /// root operation IEEE 754 *does* require to be correctly rounded. Every
387 /// step is exact or correctly rounded, so the result is identical on
388 /// every platform.
389 #[must_use]
390 pub fn bound(schema: i8, index: i32) -> f64 {
391 let s = i32::from(schema);
392 if s <= 0 {
393 // The bound is 2^(index · 2^|s|) — an exact power of two, and the
394 // shift cannot overflow for the defined schema range.
395 let shift = (-s).min(30);
396 return match index.checked_shl(shift as u32) {
397 Some(k) => exp2i(k),
398 None if index > 0 => f64::INFINITY,
399 None => 0.0,
400 };
401 }
402 // Euclidean division, so a negative index still yields 0 <= r < 2^s.
403 let m = 1i32 << s;
404 let q = index.div_euclid(m);
405 let r = index.rem_euclid(m);
406 let frac = frac_power_of_two(r as u32, s as u32);
407 let scale = exp2i(q);
408 if scale == 0.0 || !scale.is_finite() {
409 return scale * frac.signum().abs();
410 }
411 scale * frac
412 }
413
414 /// Record one observation.
415 ///
416 /// Present so a histogram can be built in a test or by a connector that
417 /// receives raw values; ingest from Prometheus or OTLP builds the buckets
418 /// directly.
419 pub fn observe(&mut self, value: f64) {
420 self.count += 1.0;
421 self.sum += value;
422 if value.abs() <= self.zero_threshold {
423 self.zero_count += 1.0;
424 return;
425 }
426 let (side, magnitude) = if value < 0.0 {
427 (&mut self.negative, -value)
428 } else {
429 (&mut self.positive, value)
430 };
431 let index = if self.schema == CUSTOM_BUCKETS_SCHEMA {
432 // Custom boundaries are upper-inclusive and ascending.
433 self.custom_values
434 .iter()
435 .position(|&b| value <= b)
436 .unwrap_or(self.custom_values.len()) as i32
437 } else {
438 Self::index_for(self.schema, magnitude)
439 };
440 match side.binary_search_by_key(&index, |b| b.index) {
441 Ok(at) => side[at].count += 1.0,
442 Err(at) => side.insert(at, Bucket { index, count: 1.0 }),
443 }
444 }
445
446 /// Put the histogram in canonical form: sorted by index, no empty buckets.
447 ///
448 /// Merges duplicate indices rather than dropping one, because a wire
449 /// decoder that produced two entries for one bucket meant their sum.
450 pub fn canonicalise(&mut self) {
451 for side in [&mut self.positive, &mut self.negative] {
452 side.sort_by_key(|b| b.index);
453 side.dedup_by(|b, a| {
454 if a.index == b.index {
455 a.count += b.count;
456 true
457 } else {
458 false
459 }
460 });
461 side.retain(|b| b.count != 0.0);
462 }
463 }
464
465 /// Check every invariant the rest of the engine relies on.
466 ///
467 /// # Errors
468 ///
469 /// See [`HistogramError`]. The checks are the ones whose violation would
470 /// produce a *plausible wrong number* rather than a crash: an unsorted
471 /// bucket list interpolates to the wrong quantile, and a `count` smaller
472 /// than its buckets makes every rank fraction exceed one.
473 pub fn validate(&self) -> Result<(), HistogramError> {
474 if self.schema != CUSTOM_BUCKETS_SCHEMA && !(MIN_SCHEMA..=MAX_SCHEMA).contains(&self.schema)
475 {
476 return Err(HistogramError::InvalidSchema(self.schema));
477 }
478 if self.has_custom_buckets() {
479 if self.custom_values.is_empty() {
480 return Err(HistogramError::CustomValues(
481 "schema -53 means the bucket boundaries are listed, and none were given",
482 ));
483 }
484 // `>=` rather than `!(<)`: a NaN boundary is incomparable, and
485 // `partial_cmp` returning `None` must count as *not ascending*
486 // rather than as ascending.
487 if self
488 .custom_values
489 .windows(2)
490 .any(|w| !matches!(w[0].partial_cmp(&w[1]), Some(std::cmp::Ordering::Less)))
491 {
492 return Err(HistogramError::CustomValues(
493 "custom bucket boundaries must be strictly ascending",
494 ));
495 }
496 if !self.negative.is_empty() {
497 return Err(HistogramError::CustomValues(
498 "custom buckets carry their own boundaries, which may be negative, \
499 so the negative bucket list must be empty",
500 ));
501 }
502 } else if !self.custom_values.is_empty() {
503 return Err(HistogramError::CustomValues(
504 "custom bucket boundaries were given for an exponential schema, \
505 where the boundaries are computed from the schema",
506 ));
507 }
508
509 if !self.zero_threshold.is_finite() || self.zero_threshold < 0.0 {
510 return Err(HistogramError::InvalidZeroThreshold(self.zero_threshold));
511 }
512
513 let mut total = 0.0;
514 for (what, v) in [("count", self.count), ("zero_count", self.zero_count)] {
515 if !v.is_finite() || v < 0.0 {
516 return Err(HistogramError::InvalidCount { what, value: v });
517 }
518 if v > MAX_EXACT_COUNT {
519 return Err(HistogramError::CountNotExact { what, value: v });
520 }
521 }
522 total += self.zero_count;
523
524 for (side, name) in [(&self.positive, "positive"), (&self.negative, "negative")] {
525 let mut previous: Option<i32> = None;
526 for b in side {
527 if !b.count.is_finite() || b.count < 0.0 {
528 return Err(HistogramError::InvalidCount {
529 what: "bucket count",
530 value: b.count,
531 });
532 }
533 if b.count > MAX_EXACT_COUNT {
534 return Err(HistogramError::CountNotExact {
535 what: "bucket count",
536 value: b.count,
537 });
538 }
539 if previous.is_some_and(|p| p >= b.index) {
540 return Err(HistogramError::UnorderedBuckets {
541 side: name,
542 index: b.index,
543 });
544 }
545 previous = Some(b.index);
546 total += b.count;
547 }
548 }
549
550 // The whole must be at least the sum of its parts. Equality is not
551 // required: a histogram whose `sum` is NaN had NaN observations, and
552 // those are counted in `count` and fall in no bucket.
553 if total > self.count {
554 return Err(HistogramError::CountMismatch {
555 observed: total,
556 declared: self.count,
557 });
558 }
559 Ok(())
560 }
561
562 /// The mean observation, or `NaN` for an empty histogram.
563 #[must_use]
564 pub fn avg(&self) -> f64 {
565 if self.count == 0.0 {
566 return f64::NAN;
567 }
568 self.sum / self.count
569 }
570
571 /// The value each bucket is taken to represent, for moment calculations.
572 ///
573 /// Prometheus uses the **geometric** mean of the bounds for exponential
574 /// buckets and the arithmetic midpoint for custom ones. That is not a
575 /// detail: a bucket spanning `(1, 2]` has geometric mean 1.414 and
576 /// arithmetic midpoint 1.5, and on a log-scaled bucket the geometric one
577 /// is the representative value.
578 fn representative(&self, index: i32, negative: bool) -> f64 {
579 let (lower, upper) = self.bucket_bounds(index, negative);
580 if self.has_custom_buckets() {
581 if lower.is_infinite() {
582 return upper;
583 }
584 if upper.is_infinite() {
585 return lower;
586 }
587 return (upper + lower) / 2.0;
588 }
589 if lower <= 0.0 && upper >= 0.0 {
590 return 0.0;
591 }
592 let g = (upper.abs() * lower.abs()).sqrt();
593 if negative { -g } else { g }
594 }
595
596 /// Variance of the observations, estimated from bucket representatives.
597 #[must_use]
598 pub fn variance(&self) -> f64 {
599 if self.count == 0.0 || self.sum.is_nan() {
600 return f64::NAN;
601 }
602 let mean = self.avg();
603 let mut acc = 0.0;
604 if self.zero_count > 0.0 {
605 acc += self.zero_count * (0.0 - mean).powi(2);
606 }
607 for (side, negative) in [(&self.positive, false), (&self.negative, true)] {
608 for b in side {
609 if b.count == 0.0 {
610 continue;
611 }
612 acc += b.count * (self.representative(b.index, negative) - mean).powi(2);
613 }
614 }
615 acc / self.count
616 }
617
618 /// Standard deviation of the observations.
619 #[must_use]
620 pub fn stddev(&self) -> f64 {
621 self.variance().sqrt()
622 }
623
624 /// Every populated bucket, ascending by lower bound.
625 ///
626 /// Negative buckets first (most negative first), then the zero bucket if
627 /// populated, then positive. This is the order every rank calculation
628 /// walks, and having exactly one function produce it is what stops
629 /// `quantile` and `fraction` disagreeing about where a bucket starts.
630 fn ordered_buckets(&self) -> Vec<(f64, f64, f64)> {
631 let mut out: Vec<(f64, f64, f64)> = Vec::with_capacity(
632 self.negative.len() + self.positive.len() + usize::from(self.zero_count > 0.0),
633 );
634 // Negative side: a larger index is more negative, so descending index
635 // is ascending value.
636 for b in self.negative.iter().rev() {
637 let (lo, hi) = self.bucket_bounds(b.index, true);
638 out.push((lo, hi, b.count));
639 }
640 if self.zero_count > 0.0 {
641 out.push((-self.zero_threshold, self.zero_threshold, self.zero_count));
642 }
643 for b in &self.positive {
644 let (lo, hi) = self.bucket_bounds(b.index, false);
645 out.push((lo, hi, b.count));
646 }
647 out
648 }
649
650 /// Interpolate a value inside one bucket at `fraction` of its population.
651 ///
652 /// **Exponential buckets interpolate on a log₂ scale**, which is the
653 /// upstream rule and the one a naive implementation gets wrong: a linear
654 /// interpolation inside `(1, 1024]` puts the midpoint at 512, while the
655 /// bucket's geometry puts it at 32. Custom buckets and any bucket
656 /// straddling zero interpolate linearly, because neither has that
657 /// geometry.
658 fn interpolate(&self, lower: f64, upper: f64, fraction: f64) -> f64 {
659 // The endpoints are returned as themselves rather than computed. A
660 // log₂ round trip does not land back on its own input — `2^log2(2)`
661 // came back as `2.000000000000003`, one ULP *outside* the bucket that
662 // produced it — and a quantile outside the bucket it was located in is
663 // wrong by definition, not merely imprecise.
664 //
665 // A `NaN` fraction satisfies neither test and falls through to the
666 // arithmetic below, which propagates it — as it did before these two
667 // guards existed. Writing the first as `!(fraction > 0.0)` would have
668 // swallowed it and returned `lower`.
669 if fraction <= 0.0 {
670 return lower;
671 }
672 if fraction >= 1.0 {
673 return upper;
674 }
675
676 let linear = lower + (upper - lower) * fraction;
677 if self.has_custom_buckets() || (lower <= 0.0 && upper >= 0.0) {
678 return linear;
679 }
680 if !lower.is_finite() || !upper.is_finite() || lower == 0.0 || upper == 0.0 {
681 return linear;
682 }
683 let interpolated = if lower < 0.0 {
684 // Mirror: interpolate over the magnitudes, from the larger
685 // magnitude (the lower, more negative bound) downwards.
686 let log_hi = (-lower).log2();
687 let log_lo = (-upper).log2();
688 -(2.0_f64).powf(log_hi + (log_lo - log_hi) * fraction)
689 } else {
690 let log_lo = lower.log2();
691 let log_hi = upper.log2();
692 (2.0_f64).powf(log_lo + (log_hi - log_lo) * fraction)
693 };
694 // `powf` is not correctly rounded, so the strictly-inside case can
695 // still land a ULP outside. The bucket is the authority.
696 interpolated.clamp(lower, upper)
697 }
698
699 /// The φ-quantile of the observations.
700 ///
701 /// Follows Prometheus's `histogramQuantile`: `q < 0` is `-Inf`, `q > 1` is
702 /// `+Inf`, and an empty histogram or a `NaN` φ is `NaN`. The value is
703 /// found by walking buckets in ascending order until the cumulative count
704 /// reaches `q · count`, then interpolating inside that bucket.
705 #[must_use]
706 pub fn quantile(&self, q: f64) -> f64 {
707 if q.is_nan() || self.count == 0.0 {
708 return f64::NAN;
709 }
710 if q < 0.0 {
711 return f64::NEG_INFINITY;
712 }
713 if q > 1.0 {
714 return f64::INFINITY;
715 }
716 let buckets = self.ordered_buckets();
717 if buckets.is_empty() {
718 return f64::NAN;
719 }
720 let rank = q * self.count;
721 let mut cumulative = 0.0;
722 for (lower, upper, count) in &buckets {
723 if cumulative + count >= rank {
724 let fraction = if *count == 0.0 {
725 0.0
726 } else {
727 ((rank - cumulative) / count).clamp(0.0, 1.0)
728 };
729 // A bucket open at the bottom (custom `-Inf`) can only answer
730 // with its upper bound.
731 if !lower.is_finite() {
732 return *upper;
733 }
734 if !upper.is_finite() {
735 return *lower;
736 }
737 return self.interpolate(*lower, *upper, fraction);
738 }
739 cumulative += count;
740 }
741 // Every bucket was below the rank: either NaN observations are
742 // unaccounted for, or floating-point drift. The largest bound is the
743 // honest answer.
744 let (_, upper, _) = buckets[buckets.len() - 1];
745 if upper.is_finite() {
746 upper
747 } else {
748 f64::INFINITY
749 }
750 }
751
752 /// The fraction of observations in `[lower, upper)`.
753 ///
754 /// `NaN` for an empty histogram or a `NaN` bound; `0` when the interval is
755 /// empty or inverted.
756 #[must_use]
757 pub fn fraction(&self, lower: f64, upper: f64) -> f64 {
758 if self.count == 0.0 || lower.is_nan() || upper.is_nan() {
759 return f64::NAN;
760 }
761 if lower >= upper {
762 return 0.0;
763 }
764 (self.rank_at(upper) - self.rank_at(lower)) / self.count
765 }
766
767 /// How many observations lie below `value`, interpolating within a bucket.
768 fn rank_at(&self, value: f64) -> f64 {
769 if value == f64::NEG_INFINITY {
770 return 0.0;
771 }
772 if value == f64::INFINITY {
773 return self.count;
774 }
775 let mut cumulative = 0.0;
776 for (lo, hi, count) in self.ordered_buckets() {
777 if value <= lo {
778 break;
779 }
780 if value >= hi {
781 cumulative += count;
782 continue;
783 }
784 // Inside this bucket: invert the interpolation.
785 let fraction = if self.has_custom_buckets() || (lo <= 0.0 && hi >= 0.0) {
786 if hi == lo {
787 0.0
788 } else {
789 (value - lo) / (hi - lo)
790 }
791 } else if lo > 0.0 {
792 let (a, b) = (lo.log2(), hi.log2());
793 if (b - a).abs() < f64::EPSILON {
794 0.0
795 } else {
796 (value.log2() - a) / (b - a)
797 }
798 } else {
799 let (a, b) = ((-lo).log2(), (-hi).log2());
800 if (b - a).abs() < f64::EPSILON {
801 0.0
802 } else {
803 ((-value).log2() - a) / (b - a)
804 }
805 };
806 cumulative += count * fraction.clamp(0.0, 1.0);
807 break;
808 }
809 cumulative
810 }
811
812 /// The classic `le`-bucketed view of this histogram.
813 ///
814 /// Returns `(le, cumulative_count)` pairs ascending, ending at `+Inf`.
815 /// This is what makes a dashboard written against `foo_bucket` keep
816 /// working once the storage holds a native histogram — the classic
817 /// exposition is a *view*, not a second stored form, which is also
818 /// upstream's direction for composite samples.
819 #[must_use]
820 pub fn classic_buckets(&self) -> Vec<(f64, f64)> {
821 let mut out = Vec::new();
822 let mut cumulative = 0.0;
823 for (_, upper, count) in self.ordered_buckets() {
824 cumulative += count;
825 if upper.is_finite() {
826 out.push((upper, cumulative));
827 }
828 }
829 out.push((f64::INFINITY, self.count));
830 out
831 }
832
833 /// Add `other` into `self`.
834 ///
835 /// Defined only for histograms that share a schema and zero threshold —
836 /// two different resolutions cannot be added without re-bucketing, and
837 /// re-bucketing to the coarser of the two is a *decision* rather than
838 /// something to do silently inside `+`.
839 ///
840 /// # Errors
841 ///
842 /// [`HistogramError::CustomValues`] when the schemas or boundaries differ.
843 pub fn merge(&mut self, other: &Self) -> Result<(), HistogramError> {
844 if self.schema != other.schema {
845 return Err(HistogramError::CustomValues(
846 "cannot merge histograms of different schemas without re-bucketing, \
847 which changes the resolution and must be asked for explicitly",
848 ));
849 }
850 if self.has_custom_buckets() && self.custom_values != other.custom_values {
851 return Err(HistogramError::CustomValues(
852 "cannot merge custom-bucket histograms with different boundaries",
853 ));
854 }
855 if self.count == 0.0 && self.positive.is_empty() && self.negative.is_empty() {
856 self.zero_threshold = other.zero_threshold;
857 } else if (self.zero_threshold - other.zero_threshold).abs() > f64::EPSILON {
858 return Err(HistogramError::CustomValues(
859 "cannot merge histograms with different zero thresholds: the zero \
860 bucket would cover different intervals on either side",
861 ));
862 }
863 self.count += other.count;
864 self.sum += other.sum;
865 self.zero_count += other.zero_count;
866 for (mine, theirs) in [
867 (&mut self.positive, &other.positive),
868 (&mut self.negative, &other.negative),
869 ] {
870 for b in theirs {
871 match mine.binary_search_by_key(&b.index, |x| x.index) {
872 Ok(at) => mine[at].count += b.count,
873 Err(at) => mine.insert(at, *b),
874 }
875 }
876 }
877 self.reset_hint = ResetHint::Unknown;
878 Ok(())
879 }
880}
881
882#[cfg(test)]
883mod tests;