1use std::collections::HashMap;
13use std::fs;
14use std::io::Write;
15use std::path::Path;
16use std::time::{SystemTime, UNIX_EPOCH};
17
18const SESSION_GAP_MS: u64 = 1_800_000; const MAX_NODES: usize = 5000; const MAX_EDGES: usize = 10_000;
23const MAX_PATTERNS: usize = 200;
24const PATTERN_MIN_FREQ: u32 = 3; const PATTERN_DECAY: f32 = 0.995; const NODE_BYTES: usize = 24;
27const EDGE_BYTES: usize = 24;
28
29pub const PATTERN_BOOST_WEIGHT: f32 = 0.15;
31
32#[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#[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, }
55
56#[derive(Clone, Debug)]
60pub struct ThoughtPattern {
61 pub id: u32,
62 pub sequence: Vec<u64>, pub frequency: u32,
64 pub strength: f32,
65 pub last_seen_ms: u64,
66 pub result_blocks: Vec<u32>, }
68
69pub 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 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 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 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 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 self.nodes.push(node);
157 if self.nodes.len() > MAX_NODES {
158 self.nodes.drain(0..(self.nodes.len() - MAX_NODES));
159 }
160
161 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 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 for p in &mut self.patterns {
188 p.strength *= PATTERN_DECAY;
189 }
190
191 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 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 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 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 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 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 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 if trail == &seq[..prefix_len] && seq[prefix_len] == current_query_hash {
277 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 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 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 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 for &b in result_blocks {
323 if !pattern.result_blocks.contains(&b) {
324 pattern.result_blocks.push(b);
325 }
326 }
327 if pattern.result_blocks.len() > 50 {
329 pattern.result_blocks.truncate(50);
330 }
331 }
332 }
333 }
334
335 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 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 pub fn crystallized_count(&self) -> usize {
363 self.patterns
364 .iter()
365 .filter(|p| p.frequency >= PATTERN_MIN_FREQ)
366 .count()
367 }
368
369 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 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 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 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 local.strength = (local.strength + remote.strength * trust * 0.3).min(5.0);
412 } else {
413 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; self.patterns.push(imported);
420 }
421 }
422
423 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 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
456fn 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#[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); assert_eq!(state.nodes.len(), 1);
812 assert_eq!(state.edges.len(), 0); }
814
815 #[test]
816 fn test_sequential_recalls() {
817 let mut state = make_state();
818 state.last_node_ts = now_epoch_ms(); 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 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); }
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 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 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 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 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 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 state.record_recall(0xAA, &[], 0);
959 state.record_recall(0xBB, &[], 0);
960
961 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}