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