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