Skip to main content

ic_metrics/summary/
mod.rs

1//! Saturating arithmetic without sampling or attribution policy.
2
3use core::fmt;
4
5#[cfg(test)]
6mod tests;
7
8/// Why a measurement aggregate cannot supply an integer mean.
9#[derive(Clone, Copy, Debug, Eq, PartialEq)]
10pub enum MeasurementMeanError {
11    /// A nonzero total has no observations to account for it.
12    TotalWithoutSamples,
13    /// The sample count is at `u64::MAX`, including an exactly reached cap.
14    SaturatedSamples,
15    /// The total is at `u64::MAX`, including an exactly reached cap.
16    SaturatedTotal,
17}
18
19impl fmt::Display for MeasurementMeanError {
20    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
21        formatter.write_str(match self {
22            Self::TotalWithoutSamples => "nonzero measurement total without samples",
23            Self::SaturatedSamples => "measurement sample count is saturated",
24            Self::SaturatedTotal => "measurement total is saturated",
25        })
26    }
27}
28
29impl core::error::Error for MeasurementMeanError {}
30
31/// Project the integer mean of consumer-owned sample count and total fields.
32///
33/// Returns `Ok(None)` for `(0, 0)` and `Ok(Some(0))` for nonempty measured zero.
34/// Other unsaturated nonempty inputs use floor division, in the total's unit.
35/// Consumers must establish that both fields describe the same observations,
36/// unit and window; this function cannot establish their identity or provenance.
37///
38/// # Errors
39///
40/// Rejects a nonzero total with zero samples first. Otherwise a count or total
41/// at `u64::MAX` is unavailable, even when reached exactly. If both are at the
42/// cap, [`MeasurementMeanError::SaturatedSamples`] takes precedence.
43///
44/// ```
45/// use ic_metrics::{checked_mean, MeasurementMeanError};
46///
47/// assert_eq!(checked_mean(0, 0), Ok(None));
48/// assert_eq!(checked_mean(2, 0), Ok(Some(0)));
49/// assert_eq!(checked_mean(2, 9), Ok(Some(4)));
50/// assert_eq!(checked_mean(1, u64::MAX), Err(MeasurementMeanError::SaturatedTotal));
51/// ```
52pub const fn checked_mean(samples: u64, total: u64) -> Result<Option<u64>, MeasurementMeanError> {
53    if samples == 0 {
54        return if total == 0 {
55            Ok(None)
56        } else {
57            Err(MeasurementMeanError::TotalWithoutSamples)
58        };
59    }
60    if samples == u64::MAX {
61        return Err(MeasurementMeanError::SaturatedSamples);
62    }
63    if total == u64::MAX {
64        return Err(MeasurementMeanError::SaturatedTotal);
65    }
66    Ok(Some(total / samples))
67}
68
69/// Record one value into a sample count and total, saturating independently.
70///
71/// Zero is a completed sample. `value` and `total` must use the same unit.
72/// This primitive supports consumer-owned report shapes without serialization
73/// dependencies. It does not validate continuity or make saturated counters
74/// suitable for exact interval arithmetic.
75pub const fn record_sample(samples: &mut u64, total: &mut u64, value: u64) {
76    *samples = samples.saturating_add(1);
77    *total = total.saturating_add(value);
78}
79
80/// Saturating summary of observations in one consumer-selected unit.
81///
82/// Count and total saturate independently at `u64::MAX`. Treat either counter
83/// at that value as unavailable for exact interval arithmetic, including when
84/// reached exactly. Latest and maximum remain individual observations after
85/// saturation. Empty and measured zero are distinct states.
86///
87/// Consumers establish identity and reset boundaries before comparing snapshots.
88/// The summary does not decide whether observations overlap or are additive.
89#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
90pub struct MeasurementSummary {
91    samples: u64,
92    total: u64,
93    latest: u64,
94    maximum: u64,
95}
96
97impl MeasurementSummary {
98    /// Empty summary, with no latest or maximum observation.
99    pub const EMPTY: Self = Self {
100        samples: 0,
101        total: 0,
102        latest: 0,
103        maximum: 0,
104    };
105
106    /// Record one completed observation, including zero.
107    ///
108    /// Every observation must use the same unit as earlier observations.
109    pub const fn record(&mut self, value: u64) {
110        record_sample(&mut self.samples, &mut self.total, value);
111        self.latest = value;
112        if value > self.maximum {
113            self.maximum = value;
114        }
115    }
116
117    /// Number of completed samples, saturating independently of the total.
118    #[must_use]
119    pub const fn samples(self) -> u64 {
120        self.samples
121    }
122
123    /// Sum of all observations, saturating independently of the sample count.
124    #[must_use]
125    pub const fn total(self) -> u64 {
126        self.total
127    }
128
129    /// Integer mean in the observation's unit, rounded down, or `None` if empty.
130    ///
131    /// # Errors
132    ///
133    /// Returns [`MeasurementMeanError`] when either counter is at `u64::MAX`,
134    /// including an exactly reached cap. Uses the same contract as [`checked_mean`].
135    pub const fn mean(self) -> Result<Option<u64>, MeasurementMeanError> {
136        checked_mean(self.samples, self.total)
137    }
138
139    /// Latest observation, or `None` when no sample has been recorded.
140    #[must_use]
141    pub const fn latest(self) -> Option<u64> {
142        if self.samples == 0 {
143            None
144        } else {
145            Some(self.latest)
146        }
147    }
148
149    /// Largest observation, or `None` when no sample has been recorded.
150    #[must_use]
151    pub const fn maximum(self) -> Option<u64> {
152        if self.samples == 0 {
153            None
154        } else {
155            Some(self.maximum)
156        }
157    }
158}