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::threshold_learning::record_signal(
250 path,
251 crate::core::threshold_learning::QualitySignal::EditFail,
252 );
253 let scent_path = norm.clone();
255 std::thread::spawn(move || {
256 crate::core::scent_field::deposit(
257 crate::core::scent_field::scent_agent_id(),
258 crate::core::scent_field::ScentKind::Stuck,
259 &scent_path,
260 1.0,
261 );
262 });
263 }
264 }
265 maybe_flush(&mut store);
266}
267
268pub fn take_pending_escalation(path: &str) -> bool {
270 let norm = crate::core::pathutil::normalize_tool_path(path);
271 let Ok(mut store) = global().lock() else {
272 return false;
273 };
274 let hit = store.take_pending_escalation(&norm, now_unix());
275 if hit {
276 maybe_flush(&mut store);
277 }
278 hit
279}
280
281pub fn is_risky_mode(path: &str, mode: &str) -> bool {
283 let ext = ext_of(path);
284 global().lock().is_ok_and(|s| s.is_risky(&ext, mode))
285}
286
287pub fn metrics_snapshot() -> serde_json::Value {
289 let Ok(store) = global().lock() else {
290 return serde_json::json!({});
291 };
292 let mut pairs: Vec<serde_json::Value> = store
293 .pairs
294 .iter()
295 .map(|(key, s)| {
296 serde_json::json!({
297 "pair": key,
298 "fails": s.fails,
299 "successes": s.successes,
300 "fail_rate": (s.fail_rate() * 1000.0).round() / 1000.0,
301 "risky": s.risky,
302 })
303 })
304 .collect();
305 pairs.sort_by(|a, b| {
306 let fa = a["fail_rate"].as_f64().unwrap_or(0.0);
307 let fb = b["fail_rate"].as_f64().unwrap_or(0.0);
308 fb.partial_cmp(&fa).unwrap_or(std::cmp::Ordering::Equal)
309 });
310 serde_json::json!({
311 "pairs": pairs,
312 "pending_escalations": store.pending_escalations.len(),
313 "escalations_served": store.escalations_served,
314 })
315}
316
317pub fn flush() {
318 if let Ok(store) = global().lock() {
319 if store.dirty {
320 let _ = store.save();
321 }
322 }
323}
324
325fn maybe_flush(store: &mut EditQualityStore) {
326 let n = RECORD_CALLS.fetch_add(1, Ordering::Relaxed) + 1;
327 if n.is_multiple_of(FLUSH_EVERY) && store.dirty && store.save().is_ok() {
328 store.dirty = false;
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 #[test]
337 fn risky_after_two_majority_fails_with_hysteresis() {
338 let mut s = EditQualityStore::default();
339 s.record_failure("rs", "map", 1000);
340 assert!(!s.is_risky("rs", "map"), "one fail is not a pattern");
341 s.record_failure("rs", "map", 1001);
342 assert!(s.is_risky("rs", "map"), "2 fails, rate 1.0 >= 0.25");
343
344 for _ in 0..11 {
346 s.record_success("rs", "map");
347 }
348 assert!(s.is_risky("rs", "map"), "2/13 ≈ 0.154 still risky");
349 s.record_success("rs", "map");
350 assert!(!s.is_risky("rs", "map"), "2/14 ≈ 0.143 < 0.15 recovers");
351 }
352
353 #[test]
354 fn entering_risky_needs_quarter_rate_not_just_two_fails() {
355 let mut s = EditQualityStore::default();
356 for _ in 0..7 {
357 s.record_success("ts", "signatures");
358 }
359 s.record_failure("ts", "signatures", 1000);
360 s.record_failure("ts", "signatures", 1001);
361 assert!(!s.is_risky("ts", "signatures"));
363 s.record_failure("ts", "signatures", 1002);
364 assert!(s.is_risky("ts", "signatures"));
366 }
367
368 #[test]
369 fn penalty_is_per_extension_not_global() {
370 let mut s = EditQualityStore::default();
371 s.record_failure("rs", "map", 1000);
372 s.record_failure("rs", "map", 1001);
373 assert!(s.is_risky("rs", "map"));
374 assert!(!s.is_risky("py", "map"), "py|map untouched");
375 assert!(!s.is_risky("rs", "signatures"), "rs|signatures untouched");
376 }
377
378 #[test]
379 fn escalation_is_one_shot_and_expires() {
380 let mut s = EditQualityStore::default();
381 s.set_pending_escalation("src/a.rs", 1000);
382 assert!(s.take_pending_escalation("src/a.rs", 1100));
383 assert!(
384 !s.take_pending_escalation("src/a.rs", 1101),
385 "consumed — second read is normal again"
386 );
387 assert_eq!(s.escalations_served, 1);
388
389 s.set_pending_escalation("src/b.rs", 1000);
390 assert!(
391 !s.take_pending_escalation("src/b.rs", 1000 + ESCALATION_TTL_SECS + 1),
392 "expired escalations are dropped, not served"
393 );
394 assert_eq!(s.escalations_served, 1);
395 }
396
397 #[test]
398 fn decay_drops_stale_pairs_and_pendings() {
399 let mut s = EditQualityStore::default();
400 s.record_failure("rs", "map", 1000);
401 s.record_failure("go", "map", 5000);
402 s.set_pending_escalation("old.rs", 1000);
403 s.set_pending_escalation("fresh.rs", 5000);
404 s.decay(5000 + DECAY_SECS - 10);
405 assert!(!s.pairs.contains_key("rs|map"));
406 assert!(s.pairs.contains_key("go|map"));
407 assert!(s.pending_escalations.is_empty());
409 }
410
411 #[test]
412 fn eviction_keeps_newest() {
413 let mut s = EditQualityStore::default();
414 for i in 0..(MAX_PAIRS + 10) {
415 s.record_failure(&format!("e{i}"), "map", 1000 + i as u64);
416 }
417 assert_eq!(s.pairs.len(), MAX_PAIRS);
418 assert!(!s.pairs.contains_key("e0|map"));
419 for i in 0..(MAX_PENDING + 5) {
420 s.set_pending_escalation(&format!("f{i}.rs"), 1000 + i as u64);
421 }
422 assert_eq!(s.pending_escalations.len(), MAX_PENDING);
423 assert!(!s.pending_escalations.contains_key("f0.rs"));
424 }
425
426 #[test]
427 fn roundtrip_serialization() {
428 let mut s = EditQualityStore::default();
429 s.record_failure("rs", "map", 42);
430 s.set_pending_escalation("x.rs", 42);
431 let json = serde_json::to_string(&s).unwrap();
432 let back: EditQualityStore = serde_json::from_str(&json).unwrap();
433 assert_eq!(back.pairs.get("rs|map").unwrap().fails, 1);
434 assert!(back.pending_escalations.contains_key("x.rs"));
435 }
436}