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}