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,
pub context_latency_buckets: [u64; 5],
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
);
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(())
}