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, add_count};
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: u64,
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) -> u64 {
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<u64> {
66        self.total.checked_div(self.samples)
67    }
68}
69
70/// Mergeable count, total and maximum in one consumer-selected unit.
71///
72/// Counts and totals clip independently at `u64::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. No allocation or platform reads.
76#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
77pub struct MeasurementAggregate {
78    samples: u64,
79    total: u64,
80    maximum: u64,
81    samples_saturated: bool,
82    total_saturated: bool,
83}
84
85impl MeasurementAggregate {
86    /// Empty aggregate, without a maximum observation.
87    pub const EMPTY: Self = Self {
88        samples: 0,
89        total: 0,
90        maximum: 0,
91        samples_saturated: false,
92        total_saturated: false,
93    };
94
95    /// Record one admitted observation, including zero, in the existing unit.
96    pub const fn record(&mut self, value: u64) {
97        add_count(&mut self.samples, &mut self.samples_saturated, 1);
98        add_count(&mut self.total, &mut self.total_saturated, value);
99        if value > self.maximum {
100            self.maximum = value;
101        }
102    }
103
104    /// Combine observations from a batch without claiming ordering or identity.
105    ///
106    /// Consumers must establish common units and deliberate sample attribution.
107    /// This is addition, not deduplication: merging the same batch twice records
108    /// it twice. Source overflow remains sticky even when its clipped value fits.
109    pub const fn merge(&mut self, other: Self) {
110        add_count(
111            &mut self.samples,
112            &mut self.samples_saturated,
113            other.samples,
114        );
115        add_count(&mut self.total, &mut self.total_saturated, other.total);
116        self.samples_saturated |= other.samples_saturated;
117        self.total_saturated |= other.total_saturated;
118        if other.maximum > self.maximum {
119            self.maximum = other.maximum;
120        }
121    }
122
123    /// Sample count, clipped at `u64::MAX`; check [`Self::samples_saturated`].
124    #[must_use]
125    pub const fn samples(self) -> u64 {
126        self.samples
127    }
128
129    /// Total, clipped at `u64::MAX`; check [`Self::total_saturated`].
130    #[must_use]
131    pub const fn total(self) -> u64 {
132        self.total
133    }
134
135    /// Whether recording or merging has actually overflowed the sample count.
136    #[must_use]
137    pub const fn samples_saturated(self) -> bool {
138        self.samples_saturated
139    }
140
141    /// Whether recording or merging has actually overflowed the value total.
142    #[must_use]
143    pub const fn total_saturated(self) -> bool {
144        self.total_saturated
145    }
146
147    /// Largest observation, or `None` when empty, including after overflow.
148    #[must_use]
149    pub const fn maximum(self) -> Option<u64> {
150        if self.samples == 0 {
151            None
152        } else {
153            Some(self.maximum)
154        }
155    }
156
157    /// Floor-rounded mean, or `None` when empty. Exactly reached caps are valid.
158    ///
159    /// # Errors
160    /// Returns [`MeasurementMeanError::SaturatedSamples`] before
161    /// [`MeasurementMeanError::SaturatedTotal`] when actual overflow is recorded.
162    pub const fn mean(self) -> Result<Option<u64>, MeasurementMeanError> {
163        if self.samples_saturated {
164            return Err(MeasurementMeanError::SaturatedSamples);
165        }
166        if self.total_saturated {
167            return Err(MeasurementMeanError::SaturatedTotal);
168        }
169        Ok(self.total.checked_div(self.samples))
170    }
171
172    /// Exact count/total difference from an earlier snapshot of this accumulator.
173    ///
174    /// Consumers must first establish matching units, window/reset identity and
175    /// append-only continuity. This cannot detect a reset followed by numerically
176    /// larger values or changed attribution. Maxima and latest values are not
177    /// subtractable and are not returned. Empty intervals differ from added zeros.
178    ///
179    /// # Errors
180    /// Rejects sample overflow, total overflow, decreasing samples, decreasing
181    /// totals, then a changed total with no added samples, in that order.
182    pub const fn interval_since(
183        self,
184        previous: Self,
185    ) -> Result<MeasurementInterval, MeasurementIntervalError> {
186        if self.samples_saturated || previous.samples_saturated {
187            return Err(MeasurementIntervalError::SaturatedSamples);
188        }
189        if self.total_saturated || previous.total_saturated {
190            return Err(MeasurementIntervalError::SaturatedTotal);
191        }
192        let Some(samples) = self.samples.checked_sub(previous.samples) else {
193            return Err(MeasurementIntervalError::DecreasingSamples);
194        };
195        let Some(total) = self.total.checked_sub(previous.total) else {
196            return Err(MeasurementIntervalError::DecreasingTotal);
197        };
198        if samples == 0 && total != 0 {
199            return Err(MeasurementIntervalError::TotalWithoutSamples);
200        }
201        Ok(MeasurementInterval { samples, total })
202    }
203}