car-memgine 0.48.0

Memgine — graph-based memory engine for Common Agent Runtime
//! Agent-native memory diagnostic — four-module scorecard + bottleneck
//! (arXiv 2606.24775, *Are We Ready For An Agent-Native Memory System?*).
//!
//! Applies the paper's *data-management* lens to CAR — see
//! `docs/proposals/agent-native-memory-diagnostic.md`. The paper argues agent
//! memory has outgrown end-to-end task metrics (F1, BLEU) and should be scored
//! as a system along four module-level dimensions:
//!
//! - **representation fidelity** — how faithfully structure is captured;
//! - **retrieval precision** — are retrieved memories the useful ones;
//! - **update correctness** — are dynamic updates reconciled correctly;
//! - **long-horizon stability** — does the store stay stable over time.
//!
//! Its headline finding — *no single architecture dominates; align the memory
//! structure to the workload bottleneck* — becomes a single actionable signal
//! here: the **bottleneck** is the lowest-scoring dimension, the binding
//! constraint to spend maintenance budget on next.
//!
//! Pure: no inference/embedding deps. The caller supplies aggregate signals
//! (`MemoryStats`) that `car-memgine` already tracks on the graph — this is the
//! same shape as [`crate::utility`] and the `harness_metrics` family: a
//! deterministic scoring core, testable in isolation.

use serde::{Deserialize, Serialize};

/// Aggregate signals describing a memory system's observable state, supplied by
/// the caller (the engine/daemon folds these from the live graph + event log).
/// Every field is a count; the scorer turns ratios of them into `[0,1]` scores.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MemoryStats {
    // --- representation & storage ---
    /// Total facts/nodes in the store.
    #[serde(default)]
    pub total_facts: u64,
    /// Facts carrying structured metadata (typed content, tags, provenance,
    /// affected files) vs. raw blobs — the representation-fidelity numerator.
    #[serde(default)]
    pub structured_facts: u64,
    /// Total edges in the graph — denser linkage = higher representational
    /// fidelity (the topology the paper credits the knowledge-graph paradigm).
    #[serde(default)]
    pub total_edges: u64,

    // --- retrieval & routing ---
    /// Deliberate fact recalls — a caller asked for the fact.
    ///
    /// Counts `usage_count` only. Harness-initiated proactive injections are
    /// reported separately as [`Self::total_proactive_injections`]; folding
    /// them in here reported display decisions as retrievals (car#816).
    #[serde(default)]
    pub total_retrievals: u64,
    /// Facts surfaced by proactive injection — the harness choosing to show a
    /// fact, not a caller asking for one. Observability only; never a ranking
    /// input (car#816).
    #[serde(default)]
    pub total_proactive_injections: u64,
    /// Retrievals that correlated with a successful outcome (helpful).
    ///
    /// **Always zero today**: `record_fact_helpful` has no production caller
    /// (car#816). Treat a 0 here as "not wired", not as "nothing helped".
    #[serde(default)]
    pub helpful_retrievals: u64,

    // --- maintenance: update correctness ---
    /// Conflicting/outdated facts that were reconciled (superseded or resolved).
    #[serde(default)]
    pub conflicts_resolved: u64,
    /// Facts flagged outdated but still outstanding (unreconciled).
    #[serde(default)]
    pub outstanding_outdated: u64,

    // --- maintenance: long-horizon stability ---
    /// Facts created over the observed horizon.
    #[serde(default)]
    pub facts_created: u64,
    /// Facts superseded over the observed horizon — high churn relative to
    /// creation signals instability.
    #[serde(default)]
    pub facts_superseded: u64,
}

/// Target average edges-per-node above which representation is considered
/// fully linked. Knowledge-graph memories in the paper cluster around a few
/// edges per node; we cap fidelity credit at this density.
const TARGET_CONNECTIVITY: f64 = 3.0;

/// A neutral prior score returned when a dimension has no evidence yet (a
/// zero denominator) — neither rewarded nor penalised.
const NEUTRAL: f64 = 0.5;

/// The four system-level dimensions the paper scores memory on.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Dimension {
    RepresentationFidelity,
    RetrievalPrecision,
    UpdateCorrectness,
    LongHorizonStability,
}

/// The bottleneck module — the binding constraint to invest in next. `None`
/// when no dimension has evidence (an empty/cold store).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Bottleneck {
    Representation,
    Retrieval,
    UpdateCorrectness,
    LongHorizonStability,
    None,
}

/// The diagnostic report: per-dimension scores in `[0,1]`, an overall mean, the
/// bottleneck dimension, and a recommendation string for the operator.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MemorySystemReport {
    pub representation_fidelity: f64,
    pub retrieval_precision: f64,
    pub update_correctness: f64,
    pub long_horizon_stability: f64,
    /// Unweighted mean of the four dimensions.
    pub overall: f64,
    pub bottleneck: Bottleneck,
    /// Human-readable next action mapped from the bottleneck.
    pub recommendation: String,
    /// Whether the store had any evidence at all (some dimension had a non-zero
    /// denominator). A fully cold store scores all-neutral and `evaluated=false`.
    pub evaluated: bool,
}

/// A ratio in `[0,1]`, or the neutral prior when the denominator is zero.
/// Returns `(score, had_evidence)`.
fn ratio(numer: u64, denom: u64) -> (f64, bool) {
    if denom == 0 {
        (NEUTRAL, false)
    } else {
        ((numer as f64 / denom as f64).clamp(0.0, 1.0), true)
    }
}

/// Score a memory system along the paper's four dimensions and name its
/// bottleneck. Deterministic and pure — a given `MemoryStats` always yields the
/// same report.
pub fn diagnose(stats: &MemoryStats) -> MemorySystemReport {
    // Representation fidelity: structured-fact ratio blended with graph
    // connectivity (edges per node, capped at TARGET_CONNECTIVITY).
    let (structured_ratio, has_facts) = ratio(stats.structured_facts, stats.total_facts);
    let connectivity = if stats.total_facts == 0 {
        NEUTRAL
    } else {
        (stats.total_edges as f64 / stats.total_facts as f64 / TARGET_CONNECTIVITY).clamp(0.0, 1.0)
    };
    let representation_fidelity = if has_facts {
        0.6 * structured_ratio + 0.4 * connectivity
    } else {
        NEUTRAL
    };

    // Retrieval precision: fraction of retrievals that proved helpful.
    let (retrieval_precision, has_retrievals) =
        ratio(stats.helpful_retrievals, stats.total_retrievals);

    // Update correctness: reconciled conflicts vs. those still outstanding.
    let (update_correctness, has_updates) = ratio(
        stats.conflicts_resolved,
        stats.conflicts_resolved + stats.outstanding_outdated,
    );

    // Long-horizon stability: 1 - churn (superseded / created). High churn means
    // the store is constantly rewriting itself — unstable.
    let (long_horizon_stability, has_horizon) = if stats.facts_created == 0 {
        (NEUTRAL, false)
    } else {
        let churn = (stats.facts_superseded as f64 / stats.facts_created as f64).clamp(0.0, 1.0);
        (1.0 - churn, true)
    };

    let overall = (representation_fidelity
        + retrieval_precision
        + update_correctness
        + long_horizon_stability)
        / 4.0;

    // Bottleneck = the lowest-scoring dimension that actually has evidence.
    // Dimensions without evidence (neutral prior) are not credible bottlenecks.
    let candidates = [
        (
            Dimension::RepresentationFidelity,
            representation_fidelity,
            has_facts,
        ),
        (
            Dimension::RetrievalPrecision,
            retrieval_precision,
            has_retrievals,
        ),
        (
            Dimension::UpdateCorrectness,
            update_correctness,
            has_updates,
        ),
        (
            Dimension::LongHorizonStability,
            long_horizon_stability,
            has_horizon,
        ),
    ];
    let evaluated = candidates.iter().any(|(_, _, ev)| *ev);
    let bottleneck = candidates
        .iter()
        .filter(|(_, _, ev)| *ev)
        .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
        .map(|(dim, _, _)| match dim {
            Dimension::RepresentationFidelity => Bottleneck::Representation,
            Dimension::RetrievalPrecision => Bottleneck::Retrieval,
            Dimension::UpdateCorrectness => Bottleneck::UpdateCorrectness,
            Dimension::LongHorizonStability => Bottleneck::LongHorizonStability,
        })
        .unwrap_or(Bottleneck::None);

    MemorySystemReport {
        representation_fidelity,
        retrieval_precision,
        update_correctness,
        long_horizon_stability,
        overall,
        bottleneck,
        recommendation: recommend(bottleneck).to_string(),
        evaluated,
    }
}

/// Map a bottleneck to the module the operator should invest in next — the
/// paper's "align the structure to the binding constraint", made actionable.
fn recommend(b: Bottleneck) -> &'static str {
    match b {
        Bottleneck::Representation => {
            "Invest in representation: enrich fact metadata and graph linkage \
             (more structured facts, more edges) so the store captures structure."
        }
        Bottleneck::Retrieval => {
            "Invest in retrieval/routing: tune relevance + utility scoring so \
             retrieved memories are the useful ones (raise the helpful ratio)."
        }
        Bottleneck::UpdateCorrectness => {
            "Invest in maintenance: reconcile outdated/conflicting facts (supersede \
             or resolve) so dynamic updates land correctly."
        }
        Bottleneck::LongHorizonStability => {
            "Invest in stability: churn is high — prefer localized maintenance over \
             global reorganization to reduce supersede thrash."
        }
        Bottleneck::None => "No evidence yet — exercise the store before diagnosing.",
    }
}

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

    #[test]
    fn cold_store_is_all_neutral_and_unevaluated() {
        let r = diagnose(&MemoryStats::default());
        assert_eq!(r.representation_fidelity, NEUTRAL);
        assert_eq!(r.retrieval_precision, NEUTRAL);
        assert_eq!(r.update_correctness, NEUTRAL);
        assert_eq!(r.long_horizon_stability, NEUTRAL);
        assert_eq!(r.bottleneck, Bottleneck::None);
        assert!(!r.evaluated);
    }

    #[test]
    fn healthy_store_scores_high() {
        let r = diagnose(&MemoryStats {
            total_facts: 100,
            structured_facts: 95,
            total_edges: 300, // 3 edges/node = full connectivity credit
            total_retrievals: 100,
            total_proactive_injections: 0,
            helpful_retrievals: 90,
            conflicts_resolved: 40,
            outstanding_outdated: 2,
            facts_created: 100,
            facts_superseded: 5,
        });
        assert!(
            r.representation_fidelity > 0.9,
            "repr={}",
            r.representation_fidelity
        );
        assert!(r.retrieval_precision > 0.85);
        assert!(r.update_correctness > 0.9);
        assert!(r.long_horizon_stability > 0.9);
        assert!(r.overall > 0.85);
        assert!(r.evaluated);
    }

    #[test]
    fn bottleneck_is_lowest_dimension_with_evidence() {
        // Retrieval is clearly the weakest module here.
        let r = diagnose(&MemoryStats {
            total_facts: 100,
            structured_facts: 90,
            total_edges: 300,
            total_retrievals: 100,
            total_proactive_injections: 0,
            helpful_retrievals: 20, // precision 0.2 — the bottleneck
            conflicts_resolved: 50,
            outstanding_outdated: 1,
            facts_created: 100,
            facts_superseded: 3,
        });
        assert_eq!(r.bottleneck, Bottleneck::Retrieval);
        assert!(r.recommendation.contains("retrieval"));
    }

    #[test]
    fn high_churn_flags_long_horizon_stability() {
        let r = diagnose(&MemoryStats {
            total_facts: 100,
            structured_facts: 95,
            total_edges: 300,
            total_retrievals: 100,
            total_proactive_injections: 0,
            helpful_retrievals: 95,
            conflicts_resolved: 50,
            outstanding_outdated: 1,
            facts_created: 100,
            facts_superseded: 80, // 80% churn → stability 0.2
        });
        assert_eq!(r.bottleneck, Bottleneck::LongHorizonStability);
        assert!((r.long_horizon_stability - 0.2).abs() < 1e-9);
        assert!(r.recommendation.contains("localized maintenance"));
    }

    #[test]
    fn unevaluated_dimensions_never_chosen_as_bottleneck() {
        // Only retrieval has evidence; representation/update/horizon are neutral
        // (no denominators). The bottleneck must be retrieval, not a 0.5 neutral.
        let r = diagnose(&MemoryStats {
            total_retrievals: 10,
            total_proactive_injections: 0,
            helpful_retrievals: 3, // 0.3 < 0.5 neutral
            ..Default::default()
        });
        assert_eq!(r.bottleneck, Bottleneck::Retrieval);
        assert!(r.evaluated);
    }

    #[test]
    fn connectivity_is_capped_not_unbounded() {
        // 10 edges/node should not score above full connectivity credit.
        let dense = diagnose(&MemoryStats {
            total_facts: 10,
            structured_facts: 10,
            total_edges: 100,
            ..Default::default()
        });
        // structured ratio 1.0 * 0.6 + capped connectivity 1.0 * 0.4 = 1.0
        assert!((dense.representation_fidelity - 1.0).abs() < 1e-9);
    }
}