Skip to main content

ic_metrics/aggregate/
mod.rs

1//! Order-independent observations and exact interval arithmetic.
2
3use core::fmt;
4
5use crate::{MeasurementStateError, add_count, add_total};
6
7#[cfg(test)]
8mod tests;
9
10// Preserve native-width division for ordinary windows; wide totals stay exact.
11const fn mean_total(samples: u64, total: u128) -> Option<u128> {
12    if total <= u64::MAX as u128 {
13        // TryFrom is not const at the arithmetic support floor.
14        #[expect(
15            clippy::cast_possible_truncation,
16            reason = "the branch admits a u64 total"
17        )]
18        let narrow = total as u64;
19        match narrow.checked_div(samples) {
20            Some(mean) => Some(mean as u128),
21            None => None,
22        }
23    } else {
24        total.checked_div(samples as u128)
25    }
26}
27
28/// Why a measurement aggregate cannot supply an integer or scaled mean.
29#[derive(Clone, Copy, Debug, Eq, PartialEq)]
30pub enum MeasurementMeanError {
31    /// The tracked sample count has actually overflowed.
32    SampleCountOverflow,
33}
34
35impl fmt::Display for MeasurementMeanError {
36    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
37        formatter.write_str("measurement sample count has overflowed")
38    }
39}
40
41impl core::error::Error for MeasurementMeanError {}
42
43/// Why two tracked snapshots cannot supply an exact count/total interval.
44#[derive(Clone, Copy, Debug, Eq, PartialEq)]
45pub enum MeasurementIntervalError {
46    /// A sample count has actually overflowed.
47    SampleCountOverflow,
48    /// The current sample count is below the previous count.
49    DecreasingSamples,
50    /// The current total is below the previous total.
51    DecreasingTotal,
52    /// The total changed without any additional observations.
53    TotalWithoutSamples,
54}
55
56impl fmt::Display for MeasurementIntervalError {
57    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
58        formatter.write_str(match self {
59            Self::SampleCountOverflow => "interval sample count has overflowed",
60            Self::DecreasingSamples => "interval sample count decreased",
61            Self::DecreasingTotal => "interval total decreased",
62            Self::TotalWithoutSamples => "interval total changed without samples",
63        })
64    }
65}
66
67impl core::error::Error for MeasurementIntervalError {}
68
69/// Exact count and total differences, without a maximum or latest observation.
70///
71/// Constructed by [`MeasurementAggregate::interval_since`] only after numeric
72/// admission. Consumers must establish common units, identity and append-only
73/// continuity; increasing numbers alone cannot establish any of those facts.
74#[derive(Clone, Copy, Debug, Eq, PartialEq)]
75pub struct MeasurementInterval {
76    samples: u64,
77    total: u128,
78}
79
80impl MeasurementInterval {
81    /// Number of added observations, including measured zero.
82    #[must_use]
83    pub const fn samples(self) -> u64 {
84        self.samples
85    }
86
87    /// Exact sum of added observations in the consumer's unit.
88    #[must_use]
89    pub const fn total(self) -> u128 {
90        self.total
91    }
92
93    /// Floor-rounded mean, or `None` when no observations were added.
94    #[must_use]
95    pub const fn mean(self) -> Option<u128> {
96        mean_total(self.samples, self.total)
97    }
98}
99
100/// Mergeable count, total and maximum in one consumer-selected unit.
101///
102/// Counts clip at `u64::MAX` and totals at `u128::MAX`; sticky flags record actual
103/// overflow, so an exactly reached cap remains exact. Maximum is an individual
104/// observation and remains valid after overflow. There is no latest observation:
105/// unordered batches cannot establish ordering. Every `u64` sample fits in a
106/// `u128` total while the sample count remains exact. No allocation or platform reads.
107#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
108pub struct MeasurementAggregate {
109    samples: u64,
110    total: u128,
111    maximum: u64,
112    samples_overflowed: bool,
113    total_overflowed: bool,
114}
115
116impl MeasurementAggregate {
117    /// Restore explicit fields after numeric consistency checks.
118    ///
119    /// Exactly reached caps with false flags stay exact. This admits arithmetic
120    /// consistency only; the caller owns provenance, units and window identity.
121    ///
122    /// # Errors
123    /// Rejects invalid empty state, missing maxima, inconsistent overflow flags
124    /// and totals outside the range allowed by count and maximum.
125    pub const fn from_parts(
126        samples: u64,
127        total: u128,
128        maximum: Option<u64>,
129        samples_overflowed: bool,
130        total_overflowed: bool,
131    ) -> Result<Self, MeasurementStateError> {
132        if samples == 0 {
133            return if total == 0 && maximum.is_none() && !samples_overflowed && !total_overflowed {
134                Ok(Self::EMPTY)
135            } else {
136                Err(MeasurementStateError::InvalidEmpty)
137            };
138        }
139        let Some(maximum) = maximum else {
140            return Err(MeasurementStateError::MissingMaximum);
141        };
142        if (samples_overflowed && samples != u64::MAX)
143            || (total_overflowed && (total != u128::MAX || !samples_overflowed))
144        {
145            return Err(MeasurementStateError::InvalidOverflowFlag);
146        }
147        if total < maximum as u128
148            || (maximum == 0 && total != 0)
149            || (!samples_overflowed && total > samples as u128 * maximum as u128)
150        {
151            return Err(MeasurementStateError::InvalidTotal);
152        }
153        Ok(Self {
154            samples,
155            total,
156            maximum,
157            samples_overflowed,
158            total_overflowed,
159        })
160    }
161
162    /// Empty aggregate, without a maximum observation.
163    pub const EMPTY: Self = Self {
164        samples: 0,
165        total: 0,
166        maximum: 0,
167        samples_overflowed: false,
168        total_overflowed: false,
169    };
170
171    /// Record one admitted observation, including zero, in the existing unit.
172    pub const fn record(&mut self, value: u64) {
173        add_count(&mut self.samples, &mut self.samples_overflowed, 1);
174        add_total(&mut self.total, &mut self.total_overflowed, value as u128);
175        if value > self.maximum {
176            self.maximum = value;
177        }
178    }
179
180    /// Combine observations from a batch without claiming ordering or identity.
181    ///
182    /// Consumers must establish common units and deliberate sample attribution.
183    /// This is addition, not deduplication: merging the same batch twice records
184    /// it twice. Source overflow remains sticky even when its clipped value fits.
185    pub const fn merge(&mut self, other: Self) {
186        add_count(
187            &mut self.samples,
188            &mut self.samples_overflowed,
189            other.samples,
190        );
191        add_total(&mut self.total, &mut self.total_overflowed, other.total);
192        self.samples_overflowed |= other.samples_overflowed;
193        self.total_overflowed |= other.total_overflowed;
194        if other.maximum > self.maximum {
195            self.maximum = other.maximum;
196        }
197    }
198
199    /// Sample count, clipped at `u64::MAX`; check [`Self::samples_overflowed`].
200    #[must_use]
201    pub const fn samples(self) -> u64 {
202        self.samples
203    }
204
205    /// Total, clipped at `u128::MAX`; check [`Self::total_overflowed`].
206    #[must_use]
207    pub const fn total(self) -> u128 {
208        self.total
209    }
210
211    /// Whether recording or merging has actually overflowed the sample count.
212    #[must_use]
213    pub const fn samples_overflowed(self) -> bool {
214        self.samples_overflowed
215    }
216
217    /// Whether recording or merging has actually overflowed the value total.
218    #[must_use]
219    pub const fn total_overflowed(self) -> bool {
220        self.total_overflowed
221    }
222
223    /// Largest observation, or `None` when empty, including after overflow.
224    #[must_use]
225    pub const fn maximum(self) -> Option<u64> {
226        if self.samples == 0 {
227            None
228        } else {
229            Some(self.maximum)
230        }
231    }
232
233    /// Floor-rounded mean, or `None` when empty. Exactly reached caps are valid.
234    ///
235    /// # Errors
236    /// Returns [`MeasurementMeanError::SampleCountOverflow`] on actual count overflow.
237    /// An exact count guarantees that the accumulated total is also exact.
238    pub const fn mean(self) -> Result<Option<u128>, MeasurementMeanError> {
239        if self.samples_overflowed {
240            return Err(MeasurementMeanError::SampleCountOverflow);
241        }
242        Ok(mean_total(self.samples, self.total))
243    }
244
245    /// Compute `floor(total * scale / samples)` after checking actual overflow.
246    ///
247    /// Scales the exact total before division, retaining fractional information
248    /// that multiplying the integer mean would lose. A scale of ten reports
249    /// tenths; callers own units and display. Empty is `None`, measured zero is
250    /// `Some(0)`, and an exactly reached count cap remains exact.
251    ///
252    /// # Errors
253    /// Returns [`MeasurementMeanError::SampleCountOverflow`] on actual count overflow,
254    /// even with scale zero. An exact count bounds the scaled result within `u128`.
255    ///
256    /// ```
257    /// use ic_metrics::MeasurementAggregate;
258    /// # fn main() -> Result<(), ic_metrics::MeasurementMeanError> {
259    /// let mut values = MeasurementAggregate::EMPTY;
260    /// assert_eq!(values.scaled_mean(10)?, None);
261    /// values.record(3);
262    /// values.record(4);
263    /// assert_eq!(values.mean()?, Some(3));
264    /// assert_eq!(values.scaled_mean(10)?, Some(35));
265    /// # Ok(())
266    /// # }
267    /// ```
268    pub const fn scaled_mean(self, scale: u64) -> Result<Option<u128>, MeasurementMeanError> {
269        if self.samples_overflowed {
270            return Err(MeasurementMeanError::SampleCountOverflow);
271        }
272        if self.samples == 0 {
273            return Ok(None);
274        }
275        let samples = self.samples as u128;
276        let scale = scale as u128;
277        // Admitted exact state has total <= samples * maximum. The quotient
278        // is at most u64::MAX, and the final result is at most maximum * scale.
279        // Decomposition avoids a potentially overflowing total * scale product.
280        Ok(Some(
281            (self.total / samples) * scale + (self.total % samples) * scale / samples,
282        ))
283    }
284
285    /// Exact count/total difference from an earlier snapshot of this accumulator.
286    ///
287    /// Consumers must first establish matching units, window/reset identity and
288    /// append-only continuity. This cannot detect a reset followed by numerically
289    /// larger values or changed attribution. Maxima and latest values are not
290    /// subtractable and are not returned. Empty intervals differ from added zeros.
291    ///
292    /// # Errors
293    /// Rejects sample overflow, decreasing samples, decreasing totals, then a
294    /// changed total with no added samples, in that order. An exact sample count
295    /// guarantees total exactness in both snapshots.
296    pub const fn interval_since(
297        self,
298        previous: Self,
299    ) -> Result<MeasurementInterval, MeasurementIntervalError> {
300        if self.samples_overflowed || previous.samples_overflowed {
301            return Err(MeasurementIntervalError::SampleCountOverflow);
302        }
303        let Some(samples) = self.samples.checked_sub(previous.samples) else {
304            return Err(MeasurementIntervalError::DecreasingSamples);
305        };
306        let Some(total) = self.total.checked_sub(previous.total) else {
307            return Err(MeasurementIntervalError::DecreasingTotal);
308        };
309        if samples == 0 && total != 0 {
310            return Err(MeasurementIntervalError::TotalWithoutSamples);
311        }
312        Ok(MeasurementInterval { samples, total })
313    }
314}