use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Mutex, OnceLock};
use serde::{Deserialize, Serialize};
const STORE_FILE: &str = "edit_quality.json";
const DECAY_SECS: u64 = 30 * 24 * 3600;
const ESCALATION_TTL_SECS: u64 = 3600;
const MAX_PAIRS: usize = 200;
const MAX_PENDING: usize = 100;
const FLUSH_EVERY: usize = 10;
const RISKY_ENTER_RATE: f64 = 0.25;
const RISKY_EXIT_RATE: f64 = 0.15;
const RISKY_MIN_FAILS: u32 = 2;
static STORE: OnceLock<Mutex<EditQualityStore>> = OnceLock::new();
static RECORD_CALLS: AtomicUsize = AtomicUsize::new(0);
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PairStats {
pub fails: u32,
pub successes: u32,
pub risky: bool,
pub last_fail_unix: u64,
}
impl PairStats {
fn fail_rate(&self) -> f64 {
let total = self.fails + self.successes;
if total == 0 {
return 0.0;
}
f64::from(self.fails) / f64::from(total)
}
fn update_risky(&mut self) {
if self.risky {
if self.fail_rate() < RISKY_EXIT_RATE {
self.risky = false;
}
} else if self.fails >= RISKY_MIN_FAILS && self.fail_rate() >= RISKY_ENTER_RATE {
self.risky = true;
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EditQualityStore {
pub pairs: HashMap<String, PairStats>,
pub pending_escalations: HashMap<String, u64>,
#[serde(default)]
pub escalations_served: u64,
#[serde(skip)]
dirty: bool,
}
fn pair_key(ext: &str, mode: &str) -> String {
format!("{ext}|{mode}")
}
impl EditQualityStore {
fn load_from_disk() -> Self {
let Ok(raw) = std::fs::read_to_string(store_path()) else {
return Self::default();
};
let mut store: Self = serde_json::from_str(&raw).unwrap_or_default();
store.decay(now_unix());
store
}
fn decay(&mut self, now: u64) {
let before = self.pairs.len() + self.pending_escalations.len();
self.pairs
.retain(|_, s| now.saturating_sub(s.last_fail_unix) <= DECAY_SECS);
self.pending_escalations
.retain(|_, ts| now.saturating_sub(*ts) <= ESCALATION_TTL_SECS);
if self.pairs.len() + self.pending_escalations.len() != before {
self.dirty = true;
}
}
fn evict_to_caps(&mut self) {
if self.pairs.len() > MAX_PAIRS {
let mut items: Vec<(String, u64)> = self
.pairs
.iter()
.map(|(k, s)| (k.clone(), s.last_fail_unix))
.collect();
items.sort_by_key(|(_, ts)| *ts);
let drop_n = self.pairs.len() - MAX_PAIRS;
for (key, _) in items.into_iter().take(drop_n) {
self.pairs.remove(&key);
}
self.dirty = true;
}
if self.pending_escalations.len() > MAX_PENDING {
let mut items: Vec<(String, u64)> = self
.pending_escalations
.iter()
.map(|(k, ts)| (k.clone(), *ts))
.collect();
items.sort_by_key(|(_, ts)| *ts);
let drop_n = self.pending_escalations.len() - MAX_PENDING;
for (key, _) in items.into_iter().take(drop_n) {
self.pending_escalations.remove(&key);
}
self.dirty = true;
}
}
pub fn record_failure(&mut self, ext: &str, mode: &str, now: u64) {
let entry = self.pairs.entry(pair_key(ext, mode)).or_default();
entry.fails = entry.fails.saturating_add(1);
entry.last_fail_unix = now;
entry.update_risky();
self.dirty = true;
self.evict_to_caps();
}
pub fn record_success(&mut self, ext: &str, mode: &str) {
let entry = self.pairs.entry(pair_key(ext, mode)).or_default();
entry.successes = entry.successes.saturating_add(1);
entry.update_risky();
self.dirty = true;
}
pub fn set_pending_escalation(&mut self, norm_path: &str, now: u64) {
self.pending_escalations.insert(norm_path.to_string(), now);
self.dirty = true;
self.evict_to_caps();
}
pub fn take_pending_escalation(&mut self, norm_path: &str, now: u64) -> bool {
match self.pending_escalations.remove(norm_path) {
Some(ts) if now.saturating_sub(ts) <= ESCALATION_TTL_SECS => {
self.escalations_served += 1;
self.dirty = true;
true
}
Some(_) => {
self.dirty = true;
false
}
None => false,
}
}
pub fn is_risky(&self, ext: &str, mode: &str) -> bool {
self.pairs
.get(&pair_key(ext, mode))
.is_some_and(|s| s.risky)
}
pub fn save(&self) -> std::io::Result<()> {
let path = store_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string(self)?;
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, &path)
}
}
fn store_path() -> PathBuf {
crate::core::data_dir::lean_ctx_data_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(STORE_FILE)
}
fn now_unix() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
fn global() -> &'static Mutex<EditQualityStore> {
STORE.get_or_init(|| Mutex::new(EditQualityStore::load_from_disk()))
}
fn ext_of(path: &str) -> String {
std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_string()
}
pub fn record_edit_outcome(path: &str, last_mode: &str, success: bool) {
if last_mode.is_empty() {
return;
}
let ext = ext_of(path);
let Ok(mut store) = global().lock() else {
return;
};
if success {
store.record_success(&ext, last_mode);
} else {
let now = now_unix();
store.record_failure(&ext, last_mode, now);
if last_mode != "full" {
let norm = crate::core::pathutil::normalize_tool_path(path);
store.set_pending_escalation(&norm, now);
crate::core::adaptive_thresholds::record_quality_signal(
path,
crate::core::threshold_learning::QualitySignal::EditFail,
);
let scent_path = norm.clone();
std::thread::spawn(move || {
crate::core::scent_field::deposit(
crate::core::scent_field::scent_agent_id(),
crate::core::scent_field::ScentKind::Stuck,
&scent_path,
1.0,
);
});
}
}
maybe_flush(&mut store);
}
pub fn take_pending_escalation(path: &str) -> bool {
let norm = crate::core::pathutil::normalize_tool_path(path);
let Ok(mut store) = global().lock() else {
return false;
};
let hit = store.take_pending_escalation(&norm, now_unix());
if hit {
maybe_flush(&mut store);
}
hit
}
pub fn is_risky_mode(path: &str, mode: &str) -> bool {
let ext = ext_of(path);
global().lock().is_ok_and(|s| s.is_risky(&ext, mode))
}
pub fn metrics_snapshot() -> serde_json::Value {
let Ok(store) = global().lock() else {
return serde_json::json!({});
};
let mut pairs: Vec<serde_json::Value> = store
.pairs
.iter()
.map(|(key, s)| {
serde_json::json!({
"pair": key,
"fails": s.fails,
"successes": s.successes,
"fail_rate": (s.fail_rate() * 1000.0).round() / 1000.0,
"risky": s.risky,
})
})
.collect();
pairs.sort_by(|a, b| {
let fa = a["fail_rate"].as_f64().unwrap_or(0.0);
let fb = b["fail_rate"].as_f64().unwrap_or(0.0);
fb.partial_cmp(&fa).unwrap_or(std::cmp::Ordering::Equal)
});
serde_json::json!({
"pairs": pairs,
"pending_escalations": store.pending_escalations.len(),
"escalations_served": store.escalations_served,
})
}
pub fn flush() {
if let Ok(store) = global().lock()
&& store.dirty
{
let _ = store.save();
}
}
fn maybe_flush(store: &mut EditQualityStore) {
let n = RECORD_CALLS.fetch_add(1, Ordering::Relaxed) + 1;
if n.is_multiple_of(FLUSH_EVERY) && store.dirty && store.save().is_ok() {
store.dirty = false;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn risky_after_two_majority_fails_with_hysteresis() {
let mut s = EditQualityStore::default();
s.record_failure("rs", "map", 1000);
assert!(!s.is_risky("rs", "map"), "one fail is not a pattern");
s.record_failure("rs", "map", 1001);
assert!(s.is_risky("rs", "map"), "2 fails, rate 1.0 >= 0.25");
for _ in 0..11 {
s.record_success("rs", "map");
}
assert!(s.is_risky("rs", "map"), "2/13 ≈ 0.154 still risky");
s.record_success("rs", "map");
assert!(!s.is_risky("rs", "map"), "2/14 ≈ 0.143 < 0.15 recovers");
}
#[test]
fn entering_risky_needs_quarter_rate_not_just_two_fails() {
let mut s = EditQualityStore::default();
for _ in 0..7 {
s.record_success("ts", "signatures");
}
s.record_failure("ts", "signatures", 1000);
s.record_failure("ts", "signatures", 1001);
assert!(!s.is_risky("ts", "signatures"));
s.record_failure("ts", "signatures", 1002);
assert!(s.is_risky("ts", "signatures"));
}
#[test]
fn penalty_is_per_extension_not_global() {
let mut s = EditQualityStore::default();
s.record_failure("rs", "map", 1000);
s.record_failure("rs", "map", 1001);
assert!(s.is_risky("rs", "map"));
assert!(!s.is_risky("py", "map"), "py|map untouched");
assert!(!s.is_risky("rs", "signatures"), "rs|signatures untouched");
}
#[test]
fn escalation_is_one_shot_and_expires() {
let mut s = EditQualityStore::default();
s.set_pending_escalation("src/a.rs", 1000);
assert!(s.take_pending_escalation("src/a.rs", 1100));
assert!(
!s.take_pending_escalation("src/a.rs", 1101),
"consumed — second read is normal again"
);
assert_eq!(s.escalations_served, 1);
s.set_pending_escalation("src/b.rs", 1000);
assert!(
!s.take_pending_escalation("src/b.rs", 1000 + ESCALATION_TTL_SECS + 1),
"expired escalations are dropped, not served"
);
assert_eq!(s.escalations_served, 1);
}
#[test]
fn decay_drops_stale_pairs_and_pendings() {
let mut s = EditQualityStore::default();
s.record_failure("rs", "map", 1000);
s.record_failure("go", "map", 5000);
s.set_pending_escalation("old.rs", 1000);
s.set_pending_escalation("fresh.rs", 5000);
s.decay(5000 + DECAY_SECS - 10);
assert!(!s.pairs.contains_key("rs|map"));
assert!(s.pairs.contains_key("go|map"));
assert!(s.pending_escalations.is_empty());
}
#[test]
fn eviction_keeps_newest() {
let mut s = EditQualityStore::default();
for i in 0..(MAX_PAIRS + 10) {
s.record_failure(&format!("e{i}"), "map", 1000 + i as u64);
}
assert_eq!(s.pairs.len(), MAX_PAIRS);
assert!(!s.pairs.contains_key("e0|map"));
for i in 0..(MAX_PENDING + 5) {
s.set_pending_escalation(&format!("f{i}.rs"), 1000 + i as u64);
}
assert_eq!(s.pending_escalations.len(), MAX_PENDING);
assert!(!s.pending_escalations.contains_key("f0.rs"));
}
#[test]
fn roundtrip_serialization() {
let mut s = EditQualityStore::default();
s.record_failure("rs", "map", 42);
s.set_pending_escalation("x.rs", 42);
let json = serde_json::to_string(&s).unwrap();
let back: EditQualityStore = serde_json::from_str(&json).unwrap();
assert_eq!(back.pairs.get("rs|map").unwrap().fails, 1);
assert!(back.pending_escalations.contains_key("x.rs"));
}
}