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// Use native-width division when the product fits; otherwise retain exact wide ranks.
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 if let Some(product) = samples.checked_mul(numerator) {
415 return Ok(Some(product.div_ceil(denominator) as u128));
416 }
417 Ok(Some(
418 ((samples as u128) * (numerator as u128)).div_ceil(denominator as u128),
419 ))
420}