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}