Skip to main content

lean_ctx/core/
mode_predictor.rs

1use std::collections::HashMap;
2use std::sync::{Arc, Mutex};
3use std::time::Instant;
4
5const STATS_FILE: &str = "mode_stats.json";
6const PREDICTOR_FLUSH_SECS: u64 = 10;
7
8static PREDICTOR_BUFFER: Mutex<Option<(Arc<ModePredictor>, Instant)>> = Mutex::new(None);
9
10/// Observed outcome of a read mode: tokens in/out and information density.
11#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
12pub struct ModeOutcome {
13    pub mode: String,
14    pub tokens_in: usize,
15    pub tokens_out: usize,
16    pub density: f64,
17}
18
19impl ModeOutcome {
20    /// Computes an efficiency score: density / compression ratio.
21    pub fn efficiency(&self) -> f64 {
22        if self.tokens_out == 0 {
23            return 0.0;
24        }
25        self.density / (self.tokens_out as f64 / self.tokens_in.max(1) as f64)
26    }
27}
28
29/// File identity for mode prediction: extension + token-count size bucket.
30#[derive(Clone, Debug, Hash, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
31pub struct FileSignature {
32    pub ext: String,
33    pub size_bucket: u8,
34}
35
36impl FileSignature {
37    /// Creates a file signature from its path and token count.
38    pub fn from_path(path: &str, token_count: usize) -> Self {
39        let ext = std::path::Path::new(path)
40            .extension()
41            .and_then(|e| e.to_str())
42            .unwrap_or("")
43            .to_string();
44        let size_bucket = match token_count {
45            0..=500 => 0,
46            501..=2000 => 1,
47            2001..=5000 => 2,
48            5001..=20000 => 3,
49            _ => 4,
50        };
51        Self { ext, size_bucket }
52    }
53}
54
55/// Learns the best read mode per file signature from historical outcomes.
56#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
57pub struct ModePredictor {
58    // `FileSignature` is a struct, so a plain `HashMap<FileSignature, _>`
59    // serializes to a JSON object with non-string keys — which `serde_json`
60    // rejects ("key must be a string"). That made `save_to_disk` fail silently,
61    // so `mode_stats.json` was never written and the predictor relearned from
62    // scratch every process (#550). Persist the history as a list of
63    // (signature, outcomes) entries, which round-trips in any serde format. No
64    // migration is needed: the broken format never produced a file to read back.
65    #[serde(with = "history_serde")]
66    history: HashMap<FileSignature, Vec<ModeOutcome>>,
67    project_root: Option<String>,
68}
69
70/// (De)serializes [`ModePredictor::history`] as a sequence of entries so the
71/// struct-keyed map survives `serde_json` (see the field comment, #550).
72mod history_serde {
73    use super::{FileSignature, ModeOutcome};
74    use serde::{Deserialize, Deserializer, Serialize, Serializer};
75    use std::collections::HashMap;
76
77    pub(super) fn serialize<S: Serializer>(
78        history: &HashMap<FileSignature, Vec<ModeOutcome>>,
79        serializer: S,
80    ) -> Result<S::Ok, S::Error> {
81        let entries: Vec<(&FileSignature, &Vec<ModeOutcome>)> = history.iter().collect();
82        entries.serialize(serializer)
83    }
84
85    pub(super) fn deserialize<'de, D: Deserializer<'de>>(
86        deserializer: D,
87    ) -> Result<HashMap<FileSignature, Vec<ModeOutcome>>, D::Error> {
88        let entries: Vec<(FileSignature, Vec<ModeOutcome>)> = Vec::deserialize(deserializer)?;
89        Ok(entries.into_iter().collect())
90    }
91}
92
93impl ModePredictor {
94    /// Loads or creates the predictor, using an in-memory buffer for caching.
95    pub fn new() -> Self {
96        let mut guard = PREDICTOR_BUFFER
97            .lock()
98            .unwrap_or_else(std::sync::PoisonError::into_inner);
99        if let Some((ref predictor, _)) = *guard {
100            return Self {
101                history: predictor.history.clone(),
102                project_root: predictor.project_root.clone(),
103            };
104        }
105        let mut loaded = Self::load_from_disk().unwrap_or_default();
106        if loaded.project_root.is_none() {
107            loaded.project_root = std::env::current_dir()
108                .ok()
109                .map(|p| p.to_string_lossy().to_string());
110        }
111        *guard = Some((Arc::new(loaded.clone()), Instant::now()));
112        loaded
113    }
114
115    pub fn with_project_root(mut self, root: &str) -> Self {
116        self.project_root = Some(root.to_string());
117        self
118    }
119
120    pub fn set_project_root(&mut self, root: &str) {
121        self.project_root = Some(root.to_string());
122    }
123
124    /// Records a mode outcome for a file signature (capped at 100 entries).
125    pub fn record(&mut self, sig: FileSignature, outcome: ModeOutcome) {
126        let entries = self.history.entry(sig).or_default();
127        entries.push(outcome);
128        if entries.len() > 100 {
129            entries.drain(0..50);
130        }
131    }
132
133    /// Returns the best mode based on historical efficiency.
134    /// Chain: local history -> cloud adaptive models -> built-in defaults.
135    pub fn predict_best_mode(&self, sig: &FileSignature) -> Option<String> {
136        let default_mode = Self::predict_from_defaults(sig);
137
138        let allow_override = |candidate: &str| -> bool {
139            let Some(def) = default_mode.as_deref() else {
140                return true;
141            };
142            if candidate == "full" {
143                return false;
144            }
145            // For code-structured defaults, never override to lossy modes.
146            if (def == "map" || def == "signatures")
147                && (candidate == "aggressive" || candidate == "entropy")
148            {
149                return false;
150            }
151            true
152        };
153
154        if let Some(local) = self.predict_from_local(sig)
155            && allow_override(&local)
156        {
157            return Some(local);
158        }
159        if let Some(bandit) = self.predict_from_bandit(sig)
160            && allow_override(&bandit)
161        {
162            return Some(bandit);
163        }
164        if let Some(cloud) = self.predict_from_cloud(sig)
165            && allow_override(&cloud)
166        {
167            return Some(cloud);
168        }
169        default_mode
170    }
171
172    fn predict_from_bandit(&self, sig: &FileSignature) -> Option<String> {
173        let key = format!("{}_feedback", sig.ext);
174        let store =
175            crate::core::bandit::BanditStore::load(self.project_root.as_deref().unwrap_or("."));
176        let bandit = store.bandits.get(&key)?;
177        if bandit.total_pulls < 5 {
178            return None;
179        }
180        let best_arm = bandit.arms.iter().max_by(|a, b| {
181            a.mean()
182                .partial_cmp(&b.mean())
183                .unwrap_or(std::cmp::Ordering::Equal)
184        })?;
185        // Arm semantics are defined by the trainer (`feedback::update_bandit`),
186        // which buckets each outcome by the entropy threshold actually used: a
187        // HIGH threshold (>= 1.0, the *most* compression) trains `conservative`,
188        // a LOW threshold (< 0.7, the *least*) trains `aggressive`. So a winning
189        // `conservative` arm means "high compression has been succeeding" and must
190        // map to a high-compression mode. The previous `conservative => "full"`
191        // inverted this: it disabled compression precisely when aggressive
192        // compression was working (GL #622). Spans heaviest → lightest structural
193        // compression; `full` is intentionally not a learned suggestion (forced
194        // full reads are handled by `should_force_full`).
195        let mode = match best_arm.name.as_str() {
196            "conservative" => "aggressive",
197            "balanced" => "signatures",
198            "aggressive" => "map",
199            _ => return None,
200        };
201        Some(mode.to_string())
202    }
203
204    fn predict_from_local(&self, sig: &FileSignature) -> Option<String> {
205        let entries = self.history.get(sig)?;
206        if entries.len() < 3 {
207            return None;
208        }
209
210        let mut mode_scores: HashMap<&str, (f64, usize)> = HashMap::new();
211        for entry in entries {
212            let (sum, count) = mode_scores.entry(&entry.mode).or_insert((0.0, 0));
213            *sum += entry.efficiency();
214            *count += 1;
215        }
216
217        mode_scores
218            .into_iter()
219            .max_by(|a, b| {
220                let avg_a = a.1.0 / a.1.1 as f64;
221                let avg_b = b.1.0 / b.1.1 as f64;
222                avg_a
223                    .partial_cmp(&avg_b)
224                    .unwrap_or(std::cmp::Ordering::Equal)
225            })
226            .map(|(mode, _)| mode.to_string())
227    }
228
229    /// Loads cloud adaptive models (synced from LeanCTX Cloud).
230    /// Models are cached locally and auto-updated for cloud users.
231    #[allow(clippy::unused_self)]
232    fn predict_from_cloud(&self, sig: &FileSignature) -> Option<String> {
233        let data = crate::cloud_client::load_cloud_models()?;
234        let models = data["models"].as_array()?;
235
236        let ext_with_dot = format!(".{}", sig.ext);
237        let bucket_name = match sig.size_bucket {
238            0 => "0-500",
239            1 => "500-2k",
240            2 => "2k-10k",
241            _ => "10k+",
242        };
243
244        let mut best: Option<(&str, f64)> = None;
245
246        for model in models {
247            let m_ext = model["file_ext"].as_str().unwrap_or("");
248            let m_bucket = model["size_bucket"].as_str().unwrap_or("");
249            let confidence = model["confidence"].as_f64().unwrap_or(0.0);
250
251            if m_ext == ext_with_dot
252                && m_bucket == bucket_name
253                && confidence > 0.5
254                && let Some(mode) = model["recommended_mode"].as_str()
255                && best.is_none_or(|(_, c)| confidence > c)
256            {
257                best = Some((mode, confidence));
258            }
259        }
260
261        if let Some((mode, _)) = best {
262            return Some(mode.to_string());
263        }
264
265        for model in models {
266            let m_ext = model["file_ext"].as_str().unwrap_or("");
267            let confidence = model["confidence"].as_f64().unwrap_or(0.0);
268            if m_ext == ext_with_dot && confidence > 0.5 {
269                return model["recommended_mode"]
270                    .as_str()
271                    .map(std::string::ToString::to_string);
272            }
273        }
274
275        None
276    }
277
278    /// Built-in defaults for common file types and sizes.
279    /// Ensures reasonable compression even without local history or cloud models.
280    /// Respects Kolmogorov-Gate: files with K>0.7 skip aggressive modes.
281    fn predict_from_defaults(sig: &FileSignature) -> Option<String> {
282        if sig.size_bucket == 0 {
283            return None;
284        }
285        if matches!(sig.ext.as_str(), "md" | "mdx" | "txt" | "rst") {
286            return None;
287        }
288
289        let mode = match (sig.ext.as_str(), sig.size_bucket) {
290            // Large code files: signatures only
291            (
292                "rs" | "ts" | "tsx" | "js" | "jsx" | "py" | "go" | "java" | "c" | "cpp" | "rb"
293                | "swift" | "kt" | "cs" | "vue" | "svelte" | "gd",
294                4..,
295            ) => "signatures",
296
297            // Code 2k-10k, SQL, lock, config/data: structured map
298            ("lock" | "json" | "yaml" | "yml" | "toml", _)
299            | (
300                "rs" | "ts" | "tsx" | "js" | "jsx" | "py" | "go" | "java" | "c" | "cpp" | "rb"
301                | "swift" | "kt" | "cs" | "vue" | "svelte" | "gd",
302                2 | 3,
303            )
304            | ("sql", 2..) => "map",
305
306            // CSS, XML/CSV, and large unknown files: aggressive
307            ("xml" | "csv", _) | ("css" | "scss" | "less" | "sass", 2..) | (_, 3..) => "aggressive",
308
309            _ => return None,
310        };
311        Some(mode.to_string())
312    }
313
314    /// Saves to the in-memory buffer and flushes to disk if the interval elapsed.
315    pub fn save(&self) {
316        let mut guard = PREDICTOR_BUFFER
317            .lock()
318            .unwrap_or_else(std::sync::PoisonError::into_inner);
319        let should_flush = match *guard {
320            Some((_, ref last_flush)) => last_flush.elapsed().as_secs() >= PREDICTOR_FLUSH_SECS,
321            None => true,
322        };
323        *guard = Some((Arc::new(self.clone()), Instant::now()));
324        if should_flush {
325            self.save_to_disk();
326        }
327    }
328
329    fn save_to_disk(&self) {
330        let Ok(dir) = crate::core::data_dir::lean_ctx_data_dir() else {
331            return;
332        };
333        let _ = std::fs::create_dir_all(&dir);
334        let path = dir.join(STATS_FILE);
335        if let Ok(json) = serde_json::to_string_pretty(self) {
336            let tmp = dir.join(".mode_stats.tmp");
337            if std::fs::write(&tmp, &json).is_ok() {
338                let _ = std::fs::rename(&tmp, &path);
339            }
340        }
341    }
342
343    /// Forces an immediate write of the buffered predictor state to disk.
344    pub fn flush() {
345        let guard = PREDICTOR_BUFFER
346            .lock()
347            .unwrap_or_else(std::sync::PoisonError::into_inner);
348        if let Some((ref predictor, _)) = *guard {
349            predictor.save_to_disk();
350        }
351    }
352
353    fn load_from_disk() -> Option<Self> {
354        let path = crate::core::data_dir::lean_ctx_data_dir()
355            .ok()?
356            .join(STATS_FILE);
357        let data = std::fs::read_to_string(path).ok()?;
358        serde_json::from_str(&data).ok()
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    #[test]
367    fn file_signature_buckets() {
368        assert_eq!(FileSignature::from_path("main.rs", 100).size_bucket, 0);
369        assert_eq!(FileSignature::from_path("main.rs", 1000).size_bucket, 1);
370        assert_eq!(FileSignature::from_path("main.rs", 3000).size_bucket, 2);
371        assert_eq!(FileSignature::from_path("main.rs", 10000).size_bucket, 3);
372        assert_eq!(FileSignature::from_path("main.rs", 50000).size_bucket, 4);
373    }
374
375    #[test]
376    fn predict_returns_none_without_history() {
377        let predictor = ModePredictor::default();
378        let sig = FileSignature::from_path("test.zzz", 500);
379        assert!(predictor.predict_from_local(&sig).is_none());
380    }
381
382    #[test]
383    fn predict_returns_none_with_too_few_entries() {
384        let mut predictor = ModePredictor::default();
385        let sig = FileSignature::from_path("test.zzz", 500);
386        predictor.record(
387            sig.clone(),
388            ModeOutcome {
389                mode: "full".to_string(),
390                tokens_in: 100,
391                tokens_out: 100,
392                density: 0.5,
393            },
394        );
395        assert!(predictor.predict_from_local(&sig).is_none());
396    }
397
398    #[test]
399    fn predict_learns_best_mode() {
400        let mut predictor = ModePredictor::default();
401        let sig = FileSignature::from_path("big.rs", 5000);
402        for _ in 0..5 {
403            predictor.record(
404                sig.clone(),
405                ModeOutcome {
406                    mode: "full".to_string(),
407                    tokens_in: 5000,
408                    tokens_out: 5000,
409                    density: 0.3,
410                },
411            );
412            predictor.record(
413                sig.clone(),
414                ModeOutcome {
415                    mode: "map".to_string(),
416                    tokens_in: 5000,
417                    tokens_out: 800,
418                    density: 0.6,
419                },
420            );
421        }
422        let best = predictor.predict_best_mode(&sig);
423        assert_eq!(best, Some("map".to_string()));
424    }
425
426    #[test]
427    fn history_round_trips_through_json() {
428        // #550 regression: the struct-keyed `HashMap<FileSignature, _>` made
429        // `serde_json` error ("key must be a string"), so `save_to_disk` failed
430        // silently and the predictor never persisted. The history must survive a
431        // JSON round-trip via the entry-list representation.
432        let mut predictor = ModePredictor::default();
433        let sig = FileSignature::from_path("main.rs", 1000);
434        predictor.record(
435            sig.clone(),
436            ModeOutcome {
437                mode: "map".to_string(),
438                tokens_in: 1000,
439                tokens_out: 200,
440                density: 0.8,
441            },
442        );
443
444        let json = serde_json::to_string(&predictor).expect("predictor must serialize to JSON");
445        let restored: ModePredictor =
446            serde_json::from_str(&json).expect("predictor must deserialize from JSON");
447
448        assert_eq!(
449            restored.history.get(&sig).map(Vec::len),
450            Some(1),
451            "recorded outcome must survive the round-trip"
452        );
453    }
454
455    #[test]
456    fn predict_from_bandit_maps_conservative_to_high_compression() {
457        // GL #622: `feedback::update_bandit` rewards the `conservative` arm on
458        // HIGH-compression success, so a winning `conservative` arm must resolve
459        // to a high-compression mode (not `full`). Guards against re-inverting the
460        // arm→mode mapping.
461        let _env = crate::core::data_dir::test_env_lock();
462        let data_dir = tempfile::tempdir().unwrap();
463        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.path());
464
465        let project = tempfile::tempdir().unwrap();
466        let root = project.path().to_string_lossy().to_string();
467
468        let mut store = crate::core::bandit::BanditStore::default();
469        let bandit = store.get_or_create("rs_feedback");
470        bandit.total_pulls = 10;
471        for _ in 0..5 {
472            bandit.update("conservative", true);
473        }
474        store.save(&root).unwrap();
475
476        let mut predictor = ModePredictor::new();
477        predictor.set_project_root(&root);
478        let sig = FileSignature::from_path("big.rs", 5000);
479        assert_eq!(
480            predictor.predict_from_bandit(&sig),
481            Some("aggressive".to_string()),
482            "winning conservative arm must map to a high-compression mode, not full"
483        );
484    }
485
486    #[test]
487    fn history_caps_at_100() {
488        let mut predictor = ModePredictor::default();
489        let sig = FileSignature::from_path("test.rs", 100);
490        for _ in 0..120 {
491            predictor.record(
492                sig.clone(),
493                ModeOutcome {
494                    mode: "full".to_string(),
495                    tokens_in: 100,
496                    tokens_out: 100,
497                    density: 0.5,
498                },
499            );
500        }
501        assert!(predictor.history.get(&sig).unwrap().len() <= 100);
502    }
503
504    #[test]
505    fn defaults_return_none_for_small_files() {
506        let sig = FileSignature::from_path("small.rs", 200);
507        assert!(ModePredictor::predict_from_defaults(&sig).is_none());
508    }
509
510    #[test]
511    fn defaults_recommend_map_for_medium_code() {
512        let sig = FileSignature::from_path("medium.rs", 3000);
513        assert_eq!(
514            ModePredictor::predict_from_defaults(&sig),
515            Some("map".to_string())
516        );
517    }
518
519    #[test]
520    fn defaults_recommend_map_for_json() {
521        let sig = FileSignature::from_path("config.json", 1000);
522        assert_eq!(
523            ModePredictor::predict_from_defaults(&sig),
524            Some("map".to_string())
525        );
526    }
527
528    #[test]
529    fn defaults_recommend_signatures_for_huge_code() {
530        let sig = FileSignature::from_path("huge.ts", 25000);
531        assert_eq!(
532            ModePredictor::predict_from_defaults(&sig),
533            Some("signatures".to_string())
534        );
535    }
536
537    #[test]
538    fn defaults_recommend_aggressive_for_large_unknown() {
539        let sig = FileSignature::from_path("data.xyz", 8000);
540        assert_eq!(
541            ModePredictor::predict_from_defaults(&sig),
542            Some("aggressive".to_string())
543        );
544    }
545
546    #[test]
547    fn defaults_never_compress_markdown() {
548        for tokens in [600, 3000, 8000, 25000] {
549            let sig = FileSignature::from_path("SKILL.md", tokens);
550            assert!(
551                ModePredictor::predict_from_defaults(&sig).is_none(),
552                "SKILL.md at {tokens} tokens should get full (None), not compressed"
553            );
554        }
555        let sig = FileSignature::from_path("AGENTS.md", 5000);
556        assert!(ModePredictor::predict_from_defaults(&sig).is_none());
557        let sig = FileSignature::from_path("README.md", 12000);
558        assert!(ModePredictor::predict_from_defaults(&sig).is_none());
559    }
560
561    #[test]
562    fn mode_outcome_efficiency() {
563        let o = ModeOutcome {
564            mode: "map".to_string(),
565            tokens_in: 1000,
566            tokens_out: 200,
567            density: 0.6,
568        };
569        assert!(o.efficiency() > 0.0);
570    }
571}