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)]
90 pub pending_anchored_escalations: HashMap<String, u64>,
91 #[serde(default)]
93 pub escalations_served: u64,
94 #[serde(skip)]
95 dirty: bool,
96}
97
98fn pair_key(ext: &str, mode: &str) -> String {
99 format!("{ext}|{mode}")
100}
101
102fn evict_pending_to_cap(map: &mut HashMap<String, u64>, dirty: &mut bool) {
106 if map.len() <= MAX_PENDING {
107 return;
108 }
109 let mut items: Vec<(String, u64)> = map.iter().map(|(k, ts)| (k.clone(), *ts)).collect();
110 items.sort_by_key(|(_, ts)| *ts);
111 let drop_n = map.len() - MAX_PENDING;
112 for (key, _) in items.into_iter().take(drop_n) {
113 map.remove(&key);
114 }
115 *dirty = true;
116}
117
118impl EditQualityStore {
119 fn load_from_disk() -> Self {
120 let Ok(raw) = std::fs::read_to_string(store_path()) else {
121 return Self::default();
122 };
123 let mut store: Self = serde_json::from_str(&raw).unwrap_or_default();
124 store.decay(now_unix());
125 store
126 }
127
128 fn decay(&mut self, now: u64) {
129 let before = self.pairs.len()
130 + self.pending_escalations.len()
131 + self.pending_anchored_escalations.len();
132 self.pairs
133 .retain(|_, s| now.saturating_sub(s.last_fail_unix) <= DECAY_SECS);
134 self.pending_escalations
135 .retain(|_, ts| now.saturating_sub(*ts) <= ESCALATION_TTL_SECS);
136 self.pending_anchored_escalations
137 .retain(|_, ts| now.saturating_sub(*ts) <= ESCALATION_TTL_SECS);
138 if self.pairs.len()
139 + self.pending_escalations.len()
140 + self.pending_anchored_escalations.len()
141 != before
142 {
143 self.dirty = true;
144 }
145 }
146
147 fn evict_to_caps(&mut self) {
148 if self.pairs.len() > MAX_PAIRS {
149 let mut items: Vec<(String, u64)> = self
150 .pairs
151 .iter()
152 .map(|(k, s)| (k.clone(), s.last_fail_unix))
153 .collect();
154 items.sort_by_key(|(_, ts)| *ts);
155 let drop_n = self.pairs.len() - MAX_PAIRS;
156 for (key, _) in items.into_iter().take(drop_n) {
157 self.pairs.remove(&key);
158 }
159 self.dirty = true;
160 }
161 evict_pending_to_cap(&mut self.pending_escalations, &mut self.dirty);
162 evict_pending_to_cap(&mut self.pending_anchored_escalations, &mut self.dirty);
163 }
164
165 pub fn record_failure(&mut self, ext: &str, mode: &str, now: u64) {
166 let entry = self.pairs.entry(pair_key(ext, mode)).or_default();
167 entry.fails = entry.fails.saturating_add(1);
168 entry.last_fail_unix = now;
169 entry.update_risky();
170 self.dirty = true;
171 self.evict_to_caps();
172 }
173
174 pub fn record_success(&mut self, ext: &str, mode: &str) {
175 let entry = self.pairs.entry(pair_key(ext, mode)).or_default();
176 entry.successes = entry.successes.saturating_add(1);
177 entry.update_risky();
178 self.dirty = true;
179 }
180
181 pub fn set_pending_escalation(&mut self, norm_path: &str, now: u64) {
182 self.pending_escalations.insert(norm_path.to_string(), now);
183 self.dirty = true;
184 self.evict_to_caps();
185 }
186
187 pub fn take_pending_escalation(&mut self, norm_path: &str, now: u64) -> bool {
189 Self::take_from(
190 &mut self.pending_escalations,
191 norm_path,
192 now,
193 &mut self.escalations_served,
194 &mut self.dirty,
195 )
196 }
197
198 pub fn set_pending_anchored_escalation(&mut self, norm_path: &str, now: u64) {
199 self.pending_anchored_escalations
200 .insert(norm_path.to_string(), now);
201 self.dirty = true;
202 self.evict_to_caps();
203 }
204
205 pub fn take_pending_anchored_escalation(&mut self, norm_path: &str, now: u64) -> bool {
207 Self::take_from(
208 &mut self.pending_anchored_escalations,
209 norm_path,
210 now,
211 &mut self.escalations_served,
212 &mut self.dirty,
213 )
214 }
215
216 fn take_from(
219 map: &mut HashMap<String, u64>,
220 norm_path: &str,
221 now: u64,
222 served: &mut u64,
223 dirty: &mut bool,
224 ) -> bool {
225 match map.remove(norm_path) {
226 Some(ts) if now.saturating_sub(ts) <= ESCALATION_TTL_SECS => {
227 *served += 1;
228 *dirty = true;
229 true
230 }
231 Some(_) => {
232 *dirty = true;
233 false
234 }
235 None => false,
236 }
237 }
238
239 pub fn is_risky(&self, ext: &str, mode: &str) -> bool {
240 self.pairs
241 .get(&pair_key(ext, mode))
242 .is_some_and(|s| s.risky)
243 }
244
245 pub fn save(&self) -> std::io::Result<()> {
246 let path = store_path();
247 if let Some(parent) = path.parent() {
248 std::fs::create_dir_all(parent)?;
249 }
250 let json = serde_json::to_string(self)?;
251 let tmp = path.with_extension("tmp");
252 std::fs::write(&tmp, json)?;
253 std::fs::rename(&tmp, &path)
254 }
255}
256
257fn store_path() -> PathBuf {
258 crate::core::data_dir::lean_ctx_data_dir()
259 .unwrap_or_else(|_| PathBuf::from("."))
260 .join(STORE_FILE)
261}
262
263fn now_unix() -> u64 {
264 std::time::SystemTime::now()
265 .duration_since(std::time::UNIX_EPOCH)
266 .map_or(0, |d| d.as_secs())
267}
268
269fn global() -> &'static Mutex<EditQualityStore> {
270 STORE.get_or_init(|| Mutex::new(EditQualityStore::load_from_disk()))
271}
272
273fn ext_of(path: &str) -> String {
274 std::path::Path::new(path)
275 .extension()
276 .and_then(|e| e.to_str())
277 .unwrap_or("")
278 .to_string()
279}
280
281pub fn record_edit_outcome(path: &str, last_mode: &str, success: bool) {
287 record_outcome_with(path, last_mode, success, Escalation::Full);
288}
289
290pub fn record_anchored_edit_outcome(path: &str, last_mode: &str, success: bool) {
294 record_outcome_with(path, last_mode, success, Escalation::Anchored);
295}
296
297#[derive(Clone, Copy)]
301enum Escalation {
302 Full,
304 Anchored,
306}
307
308impl Escalation {
309 fn target_mode(self) -> &'static str {
313 match self {
314 Escalation::Full => "full",
315 Escalation::Anchored => "anchored",
316 }
317 }
318}
319
320fn record_outcome_with(path: &str, last_mode: &str, success: bool, esc: Escalation) {
321 if last_mode.is_empty() {
322 return;
323 }
324 let ext = ext_of(path);
325 let Ok(mut store) = global().lock() else {
326 return;
327 };
328 if success {
329 store.record_success(&ext, last_mode);
330 } else {
331 let now = now_unix();
332 store.record_failure(&ext, last_mode, now);
333 if last_mode != esc.target_mode() {
334 let norm = crate::core::pathutil::normalize_tool_path(path);
335 match esc {
336 Escalation::Full => store.set_pending_escalation(&norm, now),
337 Escalation::Anchored => store.set_pending_anchored_escalation(&norm, now),
338 }
339 crate::core::adaptive_thresholds::record_quality_signal(
343 path,
344 crate::core::threshold_learning::QualitySignal::EditFail,
345 );
346 let scent_path = norm.clone();
349 std::thread::spawn(move || {
350 crate::core::scent_field::deposit(
351 crate::core::scent_field::scent_agent_id(),
352 crate::core::scent_field::ScentKind::Stuck,
353 &scent_path,
354 1.0,
355 );
356 });
357 }
358 }
359 maybe_flush(&mut store);
360}
361
362pub fn take_pending_escalation(path: &str) -> bool {
364 consume_escalation(path, false)
365}
366
367pub fn take_pending_anchored_escalation(path: &str) -> bool {
370 consume_escalation(path, true)
371}
372
373fn consume_escalation(path: &str, anchored: bool) -> bool {
374 let norm = crate::core::pathutil::normalize_tool_path(path);
375 let Ok(mut store) = global().lock() else {
376 return false;
377 };
378 let now = now_unix();
379 let hit = if anchored {
380 store.take_pending_anchored_escalation(&norm, now)
381 } else {
382 store.take_pending_escalation(&norm, now)
383 };
384 if hit {
385 maybe_flush(&mut store);
386 }
387 hit
388}
389
390pub fn is_risky_mode(path: &str, mode: &str) -> bool {
392 let ext = ext_of(path);
393 global().lock().is_ok_and(|s| s.is_risky(&ext, mode))
394}
395
396pub fn metrics_snapshot() -> serde_json::Value {
398 let Ok(store) = global().lock() else {
399 return serde_json::json!({});
400 };
401 let mut pairs: Vec<serde_json::Value> = store
402 .pairs
403 .iter()
404 .map(|(key, s)| {
405 serde_json::json!({
406 "pair": key,
407 "fails": s.fails,
408 "successes": s.successes,
409 "fail_rate": (s.fail_rate() * 1000.0).round() / 1000.0,
410 "risky": s.risky,
411 })
412 })
413 .collect();
414 pairs.sort_by(|a, b| {
415 let fa = a["fail_rate"].as_f64().unwrap_or(0.0);
416 let fb = b["fail_rate"].as_f64().unwrap_or(0.0);
417 fb.partial_cmp(&fa).unwrap_or(std::cmp::Ordering::Equal)
418 });
419 serde_json::json!({
420 "pairs": pairs,
421 "pending_escalations": store.pending_escalations.len(),
422 "pending_anchored_escalations": store.pending_anchored_escalations.len(),
423 "escalations_served": store.escalations_served,
424 })
425}
426
427pub fn flush() {
428 if let Ok(store) = global().lock()
429 && store.dirty
430 {
431 let _ = store.save();
432 }
433}
434
435fn maybe_flush(store: &mut EditQualityStore) {
436 let n = RECORD_CALLS.fetch_add(1, Ordering::Relaxed) + 1;
437 if n.is_multiple_of(FLUSH_EVERY) && store.dirty && store.save().is_ok() {
438 store.dirty = false;
439 }
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445
446 #[test]
447 fn risky_after_two_majority_fails_with_hysteresis() {
448 let mut s = EditQualityStore::default();
449 s.record_failure("rs", "map", 1000);
450 assert!(!s.is_risky("rs", "map"), "one fail is not a pattern");
451 s.record_failure("rs", "map", 1001);
452 assert!(s.is_risky("rs", "map"), "2 fails, rate 1.0 >= 0.25");
453
454 for _ in 0..11 {
456 s.record_success("rs", "map");
457 }
458 assert!(s.is_risky("rs", "map"), "2/13 ≈ 0.154 still risky");
459 s.record_success("rs", "map");
460 assert!(!s.is_risky("rs", "map"), "2/14 ≈ 0.143 < 0.15 recovers");
461 }
462
463 #[test]
464 fn entering_risky_needs_quarter_rate_not_just_two_fails() {
465 let mut s = EditQualityStore::default();
466 for _ in 0..7 {
467 s.record_success("ts", "signatures");
468 }
469 s.record_failure("ts", "signatures", 1000);
470 s.record_failure("ts", "signatures", 1001);
471 assert!(!s.is_risky("ts", "signatures"));
473 s.record_failure("ts", "signatures", 1002);
474 assert!(s.is_risky("ts", "signatures"));
476 }
477
478 #[test]
479 fn penalty_is_per_extension_not_global() {
480 let mut s = EditQualityStore::default();
481 s.record_failure("rs", "map", 1000);
482 s.record_failure("rs", "map", 1001);
483 assert!(s.is_risky("rs", "map"));
484 assert!(!s.is_risky("py", "map"), "py|map untouched");
485 assert!(!s.is_risky("rs", "signatures"), "rs|signatures untouched");
486 }
487
488 #[test]
489 fn escalation_is_one_shot_and_expires() {
490 let mut s = EditQualityStore::default();
491 s.set_pending_escalation("src/a.rs", 1000);
492 assert!(s.take_pending_escalation("src/a.rs", 1100));
493 assert!(
494 !s.take_pending_escalation("src/a.rs", 1101),
495 "consumed — second read is normal again"
496 );
497 assert_eq!(s.escalations_served, 1);
498
499 s.set_pending_escalation("src/b.rs", 1000);
500 assert!(
501 !s.take_pending_escalation("src/b.rs", 1000 + ESCALATION_TTL_SECS + 1),
502 "expired escalations are dropped, not served"
503 );
504 assert_eq!(s.escalations_served, 1);
505 }
506
507 #[test]
508 fn anchored_escalation_is_independent_and_one_shot() {
509 let mut s = EditQualityStore::default();
513 s.set_pending_anchored_escalation("src/a.rs", 1000);
514 assert!(
515 !s.take_pending_escalation("src/a.rs", 1100),
516 "anchored arming must not satisfy a full escalation"
517 );
518 assert!(s.take_pending_anchored_escalation("src/a.rs", 1100));
519 assert!(
520 !s.take_pending_anchored_escalation("src/a.rs", 1101),
521 "anchored escalation is one-shot"
522 );
523 assert_eq!(s.escalations_served, 1);
524 }
525
526 #[test]
527 fn anchored_outcome_arms_anchored_not_full() {
528 let mut s = EditQualityStore::default();
530 s.record_failure("rs", "anchored", 1000);
531 s.set_pending_anchored_escalation("src/x.rs", 1000);
532 assert!(s.pending_escalations.is_empty());
533 assert_eq!(s.pending_anchored_escalations.len(), 1);
534 }
535
536 #[test]
537 fn store_without_anchored_field_deserializes() {
538 let legacy = r#"{"pairs":{},"pending_escalations":{"old.rs":42}}"#;
541 let s: EditQualityStore = serde_json::from_str(legacy).unwrap();
542 assert!(s.pending_anchored_escalations.is_empty());
543 assert!(s.pending_escalations.contains_key("old.rs"));
544 }
545
546 #[test]
547 fn decay_drops_stale_pairs_and_pendings() {
548 let mut s = EditQualityStore::default();
549 s.record_failure("rs", "map", 1000);
550 s.record_failure("go", "map", 5000);
551 s.set_pending_escalation("old.rs", 1000);
552 s.set_pending_escalation("fresh.rs", 5000);
553 s.decay(5000 + DECAY_SECS - 10);
554 assert!(!s.pairs.contains_key("rs|map"));
555 assert!(s.pairs.contains_key("go|map"));
556 assert!(s.pending_escalations.is_empty());
558 }
559
560 #[test]
561 fn eviction_keeps_newest() {
562 let mut s = EditQualityStore::default();
563 for i in 0..(MAX_PAIRS + 10) {
564 s.record_failure(&format!("e{i}"), "map", 1000 + i as u64);
565 }
566 assert_eq!(s.pairs.len(), MAX_PAIRS);
567 assert!(!s.pairs.contains_key("e0|map"));
568 for i in 0..(MAX_PENDING + 5) {
569 s.set_pending_escalation(&format!("f{i}.rs"), 1000 + i as u64);
570 }
571 assert_eq!(s.pending_escalations.len(), MAX_PENDING);
572 assert!(!s.pending_escalations.contains_key("f0.rs"));
573 }
574
575 #[test]
576 fn roundtrip_serialization() {
577 let mut s = EditQualityStore::default();
578 s.record_failure("rs", "map", 42);
579 s.set_pending_escalation("x.rs", 42);
580 let json = serde_json::to_string(&s).unwrap();
581 let back: EditQualityStore = serde_json::from_str(&json).unwrap();
582 assert_eq!(back.pairs.get("rs|map").unwrap().fails, 1);
583 assert!(back.pending_escalations.contains_key("x.rs"));
584 }
585}