memlay 0.1.4

Repo-native, conflict-resistant shared memory and codebase navigation layer for AI coding agents
//! Local effectiveness statistics (PRD ยง13.9). Bounded, offline counters in
//! worktree-local Git metadata. Never committed, pushed, or transmitted;
//! never stores prompts, task text, record prose, or raw payloads.

use crate::cli::App;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;

#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Stats {
    pub since: Option<String>,
    pub context_calls: u64,
    pub expand_calls: u64,
    pub record_calls: u64,
    pub no_result_contexts: u64,
    pub exact_hit_contexts: u64,
    pub stale_returned: u64,
    pub conflicted_returned: u64,
    pub duplicate_key_warnings: u64,
    pub create_distinct_overrides: u64,
    pub tokens_returned_total: u64,
    pub token_budget_total: u64,
    /// Latency histogram buckets in ms: <10, <50, <250, <1000, >=1000.
    pub context_latency_buckets: [u64; 5],
    /// Times each record ref was surfaced / later expanded (bounded, ids only).
    pub surfaced: BTreeMap<String, u64>,
    pub expanded: BTreeMap<String, u64>,
}

fn path(app: &App) -> std::path::PathBuf {
    app.repo.state_dir().join("stats.json")
}

pub fn load(app: &App) -> Stats {
    std::fs::read_to_string(path(app))
        .ok()
        .and_then(|t| serde_json::from_str(&t).ok())
        .unwrap_or_default()
}

fn save(app: &App, stats: &Stats) {
    if let Ok(json) = serde_json::to_string(stats) {
        let p = path(app);
        if let Some(parent) = p.parent() {
            let _ = std::fs::create_dir_all(parent);
        }
        let _ = std::fs::write(p, json);
    }
}

const MAX_TRACKED_REFS: usize = 500;

fn bump_bounded(map: &mut BTreeMap<String, u64>, key: &str) {
    if map.len() >= MAX_TRACKED_REFS && !map.contains_key(key) {
        return;
    }
    *map.entry(key.to_string()).or_default() += 1;
}

pub fn record_context(app: &App, result: &crate::retrieval::ContextResult, elapsed_ms: u128) {
    if !app.config.stats.enabled {
        return;
    }
    let mut s = load(app);
    if s.since.is_none() {
        s.since = Some(chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true));
    }
    s.context_calls += 1;
    let empty =
        result.memories.is_empty() && result.code_targets.is_empty() && result.files.is_empty();
    if empty {
        s.no_result_contexts += 1;
    }
    if result.memories.iter().any(|m| m.score >= 100)
        || result.code_targets.iter().any(|c| c.score >= 85)
    {
        s.exact_hit_contexts += 1;
    }
    s.stale_returned += result.memories.iter().filter(|m| m.stale).count() as u64;
    s.conflicted_returned += result.conflicts.len() as u64;
    s.tokens_returned_total += result.estimated_tokens as u64;
    s.token_budget_total += result.token_budget as u64;
    let bucket = match elapsed_ms {
        0..=9 => 0,
        10..=49 => 1,
        50..=249 => 2,
        250..=999 => 3,
        _ => 4,
    };
    s.context_latency_buckets[bucket] += 1;
    for m in &result.memories {
        bump_bounded(&mut s.surfaced, &m.ref_id);
    }
    save(app, &s);
}

pub fn record_expand(app: &App, refs: &[String]) {
    if !app.config.stats.enabled {
        return;
    }
    let mut s = load(app);
    s.expand_calls += 1;
    for r in refs.iter().filter(|r| r.starts_with("memory:")) {
        bump_bounded(&mut s.expanded, r);
    }
    save(app, &s);
}

pub fn record_record(app: &App, duplicate_warnings: usize, create_distinct: bool) {
    if !app.config.stats.enabled {
        return;
    }
    let mut s = load(app);
    s.record_calls += 1;
    s.duplicate_key_warnings += duplicate_warnings as u64;
    if create_distinct {
        s.create_distinct_overrides += 1;
    }
    save(app, &s);
}

pub fn report(app: &App, reset: bool) -> Result<()> {
    if reset {
        let _ = std::fs::remove_file(path(app));
        if !app.json {
            println!("stats reset");
        }
        return Ok(());
    }
    let s = load(app);
    if app.json {
        println!("{}", serde_json::to_string_pretty(&s)?);
        return Ok(());
    }
    println!(
        "since             {}",
        s.since.as_deref().unwrap_or("(no data)")
    );
    println!("context calls     {}", s.context_calls);
    println!("expand calls      {}", s.expand_calls);
    println!("record calls      {}", s.record_calls);
    if s.context_calls > 0 {
        println!(
            "exact-hit rate    {:.0}%   no-result rate {:.0}%",
            s.exact_hit_contexts as f64 / s.context_calls as f64 * 100.0,
            s.no_result_contexts as f64 / s.context_calls as f64 * 100.0
        );
        println!(
            "budget use        {:.0}% of requested tokens returned",
            if s.token_budget_total > 0 {
                s.tokens_returned_total as f64 / s.token_budget_total as f64 * 100.0
            } else {
                0.0
            }
        );
        let b = s.context_latency_buckets;
        println!(
            "latency (ms)      <10:{} <50:{} <250:{} <1000:{} slow:{}",
            b[0], b[1], b[2], b[3], b[4]
        );
    }
    println!(
        "stale/conflicted  {} / {} returned",
        s.stale_returned, s.conflicted_returned
    );
    println!(
        "key governance    {} warnings, {} create-distinct overrides",
        s.duplicate_key_warnings, s.create_distinct_overrides
    );
    // Surfaced-but-never-expanded: repeatedly shown records agents ignore.
    let mut ignored: Vec<(&String, &u64)> = s
        .surfaced
        .iter()
        .filter(|(r, n)| **n >= 3 && !s.expanded.contains_key(*r))
        .collect();
    ignored.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
    if !ignored.is_empty() {
        println!("surfaced >=3x but never expanded:");
        for (r, n) in ignored.iter().take(5) {
            println!("  {r} ({n}x)");
        }
    }
    Ok(())
}