Skip to main content

ic_metrics/summary/
mod.rs

1//! Saturating arithmetic without sampling or attribution policy.
2
3use core::fmt;
4
5use crate::{MeasurementAggregate, MeasurementStateError, mean_total};
6
7#[cfg(test)]
8mod tests;
9
10/// Why a measurement aggregate cannot supply an integer mean.
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12pub enum MeasurementMeanError {
13    /// A nonzero total has no observations to account for it.
14    TotalWithoutSamples,
15    /// Tracked samples overflowed, or a raw count is at the ambiguous numeric cap.
16    SaturatedSamples,
17    /// The tracked total overflowed, or a raw total is at the ambiguous numeric cap.
18    SaturatedTotal,
19}
20
21impl fmt::Display for MeasurementMeanError {
22    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
23        formatter.write_str(match self {
24            Self::TotalWithoutSamples => "nonzero measurement total without samples",
25            Self::SaturatedSamples => "measurement sample count is saturated",
26            Self::SaturatedTotal => "measurement total is saturated",
27        })
28    }
29}
30
31impl core::error::Error for MeasurementMeanError {}
32
33/// Project the integer mean of consumer-owned sample count and total fields.
34///
35/// Returns `Ok(None)` for `(0, 0)` and `Ok(Some(0))` for nonempty measured zero.
36/// Other unsaturated nonempty inputs use floor division, in the total's unit.
37/// Consumers must establish that both fields describe the same observations,
38/// unit and window; this function cannot establish their identity or provenance.
39///
40/// # Errors
41///
42/// Rejects a nonzero total with zero samples first. Otherwise a count or total
43/// at its respective cap (`u64::MAX` / `u128::MAX`) is unavailable, even when reached exactly. If both are at the
44/// cap, [`MeasurementMeanError::SaturatedSamples`] takes precedence.
45///
46/// ```
47/// use ic_metrics::{checked_mean, MeasurementMeanError};
48///
49/// assert_eq!(checked_mean(0, 0), Ok(None));
50/// assert_eq!(checked_mean(2, 0), Ok(Some(0)));
51/// assert_eq!(checked_mean(2, 9), Ok(Some(4)));
52/// assert_eq!(checked_mean(1, u128::MAX), Err(MeasurementMeanError::SaturatedTotal));
53/// ```
54pub const fn checked_mean(samples: u64, total: u128) -> Result<Option<u128>, MeasurementMeanError> {
55    if samples == 0 {
56        return if total == 0 {
57            Ok(None)
58        } else {
59            Err(MeasurementMeanError::TotalWithoutSamples)
60        };
61    }
62    if samples == u64::MAX {
63        return Err(MeasurementMeanError::SaturatedSamples);
64    }
65    if total == u128::MAX {
66        return Err(MeasurementMeanError::SaturatedTotal);
67    }
68    Ok(mean_total(samples, total))
69}
70
71/// Record one value into a sample count and total, saturating independently.
72///
73/// Zero is a completed sample. `value` and `total` must use the same unit.
74/// This primitive supports consumer-owned report shapes without serialization
75/// dependencies. It does not validate continuity or make saturated counters
76/// suitable for exact interval arithmetic. Counts clip at `u64::MAX` and totals
77/// at `u128::MAX`, without retaining overflow provenance. Use
78/// [`MeasurementAggregate`] when actual overflow must be distinguished from an
79/// exactly reached cap.
80pub const fn record_sample(samples: &mut u64, total: &mut u128, value: u64) {
81    *samples = samples.saturating_add(1);
82    *total = total.saturating_add(value as u128);
83}
84
85/// Ordered observations with a mergeable aggregate and a separate latest value.
86///
87/// Count and total saturate independently, tracking actual overflow. An exactly
88/// reached numeric cap remains exact; inspect the aggregate's saturation flags
89/// before using raw counts/totals for exact arithmetic. Latest and maximum remain
90/// individual observations after overflow. Empty and measured zero are distinct.
91/// Consumers own units, identity, ordering and reset boundaries.
92#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
93pub struct MeasurementSummary {
94    aggregate: MeasurementAggregate,
95    latest: u64,
96}
97
98impl MeasurementSummary {
99    /// Restore an admitted aggregate and its latest ordered observation.
100    ///
101    /// The caller establishes identity and ordering; this validates numeric fields.
102    ///
103    /// # Errors
104    /// Rejects absent/nonempty latest values, values above the maximum and
105    /// observations inconsistent with the aggregate's exact total.
106    pub const fn from_parts(
107        aggregate: MeasurementAggregate,
108        latest: Option<u64>,
109    ) -> Result<Self, MeasurementStateError> {
110        if aggregate.samples() == 0 {
111            return if latest.is_none() {
112                Ok(Self::EMPTY)
113            } else {
114                Err(MeasurementStateError::InvalidLatest)
115            };
116        }
117        let (Some(latest), Some(maximum)) = (latest, aggregate.maximum()) else {
118            return Err(MeasurementStateError::InvalidLatest);
119        };
120        if latest > maximum
121            || (aggregate.samples() == 1 && latest != maximum)
122            || (latest < maximum && aggregate.total() < latest as u128 + maximum as u128)
123        {
124            return Err(MeasurementStateError::InvalidLatest);
125        }
126        Ok(Self { aggregate, latest })
127    }
128
129    /// Empty summary, with no latest or maximum observation.
130    pub const EMPTY: Self = Self {
131        aggregate: MeasurementAggregate::EMPTY,
132        latest: 0,
133    };
134
135    /// Record one completed observation, including zero, in the existing unit.
136    pub const fn record(&mut self, value: u64) {
137        self.aggregate.record(value);
138        self.latest = value;
139    }
140
141    /// Mergeable count/total/maximum snapshot without observation ordering.
142    #[must_use]
143    pub const fn aggregate(self) -> MeasurementAggregate {
144        self.aggregate
145    }
146
147    /// Sample count, clipped at `u64::MAX`; saturation is in [`Self::aggregate`].
148    #[must_use]
149    pub const fn samples(self) -> u64 {
150        self.aggregate.samples()
151    }
152
153    /// Observation total, clipped at `u128::MAX`; saturation is in [`Self::aggregate`].
154    #[must_use]
155    pub const fn total(self) -> u128 {
156        self.aggregate.total()
157    }
158
159    /// Floor-rounded mean, or `None` when empty. An exactly reached cap is valid.
160    ///
161    /// # Errors
162    /// Returns [`MeasurementMeanError`] on actual count or total overflow.
163    pub const fn mean(self) -> Result<Option<u128>, MeasurementMeanError> {
164        self.aggregate.mean()
165    }
166
167    /// Latest observation, or `None` when empty. Summaries cannot be merged.
168    #[must_use]
169    pub const fn latest(self) -> Option<u64> {
170        if self.samples() == 0 {
171            None
172        } else {
173            Some(self.latest)
174        }
175    }
176
177    /// Largest observation, or `None` when empty, including after overflow.
178    #[must_use]
179    pub const fn maximum(self) -> Option<u64> {
180        self.aggregate.maximum()
181    }
182}