ic_metrics/histogram/mod.rs
1//! Fixed-size distributions without sampling or reporting policy.
2
3use core::fmt;
4
5use crate::{MeasurementAggregate, MeasurementStateError, 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 CountOverflow,
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::CountOverflow => formatter.write_str("histogram reporting count has overflowed"),
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. Exact
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_overflowed: [bool; N],
159 overflow_count_overflowed: bool,
160 aggregate: MeasurementAggregate,
161}
162
163impl<const N: usize> MeasurementHistogram<N> {
164 /// Restore disjoint buckets and an admitted aggregate without replaying samples.
165 ///
166 /// This checks structural arithmetic consistency, not observation provenance.
167 /// Consumers retain units, attribution and reset identity.
168 ///
169 /// # Errors
170 /// Rejects invalid bounds, overflow flags without clipped caps, disagreement
171 /// between bucket counts and the aggregate, or an incompatible maximum.
172 pub const fn from_parts(
173 upper_bounds: [u64; N],
174 bucket_counts: [u64; N],
175 overflow: u64,
176 bucket_overflowed: [bool; N],
177 overflow_count_overflowed: bool,
178 aggregate: MeasurementAggregate,
179 ) -> Result<Self, MeasurementStateError> {
180 if validate_bounds(&upper_bounds).is_err() {
181 return Err(MeasurementStateError::InvalidBounds);
182 }
183 if let Err(error) = validate_distribution(
184 &upper_bounds,
185 &bucket_counts,
186 overflow,
187 &bucket_overflowed,
188 overflow_count_overflowed,
189 aggregate,
190 ) {
191 return Err(error);
192 }
193 Ok(Self {
194 upper_bounds,
195 bucket_counts,
196 overflow,
197 bucket_overflowed,
198 overflow_count_overflowed,
199 aggregate,
200 })
201 }
202
203 /// Construct an empty histogram with inclusive upper bounds.
204 ///
205 /// Bounds and observations must use the same unit. A bound of zero and an
206 /// empty bounds array are valid.
207 ///
208 /// # Errors
209 ///
210 /// Returns [`HistogramBoundsError`] for the first duplicate or descending
211 /// bound. The error identifies the right-hand bound in that pair.
212 pub const fn new(upper_bounds: [u64; N]) -> Result<Self, HistogramBoundsError> {
213 if let Err(error) = validate_bounds(&upper_bounds) {
214 return Err(error);
215 }
216 Ok(Self {
217 upper_bounds,
218 bucket_counts: [0; N],
219 overflow: 0,
220 bucket_overflowed: [false; N],
221 overflow_count_overflowed: false,
222 aggregate: MeasurementAggregate::EMPTY,
223 })
224 }
225
226 /// Record one completed observation, including zero.
227 ///
228 /// Updates the aggregate and exactly one disjoint bucket. Every observation
229 /// must use the same unit as the bounds and earlier observations.
230 pub const fn record(&mut self, value: u64) {
231 self.aggregate.record(value);
232 let mut index = 0;
233 while index < N {
234 // Both arrays have length N; the guard establishes valid indices.
235 if value <= self.upper_bounds[index] {
236 add_count(
237 &mut self.bucket_counts[index],
238 &mut self.bucket_overflowed[index],
239 1,
240 );
241 return;
242 }
243 index += 1;
244 }
245 add_count(&mut self.overflow, &mut self.overflow_count_overflowed, 1);
246 }
247
248 /// Immutable inclusive upper bounds corresponding to [`Self::bucket_counts`].
249 #[must_use]
250 pub const fn upper_bounds(&self) -> &[u64; N] {
251 &self.upper_bounds
252 }
253
254 /// Disjoint bucket counts, each saturating independently at `u64::MAX`.
255 ///
256 /// These exclude overflow and are not cumulative counts. Inspect
257 /// [`Self::bucket_overflowed`] before using clipped counts as exact values.
258 #[must_use]
259 pub const fn bucket_counts(&self) -> &[u64; N] {
260 &self.bucket_counts
261 }
262
263 /// Count above the last bound, saturating independently at `u64::MAX`.
264 ///
265 /// With no bounds, this counts all observations. Inspect
266 /// [`Self::overflow_count_overflowed`] before treating the clipped count as exact.
267 #[must_use]
268 pub const fn overflow(&self) -> u64 {
269 self.overflow
270 }
271
272 /// Sticky actual-overflow flags corresponding to [`Self::bucket_counts`].
273 #[must_use]
274 pub const fn bucket_overflowed(&self) -> &[bool; N] {
275 &self.bucket_overflowed
276 }
277
278 /// Whether the overflow bucket's count has actually overflowed.
279 #[must_use]
280 pub const fn overflow_count_overflowed(&self) -> bool {
281 self.overflow_count_overflowed
282 }
283
284 /// Mergeable aggregate of all observations, including overflow.
285 #[must_use]
286 pub const fn aggregate(&self) -> MeasurementAggregate {
287 self.aggregate
288 }
289
290 /// Combine a distribution with exactly the same finite bounds.
291 ///
292 /// Consumers establish matching units and deliberate sample attribution.
293 /// This adds observations without deduplication or ordering. Source overflow
294 /// propagates independently for each count and the aggregate.
295 ///
296 /// # Errors
297 /// Returns [`HistogramMergeError`] for the first differing bound, leaving
298 /// the entire destination unchanged. Different bound counts are different
299 /// Rust types and cannot be merged.
300 pub const fn merge(&mut self, other: &Self) -> Result<(), HistogramMergeError> {
301 let mut index = 0;
302 while index < N {
303 if self.upper_bounds[index] != other.upper_bounds[index] {
304 return Err(HistogramMergeError { index });
305 }
306 index += 1;
307 }
308 index = 0;
309 while index < N {
310 add_count(
311 &mut self.bucket_counts[index],
312 &mut self.bucket_overflowed[index],
313 other.bucket_counts[index],
314 );
315 self.bucket_overflowed[index] |= other.bucket_overflowed[index];
316 index += 1;
317 }
318 add_count(
319 &mut self.overflow,
320 &mut self.overflow_count_overflowed,
321 other.overflow,
322 );
323 self.overflow_count_overflowed |= other.overflow_count_overflowed;
324 self.aggregate.merge(other.aggregate);
325 Ok(())
326 }
327
328 /// Count observations at or below the finite upper bound at `index`.
329 ///
330 /// Sums disjoint buckets through that index, excluding overflow. Empty
331 /// histograms return zero for valid indices. Later buckets, overflow and
332 /// aggregate overflow do not invalidate an otherwise exact prefix count.
333 /// Arbitrary thresholds inside a bucket cannot be answered exactly.
334 ///
335 /// # Errors
336 ///
337 /// Returns [`HistogramQueryError::BucketOutOfBounds`] when `index >= N`, or
338 /// [`HistogramQueryError::CountOverflow`] when a contributing count or sum
339 /// has actually overflowed; an exactly reached `u64::MAX` is valid.
340 pub const fn cumulative_count(&self, index: usize) -> Result<u64, HistogramQueryError> {
341 if index >= N {
342 return Err(HistogramQueryError::BucketOutOfBounds { index, buckets: N });
343 }
344 let mut total = 0_u64;
345 let mut position = 0;
346 while position <= index {
347 if self.bucket_overflowed[position] {
348 return Err(HistogramQueryError::CountOverflow);
349 }
350 total = match total.checked_add(self.bucket_counts[position]) {
351 Some(next) => next,
352 None => return Err(HistogramQueryError::CountOverflow),
353 };
354 position += 1;
355 }
356 Ok(total)
357 }
358
359 /// Locate the bucket containing the nearest-rank quantile `numerator / denominator`.
360 ///
361 /// The one-based rank is `ceil(samples * numerator / denominator)`, with
362 /// `0 < numerator <= denominator`. Returns `None` for no observations and
363 /// otherwise a range, including overflow. It never interpolates an exact
364 /// percentile value. Recording and retained state are unchanged.
365 ///
366 /// # Errors
367 ///
368 /// Returns [`HistogramQueryError::InvalidQuantile`] first for an invalid
369 /// fraction, or [`HistogramQueryError::CountOverflow`] if the sample count
370 /// or any bucket count has actually overflowed. A overflowed value total does not
371 /// invalidate a distribution whose counts remain exact.
372 ///
373 /// ```
374 /// use ic_metrics::MeasurementHistogram;
375 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
376 /// let mut histogram = MeasurementHistogram::new([10, 100])?;
377 /// for value in [0, 10, 11, 101] { histogram.record(value); }
378 /// assert_eq!(histogram.cumulative_count(0)?, 2);
379 /// let median = histogram.quantile_bucket(1, 2)?.unwrap();
380 /// assert_eq!(median.upper_inclusive(), Some(10));
381 /// assert_eq!(histogram.quantile_bucket(95, 100)?.unwrap().upper_inclusive(), None);
382 /// # Ok(())
383 /// # }
384 /// ```
385 pub const fn quantile_bucket(
386 &self,
387 numerator: u64,
388 denominator: u64,
389 ) -> Result<Option<HistogramRange>, HistogramQueryError> {
390 let rank = match quantile_rank(self.aggregate.samples(), numerator, denominator) {
391 Ok(Some(rank)) => rank,
392 Ok(None) => return Ok(None),
393 Err(error) => return Err(error),
394 };
395 if self.aggregate.samples_overflowed() {
396 return Err(HistogramQueryError::CountOverflow);
397 }
398 // Recording, merging and restoration carry every bucket's count loss
399 // into the aggregate. An exact aggregate count admits the distribution.
400 let mut index = 0;
401 let mut cumulative = 0_u128;
402 while index < N {
403 cumulative += self.bucket_counts[index] as u128;
404 if cumulative >= rank {
405 return Ok(Some(HistogramRange {
406 lower_exclusive: if index == 0 {
407 None
408 } else {
409 Some(self.upper_bounds[index - 1])
410 },
411 upper_inclusive: Some(self.upper_bounds[index]),
412 }));
413 }
414 index += 1;
415 }
416 Ok(Some(HistogramRange {
417 lower_exclusive: if N == 0 {
418 None
419 } else {
420 Some(self.upper_bounds[N - 1])
421 },
422 upper_inclusive: None,
423 }))
424 }
425}
426
427// Borrow bounds for both construction and restoration, without temporary state.
428const fn validate_bounds<const N: usize>(
429 upper_bounds: &[u64; N],
430) -> Result<(), HistogramBoundsError> {
431 let mut index = 1;
432 while index < N {
433 // Both indices are within the array, including when N is zero.
434 if upper_bounds[index - 1] >= upper_bounds[index] {
435 return Err(HistogramBoundsError { index });
436 }
437 index += 1;
438 }
439 Ok(())
440}
441
442// Use native-width division when the product fits; otherwise retain exact wide ranks.
443const fn quantile_rank(
444 samples: u64,
445 numerator: u64,
446 denominator: u64,
447) -> Result<Option<u128>, HistogramQueryError> {
448 if numerator == 0 || numerator > denominator {
449 return Err(HistogramQueryError::InvalidQuantile);
450 }
451 if samples == 0 {
452 return Ok(None);
453 }
454 if let Some(product) = samples.checked_mul(numerator) {
455 return Ok(Some(product.div_ceil(denominator) as u128));
456 }
457 Ok(Some(
458 ((samples as u128) * (numerator as u128)).div_ceil(denominator as u128),
459 ))
460}
461
462// Canonical admission derives counts, highest bucket and total range together.
463const fn validate_distribution<const N: usize>(
464 upper_bounds: &[u64; N],
465 bucket_counts: &[u64; N],
466 overflow: u64,
467 bucket_overflowed: &[bool; N],
468 overflow_count_overflowed: bool,
469 aggregate: MeasurementAggregate,
470) -> Result<(), MeasurementStateError> {
471 if overflow_count_overflowed && overflow != u64::MAX {
472 return Err(MeasurementStateError::InvalidOverflowFlag);
473 }
474 let maximum = match aggregate.maximum() {
475 Some(value) => value,
476 None => 0,
477 };
478 let mut samples = 0;
479 let mut samples_overflowed = false;
480 let mut maximum_matches = false;
481 let mut minimum_total = 0_u128;
482 let mut maximum_total = 0_u128;
483 let mut maximum_overflow = false;
484 let mut minimum_overflow = false;
485 let mut maximum_lower = 0_u64;
486 let mut index = 0;
487 // Counts retain a lower bound after clipping. Exact bucket counts
488 // also constrain totals; a clipped positive bucket has no upper
489 // count bound. Keep all admission arithmetic checked or saturating.
490 while index <= N {
491 let (count, lost, lower, upper) = if index < N {
492 let lower = if index == 0 {
493 0
494 } else {
495 upper_bounds[index - 1].saturating_add(1)
496 };
497 (
498 bucket_counts[index],
499 bucket_overflowed[index],
500 lower,
501 if upper_bounds[index] < maximum {
502 upper_bounds[index]
503 } else {
504 maximum
505 },
506 )
507 } else {
508 let lower = if N == 0 {
509 0
510 } else {
511 upper_bounds[N - 1].saturating_add(1)
512 };
513 (overflow, overflow_count_overflowed, lower, maximum)
514 };
515 if lost && count != u64::MAX {
516 return Err(MeasurementStateError::InvalidOverflowFlag);
517 }
518 add_count(&mut samples, &mut samples_overflowed, count);
519 samples_overflowed |= lost;
520 if count != 0 {
521 maximum_matches = if index < N {
522 maximum <= upper_bounds[index] && (index == 0 || maximum > upper_bounds[index - 1])
523 } else {
524 N == 0 || maximum > upper_bounds[N - 1]
525 };
526 maximum_lower = lower;
527 let minimum_count = count as u128 + lost as u128;
528 let minimum = minimum_count * lower as u128;
529 if let Some(next) = minimum_total.checked_add(minimum) {
530 minimum_total = next;
531 } else {
532 minimum_overflow = true;
533 }
534 if lost && upper != 0 {
535 maximum_overflow = true;
536 }
537 let upper_total = count as u128 * upper as u128;
538 if let Some(next) = maximum_total.checked_add(upper_total) {
539 maximum_total = next;
540 } else {
541 maximum_overflow = true;
542 }
543 }
544 index += 1;
545 }
546 if samples != aggregate.samples()
547 || samples_overflowed != aggregate.samples_overflowed()
548 || (samples != 0 && !maximum_matches)
549 {
550 return Err(MeasurementStateError::InvalidDistribution);
551 }
552 if samples == 0 {
553 return Ok(());
554 }
555 // At least one observation actually attains the stored maximum.
556 if let Some(next) = minimum_total.checked_add(maximum.saturating_sub(maximum_lower) as u128) {
557 minimum_total = next;
558 } else {
559 minimum_overflow = true;
560 }
561 let valid_total = if aggregate.total_overflowed() {
562 maximum_overflow
563 } else {
564 !minimum_overflow
565 && aggregate.total() >= minimum_total
566 && (maximum_overflow || aggregate.total() <= maximum_total)
567 };
568 if valid_total {
569 Ok(())
570 } else {
571 Err(MeasurementStateError::InvalidDistribution)
572 }
573}