Skip to main content

ic_metrics/aggregate/
mod.rs

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