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/// Fixed-size histogram of observations in one consumer-selected unit.
37///
38/// `N` strictly increasing inclusive upper bounds define `N` disjoint buckets.
39/// The first bucket includes zero; later buckets exclude the preceding bound.
40/// Values above the last bound go into a separate overflow bucket. With no
41/// bounds, every observation goes into overflow. A final bound of `u64::MAX`
42/// is valid and leaves overflow empty.
43///
44/// Each observation updates one bucket and the accompanying
45/// [`MeasurementSummary`]. Bucket counts saturate independently at `u64::MAX`.
46/// Treat a count at that value as unavailable for exact arithmetic, even when
47/// reached exactly. Unsaturated bucket counts remain useful after the summary's
48/// total saturates. Saturation can prevent bucket counts from summing to the
49/// summary's sample count. Buckets describe ranges, not exact percentiles.
50///
51/// Storage is fixed arrays, one overflow count and one summary, with no heap
52/// allocation. Recording searches at most `N` bounds. Consumers choose bounds,
53/// units, sample admission, identity and reset boundaries. Bounds are immutable
54/// after construction; cumulative reporting and persistence remain consumer-owned.
55///
56/// ```
57/// use ic_metrics::MeasurementHistogram;
58///
59/// # fn main() -> Result<(), ic_metrics::HistogramBoundsError> {
60/// let mut histogram = MeasurementHistogram::new([10, 100])?;
61/// for value in [0, 10, 11, 101] {
62/// histogram.record(value);
63/// }
64/// assert_eq!(histogram.bucket_counts(), &[2, 1]);
65/// assert_eq!(histogram.overflow(), 1);
66/// assert_eq!(histogram.summary().samples(), 4);
67/// # Ok(())
68/// # }
69/// ```
70#[derive(Clone, Copy, Debug, Eq, PartialEq)]
71pub struct MeasurementHistogram<const N: usize> {
72 upper_bounds: [u64; N],
73 bucket_counts: [u64; N],
74 overflow: u64,
75 summary: MeasurementSummary,
76}
77
78impl<const N: usize> MeasurementHistogram<N> {
79 /// Construct an empty histogram with inclusive upper bounds.
80 ///
81 /// Bounds and observations must use the same unit. A bound of zero and an
82 /// empty bounds array are valid.
83 ///
84 /// # Errors
85 ///
86 /// Returns [`HistogramBoundsError`] for the first duplicate or descending
87 /// bound. The error identifies the right-hand bound in that pair.
88 pub const fn new(upper_bounds: [u64; N]) -> Result<Self, HistogramBoundsError> {
89 let mut index = 1;
90 while index < N {
91 // Both indices are within the array, including when N is zero.
92 if upper_bounds[index - 1] >= upper_bounds[index] {
93 return Err(HistogramBoundsError { index });
94 }
95 index += 1;
96 }
97 Ok(Self {
98 upper_bounds,
99 bucket_counts: [0; N],
100 overflow: 0,
101 summary: MeasurementSummary::EMPTY,
102 })
103 }
104
105 /// Record one completed observation, including zero.
106 ///
107 /// Updates the summary and exactly one disjoint bucket. Every observation
108 /// must use the same unit as the bounds and earlier observations.
109 pub const fn record(&mut self, value: u64) {
110 self.summary.record(value);
111 let mut index = 0;
112 while index < N {
113 // Both arrays have length N; the guard establishes valid indices.
114 if value <= self.upper_bounds[index] {
115 self.bucket_counts[index] = self.bucket_counts[index].saturating_add(1);
116 return;
117 }
118 index += 1;
119 }
120 self.overflow = self.overflow.saturating_add(1);
121 }
122
123 /// Immutable inclusive upper bounds corresponding to [`Self::bucket_counts`].
124 #[must_use]
125 pub const fn upper_bounds(&self) -> &[u64; N] {
126 &self.upper_bounds
127 }
128
129 /// Disjoint bucket counts, each saturating independently at `u64::MAX`.
130 ///
131 /// These exclude overflow and are not cumulative counts.
132 #[must_use]
133 pub const fn bucket_counts(&self) -> &[u64; N] {
134 &self.bucket_counts
135 }
136
137 /// Count above the last bound, saturating independently at `u64::MAX`.
138 ///
139 /// With no bounds, this counts all observations.
140 #[must_use]
141 pub const fn overflow(&self) -> u64 {
142 self.overflow
143 }
144
145 /// Summary of all observations, including overflow.
146 #[must_use]
147 pub const fn summary(&self) -> MeasurementSummary {
148 self.summary
149 }
150}