1use std::collections::HashMap;
25use std::path::PathBuf;
26use std::sync::atomic::{AtomicUsize, Ordering};
27use std::sync::{Mutex, OnceLock};
28
29use serde::{Deserialize, Serialize};
30
31const STORE_FILE: &str = "edit_quality.json";
32const DECAY_SECS: u64 = 30 * 24 * 3600;
34const ESCALATION_TTL_SECS: u64 = 3600;
36const MAX_PAIRS: usize = 200;
38const MAX_PENDING: usize = 100;
39const FLUSH_EVERY: usize = 10;
40
41const RISKY_ENTER_RATE: f64 = 0.25;
43const RISKY_EXIT_RATE: f64 = 0.15;
45const RISKY_MIN_FAILS: u32 = 2;
46
47static STORE: OnceLock<Mutex<EditQualityStore>> = OnceLock::new();
48static RECORD_CALLS: AtomicUsize = AtomicUsize::new(0);
49
50#[derive(Debug, Clone, Serialize, Deserialize, Default)]
51pub struct PairStats {
52 pub fails: u32,
53 pub successes: u32,
54 pub risky: bool,
55 pub last_fail_unix: u64,
56}
57
58impl PairStats {
59 fn fail_rate(&self) -> f64 {
60 let total = self.fails + self.successes;
61 if total == 0 {
62 return 0.0;
63 }
64 f64::from(self.fails) / f64::from(total)
65 }
66
67 fn update_risky(&mut self) {
69 if self.risky {
70 if self.fail_rate() < RISKY_EXIT_RATE {
71 self.risky = false;
72 }
73 } else if self.fails >= RISKY_MIN_FAILS && self.fail_rate() >= RISKY_ENTER_RATE {
74 self.risky = true;
75 }
76 }
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, Default)]
80pub struct EditQualityStore {
81 pub pairs: HashMap<String, PairStats>,
83 pub pending_escalations: HashMap<String, u64>,
85 #[serde(default)]
87 pub escalations_served: u64,
88 #[serde(skip)]
89 dirty: bool,
90}
91
92fn pair_key(ext: &str, mode: &str) -> String {
93 format!("{ext}|{mode}")
94}
95
96impl EditQualityStore {
97 fn load_from_disk() -> Self {
98 let Ok(raw) = std::fs::read_to_string(store_path()) else {
99 return Self::default();
100 };
101 let mut store: Self = serde_json::from_str(&raw).unwrap_or_default();
102 store.decay(now_unix());
103 store
104 }
105
106 fn decay(&mut self, now: u64) {
107 let before = self.pairs.len() + self.pending_escalations.len();
108 self.pairs
109 .retain(|_, s| now.saturating_sub(s.last_fail_unix) <= DECAY_SECS);
110 self.pending_escalations
111 .retain(|_, ts| now.saturating_sub(*ts) <= ESCALATION_TTL_SECS);
112 if self.pairs.len() + self.pending_escalations.len() != before {
113 self.dirty = true;
114 }
115 }
116
117 fn evict_to_caps(&mut self) {
118 if self.pairs.len() > MAX_PAIRS {
119 let mut items: Vec<(String, u64)> = self
120 .pairs
121 .iter()
122 .map(|(k, s)| (k.clone(), s.last_fail_unix))
123 .collect();
124 items.sort_by_key(|(_, ts)| *ts);
125 let drop_n = self.pairs.len() - MAX_PAIRS;
126 for (key, _) in items.into_iter().take(drop_n) {
127 self.pairs.remove(&key);
128 }
129 self.dirty = true;
130 }
131 if self.pending_escalations.len() > MAX_PENDING {
132 let mut items: Vec<(String, u64)> = self
133 .pending_escalations
134 .iter()
135 .map(|(k, ts)| (k.clone(), *ts))
136 .collect();
137 items.sort_by_key(|(_, ts)| *ts);
138 let drop_n = self.pending_escalations.len() - MAX_PENDING;
139 for (key, _) in items.into_iter().take(drop_n) {
140 self.pending_escalations.remove(&key);
141 }
142 self.dirty = true;
143 }
144 }
145
146 pub fn record_failure(&mut self, ext: &str, mode: &str, now: u64) {
147 let entry = self.pairs.entry(pair_key(ext, mode)).or_default();
148 entry.fails = entry.fails.saturating_add(1);
149 entry.last_fail_unix = now;
150 entry.update_risky();
151 self.dirty = true;
152 self.evict_to_caps();
153 }
154
155 pub fn record_success(&mut self, ext: &str, mode: &str) {
156 let entry = self.pairs.entry(pair_key(ext, mode)).or_default();
157 entry.successes = entry.successes.saturating_add(1);
158 entry.update_risky();
159 self.dirty = true;
160 }
161
162 pub fn set_pending_escalation(&mut self, norm_path: &str, now: u64) {
163 self.pending_escalations.insert(norm_path.to_string(), now);
164 self.dirty = true;
165 self.evict_to_caps();
166 }
167
168 pub fn take_pending_escalation(&mut self, norm_path: &str, now: u64) -> bool {
170 match self.pending_escalations.remove(norm_path) {
171 Some(ts) if now.saturating_sub(ts) <= ESCALATION_TTL_SECS => {
172 self.escalations_served += 1;
173 self.dirty = true;
174 true
175 }
176 Some(_) => {
177 self.dirty = true;
178 false
179 }
180 None => false,
181 }
182 }
183
184 pub fn is_risky(&self, ext: &str, mode: &str) -> bool {
185 self.pairs
186 .get(&pair_key(ext, mode))
187 .is_some_and(|s| s.risky)
188 }
189
190 pub fn save(&self) -> std::io::Result<()> {
191 let path = store_path();
192 if let Some(parent) = path.parent() {
193 std::fs::create_dir_all(parent)?;
194 }
195 let json = serde_json::to_string(self)?;
196 let tmp = path.with_extension("tmp");
197 std::fs::write(&tmp, json)?;
198 std::fs::rename(&tmp, &path)
199 }
200}
201
202fn store_path() -> PathBuf {
203 crate::core::data_dir::lean_ctx_data_dir()
204 .unwrap_or_else(|_| PathBuf::from("."))
205 .join(STORE_FILE)
206}
207
208fn now_unix() -> u64 {
209 std::time::SystemTime::now()
210 .duration_since(std::time::UNIX_EPOCH)
211 .map_or(0, |d| d.as_secs())
212}
213
214fn global() -> &'static Mutex<EditQualityStore> {
215 STORE.get_or_init(|| Mutex::new(EditQualityStore::load_from_disk()))
216}
217
218fn ext_of(path: &str) -> String {
219 std::path::Path::new(path)
220 .extension()
221 .and_then(|e| e.to_str())
222 .unwrap_or("")
223 .to_string()
224}
225
226pub fn record_edit_outcome(path: &str, last_mode: &str, success: bool) {
232 if last_mode.is_empty() {
233 return;
234 }
235 let ext = ext_of(path);
236 let Ok(mut store) = global().lock() else {
237 return;
238 };
239 if success {
240 store.record_success(&ext, last_mode);
241 } else {
242 let now = now_unix();
243 store.record_failure(&ext, last_mode, now);
244 if last_mode != "full" {
245 let norm = crate::core::pathutil::normalize_tool_path(path);
246 store.set_pending_escalation(&norm, now);
247 crate::core::adaptive_thresholds::record_quality_signal(
251 path,
252 crate::core::threshold_learning::QualitySignal::EditFail,
253 );
254 let scent_path = norm.clone();
256 std::thread::spawn(move || {
257 crate::core::scent_field::deposit(
258 crate::core::scent_field::scent_agent_id(),
259 crate::core::scent_field::ScentKind::Stuck,
260 &scent_path,
261 1.0,
262 );
263 });
264 }
265 }
266 maybe_flush(&mut store);
267}
268
269pub fn take_pending_escalation(path: &str) -> bool {
271 let norm = crate::core::pathutil::normalize_tool_path(path);
272 let Ok(mut store) = global().lock() else {
273 return false;
274 };
275 let hit = store.take_pending_escalation(&norm, now_unix());
276 if hit {
277 maybe_flush(&mut store);
278 }
279 hit
280}
281
282pub fn is_risky_mode(path: &str, mode: &str) -> bool {
284 let ext = ext_of(path);
285 global().lock().is_ok_and(|s| s.is_risky(&ext, mode))
286}
287
288pub fn metrics_snapshot() -> serde_json::Value {
290 let Ok(store) = global().lock() else {
291 return serde_json::json!({});
292 };
293 let mut pairs: Vec<serde_json::Value> = store
294 .pairs
295 .iter()
296 .map(|(key, s)| {
297 serde_json::json!({
298 "pair": key,
299 "fails": s.fails,
300 "successes": s.successes,
301 "fail_rate": (s.fail_rate() * 1000.0).round() / 1000.0,
302 "risky": s.risky,
303 })
304 })
305 .collect();
306 pairs.sort_by(|a, b| {
307 let fa = a["fail_rate"].as_f64().unwrap_or(0.0);
308 let fb = b["fail_rate"].as_f64().unwrap_or(0.0);
309 fb.partial_cmp(&fa).unwrap_or(std::cmp::Ordering::Equal)
310 });
311 serde_json::json!({
312 "pairs": pairs,
313 "pending_escalations": store.pending_escalations.len(),
314 "escalations_served": store.escalations_served,
315 })
316}
317
318pub fn flush() {
319 if let Ok(store) = global().lock()
320 && store.dirty
321 {
322 let _ = store.save();
323 }
324}
325
326fn maybe_flush(store: &mut EditQualityStore) {
327 let n = RECORD_CALLS.fetch_add(1, Ordering::Relaxed) + 1;
328 if n.is_multiple_of(FLUSH_EVERY) && store.dirty && store.save().is_ok() {
329 store.dirty = false;
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336
337 #[test]
338 fn risky_after_two_majority_fails_with_hysteresis() {
339 let mut s = EditQualityStore::default();
340 s.record_failure("rs", "map", 1000);
341 assert!(!s.is_risky("rs", "map"), "one fail is not a pattern");
342 s.record_failure("rs", "map", 1001);
343 assert!(s.is_risky("rs", "map"), "2 fails, rate 1.0 >= 0.25");
344
345 for _ in 0..11 {
347 s.record_success("rs", "map");
348 }
349 assert!(s.is_risky("rs", "map"), "2/13 ≈ 0.154 still risky");
350 s.record_success("rs", "map");
351 assert!(!s.is_risky("rs", "map"), "2/14 ≈ 0.143 < 0.15 recovers");
352 }
353
354 #[test]
355 fn entering_risky_needs_quarter_rate_not_just_two_fails() {
356 let mut s = EditQualityStore::default();
357 for _ in 0..7 {
358 s.record_success("ts", "signatures");
359 }
360 s.record_failure("ts", "signatures", 1000);
361 s.record_failure("ts", "signatures", 1001);
362 assert!(!s.is_risky("ts", "signatures"));
364 s.record_failure("ts", "signatures", 1002);
365 assert!(s.is_risky("ts", "signatures"));
367 }
368
369 #[test]
370 fn penalty_is_per_extension_not_global() {
371 let mut s = EditQualityStore::default();
372 s.record_failure("rs", "map", 1000);
373 s.record_failure("rs", "map", 1001);
374 assert!(s.is_risky("rs", "map"));
375 assert!(!s.is_risky("py", "map"), "py|map untouched");
376 assert!(!s.is_risky("rs", "signatures"), "rs|signatures untouched");
377 }
378
379 #[test]
380 fn escalation_is_one_shot_and_expires() {
381 let mut s = EditQualityStore::default();
382 s.set_pending_escalation("src/a.rs", 1000);
383 assert!(s.take_pending_escalation("src/a.rs", 1100));
384 assert!(
385 !s.take_pending_escalation("src/a.rs", 1101),
386 "consumed — second read is normal again"
387 );
388 assert_eq!(s.escalations_served, 1);
389
390 s.set_pending_escalation("src/b.rs", 1000);
391 assert!(
392 !s.take_pending_escalation("src/b.rs", 1000 + ESCALATION_TTL_SECS + 1),
393 "expired escalations are dropped, not served"
394 );
395 assert_eq!(s.escalations_served, 1);
396 }
397
398 #[test]
399 fn decay_drops_stale_pairs_and_pendings() {
400 let mut s = EditQualityStore::default();
401 s.record_failure("rs", "map", 1000);
402 s.record_failure("go", "map", 5000);
403 s.set_pending_escalation("old.rs", 1000);
404 s.set_pending_escalation("fresh.rs", 5000);
405 s.decay(5000 + DECAY_SECS - 10);
406 assert!(!s.pairs.contains_key("rs|map"));
407 assert!(s.pairs.contains_key("go|map"));
408 assert!(s.pending_escalations.is_empty());
410 }
411
412 #[test]
413 fn eviction_keeps_newest() {
414 let mut s = EditQualityStore::default();
415 for i in 0..(MAX_PAIRS + 10) {
416 s.record_failure(&format!("e{i}"), "map", 1000 + i as u64);
417 }
418 assert_eq!(s.pairs.len(), MAX_PAIRS);
419 assert!(!s.pairs.contains_key("e0|map"));
420 for i in 0..(MAX_PENDING + 5) {
421 s.set_pending_escalation(&format!("f{i}.rs"), 1000 + i as u64);
422 }
423 assert_eq!(s.pending_escalations.len(), MAX_PENDING);
424 assert!(!s.pending_escalations.contains_key("f0.rs"));
425 }
426
427 #[test]
428 fn roundtrip_serialization() {
429 let mut s = EditQualityStore::default();
430 s.record_failure("rs", "map", 42);
431 s.set_pending_escalation("x.rs", 42);
432 let json = serde_json::to_string(&s).unwrap();
433 let back: EditQualityStore = serde_json::from_str(&json).unwrap();
434 assert_eq!(back.pairs.get("rs|map").unwrap().fails, 1);
435 assert!(back.pending_escalations.contains_key("x.rs"));
436 }
437}