Skip to main content

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;