1use std::collections::{HashMap, HashSet, VecDeque};
7
8use hirn_core::id::MemoryId;
9use hirn_core::types::Namespace;
10use hirn_core::{HirnError, HirnResult};
11
12use crate::graph::PropertyGraph;
13
14#[derive(Debug, Clone)]
16pub struct ActivationConfig {
17 pub decay_factor: f64,
19 pub epsilon: f64,
21 pub max_iterations: usize,
24 pub max_depth: usize,
26 pub inhibition_strength: f64,
28 pub inhibition_threshold: f64,
31 pub max_frontier_size: usize,
35}
36
37impl ActivationConfig {
38 pub fn validate(&self) -> HirnResult<()> {
40 if !self.decay_factor.is_finite() || self.decay_factor <= 0.0 || self.decay_factor > 1.0 {
41 return Err(HirnError::InvalidInput(
42 "activation.decay_factor must be finite and in (0, 1]".into(),
43 ));
44 }
45 if !self.epsilon.is_finite() || self.epsilon < 0.0 || self.epsilon >= 1.0 {
46 return Err(HirnError::InvalidInput(
47 "activation.epsilon must be finite and in [0, 1)".into(),
48 ));
49 }
50 if self.max_iterations == 0 {
51 return Err(HirnError::InvalidInput(
52 "activation.max_iterations must be greater than 0".into(),
53 ));
54 }
55 if self.max_depth == 0 {
56 return Err(HirnError::InvalidInput(
57 "activation.max_depth must be greater than 0".into(),
58 ));
59 }
60 if !self.inhibition_strength.is_finite() || self.inhibition_strength < 0.0 {
61 return Err(HirnError::InvalidInput(
62 "activation.inhibition_strength must be finite and >= 0".into(),
63 ));
64 }
65 if !self.inhibition_threshold.is_finite()
66 || !(0.0..=1.0).contains(&self.inhibition_threshold)
67 {
68 return Err(HirnError::InvalidInput(
69 "activation.inhibition_threshold must be finite and in [0, 1]".into(),
70 ));
71 }
72 if self.max_frontier_size == 0 {
73 return Err(HirnError::InvalidInput(
74 "activation.max_frontier_size must be greater than 0".into(),
75 ));
76 }
77 Ok(())
78 }
79
80 #[must_use]
81 pub const fn propagation_steps(&self) -> usize {
82 if self.max_depth < self.max_iterations {
83 self.max_depth
84 } else {
85 self.max_iterations
86 }
87 }
88
89 #[must_use]
101 pub fn tuned_for_graph(&self, node_count: usize, edge_count: usize) -> Self {
102 if node_count == 0 {
103 return self.clone();
104 }
105 let avg_degree = edge_count / node_count.max(1);
106 let adaptive = (avg_degree * 100).clamp(256, self.max_frontier_size);
109 Self {
110 max_frontier_size: adaptive,
111 ..self.clone()
112 }
113 }
114}
115
116impl Default for ActivationConfig {
117 fn default() -> Self {
118 Self {
119 decay_factor: 0.7,
120 epsilon: 0.01,
121 max_iterations: 10,
122 max_depth: 3,
123 inhibition_strength: 0.1,
124 inhibition_threshold: 0.7,
125 max_frontier_size: 10_000,
126 }
127 }
128}
129
130#[derive(Debug, Clone)]
133pub struct ActivationTrace {
134 pub path: Vec<MemoryId>,
136 pub seed: MemoryId,
138}
139
140#[derive(Debug, Clone)]
142pub struct ActivationResult {
143 pub activations: HashMap<MemoryId, f64>,
145 pub traces: HashMap<MemoryId, ActivationTrace>,
147}
148
149#[derive(Debug, Default, Clone, PartialEq)]
151pub enum ActivationMode {
152 #[default]
154 None,
155 Static,
157 Spreading,
159 PersonalizedPageRank(PprConfig),
161}
162
163#[derive(Debug, Clone)]
169pub struct PprConfig {
170 pub alpha: f64,
173 pub epsilon: f64,
176 pub max_iterations: usize,
178}
179
180impl Default for PprConfig {
181 fn default() -> Self {
182 Self {
183 alpha: 0.15,
184 epsilon: 1e-6,
185 max_iterations: 100,
186 }
187 }
188}
189
190impl PartialEq for PprConfig {
191 fn eq(&self, other: &Self) -> bool {
192 self.alpha == other.alpha
193 && self.epsilon == other.epsilon
194 && self.max_iterations == other.max_iterations
195 }
196}
197
198impl PprConfig {
199 pub fn validate(&self) -> HirnResult<()> {
201 if !self.alpha.is_finite() || !(0.0..=1.0).contains(&self.alpha) {
202 return Err(HirnError::InvalidInput(
203 "ppr.alpha must be finite and in [0, 1]".into(),
204 ));
205 }
206 if !self.epsilon.is_finite() || self.epsilon < 0.0 || self.epsilon >= 1.0 {
207 return Err(HirnError::InvalidInput(
208 "ppr.epsilon must be finite and in [0, 1)".into(),
209 ));
210 }
211 if self.max_iterations == 0 {
212 return Err(HirnError::InvalidInput(
213 "ppr.max_iterations must be greater than 0".into(),
214 ));
215 }
216 Ok(())
217 }
218}
219
220#[allow(clippy::implicit_hasher)]
226pub fn spread_activation(
227 graph: &PropertyGraph,
228 seeds: &[MemoryId],
229 config: &ActivationConfig,
230 embeddings: Option<&HashMap<MemoryId, Vec<f32>>>,
231 allowed_namespaces: Option<&[Namespace]>,
232) -> HirnResult<ActivationResult> {
233 config.validate()?;
234 Ok(spread_activation_unchecked(
235 graph,
236 seeds,
237 config,
238 embeddings,
239 allowed_namespaces,
240 ))
241}
242
243#[allow(clippy::implicit_hasher)]
244fn spread_activation_unchecked(
245 graph: &PropertyGraph,
246 seeds: &[MemoryId],
247 config: &ActivationConfig,
248 embeddings: Option<&HashMap<MemoryId, Vec<f32>>>,
249 allowed_namespaces: Option<&[Namespace]>,
250) -> ActivationResult {
251 let mut activations: HashMap<MemoryId, f64> = HashMap::new();
252 let mut traces: HashMap<MemoryId, ActivationTrace> = HashMap::new();
253
254 for &seed in seeds {
256 if graph.has_node(seed) {
257 activations.insert(seed, 1.0);
258 traces.insert(
259 seed,
260 ActivationTrace {
261 path: vec![seed],
262 seed,
263 },
264 );
265 }
266 }
267
268 let mut frontier: Vec<(MemoryId, f64)> = seeds
272 .iter()
273 .filter(|s| graph.has_node(**s))
274 .map(|&s| (s, 1.0))
275 .collect();
276 let mut propagated: HashSet<MemoryId> = seeds.iter().copied().collect();
277
278 for depth in 0..config.propagation_steps() {
279 if frontier.is_empty() {
280 break;
281 }
282
283 let depth_decay = config
284 .decay_factor
285 .powi(i32::try_from(depth).unwrap_or(i32::MAX) + 1);
286 let mut next_frontier: HashMap<MemoryId, f64> = HashMap::new();
287
288 for (node_id, activation) in &frontier {
289 if *activation < config.epsilon {
290 continue;
291 }
292
293 let Some(node_idx) = graph.node_index(*node_id) else {
294 continue;
295 };
296
297 for (neighbor_idx, weight, _relation) in graph.outgoing_weighted_iter(node_idx) {
298 let Some(neighbor) = graph.node_id(neighbor_idx) else {
299 continue;
300 };
301
302 if let Some(allowed) = allowed_namespaces
304 && let Some(ns) = graph.node_namespace(neighbor)
305 && !allowed.contains(ns)
306 {
307 continue;
308 }
309
310 let contribution = activation * f64::from(weight) * depth_decay;
311 if contribution < config.epsilon {
312 continue;
313 }
314
315 *next_frontier.entry(neighbor).or_insert(0.0) += contribution;
317
318 if !traces.contains_key(&neighbor)
320 && let Some(parent_trace) = traces.get(node_id)
321 {
322 let mut path = parent_trace.path.clone();
323 path.push(neighbor);
324 traces.insert(
325 neighbor,
326 ActivationTrace {
327 path,
328 seed: parent_trace.seed,
329 },
330 );
331 }
332 }
333 }
334
335 if next_frontier.is_empty() {
336 break;
337 }
338
339 let mut new_frontier: Vec<(MemoryId, f64)> = Vec::new();
341 for (node, new_val) in next_frontier {
342 let old = activations.get(&node).copied().unwrap_or(0.0);
343 let updated = (old + new_val).min(1.0);
344 activations.insert(node, updated);
345 if propagated.insert(node) {
346 new_frontier.push((node, updated));
348 }
349 }
350
351 if config.max_frontier_size > 0 && new_frontier.len() > config.max_frontier_size {
353 tracing::warn!(
354 depth = depth,
355 frontier_before = new_frontier.len(),
356 frontier_after = config.max_frontier_size,
357 "spreading activation frontier exceeded max_frontier_size, truncating"
358 );
359 new_frontier.select_nth_unstable_by(config.max_frontier_size, |a, b| {
362 b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
363 });
364 new_frontier.truncate(config.max_frontier_size);
365 new_frontier.sort_unstable_by(|a, b| {
366 b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
367 });
368 }
369
370 tracing::info!(
372 depth = depth,
373 frontier_size = new_frontier.len(),
374 "activation_depth"
375 );
376
377 frontier = new_frontier;
378 }
379
380 if config.inhibition_strength > 0.0
382 && let Some(embs) = embeddings
383 {
384 apply_lateral_inhibition(
385 graph,
386 &mut activations,
387 config.inhibition_strength,
388 config.inhibition_threshold,
389 embs,
390 );
391 }
392
393 activations.retain(|_, v| *v >= config.epsilon);
395
396 ActivationResult {
397 activations,
398 traces,
399 }
400}
401
402pub fn static_activation(
406 graph: &PropertyGraph,
407 seeds: &[MemoryId],
408 allowed_namespaces: Option<&[Namespace]>,
409) -> HashMap<MemoryId, f64> {
410 let mut activations: HashMap<MemoryId, f64> = HashMap::new();
411 for &seed in seeds {
412 activations.insert(seed, 1.0);
413 for (neighbor, weight, _) in graph.outgoing_weighted(seed) {
414 if let Some(allowed) = allowed_namespaces
416 && let Some(ns) = graph.node_namespace(neighbor)
417 && !allowed.contains(ns)
418 {
419 continue;
420 }
421 let entry = activations.entry(neighbor).or_insert(0.0);
422 *entry = entry.max(f64::from(weight));
423 }
424 }
425 activations
426}
427
428pub fn personalized_pagerank(
444 graph: &PropertyGraph,
445 seeds: &[MemoryId],
446 config: &PprConfig,
447 allowed_namespaces: Option<&[Namespace]>,
448) -> HirnResult<HashMap<MemoryId, f64>> {
449 config.validate()?;
450 Ok(personalized_pagerank_unchecked(
451 graph,
452 seeds,
453 config,
454 allowed_namespaces,
455 ))
456}
457
458fn personalized_pagerank_unchecked(
459 graph: &PropertyGraph,
460 seeds: &[MemoryId],
461 config: &PprConfig,
462 allowed_namespaces: Option<&[Namespace]>,
463) -> HashMap<MemoryId, f64> {
464 if seeds.is_empty() {
465 return HashMap::new();
466 }
467
468 let all_nodes = collect_reachable_nodes(graph, seeds, allowed_namespaces);
471
472 if all_nodes.is_empty() {
473 return HashMap::new();
474 }
475
476 let n = all_nodes.len();
477 let node_to_idx: HashMap<MemoryId, usize> = all_nodes
478 .iter()
479 .enumerate()
480 .map(|(i, &id)| (id, i))
481 .collect();
482
483 let mut personalization = vec![0.0_f64; n];
485 let seed_count = seeds.iter().filter(|s| node_to_idx.contains_key(s)).count();
486 if seed_count == 0 {
487 return HashMap::new();
488 }
489 let seed_weight = 1.0 / seed_count as f64;
490 for &seed in seeds {
491 if let Some(&idx) = node_to_idx.get(&seed) {
492 personalization[idx] = seed_weight;
493 }
494 }
495
496 let mut out_edges: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n];
499 for (i, &node) in all_nodes.iter().enumerate() {
500 let neighbors = graph.outgoing_weighted(node);
501 let total_weight: f64 = neighbors
502 .iter()
503 .filter_map(|(nb, w, _)| node_to_idx.get(nb).map(|_| f64::from(*w)))
504 .sum();
505 if total_weight > 0.0 {
506 for (nb, w, _) in &neighbors {
507 if let Some(&j) = node_to_idx.get(nb) {
508 out_edges[i].push((j, f64::from(*w) / total_weight));
509 }
510 }
511 }
512 }
513
514 let alpha = config.alpha;
517 let mut scores = personalization.clone();
518
519 for _ in 0..config.max_iterations {
520 let mut new_scores = vec![0.0_f64; n];
521
522 let mut dangling_mass = 0.0_f64;
526 for i in 0..n {
527 if out_edges[i].is_empty() {
528 dangling_mass += scores[i];
530 } else {
531 for &(j, w) in &out_edges[i] {
532 new_scores[j] += scores[i] * w;
533 }
534 }
535 }
536
537 let mut max_delta = 0.0_f64;
539 for i in 0..n {
540 let val = alpha.mul_add(personalization[i], (1.0 - alpha) * new_scores[i])
541 + (1.0 - alpha) * dangling_mass * personalization[i];
542 let delta = (val - scores[i]).abs();
543 if delta > max_delta {
544 max_delta = delta;
545 }
546 scores[i] = val;
547 }
548
549 if max_delta < config.epsilon {
550 break;
551 }
552 }
553
554 all_nodes
556 .into_iter()
557 .zip(scores)
558 .filter(|(_, s)| *s > 1e-10)
559 .collect()
560}
561
562fn collect_reachable_nodes(
563 graph: &PropertyGraph,
564 seeds: &[MemoryId],
565 allowed_namespaces: Option<&[Namespace]>,
566) -> Vec<MemoryId> {
567 let mut visited = HashSet::new();
568 let mut queue = VecDeque::new();
569 let mut reachable = Vec::new();
570
571 for &seed in seeds {
572 if !graph.has_node(seed) {
573 continue;
574 }
575 if let Some(allowed) = allowed_namespaces
576 && let Some(ns) = graph.node_namespace(seed)
577 && !allowed.contains(ns)
578 {
579 continue;
580 }
581 if visited.insert(seed) {
582 queue.push_back(seed);
583 reachable.push(seed);
584 }
585 }
586
587 while let Some(node_id) = queue.pop_front() {
588 let Some(node_idx) = graph.node_index(node_id) else {
589 continue;
590 };
591
592 let forward = graph
596 .outgoing_weighted_iter(node_idx)
597 .map(|(nb_idx, _, _)| nb_idx);
598 let backward = graph
599 .incoming_weighted_iter(node_idx)
600 .map(|(nb_idx, _, _)| nb_idx);
601
602 for neighbor_idx in forward.chain(backward) {
603 let Some(neighbor_id) = graph.node_id(neighbor_idx) else {
604 continue;
605 };
606 if let Some(allowed) = allowed_namespaces
607 && let Some(ns) = graph.node_namespace(neighbor_id)
608 && !allowed.contains(ns)
609 {
610 continue;
611 }
612 if visited.insert(neighbor_id) {
613 queue.push_back(neighbor_id);
614 reachable.push(neighbor_id);
615 }
616 }
617 }
618
619 reachable
620}
621
622fn precompute_one_hop_neighbors(
623 graph: &PropertyGraph,
624 ids: impl IntoIterator<Item = MemoryId>,
625) -> HashMap<MemoryId, HashSet<MemoryId>> {
626 ids.into_iter()
627 .filter_map(|id| {
628 let idx = graph.node_index(id)?;
629 let neighbors = graph
630 .outgoing_weighted_iter(idx)
631 .filter_map(|(neighbor_idx, _, _)| graph.node_id(neighbor_idx))
632 .collect::<HashSet<_>>();
633 Some((id, neighbors))
634 })
635 .collect()
636}
637
638fn apply_lateral_inhibition(
648 graph: &PropertyGraph,
649 activations: &mut HashMap<MemoryId, f64>,
650 mu: f64,
651 threshold: f64,
652 embeddings: &HashMap<MemoryId, Vec<f32>>,
653) {
654 let seeds: Vec<MemoryId> = activations
656 .iter()
657 .filter(|(_, v)| (*v - 1.0).abs() < f64::EPSILON)
658 .map(|(&k, _)| k)
659 .collect();
660 let seed_set: HashSet<MemoryId> = seeds.iter().copied().collect();
661
662 let mut connected_to_seeds: HashSet<MemoryId> = HashSet::new();
664 for &seed in &seeds {
665 connected_to_seeds.insert(seed);
666 for neighbor in graph.get_neighbors(seed, 2, 0.0) {
667 connected_to_seeds.insert(neighbor);
668 }
669 }
670
671 let activated_nodes: Vec<MemoryId> = activations.keys().copied().collect();
675 let neighbor_sets = precompute_one_hop_neighbors(
676 graph,
677 activated_nodes.iter().copied().chain(seeds.iter().copied()),
678 );
679 let empty_neighbors = HashSet::new();
680
681 for node in activated_nodes {
682 if seed_set.contains(&node) || connected_to_seeds.contains(&node) {
683 continue; }
685
686 let Some(node_embedding) = embeddings.get(&node) else {
687 continue;
688 };
689
690 let mut max_sim = 0.0_f64;
692 let mut most_similar_seed = None;
693 for &seed in &seeds {
694 if let Some(seed_embedding) = embeddings.get(&seed) {
695 let sim = cosine_sim(seed_embedding, node_embedding);
696 if sim > max_sim {
697 max_sim = sim;
698 most_similar_seed = Some(seed);
699 }
700 }
701 }
702
703 if max_sim > threshold {
708 let jaccard = most_similar_seed
709 .map(|seed| {
710 let node_neighbors = neighbor_sets.get(&node).unwrap_or(&empty_neighbors);
711 let seed_neighbors = neighbor_sets.get(&seed).unwrap_or(&empty_neighbors);
712 jaccard_similarity(node_neighbors, seed_neighbors)
713 })
714 .unwrap_or(0.0);
715 let inhibition = mu * (1.0 - jaccard) * max_sim;
716 if let Some(a) = activations.get_mut(&node) {
717 let floor = *a * 0.2; *a = (*a - inhibition).max(floor);
719 }
720 }
721 }
722}
723
724fn jaccard_similarity(a: &HashSet<MemoryId>, b: &HashSet<MemoryId>) -> f64 {
728 if a.is_empty() && b.is_empty() {
729 return 0.0;
730 }
731 let intersection = a.intersection(b).count();
732 let union = a.union(b).count();
733 intersection as f64 / union as f64
734}
735
736fn cosine_sim(a: &[f32], b: &[f32]) -> f64 {
738 if a.len() != b.len() || a.is_empty() {
739 return 0.0;
740 }
741 let mut dot = 0.0_f64;
742 let mut na = 0.0_f64;
743 let mut nb = 0.0_f64;
744 for (x, y) in a.iter().zip(b.iter()) {
745 let x = f64::from(*x);
746 let y = f64::from(*y);
747 dot += x * y;
748 na += x * x;
749 nb += y * y;
750 }
751 let denom = na.sqrt() * nb.sqrt();
752 if denom < 1e-10 { 0.0 } else { dot / denom }
753}
754
755#[cfg(test)]
758mod tests {
759 use super::*;
760 use hirn_core::HirnError;
761 use hirn_core::id::MemoryId;
762 use hirn_core::metadata::Metadata;
763 use hirn_core::timestamp::Timestamp;
764 use hirn_core::types::{EdgeRelation, Layer};
765
766 fn make_graph_node(pg: &mut PropertyGraph) -> MemoryId {
767 let id = MemoryId::new();
768 pg.add_node(id, Layer::Episodic, 0.5, Timestamp::now());
769 id
770 }
771
772 fn spread_activation(
773 graph: &PropertyGraph,
774 seeds: &[MemoryId],
775 config: &ActivationConfig,
776 embeddings: Option<&HashMap<MemoryId, Vec<f32>>>,
777 allowed_namespaces: Option<&[Namespace]>,
778 ) -> ActivationResult {
779 super::spread_activation(graph, seeds, config, embeddings, allowed_namespaces)
780 .expect("test activation config should be valid")
781 }
782
783 fn personalized_pagerank(
784 graph: &PropertyGraph,
785 seeds: &[MemoryId],
786 config: &PprConfig,
787 allowed_namespaces: Option<&[Namespace]>,
788 ) -> HashMap<MemoryId, f64> {
789 super::personalized_pagerank(graph, seeds, config, allowed_namespaces)
790 .expect("test PPR config should be valid")
791 }
792
793 fn apply_lateral_inhibition_naive(
794 graph: &PropertyGraph,
795 activations: &mut HashMap<MemoryId, f64>,
796 mu: f64,
797 threshold: f64,
798 embeddings: &HashMap<MemoryId, Vec<f32>>,
799 ) {
800 let seeds: Vec<MemoryId> = activations
801 .iter()
802 .filter(|(_, v)| (*v - 1.0).abs() < f64::EPSILON)
803 .map(|(&k, _)| k)
804 .collect();
805
806 let mut connected_to_seeds: HashSet<MemoryId> = HashSet::new();
807 for &seed in &seeds {
808 connected_to_seeds.insert(seed);
809 for neighbor in graph.get_neighbors(seed, 2, 0.0) {
810 connected_to_seeds.insert(neighbor);
811 }
812 }
813
814 let seed_neighbor_sets: HashMap<MemoryId, HashSet<MemoryId>> = seeds
815 .iter()
816 .map(|&seed| {
817 let neighbors = graph.get_neighbors(seed, 1, 0.0).into_iter().collect();
818 (seed, neighbors)
819 })
820 .collect();
821
822 let activated_nodes: Vec<MemoryId> = activations.keys().copied().collect();
823 for node in activated_nodes {
824 if seeds.contains(&node) || connected_to_seeds.contains(&node) {
825 continue;
826 }
827
828 let mut max_sim = 0.0;
829 let mut most_similar_seed = None;
830 for &seed in &seeds {
831 if let (Some(seed_embedding), Some(node_embedding)) =
832 (embeddings.get(&seed), embeddings.get(&node))
833 {
834 let sim = cosine_sim(seed_embedding, node_embedding);
835 if sim > max_sim {
836 max_sim = sim;
837 most_similar_seed = Some(seed);
838 }
839 }
840 }
841
842 if max_sim > threshold {
843 let jaccard = most_similar_seed
844 .map(|seed| {
845 let node_neighbors: HashSet<MemoryId> =
846 graph.get_neighbors(node, 1, 0.0).into_iter().collect();
847 jaccard_similarity(&node_neighbors, &seed_neighbor_sets[&seed])
848 })
849 .unwrap_or(0.0);
850 let inhibition = mu * (1.0 - jaccard) * max_sim;
851 if let Some(activation) = activations.get_mut(&node) {
852 let floor = *activation * 0.2;
853 *activation = (*activation - inhibition).max(floor);
854 }
855 }
856 }
857 }
858
859 #[test]
860 fn activation_config_validate_accepts_defaults() {
861 ActivationConfig::default().validate().unwrap();
862 }
863
864 #[test]
865 fn activation_config_validate_rejects_invalid_values() {
866 assert!(
867 ActivationConfig {
868 decay_factor: 0.0,
869 ..Default::default()
870 }
871 .validate()
872 .is_err()
873 );
874 assert!(
875 ActivationConfig {
876 epsilon: f64::NAN,
877 ..Default::default()
878 }
879 .validate()
880 .is_err()
881 );
882 assert!(
883 ActivationConfig {
884 max_iterations: 0,
885 ..Default::default()
886 }
887 .validate()
888 .is_err()
889 );
890 assert!(
891 ActivationConfig {
892 max_depth: 0,
893 ..Default::default()
894 }
895 .validate()
896 .is_err()
897 );
898 assert!(
899 ActivationConfig {
900 max_frontier_size: 0,
901 ..Default::default()
902 }
903 .validate()
904 .is_err()
905 );
906 }
907
908 #[test]
909 fn spread_activation_returns_invalid_input_error_for_bad_config() {
910 let graph = PropertyGraph::new();
911 let err = super::spread_activation(
912 &graph,
913 &[],
914 &ActivationConfig {
915 max_depth: 0,
916 ..Default::default()
917 },
918 None,
919 None,
920 )
921 .unwrap_err();
922
923 assert!(matches!(err, HirnError::InvalidInput(_)));
924 }
925
926 #[test]
927 fn personalized_pagerank_returns_invalid_input_error_for_bad_config() {
928 let graph = PropertyGraph::new();
929 let err = super::personalized_pagerank(
930 &graph,
931 &[],
932 &PprConfig {
933 max_iterations: 0,
934 ..Default::default()
935 },
936 None,
937 )
938 .unwrap_err();
939
940 assert!(matches!(err, HirnError::InvalidInput(_)));
941 }
942
943 #[test]
944 fn ppr_config_validate_accepts_boundary_values() {
945 PprConfig {
946 alpha: 0.0,
947 ..Default::default()
948 }
949 .validate()
950 .unwrap();
951 PprConfig {
952 alpha: 1.0,
953 ..Default::default()
954 }
955 .validate()
956 .unwrap();
957 }
958
959 #[test]
960 fn ppr_config_validate_rejects_invalid_values() {
961 assert!(
962 PprConfig {
963 alpha: -0.1,
964 ..Default::default()
965 }
966 .validate()
967 .is_err()
968 );
969 assert!(
970 PprConfig {
971 alpha: 1.1,
972 ..Default::default()
973 }
974 .validate()
975 .is_err()
976 );
977 assert!(
978 PprConfig {
979 epsilon: f64::INFINITY,
980 ..Default::default()
981 }
982 .validate()
983 .is_err()
984 );
985 assert!(
986 PprConfig {
987 max_iterations: 0,
988 ..Default::default()
989 }
990 .validate()
991 .is_err()
992 );
993 }
994
995 #[test]
996 fn single_node_no_edges() {
997 let mut pg = PropertyGraph::new();
998 let a = make_graph_node(&mut pg);
999
1000 let result = spread_activation(&pg, &[a], &ActivationConfig::default(), None, None);
1001 assert_eq!(result.activations.len(), 1);
1002 assert!((result.activations[&a] - 1.0).abs() < f64::EPSILON);
1003 }
1004
1005 #[test]
1006 fn linear_chain_decay() {
1007 let mut pg = PropertyGraph::new();
1008 let a = make_graph_node(&mut pg);
1009 let b = make_graph_node(&mut pg);
1010 let c = make_graph_node(&mut pg);
1011 pg.add_edge(a, b, EdgeRelation::Causes, 1.0, Metadata::new())
1012 .unwrap();
1013 pg.add_edge(b, c, EdgeRelation::Causes, 1.0, Metadata::new())
1014 .unwrap();
1015
1016 let cfg = ActivationConfig {
1017 decay_factor: 0.5,
1018 ..Default::default()
1019 };
1020 let result = spread_activation(&pg, &[a], &cfg, None, None);
1021
1022 assert!((result.activations[&a] - 1.0).abs() < f64::EPSILON);
1024 assert!(
1025 (result.activations[&b] - 0.5).abs() < 0.01,
1026 "B activation: {}",
1027 result.activations[&b]
1028 );
1029 assert!(
1030 result.activations.get(&c).copied().unwrap_or(0.0) < 0.5,
1031 "C activation should be lower than B"
1032 );
1033 }
1034
1035 #[test]
1036 fn depth_decay_exponential() {
1037 let mut pg = PropertyGraph::new();
1038 let nodes: Vec<MemoryId> = (0..5).map(|_| make_graph_node(&mut pg)).collect();
1039 for i in 0..4 {
1040 pg.add_edge(
1041 nodes[i],
1042 nodes[i + 1],
1043 EdgeRelation::Causes,
1044 1.0,
1045 Metadata::new(),
1046 )
1047 .unwrap();
1048 }
1049
1050 let cfg = ActivationConfig {
1051 decay_factor: 0.5,
1052 max_depth: 5,
1053 ..Default::default()
1054 };
1055 let result = spread_activation(&pg, &[nodes[0]], &cfg, None, None);
1056
1057 let mut prev = 1.0;
1059 for node in &nodes[1..] {
1060 let act = result.activations.get(node).copied().unwrap_or(0.0);
1061 assert!(act < prev, "depth decay not decreasing");
1062 prev = act;
1063 }
1064 }
1065
1066 #[test]
1067 fn convergence_threshold() {
1068 let mut pg = PropertyGraph::new();
1069 let a = make_graph_node(&mut pg);
1070 let b = make_graph_node(&mut pg);
1071 pg.add_edge(a, b, EdgeRelation::Causes, 0.001, Metadata::new())
1072 .unwrap();
1073
1074 let cfg = ActivationConfig {
1075 epsilon: 0.01,
1076 ..Default::default()
1077 };
1078 let result = spread_activation(&pg, &[a], &cfg, None, None);
1079
1080 assert!(!result.activations.contains_key(&b) || result.activations[&b] < 0.01);
1082 }
1083
1084 #[test]
1085 fn max_iterations_one() {
1086 let mut pg = PropertyGraph::new();
1087 let a = make_graph_node(&mut pg);
1088 let b = make_graph_node(&mut pg);
1089 let c = make_graph_node(&mut pg);
1090 pg.add_edge(a, b, EdgeRelation::Causes, 1.0, Metadata::new())
1091 .unwrap();
1092 pg.add_edge(b, c, EdgeRelation::Causes, 1.0, Metadata::new())
1093 .unwrap();
1094
1095 let cfg = ActivationConfig {
1096 max_iterations: 1,
1097 ..Default::default()
1098 };
1099 let result = spread_activation(&pg, &[a], &cfg, None, None);
1100
1101 assert!(result.activations.contains_key(&b));
1103 assert!(
1104 !result.activations.contains_key(&c),
1105 "two-hop nodes should not activate when max_iterations=1"
1106 );
1107 }
1108
1109 #[test]
1110 fn provenance_tracking() {
1111 let mut pg = PropertyGraph::new();
1112 let a = make_graph_node(&mut pg);
1113 let b = make_graph_node(&mut pg);
1114 let c = make_graph_node(&mut pg);
1115 pg.add_edge(a, b, EdgeRelation::Causes, 1.0, Metadata::new())
1116 .unwrap();
1117 pg.add_edge(b, c, EdgeRelation::Causes, 1.0, Metadata::new())
1118 .unwrap();
1119
1120 let cfg = ActivationConfig {
1121 decay_factor: 0.8,
1122 max_depth: 3,
1123 ..Default::default()
1124 };
1125 let result = spread_activation(&pg, &[a], &cfg, None, None);
1126
1127 if let Some(trace) = result.traces.get(&c) {
1128 assert_eq!(trace.seed, a);
1129 assert_eq!(trace.path, vec![a, b, c]);
1130 }
1131 }
1132
1133 #[test]
1134 fn fan_out() {
1135 let mut pg = PropertyGraph::new();
1136 let center = make_graph_node(&mut pg);
1137 let mut neighbors = Vec::new();
1138 for i in 0..100 {
1139 let n = make_graph_node(&mut pg);
1140 let w = (i as f32 + 1.0) / 100.0;
1141 pg.add_edge(center, n, EdgeRelation::Causes, w, Metadata::new())
1142 .unwrap();
1143 neighbors.push((n, w));
1144 }
1145
1146 let result = spread_activation(&pg, &[center], &ActivationConfig::default(), None, None);
1147
1148 for (n, w) in &neighbors {
1150 if f64::from(*w) * 0.7 >= 0.01 {
1151 assert!(
1152 result.activations.contains_key(n),
1153 "neighbor with weight {w} not activated"
1154 );
1155 }
1156 }
1157 }
1158
1159 #[test]
1160 fn weak_vs_strong_edge() {
1161 let mut pg = PropertyGraph::new();
1162 let a = make_graph_node(&mut pg);
1163 let weak = make_graph_node(&mut pg);
1164 let strong = make_graph_node(&mut pg);
1165 pg.add_edge(a, weak, EdgeRelation::Causes, 0.1, Metadata::new())
1166 .unwrap();
1167 pg.add_edge(a, strong, EdgeRelation::Causes, 0.9, Metadata::new())
1168 .unwrap();
1169
1170 let result = spread_activation(&pg, &[a], &ActivationConfig::default(), None, None);
1171
1172 let weak_act = result.activations.get(&weak).copied().unwrap_or(0.0);
1173 let strong_act = result.activations.get(&strong).copied().unwrap_or(0.0);
1174 assert!(strong_act > weak_act, "strong edge should transmit more");
1175 }
1176
1177 #[test]
1178 fn cycle_converges() {
1179 let mut pg = PropertyGraph::new();
1180 let a = make_graph_node(&mut pg);
1181 let b = make_graph_node(&mut pg);
1182 pg.add_edge(a, b, EdgeRelation::Causes, 1.0, Metadata::new())
1183 .unwrap();
1184 pg.add_edge(b, a, EdgeRelation::Causes, 1.0, Metadata::new())
1185 .unwrap();
1186
1187 let result = spread_activation(&pg, &[a], &ActivationConfig::default(), None, None);
1188
1189 assert!(result.activations[&a] <= 1.01);
1191 assert!(result.activations.get(&b).copied().unwrap_or(0.0) <= 1.01);
1192 }
1193
1194 #[test]
1195 fn static_activation_one_hop() {
1196 let mut pg = PropertyGraph::new();
1197 let a = make_graph_node(&mut pg);
1198 let b = make_graph_node(&mut pg);
1199 let c = make_graph_node(&mut pg);
1200 pg.add_edge(a, b, EdgeRelation::Causes, 0.8, Metadata::new())
1201 .unwrap();
1202 pg.add_edge(b, c, EdgeRelation::Causes, 0.5, Metadata::new())
1203 .unwrap();
1204
1205 let result = static_activation(&pg, &[a], None);
1206 assert!((result[&a] - 1.0).abs() < f64::EPSILON);
1207 assert!((result[&b] - 0.8).abs() < 0.01);
1208 assert!(!result.contains_key(&c)); }
1210
1211 #[test]
1212 fn inhibition_suppresses_similar_disconnected() {
1213 let mut pg = PropertyGraph::new();
1214 let a = make_graph_node(&mut pg);
1215 let b = make_graph_node(&mut pg);
1216 let d = make_graph_node(&mut pg);
1217 pg.add_edge(a, b, EdgeRelation::Causes, 1.0, Metadata::new())
1219 .unwrap();
1220 let bridge = make_graph_node(&mut pg);
1222 pg.add_edge(b, bridge, EdgeRelation::Causes, 1.0, Metadata::new())
1223 .unwrap();
1224 pg.add_edge(bridge, d, EdgeRelation::Causes, 1.0, Metadata::new())
1225 .unwrap();
1226
1227 let mut embeddings: HashMap<MemoryId, Vec<f32>> = HashMap::new();
1229 embeddings.insert(a, vec![1.0, 0.0, 0.0, 0.0]);
1230 embeddings.insert(b, vec![0.0, 1.0, 0.0, 0.0]);
1231 embeddings.insert(bridge, vec![0.0, 0.0, 1.0, 0.0]);
1232 embeddings.insert(d, vec![0.99, 0.01, 0.0, 0.0]); let cfg = ActivationConfig {
1235 inhibition_strength: 0.5,
1236 max_depth: 4,
1237 decay_factor: 0.9,
1238 ..Default::default()
1239 };
1240 let result_with = spread_activation(&pg, &[a], &cfg, Some(&embeddings), None);
1241
1242 let cfg_no_inh = ActivationConfig {
1244 inhibition_strength: 0.0,
1245 max_depth: 4,
1246 decay_factor: 0.9,
1247 ..Default::default()
1248 };
1249 let result_without = spread_activation(&pg, &[a], &cfg_no_inh, Some(&embeddings), None);
1250
1251 let d_with = result_with.activations.get(&d).copied().unwrap_or(0.0);
1252 let d_without = result_without.activations.get(&d).copied().unwrap_or(0.0);
1253
1254 assert!(
1256 d_with <= d_without,
1257 "inhibition should suppress D: with={d_with}, without={d_without}"
1258 );
1259 }
1260
1261 #[test]
1262 fn inhibition_zero_disabled() {
1263 let mut pg = PropertyGraph::new();
1264 let a = make_graph_node(&mut pg);
1265 let b = make_graph_node(&mut pg);
1266 pg.add_edge(a, b, EdgeRelation::Causes, 1.0, Metadata::new())
1267 .unwrap();
1268
1269 let cfg = ActivationConfig {
1270 inhibition_strength: 0.0,
1271 ..Default::default()
1272 };
1273 let r1 = spread_activation(&pg, &[a], &cfg, None, None);
1274
1275 let cfg2 = ActivationConfig {
1276 inhibition_strength: 0.0,
1277 ..Default::default()
1278 };
1279 let r2 = spread_activation(&pg, &[a], &cfg2, None, None);
1280
1281 assert_eq!(r1.activations.len(), r2.activations.len());
1282 }
1283
1284 #[test]
1285 fn inhibition_never_negative() {
1286 let mut pg = PropertyGraph::new();
1287 let a = make_graph_node(&mut pg);
1288 let b = make_graph_node(&mut pg);
1289 pg.add_edge(a, b, EdgeRelation::Causes, 1.0, Metadata::new())
1290 .unwrap();
1291
1292 let mut embeddings: HashMap<MemoryId, Vec<f32>> = HashMap::new();
1293 embeddings.insert(a, vec![1.0, 0.0]);
1294 embeddings.insert(b, vec![1.0, 0.0]); let cfg = ActivationConfig {
1297 inhibition_strength: 100.0, ..Default::default()
1299 };
1300 let result = spread_activation(&pg, &[a], &cfg, Some(&embeddings), None);
1301
1302 for &act in result.activations.values() {
1303 assert!(act >= 0.0, "activation went negative: {act}");
1304 }
1305 }
1306
1307 #[test]
1308 fn connected_similar_not_suppressed() {
1309 let mut pg = PropertyGraph::new();
1310 let a = make_graph_node(&mut pg);
1311 let b = make_graph_node(&mut pg);
1312 pg.add_edge(a, b, EdgeRelation::Causes, 1.0, Metadata::new())
1313 .unwrap();
1314
1315 let mut embeddings: HashMap<MemoryId, Vec<f32>> = HashMap::new();
1316 embeddings.insert(a, vec![1.0, 0.0, 0.0]);
1317 embeddings.insert(b, vec![0.99, 0.01, 0.0]); let cfg = ActivationConfig {
1320 inhibition_strength: 0.5,
1321 ..Default::default()
1322 };
1323 let result = spread_activation(&pg, &[a], &cfg, Some(&embeddings), None);
1324
1325 assert!(
1327 result.activations.contains_key(&b),
1328 "connected similar node should not be suppressed"
1329 );
1330 }
1331
1332 #[test]
1333 fn namespace_boundary_blocks_activation() {
1334 let mut pg = PropertyGraph::new();
1335 let ns_a = Namespace::new("private:agent_a").unwrap();
1336 let ns_b = Namespace::new("private:agent_b").unwrap();
1337 let ns_shared = Namespace::shared();
1338
1339 let a = MemoryId::new();
1340 pg.add_node_ns(a, Layer::Episodic, 0.5, Timestamp::now(), ns_a.clone());
1341 let shared = MemoryId::new();
1342 pg.add_node_ns(
1343 shared,
1344 Layer::Episodic,
1345 0.5,
1346 Timestamp::now(),
1347 ns_shared.clone(),
1348 );
1349 let b = MemoryId::new();
1350 pg.add_node_ns(b, Layer::Episodic, 0.5, Timestamp::now(), ns_b);
1351
1352 pg.add_edge(a, shared, EdgeRelation::Causes, 1.0, Metadata::new())
1354 .unwrap();
1355 pg.add_edge(shared, b, EdgeRelation::Causes, 1.0, Metadata::new())
1356 .unwrap();
1357
1358 let allowed = vec![ns_a, ns_shared];
1360 let cfg = ActivationConfig {
1361 decay_factor: 0.9,
1362 max_depth: 5,
1363 ..Default::default()
1364 };
1365 let result = spread_activation(&pg, &[a], &cfg, None, Some(&allowed));
1366
1367 assert!(result.activations.contains_key(&a));
1368 assert!(result.activations.contains_key(&shared));
1369 assert!(
1370 !result.activations.contains_key(&b),
1371 "activation must not cross into Agent B's private namespace"
1372 );
1373
1374 let result_unrestricted = spread_activation(&pg, &[a], &cfg, None, None);
1376 assert!(result_unrestricted.activations.contains_key(&b));
1377 }
1378
1379 #[test]
1380 fn static_activation_respects_namespace() {
1381 let mut pg = PropertyGraph::new();
1382 let ns_a = Namespace::new("private:agent_a").unwrap();
1383 let ns_b = Namespace::new("private:agent_b").unwrap();
1384
1385 let a = MemoryId::new();
1386 pg.add_node_ns(a, Layer::Episodic, 0.5, Timestamp::now(), ns_a.clone());
1387 let b = MemoryId::new();
1388 pg.add_node_ns(b, Layer::Episodic, 0.5, Timestamp::now(), ns_b);
1389
1390 pg.add_edge(a, b, EdgeRelation::SimilarTo, 0.9, Metadata::new())
1391 .unwrap();
1392
1393 let allowed = vec![ns_a];
1394 let result = static_activation(&pg, &[a], Some(&allowed));
1395
1396 assert!(result.contains_key(&a));
1397 assert!(
1398 !result.contains_key(&b),
1399 "static activation crossed namespace boundary"
1400 );
1401 }
1402
1403 #[test]
1406 fn ppr_empty_seeds_returns_empty() {
1407 let pg = PropertyGraph::new();
1408 let result = personalized_pagerank(&pg, &[], &PprConfig::default(), None);
1409 assert!(result.is_empty());
1410 }
1411
1412 #[test]
1413 fn ppr_single_node() {
1414 let mut pg = PropertyGraph::new();
1415 let a = make_graph_node(&mut pg);
1416 let result = personalized_pagerank(&pg, &[a], &PprConfig::default(), None);
1417 assert!(result.contains_key(&a));
1418 assert!(
1419 (result[&a] - 1.0).abs() < 0.01,
1420 "single node should converge to ~1.0"
1421 );
1422 }
1423
1424 #[test]
1425 fn ppr_linear_chain_decay() {
1426 let mut pg = PropertyGraph::new();
1427 let a = make_graph_node(&mut pg);
1428 let b = make_graph_node(&mut pg);
1429 let c = make_graph_node(&mut pg);
1430 pg.add_edge(a, b, EdgeRelation::Causes, 1.0, Metadata::new())
1431 .unwrap();
1432 pg.add_edge(b, c, EdgeRelation::Causes, 1.0, Metadata::new())
1433 .unwrap();
1434
1435 let result = personalized_pagerank(&pg, &[a], &PprConfig::default(), None);
1436 let a_score = result.get(&a).copied().unwrap_or(0.0);
1438 let b_score = result.get(&b).copied().unwrap_or(0.0);
1439 let c_score = result.get(&c).copied().unwrap_or(0.0);
1440 assert!(
1441 a_score > b_score,
1442 "seed should rank highest: a={a_score}, b={b_score}"
1443 );
1444 assert!(
1445 b_score > c_score,
1446 "closer nodes rank higher: b={b_score}, c={c_score}"
1447 );
1448 }
1449
1450 #[test]
1451 fn ppr_scores_sum_to_one() {
1452 let mut pg = PropertyGraph::new();
1453 let nodes: Vec<MemoryId> = (0..5).map(|_| make_graph_node(&mut pg)).collect();
1454 for i in 0..4 {
1455 pg.add_edge(
1456 nodes[i],
1457 nodes[i + 1],
1458 EdgeRelation::Causes,
1459 1.0,
1460 Metadata::new(),
1461 )
1462 .unwrap();
1463 }
1464 pg.add_edge(
1465 nodes[4],
1466 nodes[0],
1467 EdgeRelation::Causes,
1468 1.0,
1469 Metadata::new(),
1470 )
1471 .unwrap();
1472
1473 let result = personalized_pagerank(&pg, &[nodes[0]], &PprConfig::default(), None);
1474 let total: f64 = result.values().sum();
1475 assert!(
1476 (total - 1.0).abs() < 0.01,
1477 "PPR scores should sum to ~1.0, got {total}"
1478 );
1479 }
1480
1481 #[test]
1482 fn ppr_high_alpha_concentrates_on_seeds() {
1483 let mut pg = PropertyGraph::new();
1484 let a = make_graph_node(&mut pg);
1485 let b = make_graph_node(&mut pg);
1486 pg.add_edge(a, b, EdgeRelation::Causes, 1.0, Metadata::new())
1487 .unwrap();
1488
1489 let high_alpha = PprConfig {
1490 alpha: 0.9,
1491 ..Default::default()
1492 };
1493 let result = personalized_pagerank(&pg, &[a], &high_alpha, None);
1494
1495 let a_score = result.get(&a).copied().unwrap_or(0.0);
1496 assert!(
1497 a_score > 0.8,
1498 "high alpha should concentrate on seed: {a_score}"
1499 );
1500 }
1501
1502 #[test]
1503 fn ppr_respects_namespace_boundary() {
1504 let mut pg = PropertyGraph::new();
1505 let ns_a = Namespace::new("private:agent_a").unwrap();
1506 let ns_b = Namespace::new("private:agent_b").unwrap();
1507
1508 let a = MemoryId::new();
1509 pg.add_node_ns(a, Layer::Episodic, 0.5, Timestamp::now(), ns_a.clone());
1510 let b = MemoryId::new();
1511 pg.add_node_ns(b, Layer::Episodic, 0.5, Timestamp::now(), ns_b);
1512 pg.add_edge(a, b, EdgeRelation::Causes, 1.0, Metadata::new())
1513 .unwrap();
1514
1515 let allowed = vec![ns_a];
1516 let result = personalized_pagerank(&pg, &[a], &PprConfig::default(), Some(&allowed));
1517 assert!(result.contains_key(&a));
1518 assert!(
1519 !result.contains_key(&b),
1520 "PPR should not cross namespace boundary"
1521 );
1522 }
1523
1524 #[test]
1525 fn ppr_cycle_converges() {
1526 let mut pg = PropertyGraph::new();
1527 let a = make_graph_node(&mut pg);
1528 let b = make_graph_node(&mut pg);
1529 pg.add_edge(a, b, EdgeRelation::Causes, 1.0, Metadata::new())
1530 .unwrap();
1531 pg.add_edge(b, a, EdgeRelation::Causes, 1.0, Metadata::new())
1532 .unwrap();
1533
1534 let result = personalized_pagerank(&pg, &[a], &PprConfig::default(), None);
1535 let a_score = result.get(&a).copied().unwrap_or(0.0);
1537 let b_score = result.get(&b).copied().unwrap_or(0.0);
1538 assert!(
1539 a_score > b_score,
1540 "seed should be favored in cycle: a={a_score}, b={b_score}"
1541 );
1542 }
1543
1544 #[test]
1545 fn ppr_multiple_seeds_distributes() {
1546 let mut pg = PropertyGraph::new();
1547 let a = make_graph_node(&mut pg);
1548 let b = make_graph_node(&mut pg);
1549 let c = make_graph_node(&mut pg);
1550 pg.add_edge(a, c, EdgeRelation::Causes, 1.0, Metadata::new())
1551 .unwrap();
1552 pg.add_edge(b, c, EdgeRelation::Causes, 1.0, Metadata::new())
1553 .unwrap();
1554
1555 let result = personalized_pagerank(&pg, &[a, b], &PprConfig::default(), None);
1556 assert!(
1558 result.contains_key(&c),
1559 "C should be activated from both seeds"
1560 );
1561 }
1562
1563 #[test]
1564 fn ppr_excludes_disconnected_components() {
1565 let mut pg = PropertyGraph::new();
1566 let a = make_graph_node(&mut pg);
1567 let b = make_graph_node(&mut pg);
1568 let d = make_graph_node(&mut pg);
1569 let e = make_graph_node(&mut pg);
1570 pg.add_edge(a, b, EdgeRelation::Causes, 1.0, Metadata::new())
1571 .unwrap();
1572 pg.add_edge(d, e, EdgeRelation::Causes, 1.0, Metadata::new())
1573 .unwrap();
1574
1575 let result = personalized_pagerank(&pg, &[a], &PprConfig::default(), None);
1576 assert!(result.contains_key(&a));
1577 assert!(result.contains_key(&b));
1578 assert!(
1579 !result.contains_key(&d),
1580 "disconnected node D should not receive PPR mass"
1581 );
1582 assert!(
1583 !result.contains_key(&e),
1584 "disconnected node E should not receive PPR mass"
1585 );
1586 }
1587
1588 #[test]
1591 fn empty_graph_no_panic() {
1592 let pg = PropertyGraph::new();
1593 let fake = MemoryId::new();
1594 let result = spread_activation(&pg, &[fake], &ActivationConfig::default(), None, None);
1595 assert!(
1596 result.activations.is_empty(),
1597 "empty graph should produce no activations"
1598 );
1599 assert!(result.traces.is_empty());
1600 }
1601
1602 #[test]
1603 #[allow(clippy::many_single_char_names)]
1604 fn disconnected_component_only_seed_side_activated() {
1605 let mut pg = PropertyGraph::new();
1606 let a = make_graph_node(&mut pg);
1608 let b = make_graph_node(&mut pg);
1609 let c = make_graph_node(&mut pg);
1610 pg.add_edge(a, b, EdgeRelation::Causes, 1.0, Metadata::new())
1611 .unwrap();
1612 pg.add_edge(b, c, EdgeRelation::Causes, 1.0, Metadata::new())
1613 .unwrap();
1614 let d = make_graph_node(&mut pg);
1616 let e = make_graph_node(&mut pg);
1617 pg.add_edge(d, e, EdgeRelation::Causes, 1.0, Metadata::new())
1618 .unwrap();
1619
1620 let result = spread_activation(&pg, &[a], &ActivationConfig::default(), None, None);
1621 assert!(result.activations.contains_key(&a));
1622 assert!(result.activations.contains_key(&b));
1623 assert!(result.activations.contains_key(&c));
1624 assert!(
1625 !result.activations.contains_key(&d),
1626 "disconnected node D should not be activated"
1627 );
1628 assert!(
1629 !result.activations.contains_key(&e),
1630 "disconnected node E should not be activated"
1631 );
1632 }
1633
1634 #[test]
1635 fn frontier_truncation_respects_max_frontier_size() {
1636 let mut pg = PropertyGraph::new();
1637 let hub = make_graph_node(&mut pg);
1640 let mut second_level = Vec::new();
1641 for _ in 0..20 {
1642 let leaf = make_graph_node(&mut pg);
1643 pg.add_edge(hub, leaf, EdgeRelation::Causes, 1.0, Metadata::new())
1644 .unwrap();
1645 let end = make_graph_node(&mut pg);
1646 pg.add_edge(leaf, end, EdgeRelation::Causes, 1.0, Metadata::new())
1647 .unwrap();
1648 second_level.push(end);
1649 }
1650
1651 let config = ActivationConfig {
1652 max_frontier_size: 5,
1653 max_depth: 3,
1654 ..Default::default()
1655 };
1656 let result = spread_activation(&pg, &[hub], &config, None, None);
1657 let activated_second: Vec<_> = second_level
1660 .iter()
1661 .filter(|n| result.activations.contains_key(n))
1662 .collect();
1663 assert!(
1664 activated_second.len() <= 5,
1665 "frontier truncation should limit second-level activation to ≤5, got {}",
1666 activated_second.len()
1667 );
1668 }
1669
1670 #[test]
1671 fn frontier_truncation_keeps_strongest_frontier_nodes() {
1672 let mut pg = PropertyGraph::new();
1673 let hub = make_graph_node(&mut pg);
1674
1675 let weighted_branches = [
1676 (1.0_f32, true),
1677 (0.9_f32, true),
1678 (0.8_f32, true),
1679 (0.1_f32, false),
1680 (0.05_f32, false),
1681 (0.01_f32, false),
1682 ];
1683
1684 let mut expected_second_level = Vec::new();
1685 let mut unexpected_second_level = Vec::new();
1686
1687 for (weight, should_survive) in weighted_branches {
1688 let leaf = make_graph_node(&mut pg);
1689 pg.add_edge(hub, leaf, EdgeRelation::Causes, weight, Metadata::new())
1690 .unwrap();
1691
1692 let end = make_graph_node(&mut pg);
1693 pg.add_edge(leaf, end, EdgeRelation::Causes, 1.0, Metadata::new())
1694 .unwrap();
1695
1696 if should_survive {
1697 expected_second_level.push(end);
1698 } else {
1699 unexpected_second_level.push(end);
1700 }
1701 }
1702
1703 let config = ActivationConfig {
1704 max_frontier_size: 3,
1705 max_depth: 3,
1706 ..Default::default()
1707 };
1708 let result = spread_activation(&pg, &[hub], &config, None, None);
1709
1710 for node in expected_second_level {
1711 assert!(
1712 result.activations.contains_key(&node),
1713 "top-scoring frontier node should keep propagating after truncation"
1714 );
1715 }
1716 for node in unexpected_second_level {
1717 assert!(
1718 !result.activations.contains_key(&node),
1719 "low-scoring frontier node should be dropped by truncation"
1720 );
1721 }
1722 }
1723
1724 #[test]
1725 fn depth_limit_one_only_direct_neighbors() {
1726 let mut pg = PropertyGraph::new();
1727 let a = make_graph_node(&mut pg);
1728 let b = make_graph_node(&mut pg);
1729 let c = make_graph_node(&mut pg);
1730 pg.add_edge(a, b, EdgeRelation::Causes, 1.0, Metadata::new())
1731 .unwrap();
1732 pg.add_edge(b, c, EdgeRelation::Causes, 1.0, Metadata::new())
1733 .unwrap();
1734
1735 let config = ActivationConfig {
1736 max_depth: 1,
1737 ..Default::default()
1738 };
1739 let result = spread_activation(&pg, &[a], &config, None, None);
1740 assert!(result.activations.contains_key(&a));
1741 assert!(
1742 result.activations.contains_key(&b),
1743 "direct neighbor should be activated"
1744 );
1745 assert!(
1746 !result.activations.contains_key(&c),
1747 "two-hop neighbor should NOT be activated with depth=1"
1748 );
1749 }
1750
1751 #[test]
1754 fn ppr_star_graph_equal_leaf_scores() {
1755 let mut pg = PropertyGraph::new();
1756 let center = make_graph_node(&mut pg);
1757 let mut leaves = Vec::new();
1758 for _ in 0..5 {
1759 let leaf = make_graph_node(&mut pg);
1760 pg.add_edge(center, leaf, EdgeRelation::Causes, 1.0, Metadata::new())
1761 .unwrap();
1762 leaves.push(leaf);
1763 }
1764
1765 let result = personalized_pagerank(&pg, &[center], &PprConfig::default(), None);
1766 let leaf_scores: Vec<f64> = leaves
1768 .iter()
1769 .map(|l| result.get(l).copied().unwrap_or(0.0))
1770 .collect();
1771 let max = leaf_scores
1772 .iter()
1773 .copied()
1774 .fold(f64::NEG_INFINITY, f64::max);
1775 let min = leaf_scores.iter().copied().fold(f64::INFINITY, f64::min);
1776 assert!(
1777 max - min < 0.01,
1778 "star leaves should have equal scores, spread = {}",
1779 max - min
1780 );
1781 }
1782
1783 #[test]
1784 fn ppr_alpha_zero_pure_random_walk() {
1785 let mut pg = PropertyGraph::new();
1788 let center = make_graph_node(&mut pg);
1789 let mut leaves = Vec::new();
1790 for _ in 0..4 {
1791 let leaf = make_graph_node(&mut pg);
1792 pg.add_edge(center, leaf, EdgeRelation::Causes, 1.0, Metadata::new())
1793 .unwrap();
1794 pg.add_edge(leaf, center, EdgeRelation::Causes, 1.0, Metadata::new())
1795 .unwrap();
1796 leaves.push(leaf);
1797 }
1798
1799 let config = PprConfig {
1800 alpha: 0.0,
1801 ..Default::default()
1802 };
1803 let result = personalized_pagerank(&pg, &[center], &config, None);
1804 let c_score = result.get(¢er).copied().unwrap_or(0.0);
1808 let leaf_scores: Vec<f64> = leaves
1809 .iter()
1810 .map(|l| result.get(l).copied().unwrap_or(0.0))
1811 .collect();
1812 for &ls in &leaf_scores {
1813 assert!(
1814 c_score > ls,
1815 "center should have higher score than leaves: center={c_score}, leaf={ls}"
1816 );
1817 }
1818 }
1819
1820 #[test]
1821 fn ppr_alpha_one_all_probability_at_seed() {
1822 let mut pg = PropertyGraph::new();
1823 let a = make_graph_node(&mut pg);
1824 let b = make_graph_node(&mut pg);
1825 pg.add_edge(a, b, EdgeRelation::Causes, 1.0, Metadata::new())
1826 .unwrap();
1827
1828 let config = PprConfig {
1829 alpha: 1.0,
1830 ..Default::default()
1831 };
1832 let result = personalized_pagerank(&pg, &[a], &config, None);
1833 let a_score = result.get(&a).copied().unwrap_or(0.0);
1835 let b_score = result.get(&b).copied().unwrap_or(0.0);
1836 assert!(
1837 a_score > 0.95,
1838 "alpha=1 should put all mass on seed: {a_score}"
1839 );
1840 assert!(
1841 b_score < 0.05,
1842 "alpha=1 neighbor should have minimal score: {b_score}"
1843 );
1844 }
1845
1846 #[test]
1847 fn ppr_empty_seeds_nonempty_graph_returns_empty() {
1848 let mut pg = PropertyGraph::new();
1849 let _a = make_graph_node(&mut pg);
1850 let result = personalized_pagerank(&pg, &[], &PprConfig::default(), None);
1851 assert!(result.is_empty(), "empty seeds should produce empty result");
1852 }
1853
1854 #[test]
1857 fn jaccard_similarity_correct_for_known_neighborhoods() {
1858 let a: HashSet<MemoryId> = [MemoryId::new(), MemoryId::new(), MemoryId::new()]
1859 .into_iter()
1860 .collect();
1861 let shared: Vec<MemoryId> = a.iter().copied().take(2).collect();
1863 let mut b: HashSet<MemoryId> = shared.into_iter().collect();
1864 b.insert(MemoryId::new());
1865 let j = super::jaccard_similarity(&a, &b);
1867 assert!((j - 0.5).abs() < f64::EPSILON, "expected 0.5, got {j}");
1868 }
1869
1870 #[test]
1871 fn jaccard_empty_sets_returns_zero() {
1872 let a: HashSet<MemoryId> = HashSet::new();
1873 let b: HashSet<MemoryId> = HashSet::new();
1874 assert_eq!(super::jaccard_similarity(&a, &b), 0.0);
1875 }
1876
1877 #[test]
1878 fn jaccard_identical_sets_returns_one() {
1879 let ids: HashSet<MemoryId> = (0..5).map(|_| MemoryId::new()).collect();
1880 let j = super::jaccard_similarity(&ids, &ids);
1881 assert!((j - 1.0).abs() < f64::EPSILON, "expected 1.0, got {j}");
1882 }
1883
1884 #[test]
1885 fn lateral_inhibition_weak_for_same_cluster() {
1886 let mut pg = PropertyGraph::new();
1888 let seed = make_graph_node(&mut pg);
1889 let competitor = make_graph_node(&mut pg);
1890 let shared1 = make_graph_node(&mut pg);
1891 let shared2 = make_graph_node(&mut pg);
1892
1893 let _ = pg.add_edge(
1895 seed,
1896 shared1,
1897 EdgeRelation::SimilarTo,
1898 0.8,
1899 Default::default(),
1900 );
1901 let _ = pg.add_edge(
1902 seed,
1903 shared2,
1904 EdgeRelation::SimilarTo,
1905 0.8,
1906 Default::default(),
1907 );
1908 let _ = pg.add_edge(
1909 competitor,
1910 shared1,
1911 EdgeRelation::SimilarTo,
1912 0.8,
1913 Default::default(),
1914 );
1915 let _ = pg.add_edge(
1916 competitor,
1917 shared2,
1918 EdgeRelation::SimilarTo,
1919 0.8,
1920 Default::default(),
1921 );
1922
1923 let emb = vec![1.0_f32; 8];
1925 let embeddings: HashMap<MemoryId, Vec<f32>> = [(seed, emb.clone()), (competitor, emb)]
1926 .into_iter()
1927 .collect();
1928
1929 let mut activations: HashMap<MemoryId, f64> =
1930 [(seed, 1.0), (competitor, 0.5)].into_iter().collect();
1931
1932 let original = activations[&competitor];
1942 super::apply_lateral_inhibition(&pg, &mut activations, 0.1, 0.5, &embeddings);
1943 let final_val = activations[&competitor];
1944 assert!(
1947 final_val >= original * 0.9,
1948 "same-cluster inhibition should be weak: original={original}, final={final_val}"
1949 );
1950 }
1951
1952 #[test]
1953 fn lateral_inhibition_strong_for_different_clusters() {
1954 let mut pg = PropertyGraph::new();
1956 let seed = make_graph_node(&mut pg);
1957 let competitor = make_graph_node(&mut pg);
1958 let seed_neighbor = make_graph_node(&mut pg);
1959 let comp_neighbor = make_graph_node(&mut pg);
1960
1961 let _ = pg.add_edge(
1963 seed,
1964 seed_neighbor,
1965 EdgeRelation::SimilarTo,
1966 0.8,
1967 Default::default(),
1968 );
1969 let _ = pg.add_edge(
1970 competitor,
1971 comp_neighbor,
1972 EdgeRelation::SimilarTo,
1973 0.8,
1974 Default::default(),
1975 );
1976
1977 let emb = vec![1.0_f32; 8];
1979 let embeddings: HashMap<MemoryId, Vec<f32>> = [(seed, emb.clone()), (competitor, emb)]
1980 .into_iter()
1981 .collect();
1982
1983 let mut activations: HashMap<MemoryId, f64> =
1984 [(seed, 1.0), (competitor, 0.5)].into_iter().collect();
1985
1986 super::apply_lateral_inhibition(&pg, &mut activations, 0.1, 0.5, &embeddings);
1987 let final_val = activations[&competitor];
1988 assert!(
1991 final_val < 0.5,
1992 "different-cluster inhibition should be strong: final={final_val}"
1993 );
1994 }
1995
1996 #[test]
1997 fn lateral_inhibition_precompute_matches_naive_reference() {
1998 let mut pg = PropertyGraph::new();
1999 let seed = make_graph_node(&mut pg);
2000 let same_cluster = make_graph_node(&mut pg);
2001 let different_cluster = make_graph_node(&mut pg);
2002 let shared_a = make_graph_node(&mut pg);
2003 let shared_b = make_graph_node(&mut pg);
2004 let different_neighbor = make_graph_node(&mut pg);
2005
2006 let _ = pg.add_edge(
2007 seed,
2008 shared_a,
2009 EdgeRelation::SimilarTo,
2010 0.8,
2011 Default::default(),
2012 );
2013 let _ = pg.add_edge(
2014 seed,
2015 shared_b,
2016 EdgeRelation::SimilarTo,
2017 0.8,
2018 Default::default(),
2019 );
2020 let _ = pg.add_edge(
2021 same_cluster,
2022 shared_a,
2023 EdgeRelation::SimilarTo,
2024 0.8,
2025 Default::default(),
2026 );
2027 let _ = pg.add_edge(
2028 same_cluster,
2029 shared_b,
2030 EdgeRelation::SimilarTo,
2031 0.8,
2032 Default::default(),
2033 );
2034 let _ = pg.add_edge(
2035 different_cluster,
2036 different_neighbor,
2037 EdgeRelation::SimilarTo,
2038 0.8,
2039 Default::default(),
2040 );
2041
2042 let embeddings: HashMap<MemoryId, Vec<f32>> = [
2043 (seed, vec![1.0_f32, 0.0, 0.0, 0.0]),
2044 (same_cluster, vec![1.0_f32, 0.0, 0.0, 0.0]),
2045 (different_cluster, vec![0.95_f32, 0.05, 0.0, 0.0]),
2046 ]
2047 .into_iter()
2048 .collect();
2049
2050 let baseline: HashMap<MemoryId, f64> =
2051 [(seed, 1.0), (same_cluster, 0.55), (different_cluster, 0.55)]
2052 .into_iter()
2053 .collect();
2054 let mut expected = baseline.clone();
2055 let mut actual = baseline;
2056
2057 apply_lateral_inhibition_naive(&pg, &mut expected, 0.2, 0.5, &embeddings);
2058 super::apply_lateral_inhibition(&pg, &mut actual, 0.2, 0.5, &embeddings);
2059
2060 for node in [seed, same_cluster, different_cluster] {
2061 let expected_value = expected.get(&node).copied().unwrap_or_default();
2062 let actual_value = actual.get(&node).copied().unwrap_or_default();
2063 assert!(
2064 (expected_value - actual_value).abs() < 1e-12,
2065 "precomputed inhibition must match naive reference for {node}: expected={expected_value}, actual={actual_value}"
2066 );
2067 }
2068 }
2069}