klieo-memory-graph 3.5.0

KnowledgeGraph trait surface + InMemoryGraph for klieo. Stable at 1.x per ADR-039 trait freeze.
Documentation
//! Retrieval quality metrics — hit-rate, projector lag, and graph
//! candidate-set size counters.
//!
//! Exported via [`RecallMetrics::snapshot`]. M2 wires an OTel
//! observable-gauge adapter in `klieo-otel` so dashboards can read
//! graph hit-rate without depending on this crate directly.

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

/// Atomic counters tracking graph-path vs pure-vector recall
/// selections, projector lag (broadcast receiver dropped episodes),
/// and the cumulative candidate-set size observed on graph-path
/// recalls.
///
/// `graph_candidates_total / graph_path_hits` yields the average
/// candidate-set size — useful for PathRAG tuning. Both counters
/// increment together via
/// [`Self::record_graph_hit_with_candidates`].
#[derive(Default)]
pub struct RecallMetrics {
    graph_path_hits: AtomicU64,
    vector_fallback_hits: AtomicU64,
    projector_lag_episodes: AtomicU64,
    graph_candidates_total: AtomicU64,
}

/// Point-in-time snapshot of [`RecallMetrics`] counters.
///
/// `#[non_exhaustive]` so additional buckets (e.g. hit@{1,3,5}) can
/// land without forcing a SemVer-major bump on
/// `klieo-memory-graph`. Match constructors must include `..`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
#[allow(missing_docs)] // counter semantics documented on RecallMetrics above; per-field paraphrase forbidden
pub struct RecallSnapshot {
    pub graph_path_hits: u64,
    pub vector_fallback_hits: u64,
    pub projector_lag_episodes: u64,
    pub graph_candidates_total: u64,
}

impl RecallMetrics {
    /// Bump the graph-path hit counter only. Prefer
    /// [`Self::record_graph_hit_with_candidates`] so the
    /// candidate-set size lands alongside the hit count in one
    /// non-atomic-across-counters call.
    pub fn record_graph_hit(&self) {
        self.graph_path_hits.fetch_add(1, Ordering::Relaxed);
    }

    /// Called by `GraphAwareLongTerm::recall` once the graph
    /// traversal surfaces at least `min_graph_hits` candidates.
    /// Prefer over [`Self::record_graph_hit`] so the candidate-set
    /// size is always paired with the hit count for downstream
    /// average-size derivation.
    pub fn record_graph_hit_with_candidates(&self, candidates: u64) {
        self.graph_path_hits.fetch_add(1, Ordering::Relaxed);
        self.graph_candidates_total
            .fetch_add(candidates, Ordering::Relaxed);
    }

    /// Called when the graph traversal returns nothing useful and
    /// the recall degrades to pure vector — i.e. graph indexing
    /// gave no signal for this query.
    pub fn record_vector_fallback(&self) {
        self.vector_fallback_hits.fetch_add(1, Ordering::Relaxed);
    }

    /// Called by `EpisodeProjector::spawn` when its broadcast
    /// receiver lags and `n` episodes were dropped without being
    /// projected into the graph.
    pub fn record_projector_lag(&self, n: u64) {
        self.projector_lag_episodes.fetch_add(n, Ordering::Relaxed);
    }

    /// Read counters into a [`RecallSnapshot`]. Each counter loads
    /// with `Ordering::Relaxed` — the snapshot is a best-effort
    /// view, not a transaction across counters.
    pub fn snapshot(&self) -> RecallSnapshot {
        RecallSnapshot {
            graph_path_hits: self.graph_path_hits.load(Ordering::Relaxed),
            vector_fallback_hits: self.vector_fallback_hits.load(Ordering::Relaxed),
            projector_lag_episodes: self.projector_lag_episodes.load(Ordering::Relaxed),
            graph_candidates_total: self.graph_candidates_total.load(Ordering::Relaxed),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn record_graph_hit_with_candidates_bumps_both_counters() {
        let m = RecallMetrics::default();
        m.record_graph_hit_with_candidates(5);
        m.record_graph_hit_with_candidates(3);
        let snap = m.snapshot();
        assert_eq!(snap.graph_path_hits, 2);
        assert_eq!(snap.graph_candidates_total, 8);
    }

    #[test]
    fn record_graph_hit_does_not_advance_candidate_total() {
        let m = RecallMetrics::default();
        m.record_graph_hit();
        let snap = m.snapshot();
        assert_eq!(snap.graph_path_hits, 1);
        assert_eq!(
            snap.graph_candidates_total, 0,
            "record_graph_hit must not touch graph_candidates_total"
        );
    }
}