Skip to main content

microscope_memory/
predictive_cache.rs

1//! Predictive Cache — pre-fetches blocks based on ThoughtGraph patterns.
2//!
3//! After each recall, the cache predicts what the *next* query will be
4//! based on recognized thought patterns. It pre-loads the expected result
5//! blocks. If the prediction hits, the pattern gets a positive reward;
6//! misses decay the prediction confidence.
7//!
8//! This is a feedback loop: good patterns → accurate predictions → rewards → stronger patterns.
9//!
10//! Binary format: predictive_cache.bin (PRC1)
11
12use std::fs;
13use std::io::Write;
14use std::path::Path;
15use std::time::{SystemTime, UNIX_EPOCH};
16
17use crate::thought_graph::{ThoughtGraphState, PATTERN_BOOST_WEIGHT};
18
19// ─── Constants ──────────────────────────────────────
20
21const MAX_PREDICTIONS: usize = 50; // max cached predictions
22const MAX_BLOCKS_PER_PREDICTION: usize = 30;
23const HIT_REWARD: f32 = 0.3; // strength reward on cache hit
24const MISS_PENALTY: f32 = 0.05; // strength penalty on miss
25const PREDICTION_DECAY: f32 = 0.98; // per-recall confidence decay
26const MIN_CONFIDENCE: f32 = 0.1; // below this, evict prediction
27const MIN_PATTERN_FREQ: u32 = 3; // only predict from crystallized patterns
28
29// ─── Prediction ─────────────────────────────────────
30
31/// A single prediction: "if next query hash is X, these blocks are likely results."
32#[derive(Clone, Debug)]
33pub struct Prediction {
34    pub predicted_query_hash: u64,
35    pub blocks: Vec<u32>,
36    pub confidence: f32,
37    pub pattern_id: u32,
38    pub created_ms: u64,
39}
40
41// ─── CacheStats ─────────────────────────────────────
42
43#[derive(Clone, Debug, Default)]
44pub struct CacheStats {
45    pub total_predictions: u32,
46    pub total_hits: u32,
47    pub total_misses: u32,
48    pub total_partial_hits: u32,
49    pub current_predictions: usize,
50    pub avg_confidence: f32,
51}
52
53impl CacheStats {
54    pub fn hit_rate(&self) -> f32 {
55        let total = self.total_hits + self.total_misses + self.total_partial_hits;
56        if total == 0 {
57            return 0.0;
58        }
59        (self.total_hits as f32 + self.total_partial_hits as f32 * 0.5) / total as f32
60    }
61}
62
63// ─── PredictiveCache ────────────────────────────────
64
65#[derive(Clone)]
66pub struct PredictiveCache {
67    pub predictions: Vec<Prediction>,
68    pub stats: CacheStats,
69}
70
71impl PredictiveCache {
72    pub fn load_or_init(output_dir: &Path) -> Self {
73        let path = output_dir.join("predictive_cache.bin");
74        if path.exists() {
75            load_cache(&path)
76        } else {
77            Self {
78                predictions: Vec::new(),
79                stats: CacheStats::default(),
80            }
81        }
82    }
83
84    /// Check if we have a prediction for this query. Returns cached blocks + confidence.
85    /// This is called BEFORE the actual search, so the results can be used immediately.
86    pub fn check(&self, query_hash: u64) -> Option<(Vec<u32>, f32)> {
87        self.predictions
88            .iter()
89            .find(|p| p.predicted_query_hash == query_hash && p.confidence >= MIN_CONFIDENCE)
90            .map(|p| (p.blocks.clone(), p.confidence))
91    }
92
93    /// Evaluate prediction accuracy after a recall completes.
94    /// Compares predicted blocks against actual results.
95    /// Returns: (hit_type, overlap_count) where hit_type is "hit", "partial", or "miss".
96    pub fn evaluate(
97        &mut self,
98        query_hash: u64,
99        actual_results: &[u32],
100        thought_graph: &mut ThoughtGraphState,
101    ) -> (&'static str, usize) {
102        let prediction = self
103            .predictions
104            .iter()
105            .find(|p| p.predicted_query_hash == query_hash);
106
107        let prediction = match prediction {
108            Some(p) => p.clone(),
109            None => return ("none", 0),
110        };
111
112        // Count overlap
113        let overlap = prediction
114            .blocks
115            .iter()
116            .filter(|b| actual_results.contains(b))
117            .count();
118
119        let hit_type = if overlap == 0 {
120            // Miss
121            self.stats.total_misses += 1;
122            // Penalize the pattern
123            if let Some(pattern) = thought_graph
124                .patterns
125                .iter_mut()
126                .find(|p| p.id == prediction.pattern_id)
127            {
128                pattern.strength = (pattern.strength - MISS_PENALTY).max(0.0);
129            }
130            // Decay prediction confidence
131            if let Some(pred) = self
132                .predictions
133                .iter_mut()
134                .find(|p| p.predicted_query_hash == query_hash)
135            {
136                pred.confidence *= 0.5; // harsh decay on miss
137            }
138            "miss"
139        } else if overlap >= prediction.blocks.len() / 2 || overlap >= 3 {
140            // Hit — majority of predicted blocks were correct
141            self.stats.total_hits += 1;
142            // Reward the pattern
143            if let Some(pattern) = thought_graph
144                .patterns
145                .iter_mut()
146                .find(|p| p.id == prediction.pattern_id)
147            {
148                pattern.strength = (pattern.strength + HIT_REWARD).min(5.0);
149            }
150            // Boost prediction confidence
151            if let Some(pred) = self
152                .predictions
153                .iter_mut()
154                .find(|p| p.predicted_query_hash == query_hash)
155            {
156                pred.confidence = (pred.confidence + 0.2).min(1.0);
157            }
158            "hit"
159        } else {
160            // Partial hit
161            self.stats.total_partial_hits += 1;
162            let reward = HIT_REWARD * (overlap as f32 / prediction.blocks.len() as f32);
163            if let Some(pattern) = thought_graph
164                .patterns
165                .iter_mut()
166                .find(|p| p.id == prediction.pattern_id)
167            {
168                pattern.strength = (pattern.strength + reward).min(5.0);
169            }
170            "partial"
171        };
172
173        (hit_type, overlap)
174    }
175
176    /// Generate predictions for the next likely query based on current session state.
177    /// Called after each recall to pre-load the cache.
178    pub fn predict_next(&mut self, thought_graph: &ThoughtGraphState) {
179        // Decay all existing predictions
180        for pred in &mut self.predictions {
181            pred.confidence *= PREDICTION_DECAY;
182        }
183        self.predictions.retain(|p| p.confidence >= MIN_CONFIDENCE);
184
185        let session_hashes: Vec<u64> = thought_graph
186            .nodes
187            .iter()
188            .filter(|n| n.session_id == thought_graph.current_session_id)
189            .map(|n| n.query_hash)
190            .collect();
191
192        if session_hashes.is_empty() {
193            return;
194        }
195
196        let now_ms = now_epoch_ms();
197
198        for pattern in &thought_graph.patterns {
199            if pattern.frequency < MIN_PATTERN_FREQ {
200                continue;
201            }
202            if pattern.result_blocks.is_empty() {
203                continue;
204            }
205
206            let seq = &pattern.sequence;
207
208            // Check if session trail matches any prefix of this pattern
209            // If trail ends with seq[0..n], predict seq[n] with its blocks
210            for prefix_len in 1..seq.len() {
211                if session_hashes.len() < prefix_len {
212                    continue;
213                }
214
215                let trail_start = session_hashes.len() - prefix_len;
216                let trail = &session_hashes[trail_start..];
217
218                if trail == &seq[..prefix_len] {
219                    let predicted_hash = seq[prefix_len];
220
221                    // Don't duplicate predictions for same hash
222                    if self
223                        .predictions
224                        .iter()
225                        .any(|p| p.predicted_query_hash == predicted_hash)
226                    {
227                        continue;
228                    }
229
230                    let confidence = pattern.strength
231                        * PATTERN_BOOST_WEIGHT
232                        * (prefix_len as f32 / seq.len() as f32);
233
234                    let blocks: Vec<u32> = pattern
235                        .result_blocks
236                        .iter()
237                        .take(MAX_BLOCKS_PER_PREDICTION)
238                        .copied()
239                        .collect();
240
241                    self.predictions.push(Prediction {
242                        predicted_query_hash: predicted_hash,
243                        blocks,
244                        confidence: confidence.min(1.0),
245                        pattern_id: pattern.id,
246                        created_ms: now_ms,
247                    });
248
249                    self.stats.total_predictions += 1;
250                }
251            }
252        }
253
254        // Cap predictions
255        if self.predictions.len() > MAX_PREDICTIONS {
256            self.predictions
257                .sort_by(|a, b| b.confidence.total_cmp(&a.confidence));
258            self.predictions.truncate(MAX_PREDICTIONS);
259        }
260
261        // Update avg confidence
262        if !self.predictions.is_empty() {
263            self.stats.avg_confidence = self.predictions.iter().map(|p| p.confidence).sum::<f32>()
264                / self.predictions.len() as f32;
265        }
266        self.stats.current_predictions = self.predictions.len();
267    }
268
269    /// Export stats for cross-instance exchange.
270    pub fn export_stats(&self) -> (u32, u32, u32, f32) {
271        (
272            self.stats.total_predictions,
273            self.stats.total_hits,
274            self.stats.total_misses,
275            self.stats.hit_rate(),
276        )
277    }
278
279    /// Merge remote stats (additive, for reporting).
280    pub fn merge_stats(&mut self, remote_predictions: u32, remote_hits: u32, remote_misses: u32) {
281        self.stats.total_predictions += remote_predictions;
282        self.stats.total_hits += remote_hits;
283        self.stats.total_misses += remote_misses;
284    }
285
286    /// Dream cleanup: remove predictions with very low confidence.
287    pub fn dream_cleanup(&mut self) {
288        self.predictions.retain(|p| p.confidence > 0.1);
289        self.stats.current_predictions = self.predictions.len();
290    }
291
292    /// Save to binary.
293    pub fn save(&self, output_dir: &Path) -> Result<(), String> {
294        save_cache(&output_dir.join("predictive_cache.bin"), self)
295    }
296
297    /// Remap block indexes after rebuild.
298    pub fn remap_indexes(&mut self, old_to_new: &[u32]) {
299        for pred in &mut self.predictions {
300            pred.blocks = pred
301                .blocks
302                .iter()
303                .filter_map(|&idx| {
304                    if (idx as usize) < old_to_new.len() {
305                        let new = old_to_new[idx as usize];
306                        if new != u32::MAX {
307                            Some(new)
308                        } else {
309                            None
310                        }
311                    } else {
312                        None
313                    }
314                })
315                .collect();
316        }
317        // Remove predictions with empty block lists
318        self.predictions.retain(|p| !p.blocks.is_empty());
319    }
320}
321
322// ─── Binary I/O ─────────────────────────────────────
323
324fn now_epoch_ms() -> u64 {
325    SystemTime::now()
326        .duration_since(UNIX_EPOCH)
327        .unwrap_or_default()
328        .as_millis() as u64
329}
330
331fn save_cache(path: &Path, cache: &PredictiveCache) -> Result<(), String> {
332    let mut buf = Vec::with_capacity(256);
333
334    // Header
335    buf.write_all(b"PRC1").map_err(|e| e.to_string())?;
336    buf.write_all(&(cache.predictions.len() as u32).to_le_bytes())
337        .map_err(|e| e.to_string())?;
338
339    // Stats
340    buf.write_all(&cache.stats.total_predictions.to_le_bytes())
341        .map_err(|e| e.to_string())?;
342    buf.write_all(&cache.stats.total_hits.to_le_bytes())
343        .map_err(|e| e.to_string())?;
344    buf.write_all(&cache.stats.total_misses.to_le_bytes())
345        .map_err(|e| e.to_string())?;
346    buf.write_all(&cache.stats.total_partial_hits.to_le_bytes())
347        .map_err(|e| e.to_string())?;
348
349    // Predictions (variable length)
350    for p in &cache.predictions {
351        buf.write_all(&p.predicted_query_hash.to_le_bytes())
352            .map_err(|e| e.to_string())?;
353        buf.write_all(&p.confidence.to_le_bytes())
354            .map_err(|e| e.to_string())?;
355        buf.write_all(&p.pattern_id.to_le_bytes())
356            .map_err(|e| e.to_string())?;
357        buf.write_all(&p.created_ms.to_le_bytes())
358            .map_err(|e| e.to_string())?;
359        buf.write_all(&(p.blocks.len() as u16).to_le_bytes())
360            .map_err(|e| e.to_string())?;
361        for &b in &p.blocks {
362            buf.write_all(&b.to_le_bytes()).map_err(|e| e.to_string())?;
363        }
364    }
365
366    let tmp_path = path.with_extension("bin.tmp");
367    fs::write(&tmp_path, &buf).map_err(|e| e.to_string())?;
368    fs::rename(&tmp_path, path).map_err(|e| e.to_string())
369}
370
371fn load_cache(path: &Path) -> PredictiveCache {
372    let data = match fs::read(path) {
373        Ok(d) => d,
374        Err(_) => {
375            return PredictiveCache {
376                predictions: Vec::new(),
377                stats: CacheStats::default(),
378            }
379        }
380    };
381
382    if data.len() < 24 || &data[0..4] != b"PRC1" {
383        return PredictiveCache {
384            predictions: Vec::new(),
385            stats: CacheStats::default(),
386        };
387    }
388
389    let pred_count = u32::from_le_bytes([data[4], data[5], data[6], data[7]]) as usize;
390
391    let total_predictions = u32::from_le_bytes([data[8], data[9], data[10], data[11]]);
392    let total_hits = u32::from_le_bytes([data[12], data[13], data[14], data[15]]);
393    let total_misses = u32::from_le_bytes([data[16], data[17], data[18], data[19]]);
394    let total_partial_hits = u32::from_le_bytes([data[20], data[21], data[22], data[23]]);
395
396    let mut offset = 24;
397    let mut predictions = Vec::with_capacity(pred_count);
398
399    for _ in 0..pred_count {
400        if offset + 22 > data.len() {
401            break;
402        }
403
404        let predicted_query_hash = u64::from_le_bytes([
405            data[offset],
406            data[offset + 1],
407            data[offset + 2],
408            data[offset + 3],
409            data[offset + 4],
410            data[offset + 5],
411            data[offset + 6],
412            data[offset + 7],
413        ]);
414        offset += 8;
415
416        let confidence = f32::from_le_bytes([
417            data[offset],
418            data[offset + 1],
419            data[offset + 2],
420            data[offset + 3],
421        ]);
422        offset += 4;
423
424        let pattern_id = u32::from_le_bytes([
425            data[offset],
426            data[offset + 1],
427            data[offset + 2],
428            data[offset + 3],
429        ]);
430        offset += 4;
431
432        let created_ms = u64::from_le_bytes([
433            data[offset],
434            data[offset + 1],
435            data[offset + 2],
436            data[offset + 3],
437            data[offset + 4],
438            data[offset + 5],
439            data[offset + 6],
440            data[offset + 7],
441        ]);
442        offset += 8;
443
444        if offset + 2 > data.len() {
445            break;
446        }
447        let block_count = u16::from_le_bytes([data[offset], data[offset + 1]]) as usize;
448        offset += 2;
449
450        if offset + block_count * 4 > data.len() {
451            break;
452        }
453        let mut blocks = Vec::with_capacity(block_count);
454        for _ in 0..block_count {
455            let b = u32::from_le_bytes([
456                data[offset],
457                data[offset + 1],
458                data[offset + 2],
459                data[offset + 3],
460            ]);
461            blocks.push(b);
462            offset += 4;
463        }
464
465        predictions.push(Prediction {
466            predicted_query_hash,
467            blocks,
468            confidence,
469            pattern_id,
470            created_ms,
471        });
472    }
473
474    let current_predictions = predictions.len();
475    let avg_confidence = if predictions.is_empty() {
476        0.0
477    } else {
478        predictions.iter().map(|p| p.confidence).sum::<f32>() / predictions.len() as f32
479    };
480
481    PredictiveCache {
482        predictions,
483        stats: CacheStats {
484            total_predictions,
485            total_hits,
486            total_misses,
487            total_partial_hits,
488            current_predictions,
489            avg_confidence,
490        },
491    }
492}
493
494// ─── Tests ──────────────────────────────────────────
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499    use crate::thought_graph::{ThoughtGraphState, ThoughtPattern};
500
501    fn make_tg() -> ThoughtGraphState {
502        ThoughtGraphState::load_or_init(Path::new("/nonexistent"))
503    }
504
505    #[test]
506    fn test_check_empty() {
507        let cache = PredictiveCache {
508            predictions: Vec::new(),
509            stats: CacheStats::default(),
510        };
511        assert!(cache.check(0xAA).is_none());
512    }
513
514    #[test]
515    fn test_check_hit() {
516        let cache = PredictiveCache {
517            predictions: vec![Prediction {
518                predicted_query_hash: 0xAA,
519                blocks: vec![10, 20, 30],
520                confidence: 0.8,
521                pattern_id: 0,
522                created_ms: 0,
523            }],
524            stats: CacheStats::default(),
525        };
526        let result = cache.check(0xAA);
527        assert!(result.is_some());
528        let (blocks, conf) = result.unwrap();
529        assert_eq!(blocks, vec![10, 20, 30]);
530        assert!((conf - 0.8).abs() < 0.001);
531    }
532
533    #[test]
534    fn test_evaluate_hit() {
535        let mut cache = PredictiveCache {
536            predictions: vec![Prediction {
537                predicted_query_hash: 0xAA,
538                blocks: vec![10, 20, 30],
539                confidence: 0.5,
540                pattern_id: 0,
541                created_ms: 0,
542            }],
543            stats: CacheStats::default(),
544        };
545        let mut tg = make_tg();
546        tg.patterns.push(ThoughtPattern {
547            id: 0,
548            sequence: vec![0xBB, 0xAA],
549            frequency: 5,
550            strength: 1.0,
551            last_seen_ms: 0,
552            result_blocks: vec![10, 20, 30],
553        });
554
555        let actual = vec![10u32, 20, 30, 40];
556        let (hit_type, overlap) = cache.evaluate(0xAA, &actual, &mut tg);
557        assert_eq!(hit_type, "hit");
558        assert_eq!(overlap, 3);
559        assert_eq!(cache.stats.total_hits, 1);
560        // Pattern should be rewarded
561        assert!(tg.patterns[0].strength > 1.0);
562    }
563
564    #[test]
565    fn test_evaluate_miss() {
566        let mut cache = PredictiveCache {
567            predictions: vec![Prediction {
568                predicted_query_hash: 0xAA,
569                blocks: vec![10, 20, 30],
570                confidence: 0.5,
571                pattern_id: 0,
572                created_ms: 0,
573            }],
574            stats: CacheStats::default(),
575        };
576        let mut tg = make_tg();
577        tg.patterns.push(ThoughtPattern {
578            id: 0,
579            sequence: vec![0xBB, 0xAA],
580            frequency: 5,
581            strength: 1.0,
582            last_seen_ms: 0,
583            result_blocks: vec![10, 20, 30],
584        });
585
586        let actual = vec![100u32, 200, 300]; // no overlap
587        let (hit_type, overlap) = cache.evaluate(0xAA, &actual, &mut tg);
588        assert_eq!(hit_type, "miss");
589        assert_eq!(overlap, 0);
590        assert_eq!(cache.stats.total_misses, 1);
591        // Pattern should be penalized
592        assert!(tg.patterns[0].strength < 1.0);
593    }
594
595    #[test]
596    fn test_evaluate_no_prediction() {
597        let mut cache = PredictiveCache {
598            predictions: Vec::new(),
599            stats: CacheStats::default(),
600        };
601        let mut tg = make_tg();
602        let (hit_type, _) = cache.evaluate(0xAA, &[10, 20], &mut tg);
603        assert_eq!(hit_type, "none");
604    }
605
606    #[test]
607    fn test_predict_next() {
608        let mut cache = PredictiveCache {
609            predictions: Vec::new(),
610            stats: CacheStats::default(),
611        };
612        let mut tg = make_tg();
613
614        // Set up: session with one recall (hash=0xAA), pattern AA→BB with blocks
615        tg.current_session_id = 1;
616        tg.nodes.push(crate::thought_graph::ThoughtNode {
617            timestamp_ms: 1000,
618            query_hash: 0xAA,
619            session_id: 1,
620            result_count: 3,
621            dominant_layer: 1,
622            centroid_hash: 0,
623        });
624        tg.patterns.push(ThoughtPattern {
625            id: 0,
626            sequence: vec![0xAA, 0xBB],
627            frequency: 5,
628            strength: 2.0,
629            last_seen_ms: 1000,
630            result_blocks: vec![10, 20, 30],
631        });
632
633        cache.predict_next(&tg);
634
635        assert_eq!(cache.predictions.len(), 1);
636        assert_eq!(cache.predictions[0].predicted_query_hash, 0xBB);
637        assert_eq!(cache.predictions[0].blocks, vec![10, 20, 30]);
638        assert!(cache.predictions[0].confidence > 0.0);
639    }
640
641    #[test]
642    fn test_predict_decay() {
643        let mut cache = PredictiveCache {
644            predictions: vec![Prediction {
645                predicted_query_hash: 0xAA,
646                blocks: vec![10],
647                confidence: MIN_CONFIDENCE + 0.01,
648                pattern_id: 0,
649                created_ms: 0,
650            }],
651            stats: CacheStats::default(),
652        };
653        let tg = make_tg();
654
655        // Multiple predict_next calls should decay confidence below threshold
656        for _ in 0..20 {
657            cache.predict_next(&tg);
658        }
659        assert!(cache.predictions.is_empty());
660    }
661
662    #[test]
663    fn test_hit_rate() {
664        let mut stats = CacheStats::default();
665        assert_eq!(stats.hit_rate(), 0.0);
666
667        stats.total_hits = 7;
668        stats.total_misses = 3;
669        assert!((stats.hit_rate() - 0.7).abs() < 0.001);
670
671        stats.total_partial_hits = 2;
672        // (7 + 2*0.5) / (7+3+2) = 8/12 = 0.667
673        assert!((stats.hit_rate() - 0.6667).abs() < 0.01);
674    }
675
676    #[test]
677    fn test_save_load_roundtrip() {
678        let dir = tempfile::tempdir().unwrap();
679
680        let cache = PredictiveCache {
681            predictions: vec![
682                Prediction {
683                    predicted_query_hash: 0xAA,
684                    blocks: vec![10, 20],
685                    confidence: 0.75,
686                    pattern_id: 1,
687                    created_ms: 12345,
688                },
689                Prediction {
690                    predicted_query_hash: 0xBB,
691                    blocks: vec![30, 40, 50],
692                    confidence: 0.5,
693                    pattern_id: 2,
694                    created_ms: 67890,
695                },
696            ],
697            stats: CacheStats {
698                total_predictions: 10,
699                total_hits: 5,
700                total_misses: 3,
701                total_partial_hits: 2,
702                current_predictions: 2,
703                avg_confidence: 0.625,
704            },
705        };
706
707        cache.save(dir.path()).unwrap();
708        let loaded = PredictiveCache::load_or_init(dir.path());
709
710        assert_eq!(loaded.predictions.len(), 2);
711        assert_eq!(loaded.predictions[0].predicted_query_hash, 0xAA);
712        assert_eq!(loaded.predictions[0].blocks, vec![10, 20]);
713        assert!((loaded.predictions[0].confidence - 0.75).abs() < 0.001);
714        assert_eq!(loaded.predictions[1].blocks, vec![30, 40, 50]);
715        assert_eq!(loaded.stats.total_hits, 5);
716        assert_eq!(loaded.stats.total_misses, 3);
717        assert_eq!(loaded.stats.total_partial_hits, 2);
718    }
719}