ic-metrics 0.6.2

Allocation-free, dependency-free measurement arithmetic
Documentation
//! Order-independent observations and exact interval arithmetic.

use core::fmt;

use crate::{MeasurementMeanError, add_count};

#[cfg(test)]
mod tests;

/// Why two tracked snapshots cannot supply an exact count/total interval.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MeasurementIntervalError {
    /// A sample count has actually overflowed.
    SaturatedSamples,
    /// An observation total has actually overflowed.
    SaturatedTotal,
    /// The current sample count is below the previous count.
    DecreasingSamples,
    /// The current total is below the previous total.
    DecreasingTotal,
    /// The total changed without any additional observations.
    TotalWithoutSamples,
}

impl fmt::Display for MeasurementIntervalError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(match self {
            Self::SaturatedSamples => "interval sample count has overflowed",
            Self::SaturatedTotal => "interval total has overflowed",
            Self::DecreasingSamples => "interval sample count decreased",
            Self::DecreasingTotal => "interval total decreased",
            Self::TotalWithoutSamples => "interval total changed without samples",
        })
    }
}

impl core::error::Error for MeasurementIntervalError {}

/// Exact count and total differences, without a maximum or latest observation.
///
/// Constructed by [`MeasurementAggregate::interval_since`] only after numeric
/// admission. Consumers must establish common units, identity and append-only
/// continuity; increasing numbers alone cannot establish any of those facts.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct MeasurementInterval {
    samples: u64,
    total: u64,
}

impl MeasurementInterval {
    /// Number of added observations, including measured zero.
    #[must_use]
    pub const fn samples(self) -> u64 {
        self.samples
    }

    /// Exact sum of added observations in the consumer's unit.
    #[must_use]
    pub const fn total(self) -> u64 {
        self.total
    }

    /// Floor-rounded mean, or `None` when no observations were added.
    #[must_use]
    pub const fn mean(self) -> Option<u64> {
        self.total.checked_div(self.samples)
    }
}

/// Mergeable count, total and maximum in one consumer-selected unit.
///
/// Counts and totals clip independently at `u64::MAX`; sticky flags record actual
/// overflow, so an exactly reached cap remains exact. Maximum is an individual
/// observation and remains valid after overflow. There is no latest observation:
/// unordered batches cannot establish ordering. No allocation or platform reads.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct MeasurementAggregate {
    samples: u64,
    total: u64,
    maximum: u64,
    samples_saturated: bool,
    total_saturated: bool,
}

impl MeasurementAggregate {
    /// Empty aggregate, without a maximum observation.
    pub const EMPTY: Self = Self {
        samples: 0,
        total: 0,
        maximum: 0,
        samples_saturated: false,
        total_saturated: false,
    };

    /// Record one admitted observation, including zero, in the existing unit.
    pub const fn record(&mut self, value: u64) {
        add_count(&mut self.samples, &mut self.samples_saturated, 1);
        add_count(&mut self.total, &mut self.total_saturated, value);
        if value > self.maximum {
            self.maximum = value;
        }
    }

    /// Combine observations from a batch without claiming ordering or identity.
    ///
    /// Consumers must establish common units and deliberate sample attribution.
    /// This is addition, not deduplication: merging the same batch twice records
    /// it twice. Source overflow remains sticky even when its clipped value fits.
    pub const fn merge(&mut self, other: Self) {
        add_count(
            &mut self.samples,
            &mut self.samples_saturated,
            other.samples,
        );
        add_count(&mut self.total, &mut self.total_saturated, other.total);
        self.samples_saturated |= other.samples_saturated;
        self.total_saturated |= other.total_saturated;
        if other.maximum > self.maximum {
            self.maximum = other.maximum;
        }
    }

    /// Sample count, clipped at `u64::MAX`; check [`Self::samples_saturated`].
    #[must_use]
    pub const fn samples(self) -> u64 {
        self.samples
    }

    /// Total, clipped at `u64::MAX`; check [`Self::total_saturated`].
    #[must_use]
    pub const fn total(self) -> u64 {
        self.total
    }

    /// Whether recording or merging has actually overflowed the sample count.
    #[must_use]
    pub const fn samples_saturated(self) -> bool {
        self.samples_saturated
    }

    /// Whether recording or merging has actually overflowed the value total.
    #[must_use]
    pub const fn total_saturated(self) -> bool {
        self.total_saturated
    }

    /// Largest observation, or `None` when empty, including after overflow.
    #[must_use]
    pub const fn maximum(self) -> Option<u64> {
        if self.samples == 0 {
            None
        } else {
            Some(self.maximum)
        }
    }

    /// Floor-rounded mean, or `None` when empty. Exactly reached caps are valid.
    ///
    /// # Errors
    /// Returns [`MeasurementMeanError::SaturatedSamples`] before
    /// [`MeasurementMeanError::SaturatedTotal`] when actual overflow is recorded.
    pub const fn mean(self) -> Result<Option<u64>, MeasurementMeanError> {
        if self.samples_saturated {
            return Err(MeasurementMeanError::SaturatedSamples);
        }
        if self.total_saturated {
            return Err(MeasurementMeanError::SaturatedTotal);
        }
        Ok(self.total.checked_div(self.samples))
    }

    /// Exact count/total difference from an earlier snapshot of this accumulator.
    ///
    /// Consumers must first establish matching units, window/reset identity and
    /// append-only continuity. This cannot detect a reset followed by numerically
    /// larger values or changed attribution. Maxima and latest values are not
    /// subtractable and are not returned. Empty intervals differ from added zeros.
    ///
    /// # Errors
    /// Rejects sample overflow, total overflow, decreasing samples, decreasing
    /// totals, then a changed total with no added samples, in that order.
    pub const fn interval_since(
        self,
        previous: Self,
    ) -> Result<MeasurementInterval, MeasurementIntervalError> {
        if self.samples_saturated || previous.samples_saturated {
            return Err(MeasurementIntervalError::SaturatedSamples);
        }
        if self.total_saturated || previous.total_saturated {
            return Err(MeasurementIntervalError::SaturatedTotal);
        }
        let Some(samples) = self.samples.checked_sub(previous.samples) else {
            return Err(MeasurementIntervalError::DecreasingSamples);
        };
        let Some(total) = self.total.checked_sub(previous.total) else {
            return Err(MeasurementIntervalError::DecreasingTotal);
        };
        if samples == 0 && total != 0 {
            return Err(MeasurementIntervalError::TotalWithoutSamples);
        }
        Ok(MeasurementInterval { samples, total })
    }
}