1use serde::{Deserialize, Serialize};
20
21const MAX_SNAPSHOTS: usize = 30;
23const SURVIVAL_AGE_TURNS: u32 = 10;
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub(crate) struct EfficacySnapshot {
28 pub day: String,
30 pub claims_rejected: u64,
32 pub playbook_entries: usize,
34 pub playbook_aged_helpful: usize,
35 pub playbook_aged_total: usize,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, Default)]
39pub(crate) struct EfficacyStore {
40 pub snapshots: Vec<EfficacySnapshot>,
41 pub schema_version: u32,
42}
43
44fn store_path() -> std::path::PathBuf {
45 crate::core::paths::cache_dir()
46 .unwrap_or_else(|_| std::path::PathBuf::from("."))
47 .join("efficacy_snapshots.json")
48}
49
50impl EfficacyStore {
51 pub(crate) fn load() -> Self {
52 if let Ok(content) = std::fs::read_to_string(store_path())
53 && let Ok(s) = serde_json::from_str::<EfficacyStore>(&content)
54 {
55 return s;
56 }
57 EfficacyStore {
58 schema_version: 1,
59 ..Default::default()
60 }
61 }
62
63 fn save(&self) {
64 let path = store_path();
65 if let Some(parent) = path.parent() {
66 let _ = std::fs::create_dir_all(parent);
67 }
68 if let Ok(json) = serde_json::to_string(self) {
69 let _ = std::fs::write(path, json);
70 }
71 }
72
73 pub(crate) fn upsert(&mut self, snap: EfficacySnapshot) {
77 match self.snapshots.iter_mut().find(|s| s.day == snap.day) {
78 Some(existing) => *existing = snap,
79 None => self.snapshots.push(snap),
80 }
81 self.snapshots.sort_by(|a, b| a.day.cmp(&b.day));
82 if self.snapshots.len() > MAX_SNAPSHOTS {
83 let excess = self.snapshots.len() - MAX_SNAPSHOTS;
84 self.snapshots.drain(0..excess);
85 }
86 }
87}
88
89fn current_snapshot() -> EfficacySnapshot {
91 let (entries, aged_helpful, aged_total) = crate::core::session::SessionState::load_latest()
92 .map_or((0, 0, 0), |s| {
93 let turn = s.stats.total_tool_calls;
94 let aged: Vec<_> = s
95 .playbook
96 .entries
97 .iter()
98 .filter(|e| turn.saturating_sub(e.created_turn) >= SURVIVAL_AGE_TURNS)
99 .collect();
100 let helpful = aged
101 .iter()
102 .filter(|e| e.helpful_votes >= e.harmful_votes)
103 .count();
104 (s.playbook.entries.len(), helpful, aged.len())
105 });
106
107 EfficacySnapshot {
108 day: chrono::Utc::now().format("%Y-%m-%d").to_string(),
109 claims_rejected: crate::core::scent_field::claims_rejected_total(),
110 playbook_entries: entries,
111 playbook_aged_helpful: aged_helpful,
112 playbook_aged_total: aged_total,
113 }
114}
115
116pub fn capture() {
118 let mut store = EfficacyStore::load();
119 store.upsert(current_snapshot());
120 store.save();
121}
122
123fn bounce_week_over_week() -> (Option<(f64, u64)>, Option<(f64, u64)>) {
127 let trend = crate::core::savings_ledger::daily_bounce_trend(14);
128 if trend.is_empty() {
129 return (None, None);
130 }
131 let today = chrono::Utc::now().date_naive();
132 let mut prev = (0u64, 0u64); let mut recent = (0u64, 0u64); for (day, bounces, reads) in &trend {
135 let Ok(d) = chrono::NaiveDate::parse_from_str(day, "%Y-%m-%d") else {
136 continue;
137 };
138 let age = (today - d).num_days();
139 if age < 7 {
140 recent.0 += bounces;
141 recent.1 += reads;
142 } else {
143 prev.0 += bounces;
144 prev.1 += reads;
145 }
146 }
147 let rate = |(b, r): (u64, u64)| {
148 if r == 0 {
149 None
150 } else {
151 Some((b as f64 / r as f64, r))
152 }
153 };
154 (rate(prev), rate(recent))
155}
156
157fn fmt_pct(x: f64) -> String {
158 format!("{:.1}%", x * 100.0)
159}
160
161pub fn report() -> Vec<String> {
163 let mut out = Vec::new();
164
165 match bounce_week_over_week() {
166 (Some((prev, prev_n)), Some((rec, rec_n))) => {
167 let arrow = if rec < prev {
168 "improving"
169 } else if rec > prev {
170 "regressing"
171 } else {
172 "flat"
173 };
174 out.push(format!(
175 "bounce rate: {} (prev 7d, n={prev_n}) -> {} (last 7d, n={rec_n}) [{arrow}]",
176 fmt_pct(prev),
177 fmt_pct(rec)
178 ));
179 }
180 (None, Some((rec, rec_n))) => {
181 out.push(format!(
182 "bounce rate: {} (last 7d, n={rec_n}) — no prior week yet",
183 fmt_pct(rec)
184 ));
185 }
186 _ => {}
187 }
188
189 let store = EfficacyStore::load();
190 if let (Some(first), Some(last)) = (store.snapshots.first(), store.snapshots.last())
191 && first.day != last.day
192 {
193 let delta = last.claims_rejected.saturating_sub(first.claims_rejected);
194 if delta > 0 {
195 out.push(format!(
196 "duplicate work prevented: {delta} rejected claim(s) since {}",
197 first.day
198 ));
199 }
200 }
201 if let Some(last) = store.snapshots.last()
202 && last.playbook_aged_total > 0
203 {
204 out.push(format!(
205 "playbook survival: {}/{} aged entries net-helpful ({})",
206 last.playbook_aged_helpful,
207 last.playbook_aged_total,
208 fmt_pct(last.playbook_aged_helpful as f64 / last.playbook_aged_total as f64)
209 ));
210 }
211
212 out
213}
214
215pub fn report_json() -> serde_json::Value {
217 let (prev, recent) = bounce_week_over_week();
218 let store = EfficacyStore::load();
219 serde_json::json!({
220 "bounce": {
221 "prev_week": prev.map(|(r, n)| serde_json::json!({"rate": r, "reads": n})),
222 "last_week": recent.map(|(r, n)| serde_json::json!({"rate": r, "reads": n})),
223 "daily": crate::core::savings_ledger::daily_bounce_trend(14)
224 .into_iter()
225 .map(|(d, b, r)| serde_json::json!({"day": d, "bounces": b, "reads": r}))
226 .collect::<Vec<_>>(),
227 },
228 "snapshots": store.snapshots,
229 "claims_rejected_total": crate::core::scent_field::claims_rejected_total(),
230 })
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 fn snap(day: &str, claims: u64) -> EfficacySnapshot {
238 EfficacySnapshot {
239 day: day.to_string(),
240 claims_rejected: claims,
241 playbook_entries: 4,
242 playbook_aged_helpful: 3,
243 playbook_aged_total: 4,
244 }
245 }
246
247 #[test]
248 fn upsert_replaces_same_day_and_appends_new() {
249 let mut s = EfficacyStore::default();
250 s.upsert(snap("2026-06-10", 1));
251 s.upsert(snap("2026-06-10", 2));
252 assert_eq!(s.snapshots.len(), 1);
253 assert_eq!(s.snapshots[0].claims_rejected, 2);
254 s.upsert(snap("2026-06-11", 3));
255 assert_eq!(s.snapshots.len(), 2);
256 }
257
258 #[test]
259 fn ring_is_bounded_to_30_days() {
260 let mut s = EfficacyStore::default();
261 for i in 0..40 {
262 s.upsert(snap(&format!("2026-05-{:02}", i % 31 + 1), i));
263 }
264 assert!(s.snapshots.len() <= MAX_SNAPSHOTS);
266 assert!(s.snapshots.first().unwrap().day.as_str() > "2026-05-01");
267 }
268
269 #[test]
270 fn snapshots_stay_sorted_by_day() {
271 let mut s = EfficacyStore::default();
272 s.upsert(snap("2026-06-11", 1));
273 s.upsert(snap("2026-06-09", 1));
274 s.upsert(snap("2026-06-10", 1));
275 let days: Vec<&str> = s.snapshots.iter().map(|x| x.day.as_str()).collect();
276 assert_eq!(days, vec!["2026-06-09", "2026-06-10", "2026-06-11"]);
277 }
278
279 #[test]
280 fn a_store_with_retired_litm_counters_still_loads() {
281 let json = r#"{"schema_version":1,"snapshots":[{"day":"2026-06-10",
282 "litm_begin_hits":10,"litm_begin_misses":2,"litm_end_hits":5,"litm_end_misses":3,
283 "claims_rejected":4,"playbook_entries":1,"playbook_aged_helpful":1,
284 "playbook_aged_total":1}]}"#;
285 let store: EfficacyStore = serde_json::from_str(json).expect("old store loads");
286 assert_eq!(store.snapshots.len(), 1);
287 assert_eq!(store.snapshots[0].claims_rejected, 4);
288 let saved = serde_json::to_string(&store).expect("serialize");
289 assert!(!saved.contains("litm"), "{saved}");
290 }
291
292 #[test]
293 fn current_snapshot_has_today() {
294 let snap = current_snapshot();
295 assert_eq!(snap.day, chrono::Utc::now().format("%Y-%m-%d").to_string());
296 }
297}