Skip to main content

ic_metrics/aggregate/
mod.rs

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