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
69#[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 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 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 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 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 self.nodes.push(node);
158 if self.nodes.len() > MAX_NODES {
159 self.nodes.drain(0..(self.nodes.len() - MAX_NODES));
160 }
161
162 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 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 for p in &mut self.patterns {
189 p.strength *= PATTERN_DECAY;
190 }
191
192 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 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 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 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 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 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 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 if trail == &seq[..prefix_len] && seq[prefix_len] == current_query_hash {
278 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 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 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 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 for &b in result_blocks {
324 if !pattern.result_blocks.contains(&b) {
325 pattern.result_blocks.push(b);
326 }
327 }
328 if pattern.result_blocks.len() > 50 {
330 pattern.result_blocks.truncate(50);
331 }
332 }
333 }
334 }
335
336 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 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 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 pub fn crystallized_count(&self) -> usize {
383 self.patterns
384 .iter()
385 .filter(|p| p.frequency >= PATTERN_MIN_FREQ)
386 .count()
387 }
388
389 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 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 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 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 local.strength = (local.strength + remote.strength * trust * 0.3).min(5.0);
432 } else {
433 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; self.patterns.push(imported);
440 }
441 }
442
443 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 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
476fn 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#[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); assert_eq!(state.nodes.len(), 1);
836 assert_eq!(state.edges.len(), 0); }
838
839 #[test]
840 fn test_sequential_recalls() {
841 let mut state = make_state();
842 state.last_node_ts = now_epoch_ms(); 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 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); }
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 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 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 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 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 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 state.record_recall(0xAA, &[], 0);
983 state.record_recall(0xBB, &[], 0);
984
985 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}