Skip to main content

hirn_graph/
activation.rs

1//! Spreading activation engine with lateral inhibition.
2//!
3//! Implements the activation propagation algorithm from CONCEPT.md §6.3:
4//! `A(j) += A(i) × w(i,j) × d^l` where `d` is depth decay.
5
6use 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/// Activation configuration.
15#[derive(Debug, Clone)]
16pub struct ActivationConfig {
17    /// Depth decay factor (default 0.7). Each layer multiplies activation by `d`.
18    pub decay_factor: f64,
19    /// Convergence threshold — nodes below this are excluded from results (default 0.01).
20    pub epsilon: f64,
21    /// Maximum propagation iterations (default 10).
22    /// Acts as a secondary cap in addition to `max_depth`.
23    pub max_iterations: usize,
24    /// Maximum traversal depth from seed nodes (default 3).
25    pub max_depth: usize,
26    /// Lateral inhibition strength μ (default 0.1). Set to 0.0 to disable.
27    pub inhibition_strength: f64,
28    /// Cosine similarity threshold for lateral inhibition (default 0.7).
29    /// Nodes with similarity above this to seeds but not graph-connected are suppressed.
30    pub inhibition_threshold: f64,
31    /// Hard safety cap on frontier size per depth level (default 10,000).
32    /// When the frontier exceeds this limit, only the highest-scoring entries
33    /// are kept. Prevents OOM/DoS from high-degree hub nodes (F-ENG-01).
34    pub max_frontier_size: usize,
35}
36
37impl ActivationConfig {
38    /// Validate graph activation bounds before execution.
39    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    /// Return a copy of this config with `max_frontier_size` adaptively scaled to
90    /// the observed graph density (F-103 fix).
91    ///
92    /// The heuristic is:
93    /// - If the graph has no edges or no nodes, return self unchanged.
94    /// - Compute average out-degree = `edge_count / node_count`.
95    /// - `effective = min(max_frontier_size, max(256, avg_degree * 100))`
96    ///
97    /// This caps the frontier relative to how dense the graph actually is,
98    /// so a sparse graph doesn't waste a 10 K buffer and a dense hub graph
99    /// doesn't build a 100 K BinaryHeap per depth step.
100    #[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        // At avg_degree 1 → 100 cap; at avg_degree 100 → 10_000 cap (matches default).
107        // Floor at 256 so very sparse graphs still activate a reasonable neighbourhood.
108        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/// How a node was activated (provenance tracking).
131/// F-47 FIX: Full seed→intermediate→result path provenance tracking.
132#[derive(Debug, Clone)]
133pub struct ActivationTrace {
134    /// Sequence of node IDs from seed to this node.
135    pub path: Vec<MemoryId>,
136    /// The seed node that initiated this activation.
137    pub seed: MemoryId,
138}
139
140/// Result of spreading activation.
141#[derive(Debug, Clone)]
142pub struct ActivationResult {
143    /// Map of node ID → activation score.
144    pub activations: HashMap<MemoryId, f64>,
145    /// Provenance: how each node was activated.
146    pub traces: HashMap<MemoryId, ActivationTrace>,
147}
148
149/// Activation mode for recall queries.
150#[derive(Debug, Default, Clone, PartialEq)]
151pub enum ActivationMode {
152    /// No graph traversal — pure vector search.
153    #[default]
154    None,
155    /// Simple graph expansion without decay (one-hop neighbors).
156    Static,
157    /// Full spreading activation with inhibition.
158    Spreading,
159    /// Personalized `PageRank` — random-walk-based retrieval (F-057).
160    PersonalizedPageRank(PprConfig),
161}
162
163/// Configuration for Personalized `PageRank` (F-057).
164///
165/// PPR computes node importance relative to seed (personalization) nodes using
166/// iterative power method. Proven superior for multi-hop QA by `HippoRAG`
167/// (arXiv:2405.14831) and Graphiti/Zep (arXiv:2501.13956).
168#[derive(Debug, Clone)]
169pub struct PprConfig {
170    /// Teleport (restart) probability α. Higher values bias toward seed nodes.
171    /// Typical range: 0.10–0.25. Default: 0.15.
172    pub alpha: f64,
173    /// Convergence tolerance. Iteration stops when max delta < epsilon.
174    /// Default: 1e-6.
175    pub epsilon: f64,
176    /// Maximum iterations. Default: 100.
177    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    /// Validate Personalized PageRank bounds before execution.
200    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/// Run spreading activation from seed nodes through the graph.
221///
222/// If `allowed_namespaces` is `Some`, activation will not propagate into nodes
223/// whose namespace is not in the allowed set. This enforces namespace isolation.
224/// Returns an error if `config` fails validation.
225#[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    // Initialize seeds with A₀ = 1.0.
255    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    // BFS wavefront propagation: process each depth level exactly once.
269    // This ensures additive accumulation from convergent paths at the same
270    // depth without re-propagating from already-settled nodes.
271    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                // Namespace boundary enforcement.
303                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                // Additive accumulation: convergent paths sum their contributions.
316                *next_frontier.entry(neighbor).or_insert(0.0) += contribution;
317
318                // Track provenance (best path).
319                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        // Update activations and build the next frontier (only newly reached nodes propagate).
340        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                // Node newly reached — it will propagate in the next depth level.
347                new_frontier.push((node, updated));
348            }
349        }
350
351        // Frontier truncation — hard safety cap against OOM/DoS (F-ENG-01).
352        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            // Keep only the strongest prefix in O(n), then sort just the
360            // retained frontier to preserve deterministic propagation order.
361            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        // Emit structured tracing span for frontier monitoring.
371        tracing::info!(
372            depth = depth,
373            frontier_size = new_frontier.len(),
374            "activation_depth"
375        );
376
377        frontier = new_frontier;
378    }
379
380    // Apply lateral inhibition.
381    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    // Filter out nodes below threshold.
394    activations.retain(|_, v| *v >= config.epsilon);
395
396    ActivationResult {
397        activations,
398        traces,
399    }
400}
401
402/// Static activation: simple one-hop graph expansion from seeds.
403///
404/// If `allowed_namespaces` is `Some`, neighbors outside the allowed set are skipped.
405pub 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            // Namespace boundary enforcement.
415            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
428/// Personalized `PageRank` (F-057).
429///
430/// Computes PPR scores for all reachable nodes relative to the given seed
431/// (personalization) nodes using the power iteration method.
432///
433/// The random surfer model: at each step, with probability α teleport back to a
434/// seed node (uniformly), otherwise follow an outgoing edge proportional to its
435/// weight. The stationary distribution gives each node's relevance to the seeds.
436///
437/// If `allowed_namespaces` is `Some`, nodes outside the allowed namespaces are
438/// excluded from the walk.
439///
440/// Reference: `HippoRAG` (Gutierrez et al., arXiv:2405.14831) — uses PPR over a
441/// knowledge graph with LLM-extracted entities as personalization nodes.
442/// Returns an error if `config` fails validation.
443pub 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    // Restrict PPR to the seed-reachable induced subgraph. Full-graph ranking
469    // biases toward hubs and turns each query into a global walk.
470    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    // Personalization vector: uniform over seeds that exist in the graph.
484    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    // Build sparse out-degree structure for efficient iteration.
497    // For each node, store (neighbor_idx, normalized_weight).
498    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    // Power iteration: r(t+1) = α·p + (1-α)·M^T·r(t)
515    // where M is the column-stochastic transition matrix and p is personalization.
516    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        // Accumulate contributions from incoming edges.
523        // M^T·r: for each node i with outgoing edges to j, node j receives
524        // r[i] * edge_weight_normalized.
525        let mut dangling_mass = 0.0_f64;
526        for i in 0..n {
527            if out_edges[i].is_empty() {
528                // Dangling node: redistribute its score to personalization nodes.
529                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        // Apply teleportation and dangling node redistribution.
538        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    // Convert to HashMap, exclude near-zero scores.
555    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        // Traverse outgoing edges (forward reachability: what does this node cause?)
593        // AND incoming edges (backward reachability: what caused this node?).
594        // Including both directions ensures upstream causes appear in the PPR subgraph.
595        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
638/// Lateral inhibition: suppress nodes that are semantically similar to seeds
639/// but not graph-connected.
640///
641/// Inhibition strength is modulated by topical dissimilarity (Jaccard coefficient
642/// of 1-hop graph neighborhoods). Nodes in the same semantic cluster (high Jaccard)
643/// receive weak inhibition; nodes in different clusters (low Jaccard) receive strong
644/// inhibition. This implements the SYNAPSE refinement.
645///
646/// Competitors: high embedding similarity BUT low graph connectivity.
647fn 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    // Identify seed nodes (activation == 1.0).
655    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    // Collect connected nodes for each seed (within 2 hops).
663    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    // For each activated non-seed node, check if it's a competitor:
672    // - similar to seeds (high cosine similarity)
673    // - but NOT connected to seeds
674    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; // Connected nodes are NOT suppressed.
684        }
685
686        let Some(node_embedding) = embeddings.get(&node) else {
687            continue;
688        };
689
690        // Compute similarity to seeds and find the most-similar seed.
691        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 similar but not connected → suppress.
704        // Inhibition modulated by topical dissimilarity (Jaccard):
705        //   inhibition = µ × (1 - jaccard(node, seed)) × cosine_sim
706        // Cap inhibition at 80% of activation to preserve a minimum floor.
707        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; // preserve at least 20%
718                *a = (*a - inhibition).max(floor);
719            }
720        }
721    }
722}
723
724/// Jaccard similarity coefficient: |A ∩ B| / |A ∪ B|.
725///
726/// Returns 0.0 if both sets are empty.
727fn 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
736/// Simple cosine similarity for inhibition check (no SIMD needed — small scale).
737fn 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// ── Tests ────────────────────────────────────────────────────────────────
756
757#[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        // A = 1.0, B = 1.0 * 1.0 * 0.5 = 0.5, C = 0.5 * 1.0 * 0.25 = 0.125
1023        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        // Each deeper node should have strictly less activation.
1058        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        // B should not be activated due to tiny weight × decay < epsilon.
1081        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        // With 1 iteration, only direct neighbors should be activated.
1102        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        // All neighbors with sufficient activation should be present.
1149        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        // Should converge — activation shouldn't grow unbounded.
1190        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)); // Only one hop.
1209    }
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        // A→B connected, D disconnected from A but similar embedding.
1218        pg.add_edge(a, b, EdgeRelation::Causes, 1.0, Metadata::new())
1219            .unwrap();
1220        // Give D some activation route (through another node for realism).
1221        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        // Create embeddings: A and D are very similar, B and bridge are different.
1228        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]); // Very similar to A.
1233
1234        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        // Without inhibition.
1243        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        // D should be suppressed (lower activation with inhibition).
1255        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]); // Very similar.
1295
1296        let cfg = ActivationConfig {
1297            inhibition_strength: 100.0, // Extreme.
1298            ..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]); // Similar AND connected.
1318
1319        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        // B is connected to A, so it should NOT be suppressed.
1326        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        // a → shared → b
1353        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        // Agent A should see a and shared, but NOT b.
1359        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        // Without namespace restriction, b IS reachable.
1375        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    // ── Personalized PageRank tests (F-057) ─────────────────────────────
1404
1405    #[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        // Seed should have highest score, followed by neighbors in order.
1437        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        // Should converge. Seed gets higher score due to teleportation bias.
1536        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        // C should be reachable from both seeds and have higher score than isolated node.
1557        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    // ── additional tests ──────────────────────────────────────
1589
1590    #[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        // Component 1: A → B → C
1607        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        // Component 2: D → E (disconnected)
1615        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        // Hub → 20 leaves → second-level nodes.
1638        // Truncation limits propagation from depth 1 to depth 2.
1639        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        // All 20 leaves are activated at depth 1, but the frontier is truncated
1658        // to 5 nodes, so at most 5 second-level nodes can be activated.
1659        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    // ── additional tests ──────────────────────────────────────
1752
1753    #[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        // All leaves should have approximately equal scores.
1767        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        // With alpha=0 on a star graph (center→leaf1..5 and all leaves→center),
1786        // center acts as hub and should accumulate mass.
1787        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        // With alpha=0, pure random walk: center has 4 outgoing edges, each leaf
1805        // has 1 outgoing back to center. Stationary distribution is proportional
1806        // to in-degree. Center gets 4x the flow of each leaf.
1807        let c_score = result.get(&center).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        // With alpha=1, always teleport back to seed — seed gets all probability.
1834        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    // ── SYNAPSE Jaccard-based lateral inhibition tests ──────────────
1855
1856    #[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        // b shares 2 of 3 elements, plus one unique
1862        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        // intersection = 2, union = 4 → Jaccard = 0.5
1866        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        // Two nodes sharing all neighbors → high Jaccard → weak inhibition.
1887        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        // Both seed and competitor connect to shared1 and shared2.
1894        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        // High cosine similarity embeddings.
1924        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        // competitor is NOT within 2 hops of seed via edges going *to* seed,
1933        // so it's eligible for suppression. However their shared neighbors
1934        // means high Jaccard → inhibition should be very weak.
1935        //
1936        // But competitor IS within 2 hops of seed via shared neighbors (seed→shared1←competitor).
1937        // PropertyGraph get_neighbors only follows outgoing edges by default,
1938        // so competitor may or may not be in connected_to_seeds.
1939        // The test verifies that Jaccard modulation reduces inhibition relative
1940        // to what uniform inhibition would produce.
1941        let original = activations[&competitor];
1942        super::apply_lateral_inhibition(&pg, &mut activations, 0.1, 0.5, &embeddings);
1943        let final_val = activations[&competitor];
1944        // With same-cluster (Jaccard=1.0): inhibition = µ × 0 × sim = 0
1945        // So activation should be unchanged or very close.
1946        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        // Competitor has NO shared neighbors with seed → Jaccard=0 → max inhibition.
1955        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        // Seed connects to seed_neighbor only; competitor to comp_neighbor only.
1962        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        // High cosine similarity embeddings.
1978        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        // Different clusters (Jaccard=0): inhibition = 0.1 × 1.0 × 1.0 = 0.1
1989        // final = max(0.5 - 0.1, 0.5 * 0.2) = 0.4
1990        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}