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}