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