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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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 charge_compressed_read(path: &str, last_mode: &str) {
324 use crate::core::context_store::task_signals::{self, Signal};
325 if matches!(last_mode, "full" | "diff") {
326 return;
327 }
328 let last = crate::core::bounce_tracker::global()
329 .lock()
330 .ok()
331 .and_then(|tracker| tracker.last_read(path));
332 match last {
333 Some((true, Some(origin))) => task_signals::record(&origin, Signal::EditFailure),
334 Some((false, _)) => {}
336 Some((true, None)) | None => task_signals::record_unattributed(),
337 }
338}
339
340fn record_outcome_with(path: &str, last_mode: &str, success: bool, esc: Escalation) {
341 if last_mode.is_empty() {
342 return;
343 }
344 if !success && last_mode != esc.target_mode() {
346 charge_compressed_read(path, last_mode);
347 }
348 let ext = ext_of(path);
349 let Ok(mut store) = global().lock() else {
350 return;
351 };
352 if success {
353 store.record_success(&ext, last_mode);
354 } else {
355 let now = now_unix();
356 store.record_failure(&ext, last_mode, now);
357 if last_mode != esc.target_mode() {
358 let norm = crate::core::pathutil::normalize_tool_path(path);
359 match esc {
360 Escalation::Full => store.set_pending_escalation(&norm, now),
361 Escalation::Anchored => store.set_pending_anchored_escalation(&norm, now),
362 }
363 let scent_path = norm.clone();
367 if let Some(project_root) = crate::core::scent_field::current_project() {
368 std::thread::spawn(move || {
369 crate::core::scent_field::deposit_for(
370 &project_root,
371 crate::core::scent_field::scent_agent_id(),
372 crate::core::scent_field::ScentKind::Stuck,
373 &scent_path,
374 1.0,
375 );
376 });
377 }
378 }
379 }
380 maybe_flush(&mut store);
381}
382
383pub(crate) fn take_pending_escalation(path: &str) -> bool {
385 consume_escalation(path, false)
386}
387
388pub(crate) fn take_pending_anchored_escalation(path: &str) -> bool {
391 consume_escalation(path, true)
392}
393
394fn consume_escalation(path: &str, anchored: bool) -> bool {
395 let norm = crate::core::pathutil::normalize_tool_path(path);
396 let Ok(mut store) = global().lock() else {
397 return false;
398 };
399 let now = now_unix();
400 let hit = if anchored {
401 store.take_pending_anchored_escalation(&norm, now)
402 } else {
403 store.take_pending_escalation(&norm, now)
404 };
405 if hit {
406 maybe_flush(&mut store);
407 }
408 hit
409}
410
411pub(crate) fn is_risky_mode(path: &str, mode: &str) -> bool {
413 let ext = ext_of(path);
414 global().lock().is_ok_and(|s| s.is_risky(&ext, mode))
415}
416
417pub fn metrics_snapshot() -> serde_json::Value {
419 let Ok(store) = global().lock() else {
420 return serde_json::json!({});
421 };
422 let mut pairs: Vec<serde_json::Value> = store
423 .pairs
424 .iter()
425 .map(|(key, s)| {
426 serde_json::json!({
427 "pair": key,
428 "fails": s.fails,
429 "successes": s.successes,
430 "fail_rate": (s.fail_rate() * 1000.0).round() / 1000.0,
431 "risky": s.risky,
432 })
433 })
434 .collect();
435 pairs.sort_by(|a, b| {
436 let fa = a["fail_rate"].as_f64().unwrap_or(0.0);
437 let fb = b["fail_rate"].as_f64().unwrap_or(0.0);
438 fb.partial_cmp(&fa).unwrap_or(std::cmp::Ordering::Equal)
439 });
440 serde_json::json!({
441 "pairs": pairs,
442 "pending_escalations": store.pending_escalations.len(),
443 "pending_anchored_escalations": store.pending_anchored_escalations.len(),
444 "escalations_served": store.escalations_served,
445 })
446}
447
448pub(crate) fn flush() {
449 if let Ok(store) = global().lock()
450 && store.dirty
451 {
452 let _ = store.save();
453 }
454}
455
456fn maybe_flush(store: &mut EditQualityStore) {
457 let n = RECORD_CALLS.fetch_add(1, Ordering::Relaxed) + 1;
458 if n.is_multiple_of(FLUSH_EVERY) && store.dirty && store.save().is_ok() {
459 store.dirty = false;
460 }
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466
467 #[test]
468 fn risky_after_two_majority_fails_with_hysteresis() {
469 let mut s = EditQualityStore::default();
470 s.record_failure("rs", "map", 1000);
471 assert!(!s.is_risky("rs", "map"), "one fail is not a pattern");
472 s.record_failure("rs", "map", 1001);
473 assert!(s.is_risky("rs", "map"), "2 fails, rate 1.0 >= 0.25");
474
475 for _ in 0..11 {
477 s.record_success("rs", "map");
478 }
479 assert!(s.is_risky("rs", "map"), "2/13 ≈ 0.154 still risky");
480 s.record_success("rs", "map");
481 assert!(!s.is_risky("rs", "map"), "2/14 ≈ 0.143 < 0.15 recovers");
482 }
483
484 #[test]
485 fn entering_risky_needs_quarter_rate_not_just_two_fails() {
486 let mut s = EditQualityStore::default();
487 for _ in 0..7 {
488 s.record_success("ts", "signatures");
489 }
490 s.record_failure("ts", "signatures", 1000);
491 s.record_failure("ts", "signatures", 1001);
492 assert!(!s.is_risky("ts", "signatures"));
494 s.record_failure("ts", "signatures", 1002);
495 assert!(s.is_risky("ts", "signatures"));
497 }
498
499 #[test]
500 fn penalty_is_per_extension_not_global() {
501 let mut s = EditQualityStore::default();
502 s.record_failure("rs", "map", 1000);
503 s.record_failure("rs", "map", 1001);
504 assert!(s.is_risky("rs", "map"));
505 assert!(!s.is_risky("py", "map"), "py|map untouched");
506 assert!(!s.is_risky("rs", "signatures"), "rs|signatures untouched");
507 }
508
509 #[test]
510 fn escalation_is_one_shot_and_expires() {
511 let mut s = EditQualityStore::default();
512 s.set_pending_escalation("src/a.rs", 1000);
513 assert!(s.take_pending_escalation("src/a.rs", 1100));
514 assert!(
515 !s.take_pending_escalation("src/a.rs", 1101),
516 "consumed — second read is normal again"
517 );
518 assert_eq!(s.escalations_served, 1);
519
520 s.set_pending_escalation("src/b.rs", 1000);
521 assert!(
522 !s.take_pending_escalation("src/b.rs", 1000 + ESCALATION_TTL_SECS + 1),
523 "expired escalations are dropped, not served"
524 );
525 assert_eq!(s.escalations_served, 1);
526 }
527
528 #[test]
529 fn anchored_escalation_is_independent_and_one_shot() {
530 let mut s = EditQualityStore::default();
534 s.set_pending_anchored_escalation("src/a.rs", 1000);
535 assert!(
536 !s.take_pending_escalation("src/a.rs", 1100),
537 "anchored arming must not satisfy a full escalation"
538 );
539 assert!(s.take_pending_anchored_escalation("src/a.rs", 1100));
540 assert!(
541 !s.take_pending_anchored_escalation("src/a.rs", 1101),
542 "anchored escalation is one-shot"
543 );
544 assert_eq!(s.escalations_served, 1);
545 }
546
547 #[test]
548 fn anchored_outcome_arms_anchored_not_full() {
549 let mut s = EditQualityStore::default();
551 s.record_failure("rs", "anchored", 1000);
552 s.set_pending_anchored_escalation("src/x.rs", 1000);
553 assert!(s.pending_escalations.is_empty());
554 assert_eq!(s.pending_anchored_escalations.len(), 1);
555 }
556
557 #[test]
558 fn store_without_anchored_field_deserializes() {
559 let legacy = r#"{"pairs":{},"pending_escalations":{"old.rs":42}}"#;
562 let s: EditQualityStore = serde_json::from_str(legacy).unwrap();
563 assert!(s.pending_anchored_escalations.is_empty());
564 assert!(s.pending_escalations.contains_key("old.rs"));
565 }
566
567 #[test]
568 fn decay_drops_stale_pairs_and_pendings() {
569 let mut s = EditQualityStore::default();
570 s.record_failure("rs", "map", 1000);
571 s.record_failure("go", "map", 5000);
572 s.set_pending_escalation("old.rs", 1000);
573 s.set_pending_escalation("fresh.rs", 5000);
574 s.decay(5000 + DECAY_SECS - 10);
575 assert!(!s.pairs.contains_key("rs|map"));
576 assert!(s.pairs.contains_key("go|map"));
577 assert!(s.pending_escalations.is_empty());
579 }
580
581 #[test]
582 fn eviction_keeps_newest() {
583 let mut s = EditQualityStore::default();
584 for i in 0..(MAX_PAIRS + 10) {
585 s.record_failure(&format!("e{i}"), "map", 1000 + i as u64);
586 }
587 assert_eq!(s.pairs.len(), MAX_PAIRS);
588 assert!(!s.pairs.contains_key("e0|map"));
589 for i in 0..(MAX_PENDING + 5) {
590 s.set_pending_escalation(&format!("f{i}.rs"), 1000 + i as u64);
591 }
592 assert_eq!(s.pending_escalations.len(), MAX_PENDING);
593 assert!(!s.pending_escalations.contains_key("f0.rs"));
594 }
595
596 #[test]
597 fn roundtrip_serialization() {
598 let mut s = EditQualityStore::default();
599 s.record_failure("rs", "map", 42);
600 s.set_pending_escalation("x.rs", 42);
601 let json = serde_json::to_string(&s).unwrap();
602 let back: EditQualityStore = serde_json::from_str(&json).unwrap();
603 assert_eq!(back.pairs.get("rs|map").unwrap().fails, 1);
604 assert!(back.pending_escalations.contains_key("x.rs"));
605 }
606}