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::MeasurementSummary;
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 is at the `u64::MAX` cap.
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/// The observation range of a histogram bucket, not an exact percentile value.
72///
73/// A missing lower bound includes zero; otherwise the lower bound is exclusive.
74/// The upper bound is inclusive when present. A missing upper bound is overflow
75/// beyond the configured bounds. With no configured bounds both are absent and
76/// the range covers all `u64` observations.
77#[derive(Clone, Copy, Debug, Eq, PartialEq)]
78pub struct HistogramRange {
79    lower_exclusive: Option<u64>,
80    upper_inclusive: Option<u64>,
81}
82
83impl HistogramRange {
84    /// Exclusive lower bound, or `None` when zero is included.
85    #[must_use]
86    pub const fn lower_exclusive(self) -> Option<u64> {
87        self.lower_exclusive
88    }
89
90    /// Inclusive upper bound, or `None` for overflow without a configured ceiling.
91    #[must_use]
92    pub const fn upper_inclusive(self) -> Option<u64> {
93        self.upper_inclusive
94    }
95}
96
97/// Fixed-size histogram of observations in one consumer-selected unit.
98///
99/// `N` strictly increasing inclusive upper bounds define `N` disjoint buckets.
100/// The first bucket includes zero; later buckets exclude the preceding bound.
101/// Values above the last bound go into a separate overflow bucket. With no
102/// bounds, every observation goes into overflow. A final bound of `u64::MAX`
103/// is valid and leaves overflow empty.
104///
105/// Each observation updates one bucket and the accompanying
106/// [`MeasurementSummary`]. Bucket counts saturate independently at `u64::MAX`.
107/// Treat a count at that value as unavailable for exact arithmetic, even when
108/// reached exactly. Unsaturated bucket counts remain useful after the summary's
109/// total saturates. Saturation can prevent bucket counts from summing to the
110/// summary's sample count. Buckets describe ranges, not exact percentiles.
111///
112/// Storage is fixed arrays, one overflow count and one summary, with no heap
113/// allocation. Recording searches at most `N` bounds. Consumers choose bounds,
114/// units, sample admission, identity and reset boundaries. Bounds are immutable
115/// after construction; cumulative reporting and persistence remain consumer-owned.
116///
117/// ```
118/// use ic_metrics::MeasurementHistogram;
119///
120/// # fn main() -> Result<(), ic_metrics::HistogramBoundsError> {
121/// let mut histogram = MeasurementHistogram::new([10, 100])?;
122/// for value in [0, 10, 11, 101] {
123///     histogram.record(value);
124/// }
125/// assert_eq!(histogram.bucket_counts(), &[2, 1]);
126/// assert_eq!(histogram.overflow(), 1);
127/// assert_eq!(histogram.summary().samples(), 4);
128/// # Ok(())
129/// # }
130/// ```
131#[derive(Clone, Copy, Debug, Eq, PartialEq)]
132pub struct MeasurementHistogram<const N: usize> {
133    upper_bounds: [u64; N],
134    bucket_counts: [u64; N],
135    overflow: u64,
136    summary: MeasurementSummary,
137}
138
139impl<const N: usize> MeasurementHistogram<N> {
140    /// Construct an empty histogram with inclusive upper bounds.
141    ///
142    /// Bounds and observations must use the same unit. A bound of zero and an
143    /// empty bounds array are valid.
144    ///
145    /// # Errors
146    ///
147    /// Returns [`HistogramBoundsError`] for the first duplicate or descending
148    /// bound. The error identifies the right-hand bound in that pair.
149    pub const fn new(upper_bounds: [u64; N]) -> Result<Self, HistogramBoundsError> {
150        let mut index = 1;
151        while index < N {
152            // Both indices are within the array, including when N is zero.
153            if upper_bounds[index - 1] >= upper_bounds[index] {
154                return Err(HistogramBoundsError { index });
155            }
156            index += 1;
157        }
158        Ok(Self {
159            upper_bounds,
160            bucket_counts: [0; N],
161            overflow: 0,
162            summary: MeasurementSummary::EMPTY,
163        })
164    }
165
166    /// Record one completed observation, including zero.
167    ///
168    /// Updates the summary and exactly one disjoint bucket. Every observation
169    /// must use the same unit as the bounds and earlier observations.
170    pub const fn record(&mut self, value: u64) {
171        self.summary.record(value);
172        let mut index = 0;
173        while index < N {
174            // Both arrays have length N; the guard establishes valid indices.
175            if value <= self.upper_bounds[index] {
176                self.bucket_counts[index] = self.bucket_counts[index].saturating_add(1);
177                return;
178            }
179            index += 1;
180        }
181        self.overflow = self.overflow.saturating_add(1);
182    }
183
184    /// Immutable inclusive upper bounds corresponding to [`Self::bucket_counts`].
185    #[must_use]
186    pub const fn upper_bounds(&self) -> &[u64; N] {
187        &self.upper_bounds
188    }
189
190    /// Disjoint bucket counts, each saturating independently at `u64::MAX`.
191    ///
192    /// These exclude overflow and are not cumulative counts.
193    #[must_use]
194    pub const fn bucket_counts(&self) -> &[u64; N] {
195        &self.bucket_counts
196    }
197
198    /// Count above the last bound, saturating independently at `u64::MAX`.
199    ///
200    /// With no bounds, this counts all observations.
201    #[must_use]
202    pub const fn overflow(&self) -> u64 {
203        self.overflow
204    }
205
206    /// Summary of all observations, including overflow.
207    #[must_use]
208    pub const fn summary(&self) -> MeasurementSummary {
209        self.summary
210    }
211
212    /// Count observations at or below the finite upper bound at `index`.
213    ///
214    /// Sums disjoint buckets through that index, excluding overflow. Empty
215    /// histograms return zero for valid indices. Later buckets, overflow and
216    /// summary saturation do not invalidate an otherwise exact prefix count.
217    /// Arbitrary thresholds inside a bucket cannot be answered exactly.
218    ///
219    /// # Errors
220    ///
221    /// Returns [`HistogramQueryError::BucketOutOfBounds`] when `index >= N`, or
222    /// [`HistogramQueryError::SaturatedCount`] when a contributing count or sum
223    /// reaches `u64::MAX`, including an exactly reached cap.
224    pub const fn cumulative_count(&self, index: usize) -> Result<u64, HistogramQueryError> {
225        if index >= N {
226            return Err(HistogramQueryError::BucketOutOfBounds { index, buckets: N });
227        }
228        let mut total = 0_u64;
229        let mut position = 0;
230        while position <= index {
231            total = total.saturating_add(self.bucket_counts[position]);
232            if total == u64::MAX {
233                return Err(HistogramQueryError::SaturatedCount);
234            }
235            position += 1;
236        }
237        Ok(total)
238    }
239
240    /// Locate the bucket containing the nearest-rank quantile `numerator / denominator`.
241    ///
242    /// The one-based rank is `ceil(samples * numerator / denominator)`, with
243    /// `0 < numerator <= denominator`. Returns `None` for no observations and
244    /// otherwise a range, including overflow. It never interpolates an exact
245    /// percentile value. Recording and retained state are unchanged.
246    ///
247    /// # Errors
248    ///
249    /// Returns [`HistogramQueryError::InvalidQuantile`] first for an invalid
250    /// fraction, or [`HistogramQueryError::SaturatedCount`] if the sample count
251    /// or any bucket count is at `u64::MAX`. A saturated value total does not
252    /// invalidate a distribution whose counts remain exact.
253    ///
254    /// ```
255    /// use ic_metrics::MeasurementHistogram;
256    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
257    /// let mut histogram = MeasurementHistogram::new([10, 100])?;
258    /// for value in [0, 10, 11, 101] { histogram.record(value); }
259    /// assert_eq!(histogram.cumulative_count(0)?, 2);
260    /// let median = histogram.quantile_bucket(1, 2)?.unwrap();
261    /// assert_eq!(median.upper_inclusive(), Some(10));
262    /// assert_eq!(histogram.quantile_bucket(95, 100)?.unwrap().upper_inclusive(), None);
263    /// # Ok(())
264    /// # }
265    /// ```
266    pub const fn quantile_bucket(
267        &self,
268        numerator: u64,
269        denominator: u64,
270    ) -> Result<Option<HistogramRange>, HistogramQueryError> {
271        let rank = match quantile_rank(self.summary.samples(), numerator, denominator) {
272            Ok(Some(rank)) => rank,
273            Ok(None) => return Ok(None),
274            Err(error) => return Err(error),
275        };
276        // Check every count before returning a whole-distribution projection.
277        let mut index = 0;
278        while index < N {
279            if self.bucket_counts[index] == u64::MAX {
280                return Err(HistogramQueryError::SaturatedCount);
281            }
282            index += 1;
283        }
284        if self.overflow == u64::MAX {
285            return Err(HistogramQueryError::SaturatedCount);
286        }
287        let mut cumulative = 0_u128;
288        index = 0;
289        while index < N {
290            cumulative += self.bucket_counts[index] as u128;
291            if cumulative >= rank {
292                return Ok(Some(HistogramRange {
293                    lower_exclusive: if index == 0 {
294                        None
295                    } else {
296                        Some(self.upper_bounds[index - 1])
297                    },
298                    upper_inclusive: Some(self.upper_bounds[index]),
299                }));
300            }
301            index += 1;
302        }
303        Ok(Some(HistogramRange {
304            lower_exclusive: if N == 0 {
305                None
306            } else {
307                Some(self.upper_bounds[N - 1])
308            },
309            upper_inclusive: None,
310        }))
311    }
312}
313
314// Wide multiplication retains exact ranks for every valid u64 count and fraction.
315const fn quantile_rank(
316    samples: u64,
317    numerator: u64,
318    denominator: u64,
319) -> Result<Option<u128>, HistogramQueryError> {
320    if numerator == 0 || numerator > denominator {
321        return Err(HistogramQueryError::InvalidQuantile);
322    }
323    if samples == u64::MAX {
324        return Err(HistogramQueryError::SaturatedCount);
325    }
326    if samples == 0 {
327        return Ok(None);
328    }
329    Ok(Some(
330        ((samples as u128) * (numerator as u128)).div_ceil(denominator as u128),
331    ))
332}