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