kitt_score 0.1.0

Decision engine at the core of Project KITT — in-memory stateful matching with pluggable scoring backends.
Documentation
//! Aggregate engine metrics — counters and a decide-latency histogram.
//!
//! [`EngineMetrics`] is the live, atomically-updated store held inside
//! [`Engine<T>`][crate::engine::Engine].  Call
//! [`EngineMetrics::snapshot`] to get a point-in-time [`MetricsSnapshot`]
//! suitable for shipping to Prometheus, `StatsD`, or any other sink.
//!
//! The library is deliberately transport-agnostic: nothing here sends bytes
//! anywhere.

use hdrhistogram::Histogram;
use std::sync::atomic::{AtomicU64, Ordering};

/// Live, lock-minimal metrics store embedded in the engine.
///
/// All counters use `Ordering::Relaxed` — slight skew across atoms is
/// acceptable for observability data; there is no ordering dependency
/// between them.
///
/// # Note on `reloads`
///
/// The `reloads` counter is wired in but always reads `0` in M7.
/// The reload-completion signal that increments it arrives in a later
/// milestone when the full reload plumbing is instrumented.
pub struct EngineMetrics {
    /// Number of [`Trigger`][crate::event::Trigger] events processed.
    pub triggers: AtomicU64,
    /// Number of [`ActionIngest`][crate::event::ActionIngest] events processed.
    pub registrations: AtomicU64,
    /// Number of [`StateUpdate`][crate::event::StateUpdate] events processed.
    pub updates: AtomicU64,
    /// Number of full-table reloads completed (always 0 in M7; see struct doc).
    pub reloads: AtomicU64,
    /// Histogram of `ingest_trigger` wall-clock latencies in nanoseconds.
    pub decide_latency_ns: parking_lot::Mutex<Histogram<u64>>,
}

impl EngineMetrics {
    /// Create a new `EngineMetrics` with all counters at zero and an empty
    /// latency histogram covering 1 ns to 10 s at 3 significant figures.
    ///
    /// # Panics
    ///
    /// This function calls `expect` on the histogram constructor.  The
    /// constructor can only fail when the bounds or significant-figures
    /// arguments are invalid; the compile-time constants used here
    /// (`1`, `10_000_000_000`, `3`) are always valid, so this cannot panic
    /// in practice.
    #[allow(clippy::expect_used)]
    #[must_use]
    pub fn new() -> Self {
        Self {
            triggers: AtomicU64::new(0),
            registrations: AtomicU64::new(0),
            updates: AtomicU64::new(0),
            reloads: AtomicU64::new(0),
            decide_latency_ns: parking_lot::Mutex::new(
                Histogram::new_with_bounds(1, 10_000_000_000, 3)
                    .expect("histogram bounds (1, 10_000_000_000, 3) are always valid"),
            ),
        }
    }

    /// Record a single `ingest_trigger` latency sample.
    ///
    /// Uses `try_lock` — if the histogram mutex is already held the sample is
    /// silently dropped.  This keeps the hot path allocation-free and
    /// contention-free.  Values above 10 s are clamped to 10 s.
    #[inline]
    pub fn record_decide_latency_ns(&self, ns: u64) {
        let clamped = ns.min(10_000_000_000);
        if let Some(mut hist) = self.decide_latency_ns.try_lock() {
            // saturating_add clamps silently; the histogram range already
            // guards against out-of-bounds values.
            let _ = hist.record(clamped);
        }
    }

    /// Atomically read all counters and derive histogram percentiles into a
    /// [`MetricsSnapshot`].
    ///
    /// All counter reads use `Ordering::Relaxed`.  The snapshot is inherently
    /// racy across atoms — that is acceptable for observability data.
    #[must_use]
    pub fn snapshot(&self) -> MetricsSnapshot {
        let hist = self.decide_latency_ns.lock();
        MetricsSnapshot {
            triggers: self.triggers.load(Ordering::Relaxed),
            registrations: self.registrations.load(Ordering::Relaxed),
            updates: self.updates.load(Ordering::Relaxed),
            reloads: self.reloads.load(Ordering::Relaxed),
            decide_latency_ns_p50: hist.value_at_quantile(0.50),
            decide_latency_ns_p99: hist.value_at_quantile(0.99),
            decide_latency_ns_max: hist.max(),
        }
    }
}

impl Default for EngineMetrics {
    fn default() -> Self {
        Self::new()
    }
}

/// A point-in-time snapshot of engine metrics.
///
/// Obtain one by calling [`EngineMetrics::snapshot`] (or the convenience
/// wrapper [`Engine::metrics`][crate::engine::Engine::metrics]).
///
/// Implements `serde::Serialize` when the `serde` feature is enabled.
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone)]
pub struct MetricsSnapshot {
    /// Number of trigger events processed since engine creation.
    pub triggers: u64,
    /// Number of register-action events processed since engine creation.
    pub registrations: u64,
    /// Number of state-update events processed since engine creation.
    pub updates: u64,
    /// Number of full-table reloads completed (always 0 in M7).
    pub reloads: u64,
    /// 50th-percentile `ingest_trigger` latency in nanoseconds.
    pub decide_latency_ns_p50: u64,
    /// 99th-percentile `ingest_trigger` latency in nanoseconds.
    pub decide_latency_ns_p99: u64,
    /// Maximum `ingest_trigger` latency in nanoseconds observed so far.
    pub decide_latency_ns_max: u64,
}