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}