Skip to main content

microscope_memory/
thought_graph.rs

1//! ThoughtGraph — L6: Pattern Recognition for Microscope Memory.
2//!
3//! Tracks sequential recall paths and detects recurring thought patterns.
4//! Every recall creates a node; consecutive recalls form edges.
5//! When a sequence (A→B→C) recurs enough times, it crystallizes into a pattern.
6//! Recognized patterns boost future search results.
7//!
8//! Binary formats:
9//!   thought_graph.bin — nodes + edges (THG1)
10//!   thought_patterns.bin — crystallized patterns (PTN1)
11
12use std::collections::HashMap;
13use std::fs;
14use std::io::Write;
15use std::path::Path;
16use std::time::{SystemTime, UNIX_EPOCH};
17
18// ─── Constants ──────────────────────────────────────
19
20const SESSION_GAP_MS: u64 = 1_800_000; // 30 min = new session
21const MAX_NODES: usize = 5000; // ring buffer
22const MAX_EDGES: usize = 10_000;
23const MAX_PATTERNS: usize = 200;
24const PATTERN_MIN_FREQ: u32 = 3; // min traversals to crystallize
25const PATTERN_DECAY: f32 = 0.995; // per-recall decay
26const NODE_BYTES: usize = 24;
27const EDGE_BYTES: usize = 24;
28
29/// How much pattern recognition boosts search scores.
30pub const PATTERN_BOOST_WEIGHT: f32 = 0.15;
31
32// ─── ThoughtNode ────────────────────────────────────
33
34/// A single recall event.
35#[derive(Clone, Debug)]
36pub struct ThoughtNode {
37    pub timestamp_ms: u64,
38    pub query_hash: u64,
39    pub session_id: u32,
40    pub result_count: u16,
41    pub dominant_layer: u8,
42    pub centroid_hash: u8,
43}
44
45// ─── ThoughtEdge ────────────────────────────────────
46
47/// Directed edge between two query types.
48#[derive(Clone, Debug)]
49pub struct ThoughtEdge {
50    pub from_hash: u64,
51    pub to_hash: u64,
52    pub count: u32,
53    pub last_ms: u32, // lower 32 bits of epoch ms
54}
55
56// ─── ThoughtPattern ─────────────────────────────────
57
58/// A crystallized recall sequence.
59#[derive(Clone, Debug)]
60pub struct ThoughtPattern {
61    pub id: u32,
62    pub sequence: Vec<u64>, // ordered query hashes (len 2..=5)
63    pub frequency: u32,
64    pub strength: f32,
65    pub last_seen_ms: u64,
66    pub result_blocks: Vec<u32>, // union of top block indices
67}
68
69// ─── ThoughtGraphState ──────────────────────────────
70
71#[derive(Clone)]
72pub struct ThoughtGraphState {
73    pub nodes: Vec<ThoughtNode>,
74    pub edges: HashMap<(u64, u64), ThoughtEdge>,
75    pub patterns: Vec<ThoughtPattern>,
76    pub current_session_id: u32,
77    last_node_ts: u64,
78    next_pattern_id: u32,
79}
80
81impl ThoughtGraphState {
82    pub fn load_or_init(output_dir: &Path) -> Self {
83        let graph_path = output_dir.join("thought_graph.bin");
84        let pattern_path = output_dir.join("thought_patterns.bin");
85
86        let (nodes, edges, session_id, last_ts, next_pid) = if graph_path.exists() {
87            load_graph(&graph_path)
88        } else {
89            (Vec::new(), HashMap::new(), 0u32, 0u64, 0u32)
90        };
91
92        let patterns = if pattern_path.exists() {
93            load_patterns(&pattern_path)
94        } else {
95            Vec::new()
96        };
97
98        Self {
99            nodes,
100            edges,
101            patterns,
102            current_session_id: session_id,
103            last_node_ts: last_ts,
104            next_pattern_id: next_pid,
105        }
106    }
107
108    /// Record a recall event. Returns session_id.
109    pub fn record_recall(
110        &mut self,
111        query_hash: u64,
112        results: &[(u32, f32)],
113        dominant_layer: u8,
114    ) -> u32 {
115        let now_ms = now_epoch_ms();
116
117        // Session detection
118        if self.last_node_ts == 0 || (now_ms - self.last_node_ts) > SESSION_GAP_MS {
119            self.current_session_id += 1;
120        }
121        self.last_node_ts = now_ms;
122
123        // Centroid hash: spatial bucket of result center
124        let centroid_hash = if results.is_empty() {
125            0u8
126        } else {
127            let avg_idx =
128                results.iter().map(|&(i, _)| i as u64).sum::<u64>() / results.len() as u64;
129            (avg_idx & 0xFF) as u8
130        };
131
132        let node = ThoughtNode {
133            timestamp_ms: now_ms,
134            query_hash,
135            session_id: self.current_session_id,
136            result_count: results.len().min(u16::MAX as usize) as u16,
137            dominant_layer,
138            centroid_hash,
139        };
140
141        // Edge: connect to previous node in same session
142        if let Some(prev) = self.nodes.last() {
143            if prev.session_id == self.current_session_id {
144                let key = (prev.query_hash, query_hash);
145                let edge = self.edges.entry(key).or_insert(ThoughtEdge {
146                    from_hash: prev.query_hash,
147                    to_hash: query_hash,
148                    count: 0,
149                    last_ms: 0,
150                });
151                edge.count += 1;
152                edge.last_ms = (now_ms & 0xFFFFFFFF) as u32;
153            }
154        }
155
156        // Ring buffer
157        self.nodes.push(node);
158        if self.nodes.len() > MAX_NODES {
159            self.nodes.drain(0..(self.nodes.len() - MAX_NODES));
160        }
161
162        // Evict edges if too many (drop least used)
163        if self.edges.len() > MAX_EDGES {
164            let mut edge_list: Vec<_> = self.edges.keys().cloned().collect();
165            edge_list.sort_by_key(|k| self.edges[k].count);
166            for key in edge_list.iter().take(self.edges.len() - MAX_EDGES) {
167                self.edges.remove(key);
168            }
169        }
170
171        self.current_session_id
172    }
173
174    /// Detect and crystallize patterns from recent session history.
175    /// Uses sliding-window n-gram (lengths 2..=5) on the current session's recalls.
176    pub fn detect_patterns(&mut self) {
177        let session_nodes: Vec<&ThoughtNode> = self
178            .nodes
179            .iter()
180            .filter(|n| n.session_id == self.current_session_id)
181            .collect();
182
183        if session_nodes.len() < 2 {
184            return;
185        }
186
187        // Decay existing patterns
188        for p in &mut self.patterns {
189            p.strength *= PATTERN_DECAY;
190        }
191
192        // Check n-grams of length 2..=5
193        for window_size in 2..=5usize {
194            if session_nodes.len() < window_size {
195                continue;
196            }
197
198            let start = session_nodes.len() - window_size;
199            let seq: Vec<u64> = session_nodes[start..]
200                .iter()
201                .map(|n| n.query_hash)
202                .collect();
203
204            // Check if edges support this sequence (all transitions seen >= 2 times)
205            let edges_ok = seq
206                .windows(2)
207                .all(|w| self.edges.get(&(w[0], w[1])).is_some_and(|e| e.count >= 2));
208
209            if !edges_ok {
210                continue;
211            }
212
213            // Find existing pattern or create candidate
214            if let Some(p) = self.patterns.iter_mut().find(|p| p.sequence == seq) {
215                p.frequency += 1;
216                p.strength = (p.strength + 0.2).min(5.0);
217                p.last_seen_ms = now_epoch_ms();
218            } else {
219                // New candidate
220                let pattern = ThoughtPattern {
221                    id: self.next_pattern_id,
222                    sequence: seq,
223                    frequency: 1,
224                    strength: 1.0,
225                    last_seen_ms: now_epoch_ms(),
226                    result_blocks: Vec::new(),
227                };
228                self.next_pattern_id += 1;
229                self.patterns.push(pattern);
230            }
231        }
232
233        // Evict weak patterns and enforce limit
234        self.patterns
235            .retain(|p| p.strength >= 0.05 || p.frequency >= PATTERN_MIN_FREQ);
236        if self.patterns.len() > MAX_PATTERNS {
237            self.patterns.sort_by(|a, b| {
238                let sa = a.strength * a.frequency as f32;
239                let sb = b.strength * b.frequency as f32;
240                sb.total_cmp(&sa)
241            });
242            self.patterns.truncate(MAX_PATTERNS);
243        }
244    }
245
246    /// Compute pattern boost for a new query.
247    /// Checks if recent session recalls + this query form a known pattern prefix/match.
248    /// Returns block indices with boost scores.
249    pub fn pattern_boost(&self, current_query_hash: u64) -> Vec<(u32, f32)> {
250        let session_hashes: Vec<u64> = self
251            .nodes
252            .iter()
253            .filter(|n| n.session_id == self.current_session_id)
254            .map(|n| n.query_hash)
255            .collect();
256
257        let mut boosts: HashMap<u32, f32> = HashMap::new();
258
259        for pattern in &self.patterns {
260            if pattern.frequency < PATTERN_MIN_FREQ {
261                continue;
262            }
263
264            let seq = &pattern.sequence;
265
266            // Check if the session trail + current_query matches this pattern
267            // Build the trail: last (seq.len()-1) session hashes + current_query_hash
268            let prefix_len = seq.len() - 1;
269            if session_hashes.len() < prefix_len {
270                continue;
271            }
272
273            let trail_start = session_hashes.len() - prefix_len;
274            let trail = &session_hashes[trail_start..];
275
276            // Check if trail matches pattern prefix and current query matches the last element
277            if trail == &seq[..prefix_len] && seq[prefix_len] == current_query_hash {
278                // Full match — boost result blocks
279                let boost = pattern.strength * PATTERN_BOOST_WEIGHT;
280                for &block_idx in &pattern.result_blocks {
281                    let entry = boosts.entry(block_idx).or_insert(0.0);
282                    *entry += boost;
283                }
284            }
285        }
286
287        boosts.into_iter().collect()
288    }
289
290    /// Update pattern result blocks with the actual results from a recall.
291    /// Called after record_recall when a pattern was matched.
292    pub fn update_pattern_blocks(&mut self, query_hash: u64, result_blocks: &[u32]) {
293        let session_hashes: Vec<u64> = self
294            .nodes
295            .iter()
296            .filter(|n| n.session_id == self.current_session_id)
297            .map(|n| n.query_hash)
298            .collect();
299
300        for pattern in &mut self.patterns {
301            if pattern.frequency < PATTERN_MIN_FREQ {
302                continue;
303            }
304
305            let seq = &pattern.sequence;
306
307            // Check if this recall matches the last step of any pattern
308            if seq.last() != Some(&query_hash) {
309                continue;
310            }
311
312            let prefix_len = seq.len() - 1;
313            if session_hashes.len() < prefix_len + 1 {
314                continue;
315            }
316
317            // The node was already added, so check one before last
318            let trail_start = session_hashes.len() - prefix_len - 1;
319            let trail = &session_hashes[trail_start..session_hashes.len() - 1];
320
321            if trail == &seq[..prefix_len] {
322                // Merge result blocks (union, capped)
323                for &b in result_blocks {
324                    if !pattern.result_blocks.contains(&b) {
325                        pattern.result_blocks.push(b);
326                    }
327                }
328                // Cap at 50 blocks
329                if pattern.result_blocks.len() > 50 {
330                    pattern.result_blocks.truncate(50);
331                }
332            }
333        }
334    }
335
336    /// Remap pattern result-block indices after a rebuild changed the block
337    /// layout. `old_to_new[old_idx] = new_idx`; blocks that no longer exist are
338    /// dropped. The pattern itself (query sequence) is kept even when it has no
339    /// surviving result blocks — the sequence still predicts future queries.
340    pub fn remap_indexes(&mut self, old_to_new: &[u32]) {
341        for p in &mut self.patterns {
342            let mut new_blocks = Vec::with_capacity(p.result_blocks.len());
343            for &b in &p.result_blocks {
344                if (b as usize) < old_to_new.len() {
345                    let new_idx = old_to_new[b as usize];
346                    if new_idx != u32::MAX && !new_blocks.contains(&new_idx) {
347                        new_blocks.push(new_idx);
348                    }
349                }
350            }
351            p.result_blocks = new_blocks;
352        }
353    }
354
355    /// Save to binary files.
356    pub fn save(&self, output_dir: &Path) -> Result<(), String> {
357        save_graph(
358            &output_dir.join("thought_graph.bin"),
359            &self.nodes,
360            &self.edges,
361            self.current_session_id,
362            self.last_node_ts,
363            self.next_pattern_id,
364        )?;
365        save_patterns(&output_dir.join("thought_patterns.bin"), &self.patterns)?;
366        Ok(())
367    }
368
369    /// Top patterns by strength * frequency.
370    pub fn top_patterns(&self, n: usize) -> Vec<&ThoughtPattern> {
371        let mut sorted: Vec<&ThoughtPattern> = self.patterns.iter().collect();
372        sorted.sort_by(|a, b| {
373            let sa = a.strength * a.frequency as f32;
374            let sb = b.strength * b.frequency as f32;
375            sb.total_cmp(&sa)
376        });
377        sorted.truncate(n);
378        sorted
379    }
380
381    /// Crystallized patterns (frequency >= PATTERN_MIN_FREQ).
382    pub fn crystallized_count(&self) -> usize {
383        self.patterns
384            .iter()
385            .filter(|p| p.frequency >= PATTERN_MIN_FREQ)
386            .count()
387    }
388
389    /// Get current session's recall path.
390    pub fn current_path(&self) -> Vec<&ThoughtNode> {
391        self.nodes
392            .iter()
393            .filter(|n| n.session_id == self.current_session_id)
394            .collect()
395    }
396
397    /// Get recent sessions (unique session IDs, most recent first).
398    pub fn recent_sessions(&self, n: usize) -> Vec<Vec<&ThoughtNode>> {
399        let mut session_map: HashMap<u32, Vec<&ThoughtNode>> = HashMap::new();
400        for node in &self.nodes {
401            session_map.entry(node.session_id).or_default().push(node);
402        }
403
404        let mut session_ids: Vec<u32> = session_map.keys().cloned().collect();
405        session_ids.sort_unstable_by(|a, b| b.cmp(a));
406        session_ids.truncate(n);
407
408        session_ids
409            .into_iter()
410            .filter_map(|id| session_map.remove(&id))
411            .collect()
412    }
413
414    /// Export crystallized patterns for cross-instance exchange.
415    pub fn export_patterns(&self) -> Vec<&ThoughtPattern> {
416        self.patterns
417            .iter()
418            .filter(|p| p.frequency >= PATTERN_MIN_FREQ)
419            .collect()
420    }
421
422    /// Import patterns from a remote instance with trust weighting.
423    pub fn import_patterns(&mut self, patterns: &[ThoughtPattern], trust: f32) {
424        for remote in patterns {
425            if let Some(local) = self
426                .patterns
427                .iter_mut()
428                .find(|p| p.sequence == remote.sequence)
429            {
430                // Reinforce existing pattern
431                local.strength = (local.strength + remote.strength * trust * 0.3).min(5.0);
432            } else {
433                // Add new with trust-weighted strength
434                let mut imported = remote.clone();
435                imported.id = self.next_pattern_id;
436                self.next_pattern_id += 1;
437                imported.strength = remote.strength * trust * 0.5;
438                imported.frequency = 1; // starts as candidate
439                self.patterns.push(imported);
440            }
441        }
442
443        // Enforce cap
444        if self.patterns.len() > MAX_PATTERNS {
445            self.patterns.sort_by(|a, b| {
446                let sa = a.strength * a.frequency as f32;
447                let sb = b.strength * b.frequency as f32;
448                sb.total_cmp(&sa)
449            });
450            self.patterns.truncate(MAX_PATTERNS);
451        }
452    }
453
454    /// Stats summary.
455    pub fn stats(&self) -> ThoughtGraphStats {
456        ThoughtGraphStats {
457            node_count: self.nodes.len(),
458            edge_count: self.edges.len(),
459            pattern_count: self.patterns.len(),
460            crystallized: self.crystallized_count(),
461            current_session_id: self.current_session_id,
462            current_path_len: self.current_path().len(),
463        }
464    }
465}
466
467pub struct ThoughtGraphStats {
468    pub node_count: usize,
469    pub edge_count: usize,
470    pub pattern_count: usize,
471    pub crystallized: usize,
472    pub current_session_id: u32,
473    pub current_path_len: usize,
474}
475
476// ─── Binary I/O ─────────────────────────────────────
477
478fn now_epoch_ms() -> u64 {
479    SystemTime::now()
480        .duration_since(UNIX_EPOCH)
481        .unwrap_or_default()
482        .as_millis() as u64
483}
484
485fn save_graph(
486    path: &Path,
487    nodes: &[ThoughtNode],
488    edges: &HashMap<(u64, u64), ThoughtEdge>,
489    session_id: u32,
490    last_ts: u64,
491    next_pid: u32,
492) -> Result<(), String> {
493    let edge_vec: Vec<&ThoughtEdge> = edges.values().collect();
494    let capacity = 4 + 4 + 8 + 4 + 4 + 4 + nodes.len() * NODE_BYTES + edge_vec.len() * EDGE_BYTES;
495    let mut buf = Vec::with_capacity(capacity);
496
497    buf.write_all(b"THG1").map_err(|e| e.to_string())?;
498    buf.write_all(&session_id.to_le_bytes())
499        .map_err(|e| e.to_string())?;
500    buf.write_all(&last_ts.to_le_bytes())
501        .map_err(|e| e.to_string())?;
502    buf.write_all(&next_pid.to_le_bytes())
503        .map_err(|e| e.to_string())?;
504    buf.write_all(&(nodes.len() as u32).to_le_bytes())
505        .map_err(|e| e.to_string())?;
506    buf.write_all(&(edge_vec.len() as u32).to_le_bytes())
507        .map_err(|e| e.to_string())?;
508
509    for n in nodes {
510        buf.write_all(&n.timestamp_ms.to_le_bytes())
511            .map_err(|e| e.to_string())?;
512        buf.write_all(&n.query_hash.to_le_bytes())
513            .map_err(|e| e.to_string())?;
514        buf.write_all(&n.session_id.to_le_bytes())
515            .map_err(|e| e.to_string())?;
516        buf.write_all(&n.result_count.to_le_bytes())
517            .map_err(|e| e.to_string())?;
518        buf.write_all(&[n.dominant_layer, n.centroid_hash])
519            .map_err(|e| e.to_string())?;
520    }
521
522    for e in &edge_vec {
523        buf.write_all(&e.from_hash.to_le_bytes())
524            .map_err(|e| e.to_string())?;
525        buf.write_all(&e.to_hash.to_le_bytes())
526            .map_err(|e| e.to_string())?;
527        buf.write_all(&e.count.to_le_bytes())
528            .map_err(|e| e.to_string())?;
529        buf.write_all(&e.last_ms.to_le_bytes())
530            .map_err(|e| e.to_string())?;
531    }
532
533    let tmp_path = path.with_extension("bin.tmp");
534    fs::write(&tmp_path, &buf).map_err(|e| e.to_string())?;
535    fs::rename(&tmp_path, path).map_err(|e| e.to_string())
536}
537
538type GraphData = (
539    Vec<ThoughtNode>,
540    HashMap<(u64, u64), ThoughtEdge>,
541    u32,
542    u64,
543    u32,
544);
545
546fn load_graph(path: &Path) -> GraphData {
547    let data = match fs::read(path) {
548        Ok(d) => d,
549        Err(_) => return (Vec::new(), HashMap::new(), 0, 0, 0),
550    };
551
552    if data.len() < 28 || &data[0..4] != b"THG1" {
553        return (Vec::new(), HashMap::new(), 0, 0, 0);
554    }
555
556    let session_id = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
557    let last_ts = u64::from_le_bytes([
558        data[8], data[9], data[10], data[11], data[12], data[13], data[14], data[15],
559    ]);
560    let next_pid = u32::from_le_bytes([data[16], data[17], data[18], data[19]]);
561    let node_count = u32::from_le_bytes([data[20], data[21], data[22], data[23]]) as usize;
562    let edge_count = u32::from_le_bytes([data[24], data[25], data[26], data[27]]) as usize;
563
564    let mut offset = 28;
565    let mut nodes = Vec::with_capacity(node_count);
566
567    for _ in 0..node_count {
568        if offset + NODE_BYTES > data.len() {
569            break;
570        }
571        let timestamp_ms = u64::from_le_bytes([
572            data[offset],
573            data[offset + 1],
574            data[offset + 2],
575            data[offset + 3],
576            data[offset + 4],
577            data[offset + 5],
578            data[offset + 6],
579            data[offset + 7],
580        ]);
581        let query_hash = u64::from_le_bytes([
582            data[offset + 8],
583            data[offset + 9],
584            data[offset + 10],
585            data[offset + 11],
586            data[offset + 12],
587            data[offset + 13],
588            data[offset + 14],
589            data[offset + 15],
590        ]);
591        let session_id_n = u32::from_le_bytes([
592            data[offset + 16],
593            data[offset + 17],
594            data[offset + 18],
595            data[offset + 19],
596        ]);
597        let result_count = u16::from_le_bytes([data[offset + 20], data[offset + 21]]);
598        let dominant_layer = data[offset + 22];
599        let centroid_hash = data[offset + 23];
600
601        nodes.push(ThoughtNode {
602            timestamp_ms,
603            query_hash,
604            session_id: session_id_n,
605            result_count,
606            dominant_layer,
607            centroid_hash,
608        });
609        offset += NODE_BYTES;
610    }
611
612    let mut edges = HashMap::with_capacity(edge_count);
613    for _ in 0..edge_count {
614        if offset + EDGE_BYTES > data.len() {
615            break;
616        }
617        let from_hash = u64::from_le_bytes([
618            data[offset],
619            data[offset + 1],
620            data[offset + 2],
621            data[offset + 3],
622            data[offset + 4],
623            data[offset + 5],
624            data[offset + 6],
625            data[offset + 7],
626        ]);
627        let to_hash = u64::from_le_bytes([
628            data[offset + 8],
629            data[offset + 9],
630            data[offset + 10],
631            data[offset + 11],
632            data[offset + 12],
633            data[offset + 13],
634            data[offset + 14],
635            data[offset + 15],
636        ]);
637        let count = u32::from_le_bytes([
638            data[offset + 16],
639            data[offset + 17],
640            data[offset + 18],
641            data[offset + 19],
642        ]);
643        let last_ms = u32::from_le_bytes([
644            data[offset + 20],
645            data[offset + 21],
646            data[offset + 22],
647            data[offset + 23],
648        ]);
649
650        edges.insert(
651            (from_hash, to_hash),
652            ThoughtEdge {
653                from_hash,
654                to_hash,
655                count,
656                last_ms,
657            },
658        );
659        offset += EDGE_BYTES;
660    }
661
662    (nodes, edges, session_id, last_ts, next_pid)
663}
664
665fn save_patterns(path: &Path, patterns: &[ThoughtPattern]) -> Result<(), String> {
666    let mut buf = Vec::with_capacity(8 + patterns.len() * 64);
667
668    buf.write_all(b"PTN1").map_err(|e| e.to_string())?;
669    buf.write_all(&(patterns.len() as u32).to_le_bytes())
670        .map_err(|e| e.to_string())?;
671
672    for p in patterns {
673        buf.write_all(&p.id.to_le_bytes())
674            .map_err(|e| e.to_string())?;
675        buf.write_all(&(p.sequence.len() as u16).to_le_bytes())
676            .map_err(|e| e.to_string())?;
677        for &h in &p.sequence {
678            buf.write_all(&h.to_le_bytes()).map_err(|e| e.to_string())?;
679        }
680        buf.write_all(&p.frequency.to_le_bytes())
681            .map_err(|e| e.to_string())?;
682        buf.write_all(&p.strength.to_le_bytes())
683            .map_err(|e| e.to_string())?;
684        buf.write_all(&p.last_seen_ms.to_le_bytes())
685            .map_err(|e| e.to_string())?;
686        buf.write_all(&(p.result_blocks.len() as u16).to_le_bytes())
687            .map_err(|e| e.to_string())?;
688        for &b in &p.result_blocks {
689            buf.write_all(&b.to_le_bytes()).map_err(|e| e.to_string())?;
690        }
691    }
692
693    let tmp_path = path.with_extension("bin.tmp");
694    fs::write(&tmp_path, &buf).map_err(|e| e.to_string())?;
695    fs::rename(&tmp_path, path).map_err(|e| e.to_string())
696}
697
698fn load_patterns(path: &Path) -> Vec<ThoughtPattern> {
699    let data = match fs::read(path) {
700        Ok(d) => d,
701        Err(_) => return Vec::new(),
702    };
703
704    if data.len() < 8 || &data[0..4] != b"PTN1" {
705        return Vec::new();
706    }
707
708    let count = u32::from_le_bytes([data[4], data[5], data[6], data[7]]) as usize;
709    let mut offset = 8;
710    let mut patterns = Vec::with_capacity(count);
711
712    for _ in 0..count {
713        if offset + 6 > data.len() {
714            break;
715        }
716
717        let id = u32::from_le_bytes([
718            data[offset],
719            data[offset + 1],
720            data[offset + 2],
721            data[offset + 3],
722        ]);
723        offset += 4;
724
725        let seq_len = u16::from_le_bytes([data[offset], data[offset + 1]]) as usize;
726        offset += 2;
727
728        if offset + seq_len * 8 > data.len() {
729            break;
730        }
731        let mut sequence = Vec::with_capacity(seq_len);
732        for _ in 0..seq_len {
733            let h = u64::from_le_bytes([
734                data[offset],
735                data[offset + 1],
736                data[offset + 2],
737                data[offset + 3],
738                data[offset + 4],
739                data[offset + 5],
740                data[offset + 6],
741                data[offset + 7],
742            ]);
743            sequence.push(h);
744            offset += 8;
745        }
746
747        if offset + 16 > data.len() {
748            break;
749        }
750        let frequency = u32::from_le_bytes([
751            data[offset],
752            data[offset + 1],
753            data[offset + 2],
754            data[offset + 3],
755        ]);
756        offset += 4;
757
758        let strength = f32::from_le_bytes([
759            data[offset],
760            data[offset + 1],
761            data[offset + 2],
762            data[offset + 3],
763        ]);
764        offset += 4;
765
766        let last_seen_ms = u64::from_le_bytes([
767            data[offset],
768            data[offset + 1],
769            data[offset + 2],
770            data[offset + 3],
771            data[offset + 4],
772            data[offset + 5],
773            data[offset + 6],
774            data[offset + 7],
775        ]);
776        offset += 8;
777
778        if offset + 2 > data.len() {
779            break;
780        }
781        let block_count = u16::from_le_bytes([data[offset], data[offset + 1]]) as usize;
782        offset += 2;
783
784        if offset + block_count * 4 > data.len() {
785            break;
786        }
787        let mut result_blocks = Vec::with_capacity(block_count);
788        for _ in 0..block_count {
789            let b = u32::from_le_bytes([
790                data[offset],
791                data[offset + 1],
792                data[offset + 2],
793                data[offset + 3],
794            ]);
795            result_blocks.push(b);
796            offset += 4;
797        }
798
799        patterns.push(ThoughtPattern {
800            id,
801            sequence,
802            frequency,
803            strength,
804            last_seen_ms,
805            result_blocks,
806        });
807    }
808
809    patterns
810}
811
812// ─── Tests ──────────────────────────────────────────
813
814#[cfg(test)]
815mod tests {
816    use super::*;
817
818    fn make_state() -> ThoughtGraphState {
819        ThoughtGraphState {
820            nodes: Vec::new(),
821            edges: HashMap::new(),
822            patterns: Vec::new(),
823            current_session_id: 0,
824            last_node_ts: 0,
825            next_pattern_id: 0,
826        }
827    }
828
829    #[test]
830    fn test_record_recall() {
831        let mut state = make_state();
832        let results = vec![(10u32, 0.5f32), (20, 0.3)];
833        let sid = state.record_recall(0xAABB, &results, 1);
834        assert_eq!(sid, 1); // first recall starts session 1
835        assert_eq!(state.nodes.len(), 1);
836        assert_eq!(state.edges.len(), 0); // only 1 node, no edge
837    }
838
839    #[test]
840    fn test_sequential_recalls() {
841        let mut state = make_state();
842        state.last_node_ts = now_epoch_ms(); // force same session
843        state.current_session_id = 1;
844
845        state.record_recall(0xAA, &[(1, 0.5)], 1);
846        state.record_recall(0xBB, &[(2, 0.3)], 1);
847
848        assert_eq!(state.nodes.len(), 2);
849        assert_eq!(state.edges.len(), 1);
850        assert!(state.edges.contains_key(&(0xAA, 0xBB)));
851        assert_eq!(state.edges[&(0xAA, 0xBB)].count, 1);
852    }
853
854    #[test]
855    fn test_session_gap() {
856        let mut state = make_state();
857        state.record_recall(0xAA, &[], 0);
858        let sid1 = state.current_session_id;
859
860        // Simulate gap
861        state.last_node_ts = now_epoch_ms() - SESSION_GAP_MS - 1;
862        state.record_recall(0xBB, &[], 0);
863        let sid2 = state.current_session_id;
864
865        assert!(sid2 > sid1);
866        assert_eq!(state.edges.len(), 0); // different sessions, no edge
867    }
868
869    #[test]
870    fn test_pattern_detection() {
871        let mut state = make_state();
872        state.current_session_id = 1;
873        state.last_node_ts = now_epoch_ms();
874
875        // Simulate the sequence A→B→C three times
876        // We need edges with count >= 2 for patterns to form
877        // So we repeat the full sequence multiple times in one session
878
879        for _ in 0..4 {
880            state.record_recall(0xAA, &[(1, 0.5)], 1);
881            state.record_recall(0xBB, &[(2, 0.3)], 1);
882            state.record_recall(0xCC, &[(3, 0.2)], 1);
883        }
884
885        state.detect_patterns();
886
887        // Should have found patterns (at least the 2-gram BB→CC)
888        assert!(!state.patterns.is_empty());
889    }
890
891    #[test]
892    fn test_pattern_boost_empty() {
893        let state = make_state();
894        let boosts = state.pattern_boost(0xAA);
895        assert!(boosts.is_empty());
896    }
897
898    #[test]
899    fn test_pattern_boost_with_match() {
900        let mut state = make_state();
901        state.current_session_id = 1;
902        state.last_node_ts = now_epoch_ms();
903
904        // Create a crystallized pattern AA→BB with result blocks
905        state.patterns.push(ThoughtPattern {
906            id: 0,
907            sequence: vec![0xAA, 0xBB],
908            frequency: PATTERN_MIN_FREQ,
909            strength: 2.0,
910            last_seen_ms: now_epoch_ms(),
911            result_blocks: vec![10, 20, 30],
912        });
913
914        // Simulate: last recall was AA, now querying BB
915        state.nodes.push(ThoughtNode {
916            timestamp_ms: now_epoch_ms(),
917            query_hash: 0xAA,
918            session_id: 1,
919            result_count: 1,
920            dominant_layer: 0,
921            centroid_hash: 0,
922        });
923
924        let boosts = state.pattern_boost(0xBB);
925        assert!(!boosts.is_empty());
926        // Should boost blocks 10, 20, 30
927        let boost_map: HashMap<u32, f32> = boosts.into_iter().collect();
928        assert!(boost_map.contains_key(&10));
929        assert!(boost_map.contains_key(&20));
930        assert!(boost_map.contains_key(&30));
931    }
932
933    #[test]
934    fn test_save_load_roundtrip() {
935        let dir = tempfile::tempdir().unwrap();
936        let mut state = make_state();
937        state.current_session_id = 1;
938        state.last_node_ts = now_epoch_ms();
939
940        state.record_recall(0xAA, &[(1, 0.5)], 1);
941        state.record_recall(0xBB, &[(2, 0.3)], 2);
942
943        state.patterns.push(ThoughtPattern {
944            id: 0,
945            sequence: vec![0xAA, 0xBB],
946            frequency: 5,
947            strength: 2.0,
948            last_seen_ms: 12345678,
949            result_blocks: vec![10, 20],
950        });
951
952        state.save(dir.path()).unwrap();
953
954        let loaded = ThoughtGraphState::load_or_init(dir.path());
955        assert_eq!(loaded.nodes.len(), 2);
956        assert_eq!(loaded.edges.len(), 1);
957        assert_eq!(loaded.patterns.len(), 1);
958        assert_eq!(loaded.patterns[0].sequence, vec![0xAA, 0xBB]);
959        assert_eq!(loaded.patterns[0].frequency, 5);
960        assert_eq!(loaded.patterns[0].result_blocks, vec![10, 20]);
961        assert_eq!(loaded.current_session_id, 1);
962    }
963
964    #[test]
965    fn test_node_ring_buffer() {
966        let mut state = make_state();
967        state.current_session_id = 1;
968        state.last_node_ts = now_epoch_ms();
969
970        for i in 0..MAX_NODES + 100 {
971            state.record_recall(i as u64, &[], 0);
972        }
973
974        assert_eq!(state.nodes.len(), MAX_NODES);
975    }
976
977    #[test]
978    fn test_recent_sessions() {
979        let mut state = make_state();
980
981        // Session 1
982        state.record_recall(0xAA, &[], 0);
983        state.record_recall(0xBB, &[], 0);
984
985        // Force new session
986        state.last_node_ts = now_epoch_ms() - SESSION_GAP_MS - 1;
987        state.record_recall(0xCC, &[], 0);
988
989        let sessions = state.recent_sessions(5);
990        assert_eq!(sessions.len(), 2);
991    }
992
993    #[test]
994    fn test_stats() {
995        let mut state = make_state();
996        state.record_recall(0xAA, &[], 0);
997        state.record_recall(0xBB, &[], 0);
998
999        let stats = state.stats();
1000        assert_eq!(stats.node_count, 2);
1001        assert_eq!(stats.edge_count, 1);
1002        assert_eq!(stats.pattern_count, 0);
1003    }
1004}