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}