Skip to main content

ic_metrics/histogram/
mod.rs

1//! Fixed-size distributions without sampling or reporting policy.
2
3use core::fmt;
4
5use crate::{MeasurementAggregate, add_count};
6
7#[cfg(test)]
8mod tests;
9
10/// A bound is not strictly greater than the preceding bound.
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12pub struct HistogramBoundsError {
13    index: usize,
14}
15
16impl HistogramBoundsError {
17    /// Zero-based index of the first invalid bound, always at least one.
18    #[must_use]
19    pub const fn index(self) -> usize {
20        self.index
21    }
22}
23
24impl fmt::Display for HistogramBoundsError {
25    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
26        write!(
27            formatter,
28            "histogram bound {} is not strictly increasing",
29            self.index
30        )
31    }
32}
33
34impl core::error::Error for HistogramBoundsError {}
35
36/// Why a histogram cannot supply an exact reporting projection.
37#[derive(Clone, Copy, Debug, Eq, PartialEq)]
38pub enum HistogramQueryError {
39    /// The index does not name one of the configured finite upper bounds.
40    BucketOutOfBounds {
41        /// Requested zero-based bound index.
42        index: usize,
43        /// Number of configured finite upper bounds.
44        buckets: usize,
45    },
46    /// A nearest-rank fraction must satisfy `0 < numerator <= denominator`.
47    InvalidQuantile,
48    /// A required count or cumulative sum has actually overflowed.
49    SaturatedCount,
50}
51
52impl fmt::Display for HistogramQueryError {
53    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
54        match self {
55            Self::BucketOutOfBounds { index, buckets } => {
56                write!(
57                    formatter,
58                    "histogram bound index {index} is outside {buckets} bounds"
59                )
60            }
61            Self::InvalidQuantile => {
62                formatter.write_str("quantile must be greater than zero and at most one")
63            }
64            Self::SaturatedCount => formatter.write_str("histogram reporting count is saturated"),
65        }
66    }
67}
68
69impl core::error::Error for HistogramQueryError {}
70
71/// Why two histogram distributions cannot be combined.
72#[derive(Clone, Copy, Debug, Eq, PartialEq)]
73pub struct HistogramMergeError {
74    index: usize,
75}
76
77impl HistogramMergeError {
78    /// Zero-based index of the first differing inclusive upper bound.
79    #[must_use]
80    pub const fn index(self) -> usize {
81        self.index
82    }
83}
84
85impl fmt::Display for HistogramMergeError {
86    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
87        write!(formatter, "histogram merge bound {} differs", self.index)
88    }
89}
90
91impl core::error::Error for HistogramMergeError {}
92
93/// The observation range of a histogram bucket, not an exact percentile value.
94///
95/// A missing lower bound includes zero; otherwise the lower bound is exclusive.
96/// The upper bound is inclusive when present. A missing upper bound is overflow
97/// beyond the configured bounds. With no configured bounds both are absent and
98/// the range covers all `u64` observations.
99#[derive(Clone, Copy, Debug, Eq, PartialEq)]
100pub struct HistogramRange {
101    lower_exclusive: Option<u64>,
102    upper_inclusive: Option<u64>,
103}
104
105impl HistogramRange {
106    /// Exclusive lower bound, or `None` when zero is included.
107    #[must_use]
108    pub const fn lower_exclusive(self) -> Option<u64> {
109        self.lower_exclusive
110    }
111
112    /// Inclusive upper bound, or `None` for overflow without a configured ceiling.
113    #[must_use]
114    pub const fn upper_inclusive(self) -> Option<u64> {
115        self.upper_inclusive
116    }
117}
118
119/// Fixed-size histogram of observations in one consumer-selected unit.
120///
121/// `N` strictly increasing inclusive upper bounds define `N` disjoint buckets.
122/// The first bucket includes zero; later buckets exclude the preceding bound.
123/// Values above the last bound go into a separate overflow bucket. With no
124/// bounds, every observation goes into overflow. A final bound of `u64::MAX`
125/// is valid and leaves overflow empty.
126///
127/// Each observation updates one bucket and the accompanying
128/// [`MeasurementAggregate`]. Counts clip independently at `u64::MAX`, with sticky
129/// flags for actual overflow; exactly reached caps remain exact. Unsaturated
130/// bucket counts remain useful after the aggregate's total overflows. Overflow
131/// can prevent bucket counts from summing to the aggregate's sample count.
132/// Buckets describe ranges, not exact percentiles; no latest value is retained.
133///
134/// Storage is fixed arrays, overflow state and one aggregate, with no heap
135/// allocation. Recording searches at most `N` bounds. Consumers choose bounds,
136/// units, sample admission, identity and reset boundaries. Bounds are immutable
137/// after construction; report encoding and persistence remain consumer-owned.
138///
139/// ```
140/// use ic_metrics::MeasurementHistogram;
141///
142/// # fn main() -> Result<(), ic_metrics::HistogramBoundsError> {
143/// let mut histogram = MeasurementHistogram::new([10, 100])?;
144/// for value in [0, 10, 11, 101] {
145///     histogram.record(value);
146/// }
147/// assert_eq!(histogram.bucket_counts(), &[2, 1]);
148/// assert_eq!(histogram.overflow(), 1);
149/// assert_eq!(histogram.aggregate().samples(), 4);
150/// # Ok(())
151/// # }
152/// ```
153#[derive(Clone, Copy, Debug, Eq, PartialEq)]
154pub struct MeasurementHistogram<const N: usize> {
155    upper_bounds: [u64; N],
156    bucket_counts: [u64; N],
157    overflow: u64,
158    bucket_saturation: [bool; N],
159    overflow_saturated: bool,
160    aggregate: MeasurementAggregate,
161}
162
163impl<const N: usize> MeasurementHistogram<N> {
164    /// Construct an empty histogram with inclusive upper bounds.
165    ///
166    /// Bounds and observations must use the same unit. A bound of zero and an
167    /// empty bounds array are valid.
168    ///
169    /// # Errors
170    ///
171    /// Returns [`HistogramBoundsError`] for the first duplicate or descending
172    /// bound. The error identifies the right-hand bound in that pair.
173    pub const fn new(upper_bounds: [u64; N]) -> Result<Self, HistogramBoundsError> {
174        let mut index = 1;
175        while index < N {
176            // Both indices are within the array, including when N is zero.
177            if upper_bounds[index - 1] >= upper_bounds[index] {
178                return Err(HistogramBoundsError { index });
179            }
180            index += 1;
181        }
182        Ok(Self {
183            upper_bounds,
184            bucket_counts: [0; N],
185            overflow: 0,
186            bucket_saturation: [false; N],
187            overflow_saturated: false,
188            aggregate: MeasurementAggregate::EMPTY,
189        })
190    }
191
192    /// Record one completed observation, including zero.
193    ///
194    /// Updates the aggregate and exactly one disjoint bucket. Every observation
195    /// must use the same unit as the bounds and earlier observations.
196    pub const fn record(&mut self, value: u64) {
197        self.aggregate.record(value);
198        let mut index = 0;
199        while index < N {
200            // Both arrays have length N; the guard establishes valid indices.
201            if value <= self.upper_bounds[index] {
202                add_count(
203                    &mut self.bucket_counts[index],
204                    &mut self.bucket_saturation[index],
205                    1,
206                );
207                return;
208            }
209            index += 1;
210        }
211        add_count(&mut self.overflow, &mut self.overflow_saturated, 1);
212    }
213
214    /// Immutable inclusive upper bounds corresponding to [`Self::bucket_counts`].
215    #[must_use]
216    pub const fn upper_bounds(&self) -> &[u64; N] {
217        &self.upper_bounds
218    }
219
220    /// Disjoint bucket counts, each saturating independently at `u64::MAX`.
221    ///
222    /// These exclude overflow and are not cumulative counts. Inspect
223    /// [`Self::bucket_saturation`] before using clipped counts as exact values.
224    #[must_use]
225    pub const fn bucket_counts(&self) -> &[u64; N] {
226        &self.bucket_counts
227    }
228
229    /// Count above the last bound, saturating independently at `u64::MAX`.
230    ///
231    /// With no bounds, this counts all observations. Inspect
232    /// [`Self::overflow_saturated`] before treating the clipped count as exact.
233    #[must_use]
234    pub const fn overflow(&self) -> u64 {
235        self.overflow
236    }
237
238    /// Sticky actual-overflow flags corresponding to [`Self::bucket_counts`].
239    #[must_use]
240    pub const fn bucket_saturation(&self) -> &[bool; N] {
241        &self.bucket_saturation
242    }
243
244    /// Whether the overflow bucket's count has actually overflowed.
245    #[must_use]
246    pub const fn overflow_saturated(&self) -> bool {
247        self.overflow_saturated
248    }
249
250    /// Mergeable aggregate of all observations, including overflow.
251    #[must_use]
252    pub const fn aggregate(&self) -> MeasurementAggregate {
253        self.aggregate
254    }
255
256    /// Combine a distribution with exactly the same finite bounds.
257    ///
258    /// Consumers establish matching units and deliberate sample attribution.
259    /// This adds observations without deduplication or ordering. Source overflow
260    /// propagates independently for each count and the aggregate.
261    ///
262    /// # Errors
263    /// Returns [`HistogramMergeError`] for the first differing bound, leaving
264    /// the entire destination unchanged. Different bound counts are different
265    /// Rust types and cannot be merged.
266    pub const fn merge(&mut self, other: &Self) -> Result<(), HistogramMergeError> {
267        let mut index = 0;
268        while index < N {
269            if self.upper_bounds[index] != other.upper_bounds[index] {
270                return Err(HistogramMergeError { index });
271            }
272            index += 1;
273        }
274        index = 0;
275        while index < N {
276            add_count(
277                &mut self.bucket_counts[index],
278                &mut self.bucket_saturation[index],
279                other.bucket_counts[index],
280            );
281            self.bucket_saturation[index] |= other.bucket_saturation[index];
282            index += 1;
283        }
284        add_count(
285            &mut self.overflow,
286            &mut self.overflow_saturated,
287            other.overflow,
288        );
289        self.overflow_saturated |= other.overflow_saturated;
290        self.aggregate.merge(other.aggregate);
291        Ok(())
292    }
293
294    /// Count observations at or below the finite upper bound at `index`.
295    ///
296    /// Sums disjoint buckets through that index, excluding overflow. Empty
297    /// histograms return zero for valid indices. Later buckets, overflow and
298    /// aggregate saturation do not invalidate an otherwise exact prefix count.
299    /// Arbitrary thresholds inside a bucket cannot be answered exactly.
300    ///
301    /// # Errors
302    ///
303    /// Returns [`HistogramQueryError::BucketOutOfBounds`] when `index >= N`, or
304    /// [`HistogramQueryError::SaturatedCount`] when a contributing count or sum
305    /// has actually overflowed; an exactly reached `u64::MAX` is valid.
306    pub const fn cumulative_count(&self, index: usize) -> Result<u64, HistogramQueryError> {
307        if index >= N {
308            return Err(HistogramQueryError::BucketOutOfBounds { index, buckets: N });
309        }
310        let mut total = 0_u64;
311        let mut position = 0;
312        while position <= index {
313            if self.bucket_saturation[position] {
314                return Err(HistogramQueryError::SaturatedCount);
315            }
316            total = match total.checked_add(self.bucket_counts[position]) {
317                Some(next) => next,
318                None => return Err(HistogramQueryError::SaturatedCount),
319            };
320            position += 1;
321        }
322        Ok(total)
323    }
324
325    /// Locate the bucket containing the nearest-rank quantile `numerator / denominator`.
326    ///
327    /// The one-based rank is `ceil(samples * numerator / denominator)`, with
328    /// `0 < numerator <= denominator`. Returns `None` for no observations and
329    /// otherwise a range, including overflow. It never interpolates an exact
330    /// percentile value. Recording and retained state are unchanged.
331    ///
332    /// # Errors
333    ///
334    /// Returns [`HistogramQueryError::InvalidQuantile`] first for an invalid
335    /// fraction, or [`HistogramQueryError::SaturatedCount`] if the sample count
336    /// or any bucket count has actually overflowed. A saturated value total does not
337    /// invalidate a distribution whose counts remain exact.
338    ///
339    /// ```
340    /// use ic_metrics::MeasurementHistogram;
341    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
342    /// let mut histogram = MeasurementHistogram::new([10, 100])?;
343    /// for value in [0, 10, 11, 101] { histogram.record(value); }
344    /// assert_eq!(histogram.cumulative_count(0)?, 2);
345    /// let median = histogram.quantile_bucket(1, 2)?.unwrap();
346    /// assert_eq!(median.upper_inclusive(), Some(10));
347    /// assert_eq!(histogram.quantile_bucket(95, 100)?.unwrap().upper_inclusive(), None);
348    /// # Ok(())
349    /// # }
350    /// ```
351    pub const fn quantile_bucket(
352        &self,
353        numerator: u64,
354        denominator: u64,
355    ) -> Result<Option<HistogramRange>, HistogramQueryError> {
356        let rank = match quantile_rank(self.aggregate.samples(), numerator, denominator) {
357            Ok(Some(rank)) => rank,
358            Ok(None) => return Ok(None),
359            Err(error) => return Err(error),
360        };
361        if self.aggregate.samples_saturated() {
362            return Err(HistogramQueryError::SaturatedCount);
363        }
364        // Check every count before returning a whole-distribution projection.
365        let mut index = 0;
366        while index < N {
367            if self.bucket_saturation[index] {
368                return Err(HistogramQueryError::SaturatedCount);
369            }
370            index += 1;
371        }
372        if self.overflow_saturated {
373            return Err(HistogramQueryError::SaturatedCount);
374        }
375        let mut cumulative = 0_u128;
376        index = 0;
377        while index < N {
378            cumulative += self.bucket_counts[index] as u128;
379            if cumulative >= rank {
380                return Ok(Some(HistogramRange {
381                    lower_exclusive: if index == 0 {
382                        None
383                    } else {
384                        Some(self.upper_bounds[index - 1])
385                    },
386                    upper_inclusive: Some(self.upper_bounds[index]),
387                }));
388            }
389            index += 1;
390        }
391        Ok(Some(HistogramRange {
392            lower_exclusive: if N == 0 {
393                None
394            } else {
395                Some(self.upper_bounds[N - 1])
396            },
397            upper_inclusive: None,
398        }))
399    }
400}
401
402// Wide multiplication retains exact ranks for every valid u64 count and fraction.
403const fn quantile_rank(
404    samples: u64,
405    numerator: u64,
406    denominator: u64,
407) -> Result<Option<u128>, HistogramQueryError> {
408    if numerator == 0 || numerator > denominator {
409        return Err(HistogramQueryError::InvalidQuantile);
410    }
411    if samples == 0 {
412        return Ok(None);
413    }
414    Ok(Some(
415        ((samples as u128) * (numerator as u128)).div_ceil(denominator as u128),
416    ))
417}