zagens_topic_memory/
metrics.rs1use std::fs;
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Default, Serialize, Deserialize)]
10pub struct TopicMemoryMetrics {
11 pub turn_updates: u64,
12 pub inject_count: u64,
13 #[serde(skip_serializing_if = "Option::is_none")]
14 pub last_inject_at: Option<String>,
15 pub clarification_rounds: u64,
16 pub repeat_topic_turns: u64,
17 #[serde(skip_serializing_if = "Option::is_none")]
18 pub last_user_topics: Option<Vec<String>>,
19}
20
21#[must_use]
22pub fn metrics_path_for_graph(graph_path: &Path) -> PathBuf {
23 graph_path
24 .parent()
25 .map(|p| p.join("metrics.json"))
26 .unwrap_or_else(|| PathBuf::from("metrics.json"))
27}
28
29#[must_use]
30pub fn load_metrics(path: &Path) -> TopicMemoryMetrics {
31 fs::read_to_string(path)
32 .ok()
33 .and_then(|s| serde_json::from_str(&s).ok())
34 .unwrap_or_default()
35}
36
37pub fn save_metrics(path: &Path, metrics: &TopicMemoryMetrics) -> std::io::Result<()> {
39 if let Some(parent) = path.parent() {
40 fs::create_dir_all(parent)?;
41 }
42 let json = serde_json::to_string_pretty(metrics)?;
43 let tmp = path.with_extension("json.tmp");
44 fs::write(&tmp, json)?;
45 fs::rename(tmp, path)?;
46 Ok(())
47}
48
49pub fn record_turn_update(metrics: &mut TopicMemoryMetrics, user_topics: &[String]) {
51 metrics.turn_updates = metrics.turn_updates.saturating_add(1);
52
53 if let Some(prev) = metrics.last_user_topics.as_ref() {
54 let overlap = user_topics.iter().any(|t| {
55 prev.iter()
56 .any(|p| p == t || p.contains(t) || t.contains(p))
57 });
58 if overlap && !user_topics.is_empty() {
59 metrics.repeat_topic_turns = metrics.repeat_topic_turns.saturating_add(1);
60 metrics.clarification_rounds = metrics.clarification_rounds.saturating_add(1);
61 }
62 }
63
64 if !user_topics.is_empty() {
65 metrics.last_user_topics = Some(user_topics.to_vec());
66 }
67}
68
69pub fn record_inject(metrics: &mut TopicMemoryMetrics, today: &str) {
70 metrics.inject_count = metrics.inject_count.saturating_add(1);
71 metrics.last_inject_at = Some(today.to_string());
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
76pub struct TopicMemoryEvalReport {
77 pub turn_updates: u64,
78 pub inject_count: u64,
79 pub clarification_rounds: u64,
80 pub repeat_topic_turns: u64,
81 pub clarification_rate: f64,
83 pub repeat_topic_rate: f64,
85 pub injects_per_10_turns: f64,
87 #[serde(skip_serializing_if = "Option::is_none")]
88 pub last_inject_at: Option<String>,
89}
90
91#[must_use]
92pub fn eval_report(metrics: &TopicMemoryMetrics) -> TopicMemoryEvalReport {
93 let turns = metrics.turn_updates.max(1) as f64;
94 TopicMemoryEvalReport {
95 turn_updates: metrics.turn_updates,
96 inject_count: metrics.inject_count,
97 clarification_rounds: metrics.clarification_rounds,
98 repeat_topic_turns: metrics.repeat_topic_turns,
99 clarification_rate: metrics.clarification_rounds as f64 / turns,
100 repeat_topic_rate: metrics.repeat_topic_turns as f64 / turns,
101 injects_per_10_turns: metrics.inject_count as f64 / turns * 10.0,
102 last_inject_at: metrics.last_inject_at.clone(),
103 }
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
108pub struct TopicMemoryEvalComparison {
109 pub current: TopicMemoryEvalReport,
110 pub baseline: TopicMemoryEvalReport,
111 pub clarification_rate_delta: f64,
112 pub repeat_topic_rate_delta: f64,
113 pub regression: bool,
115}
116
117#[must_use]
118pub fn compare_eval(
119 current: &TopicMemoryMetrics,
120 baseline: &TopicMemoryMetrics,
121 max_regression: f64,
122) -> TopicMemoryEvalComparison {
123 let current = eval_report(current);
124 let baseline = eval_report(baseline);
125 let clarification_rate_delta = current.clarification_rate - baseline.clarification_rate;
126 let repeat_topic_rate_delta = current.repeat_topic_rate - baseline.repeat_topic_rate;
127 let regression = clarification_rate_delta > max_regression;
128 TopicMemoryEvalComparison {
129 current,
130 baseline,
131 clarification_rate_delta,
132 repeat_topic_rate_delta,
133 regression,
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140
141 #[test]
142 fn eval_report_rates() {
143 let m = TopicMemoryMetrics {
144 turn_updates: 10,
145 inject_count: 2,
146 clarification_rounds: 3,
147 repeat_topic_turns: 3,
148 ..Default::default()
149 };
150 let r = eval_report(&m);
151 assert!((r.clarification_rate - 0.3).abs() < f64::EPSILON);
152 assert!((r.injects_per_10_turns - 2.0).abs() < f64::EPSILON);
153 }
154
155 #[test]
156 fn compare_detects_regression() {
157 let baseline = TopicMemoryMetrics {
158 turn_updates: 20,
159 clarification_rounds: 2,
160 ..Default::default()
161 };
162 let current = TopicMemoryMetrics {
163 turn_updates: 20,
164 clarification_rounds: 8,
165 ..Default::default()
166 };
167 let cmp = compare_eval(¤t, &baseline, 0.1);
168 assert!(cmp.regression);
169 }
170}