1use std::collections::HashMap;
15use std::sync::Mutex;
16use std::time::Instant;
17
18use serde::{Deserialize, Serialize};
19
20const LR: f64 = 0.02;
22const CLAMP: f64 = 0.15;
24const MIN_SAMPLES: u32 = 10;
26const DAILY_DECAY: f64 = 0.98;
28const FLUSH_SECS: u64 = 60;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum QualitySignal {
33 Bounce,
35 EditFail,
37 CleanCompressed,
39 WastedFull,
42}
43
44impl QualitySignal {
45 fn weight(self) -> f64 {
48 match self {
49 QualitySignal::Bounce => -3.0,
50 QualitySignal::EditFail => -6.0,
51 QualitySignal::CleanCompressed => 0.5,
52 QualitySignal::WastedFull => 2.0,
53 }
54 }
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, Default)]
58pub struct LearnedDelta {
59 pub delta_entropy: f64,
60 pub samples: u32,
61 pub last_decay_day: u64,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize, Default)]
66pub struct ThresholdLearner {
67 pub per_ext: HashMap<String, LearnedDelta>,
69 pub schema_version: u32,
70}
71
72static BUFFER: Mutex<Option<(ThresholdLearner, Instant)>> = Mutex::new(None);
73
74fn store_path() -> std::path::PathBuf {
75 crate::core::paths::cache_dir()
76 .unwrap_or_else(|_| std::path::PathBuf::from("."))
77 .join("thresholds_learned.json")
78}
79
80fn epoch_day(now_secs: u64) -> u64 {
81 now_secs / 86_400
82}
83
84fn now_secs() -> u64 {
85 std::time::SystemTime::now()
86 .duration_since(std::time::UNIX_EPOCH)
87 .map_or(0, |d| d.as_secs())
88}
89
90impl ThresholdLearner {
91 fn load_from_disk() -> Self {
92 let path = store_path();
93 if let Ok(content) = std::fs::read_to_string(&path)
94 && let Ok(learner) = serde_json::from_str::<ThresholdLearner>(&content)
95 {
96 return learner;
97 }
98 ThresholdLearner {
99 schema_version: 1,
100 ..Default::default()
101 }
102 }
103
104 fn save_to_disk(&self) {
105 let path = store_path();
106 if let Some(parent) = path.parent() {
107 let _ = std::fs::create_dir_all(parent);
108 }
109 if let Ok(json) = serde_json::to_string_pretty(self) {
110 let _ = std::fs::write(path, json);
111 }
112 }
113
114 pub fn merge_from(&mut self, other: &Self) {
119 for (ext, theirs) in &other.per_ext {
120 match self.per_ext.get_mut(ext) {
121 None => {
122 let mut d = theirs.clone();
123 d.delta_entropy = d.delta_entropy.clamp(-CLAMP, CLAMP);
124 self.per_ext.insert(ext.clone(), d);
125 }
126 Some(ours) => {
127 let total = u64::from(ours.samples) + u64::from(theirs.samples);
128 if theirs.samples == 0 || total == 0 {
129 continue;
130 }
131 let weighted = (ours.delta_entropy * f64::from(ours.samples)
132 + theirs.delta_entropy * f64::from(theirs.samples))
133 / total as f64;
134 ours.delta_entropy = weighted.clamp(-CLAMP, CLAMP);
135 ours.samples = ours.samples.max(theirs.samples);
136 ours.last_decay_day = ours.last_decay_day.max(theirs.last_decay_day);
137 }
138 }
139 }
140 }
141
142 pub fn record(&mut self, ext: &str, signal: QualitySignal, now: u64) {
144 let ext = normalize_ext(ext);
145 if ext.is_empty() {
146 return;
147 }
148 let day = epoch_day(now);
149 let entry = self.per_ext.entry(ext).or_default();
150 Self::apply_decay(entry, day);
151 entry.delta_entropy = (entry.delta_entropy + LR * signal.weight()).clamp(-CLAMP, CLAMP);
152 entry.samples = entry.samples.saturating_add(1);
153 }
154
155 pub fn delta_for(&mut self, ext: &str, now: u64) -> f64 {
157 let ext = normalize_ext(ext);
158 let day = epoch_day(now);
159 match self.per_ext.get_mut(&ext) {
160 Some(entry) => {
161 Self::apply_decay(entry, day);
162 if entry.samples >= MIN_SAMPLES {
163 entry.delta_entropy
164 } else {
165 0.0
166 }
167 }
168 None => 0.0,
169 }
170 }
171
172 fn apply_decay(entry: &mut LearnedDelta, today: u64) {
173 if entry.last_decay_day == 0 {
174 entry.last_decay_day = today;
175 return;
176 }
177 let days = today.saturating_sub(entry.last_decay_day);
178 if days > 0 {
179 let factor = DAILY_DECAY.powi(days.min(365) as i32);
181 entry.delta_entropy *= factor;
182 entry.last_decay_day = today;
183 }
184 }
185
186 pub fn report_lines(&self) -> Vec<String> {
188 let mut exts: Vec<_> = self.per_ext.iter().collect();
189 exts.sort_by(|a, b| a.0.cmp(b.0));
190 exts.iter()
191 .map(|(ext, d)| {
192 let active = if d.samples >= MIN_SAMPLES {
193 "active"
194 } else {
195 "warmup"
196 };
197 format!(
198 " .{ext}: delta={:+.3} (n={}, {active})",
199 d.delta_entropy, d.samples
200 )
201 })
202 .collect()
203 }
204}
205
206fn normalize_ext(ext: &str) -> String {
207 ext.trim_start_matches('.').to_ascii_lowercase()
208}
209
210fn with_buffer<R>(f: impl FnOnce(&mut ThresholdLearner) -> R) -> R {
211 let mut guard = BUFFER
212 .lock()
213 .unwrap_or_else(std::sync::PoisonError::into_inner);
214 if guard.is_none() {
215 *guard = Some((ThresholdLearner::load_from_disk(), Instant::now()));
216 }
217 let (learner, last_flush) = guard.as_mut().expect("buffer initialized above");
218 let result = f(learner);
219 if last_flush.elapsed().as_secs() >= FLUSH_SECS {
220 learner.save_to_disk();
221 *last_flush = Instant::now();
222 }
223 result
224}
225
226pub fn record_signal(path: &str, signal: QualitySignal) {
228 let ext = std::path::Path::new(path)
229 .extension()
230 .and_then(|e| e.to_str())
231 .unwrap_or("")
232 .to_string();
233 if ext.is_empty() {
234 return;
235 }
236 with_buffer(|l| l.record(&ext, signal, now_secs()));
237}
238
239pub fn learned_delta(ext: &str) -> f64 {
241 with_buffer(|l| l.delta_for(ext, now_secs()))
242}
243
244pub fn flush() {
246 let guard = BUFFER
247 .lock()
248 .unwrap_or_else(std::sync::PoisonError::into_inner);
249 if let Some((ref learner, _)) = *guard {
250 learner.save_to_disk();
251 }
252}
253
254pub fn report() -> Vec<String> {
256 with_buffer(|l| l.report_lines())
257}
258
259pub fn snapshot() -> Vec<(String, LearnedDelta)> {
262 with_buffer(|l| {
263 let mut v: Vec<_> = l
264 .per_ext
265 .iter()
266 .map(|(k, d)| (k.clone(), d.clone()))
267 .collect();
268 v.sort_by(|a, b| a.0.cmp(&b.0));
269 v
270 })
271}
272
273pub fn export_state() -> ThresholdLearner {
275 with_buffer(|l| l.clone())
276}
277
278pub fn merge_state(other: &ThresholdLearner) {
280 with_buffer(|l| l.merge_from(other));
281 flush();
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 const NOW: u64 = 1_780_000_000;
289
290 #[test]
291 fn warmup_returns_zero_delta() {
292 let mut l = ThresholdLearner::default();
293 for _ in 0..MIN_SAMPLES - 1 {
294 l.record("rs", QualitySignal::Bounce, NOW);
295 }
296 assert_eq!(l.delta_for("rs", NOW), 0.0);
297 l.record("rs", QualitySignal::Bounce, NOW);
298 assert!(l.delta_for("rs", NOW) < 0.0);
299 }
300
301 #[test]
302 fn bounce_burst_lowers_threshold() {
303 let mut l = ThresholdLearner::default();
304 for _ in 0..20 {
305 l.record("yml", QualitySignal::Bounce, NOW);
306 }
307 let d = l.delta_for("yml", NOW);
308 assert!(d < -0.05, "bounce burst should push delta down, got {d}");
309 }
310
311 #[test]
312 fn edit_fail_is_stronger_than_bounce() {
313 let mut l = ThresholdLearner::default();
314 for _ in 0..MIN_SAMPLES {
315 l.record("a", QualitySignal::Bounce, NOW);
316 l.record("b", QualitySignal::EditFail, NOW);
317 }
318 assert!(l.delta_for("b", NOW) <= l.delta_for("a", NOW));
319 }
320
321 #[test]
322 fn waste_burst_raises_threshold() {
323 let mut l = ThresholdLearner::default();
324 for _ in 0..20 {
325 l.record("json", QualitySignal::WastedFull, NOW);
326 }
327 let d = l.delta_for("json", NOW);
328 assert!(d > 0.05, "waste burst should push delta up, got {d}");
329 }
330
331 #[test]
332 fn clamp_holds_under_extreme_signals() {
333 let mut l = ThresholdLearner::default();
334 for _ in 0..500 {
335 l.record("rs", QualitySignal::EditFail, NOW);
336 }
337 assert!(l.delta_for("rs", NOW) >= -CLAMP - f64::EPSILON);
338 for _ in 0..2000 {
339 l.record("rs", QualitySignal::WastedFull, NOW);
340 }
341 assert!(l.delta_for("rs", NOW) <= CLAMP + f64::EPSILON);
342 }
343
344 #[test]
345 fn decay_drifts_back_toward_zero() {
346 let mut l = ThresholdLearner::default();
347 for _ in 0..30 {
348 l.record("rs", QualitySignal::Bounce, NOW);
349 }
350 let before = l.delta_for("rs", NOW);
351 let after = l.delta_for("rs", NOW + 30 * 86_400);
352 assert!(
353 after.abs() < before.abs(),
354 "30 days of decay should shrink |delta|: {before} -> {after}"
355 );
356 }
357
358 #[test]
359 fn clean_reads_recover_after_bounces() {
360 let mut l = ThresholdLearner::default();
361 for _ in 0..15 {
362 l.record("ts", QualitySignal::Bounce, NOW);
363 }
364 let low = l.delta_for("ts", NOW);
365 for _ in 0..200 {
366 l.record("ts", QualitySignal::CleanCompressed, NOW);
367 }
368 assert!(l.delta_for("ts", NOW) > low);
369 }
370
371 #[test]
372 fn ext_normalization() {
373 let mut l = ThresholdLearner::default();
374 for _ in 0..MIN_SAMPLES {
375 l.record(".RS", QualitySignal::Bounce, NOW);
376 }
377 assert!(l.delta_for("rs", NOW) < 0.0);
378 }
379}