Skip to main content

shodh_memory/memory/
graph_retrieval.rs

1//! Graph-Aware Retrieval using Spreading Activation
2//!
3//! Based on:
4//! - Anderson & Pirolli (1984): "Spread of Activation"
5//! - Collins & Loftus (1975): "Spreading-activation theory of semantic processing"
6//! - Xiong et al. (2017): "Explicit Semantic Ranking via Knowledge Graph Embedding"
7//! - GraphRAG Survey (arXiv 2408.08921): Hybrid KG-Vector improves 13.1%
8//! - spreadr R package (Siew, 2019): Importance-weighted decay
9//! - ACT-R cognitive architecture: Multi-source activation with intersection boost
10//!
11//! Implements spreading activation algorithm for memory retrieval:
12//! 1. Extract entities from query (using linguistic analysis)
13//! 2. Activate entities in knowledge graph (salience-weighted, ACT-R inspired)
14//! 3. Spread activation through graph relationships (importance-weighted decay)
15//!    - PIPE-7: Bidirectional spreading for 2+ entities (meet-in-middle)
16//! 4. Retrieve episodic memories connected to activated entities
17//! 5. Score using hybrid method (density-dependent graph + semantic + linguistic)
18//!
19//! SHO-26 Enhancements:
20//! - Density-dependent hybrid weights: Graph trust scales with learned associations
21//! - Importance-weighted decay: Important memories decay slower, preserve signal
22//!
23//! PIPE-7 Enhancements:
24//! - Bidirectional spreading: When 2+ focal entities, split and spread from both ends
25//! - Intersection boost: Entities reached from both directions get 1.5× activation
26//! - Complexity reduction: O(b^d) → O(2 × b^(d/2)) for multi-entity queries
27
28use anyhow::Result;
29use std::collections::HashMap;
30use std::time::Instant;
31use uuid::Uuid;
32
33use crate::constants::{
34    BIDIRECTIONAL_DENSITY_DENSE, BIDIRECTIONAL_DENSITY_SPARSE, BIDIRECTIONAL_HOPS_DENSE,
35    BIDIRECTIONAL_HOPS_MEDIUM, BIDIRECTIONAL_HOPS_SPARSE, BIDIRECTIONAL_INTERSECTION_BOOST,
36    BIDIRECTIONAL_INTERSECTION_MIN, BIDIRECTIONAL_MIN_ENTITIES, DENSITY_GRAPH_WEIGHT_MAX,
37    DENSITY_GRAPH_WEIGHT_MIN, DENSITY_LINGUISTIC_WEIGHT, DENSITY_THRESHOLD_MAX,
38    DENSITY_THRESHOLD_MIN, EDGE_TIER_TRUST_L1, EDGE_TIER_TRUST_L2, EDGE_TIER_TRUST_L3,
39    EDGE_TIER_TRUST_LTP, HYBRID_GRAPH_WEIGHT, HYBRID_LINGUISTIC_WEIGHT, HYBRID_SEMANTIC_WEIGHT,
40    IMPORTANCE_DECAY_MAX, IMPORTANCE_DECAY_MIN, MEMORY_TIER_GRAPH_MULT_ARCHIVE,
41    MEMORY_TIER_GRAPH_MULT_LONGTERM, MEMORY_TIER_GRAPH_MULT_SESSION,
42    MEMORY_TIER_GRAPH_MULT_WORKING, SALIENCE_BOOST_FACTOR, SPREADING_ACTIVATION_THRESHOLD,
43    SPREADING_DEGREE_NORMALIZATION, SPREADING_EARLY_TERMINATION_CANDIDATES,
44    SPREADING_EARLY_TERMINATION_RATIO, SPREADING_MAX_HOPS, SPREADING_MIN_CANDIDATES,
45    SPREADING_MIN_HOPS, SPREADING_NORMALIZATION_FACTOR, SPREADING_RELAXED_THRESHOLD,
46};
47use crate::embeddings::Embedder;
48use crate::graph_memory::{EdgeTier, EpisodicNode, GraphMemory};
49use crate::memory::types::MemoryTier;
50// Note: compute_relevance removed - using unified density-weighted scoring directly
51use crate::memory::query_parser::{analyze_query, QueryAnalysis};
52use crate::memory::types::{Memory, Query, RetrievalStats, SharedMemory};
53use crate::similarity::cosine_similarity;
54
55/// Memory with activation score
56#[derive(Debug, Clone)]
57pub struct ActivatedMemory {
58    pub memory: SharedMemory,
59    #[allow(dead_code)] // Useful for debugging score breakdown
60    pub activation_score: f32,
61    #[allow(dead_code)] // Useful for debugging score breakdown
62    pub semantic_score: f32,
63    #[allow(dead_code)] // Useful for debugging score breakdown
64    pub linguistic_score: f32,
65    pub final_score: f32,
66}
67
68/// Calculate density-dependent graph weight (SHO-26, corrected)
69///
70/// SPARSE = TRUST GRAPH (edges that survived Hebbian decay are high-quality)
71/// DENSE = TRUST VECTOR (too many noisy edges, use semantic similarity)
72///
73/// Graph weight scales INVERSELY with density:
74/// - density < 0.5: weight = 0.5 (sparse graph, trust high-signal associations)
75/// - density > 2.0: weight = 0.1 (dense graph, noisy, trust vector search)
76/// - in between: linear interpolation
77///
78/// Reference: GraphRAG Survey (arXiv 2408.08921) + Hebbian learning insight
79pub fn calculate_density_weights(graph_density: f32) -> (f32, f32, f32) {
80    // INVERTED logic: sparse graphs have higher graph weight
81    let graph_weight = if graph_density <= DENSITY_THRESHOLD_MIN {
82        DENSITY_GRAPH_WEIGHT_MAX // Sparse = trust graph more
83    } else if graph_density >= DENSITY_THRESHOLD_MAX {
84        DENSITY_GRAPH_WEIGHT_MIN // Dense = trust vector more
85    } else {
86        // Linear interpolation: higher density = lower graph weight
87        let ratio = (graph_density - DENSITY_THRESHOLD_MIN)
88            / (DENSITY_THRESHOLD_MAX - DENSITY_THRESHOLD_MIN);
89        DENSITY_GRAPH_WEIGHT_MAX - ratio * (DENSITY_GRAPH_WEIGHT_MAX - DENSITY_GRAPH_WEIGHT_MIN)
90    };
91
92    let linguistic_weight = DENSITY_LINGUISTIC_WEIGHT;
93    let semantic_weight = 1.0 - graph_weight - linguistic_weight;
94
95    (semantic_weight, graph_weight, linguistic_weight)
96}
97
98/// Calculate importance-weighted decay for spreading activation (SHO-26)
99///
100/// Important memories (decisions, learnings) decay slower to preserve signal.
101/// Weak memories (observations, context) decay faster for exploration.
102///
103/// Formula: decay = DECAY_MIN + (1.0 - importance) * (DECAY_MAX - DECAY_MIN)
104/// - importance = 1.0: decay = 0.1 (high-value, preserve)
105/// - importance = 0.0: decay = 0.4 (low-value, explore)
106///
107/// Reference: spreadr R package (Siew, 2019)
108pub fn calculate_importance_weighted_decay(importance: f32) -> f32 {
109    let clamped_importance = importance.clamp(0.0, 1.0);
110    IMPORTANCE_DECAY_MIN
111        + (1.0 - clamped_importance) * (IMPORTANCE_DECAY_MAX - IMPORTANCE_DECAY_MIN)
112}
113
114// =============================================================================
115// PIPE-7: BIDIRECTIONAL SPREADING ACTIVATION
116// =============================================================================
117//
118// Based on:
119// - Collins & Loftus (1975): Spreading activation in semantic memory
120// - ACT-R cognitive architecture: Multi-source activation with intersection boost
121// - Meet-in-middle search: O(b^d) → O(2 × b^(d/2)) complexity reduction
122//
123// Key insight: When query has multiple focal entities (e.g., "Rust" and "database"),
124// spreading from both ends and boosting intersection entities finds the "bridge"
125// concepts that connect the query terms - these are often the most relevant.
126//
127// Density-adaptive hop count:
128// - Dense (fresh) graphs: fewer hops to avoid noise from L1 edges
129// - Sparse (mature) graphs: more hops to find connections through curated L2/L3 edges
130
131/// Calculate density-adaptive hop count for bidirectional spreading
132///
133/// Graph lifecycle: Dense → Sparse as decay prunes weak edges over time.
134/// - Fresh system (dense): many noisy L1 edges → fewer hops (2)
135/// - Mature system (sparse): curated L2/L3 edges → more hops (4)
136///
137/// Returns: number of hops per direction
138pub fn calculate_adaptive_hops(graph_density: Option<f32>) -> usize {
139    match graph_density {
140        Some(density) if density > BIDIRECTIONAL_DENSITY_DENSE => {
141            // Dense graph: limit exploration to avoid noise
142            BIDIRECTIONAL_HOPS_DENSE
143        }
144        Some(density) if density < BIDIRECTIONAL_DENSITY_SPARSE => {
145            // Sparse graph: explore deeper through quality edges
146            BIDIRECTIONAL_HOPS_SPARSE
147        }
148        Some(_) => {
149            // Medium density: balanced exploration
150            BIDIRECTIONAL_HOPS_MEDIUM
151        }
152        None => {
153            // No density info: use medium as safe default
154            BIDIRECTIONAL_HOPS_MEDIUM
155        }
156    }
157}
158
159/// Spread activation from a set of seed entities for a fixed number of hops
160///
161/// This is a single-direction spread used by bidirectional algorithm.
162/// Returns the activation map after spreading.
163fn spread_single_direction(
164    seeds: &[(Uuid, f32)],
165    graph: &GraphMemory,
166    max_hops: usize,
167    threshold: f32,
168) -> Result<(HashMap<Uuid, f32>, Vec<Uuid>)> {
169    let mut activation_map: HashMap<Uuid, f32> = seeds.iter().cloned().collect();
170    let mut traversed_edges: Vec<Uuid> = Vec::new();
171
172    for hop in 1..=max_hops {
173        let current_activated: Vec<(Uuid, f32)> =
174            activation_map.iter().map(|(id, act)| (*id, *act)).collect();
175
176        for (entity_uuid, source_activation) in current_activated {
177            if source_activation < threshold {
178                continue;
179            }
180
181            const MAX_EDGES_PER_SPREAD: usize = 100;
182            let edges =
183                graph.get_entity_relationships_limited(&entity_uuid, Some(MAX_EDGES_PER_SPREAD))?;
184
185            // Degree normalization: prevent hub nodes from flooding the network.
186            // Divides outgoing activation by sqrt(1 + degree), matching the fan effect
187            // in ACT-R spreading activation (Anderson & Reder 1999).
188            let degree_norm = if SPREADING_DEGREE_NORMALIZATION {
189                1.0 / (1.0 + edges.len() as f32).sqrt()
190            } else {
191                1.0
192            };
193
194            for edge in edges {
195                let target_uuid = edge.to_entity;
196
197                // Edge-tier trust weight
198                let tier_trust = if edge.is_potentiated() {
199                    EDGE_TIER_TRUST_LTP
200                } else {
201                    match edge.tier {
202                        EdgeTier::L3Semantic => EDGE_TIER_TRUST_L3,
203                        EdgeTier::L2Episodic => EDGE_TIER_TRUST_L2,
204                        EdgeTier::L1Working => EDGE_TIER_TRUST_L1,
205                    }
206                };
207
208                // Importance-weighted decay (using effective_strength for time-aware decay)
209                let effective = edge.effective_strength();
210                let decay_rate = calculate_importance_weighted_decay(effective);
211                let decay = (-decay_rate * hop as f32).exp();
212
213                let spread_amount =
214                    source_activation * decay * effective * tier_trust * degree_norm;
215
216                let new_activation = activation_map.entry(target_uuid).or_insert(0.0);
217                *new_activation += spread_amount;
218
219                if spread_amount > 0.01 {
220                    traversed_edges.push(edge.uuid);
221                }
222            }
223        }
224
225        // Normalize to prevent unbounded growth
226        let max_activation = activation_map
227            .values()
228            .cloned()
229            .max_by(|a, b| a.total_cmp(b))
230            .unwrap_or(1.0);
231
232        if max_activation > SPREADING_NORMALIZATION_FACTOR {
233            let scale = SPREADING_NORMALIZATION_FACTOR / max_activation;
234            for activation in activation_map.values_mut() {
235                *activation *= scale;
236            }
237        }
238
239        // Prune weak activations
240        activation_map.retain(|_, activation| *activation > threshold);
241    }
242
243    Ok((activation_map, traversed_edges))
244}
245
246/// Bidirectional spreading activation with intersection boost (PIPE-7)
247///
248/// Splits focal entities into forward/backward sets, spreads from each,
249/// and boosts entities found at the intersection.
250///
251/// # Arguments
252/// - `entity_data`: Focal entities with (uuid, name, ic_weight, salience)
253/// - `graph`: The knowledge graph
254/// - `total_salience`: Sum of entity saliences for normalization
255/// - `hops_per_direction`: Density-adaptive hop count (2-4)
256///
257/// Returns: (combined_activation_map, traversed_edges, intersection_count)
258fn bidirectional_spread(
259    entity_data: &[(Uuid, String, f32, f32)], // (uuid, name, ic_weight, salience)
260    graph: &GraphMemory,
261    total_salience: f32,
262    hops_per_direction: usize,
263) -> Result<(HashMap<Uuid, f32>, Vec<Uuid>, usize)> {
264    // Split entities into forward/backward sets (alternating assignment)
265    // This distributes entities evenly regardless of count
266    let mut forward_seeds: Vec<(Uuid, f32)> = Vec::new();
267    let mut backward_seeds: Vec<(Uuid, f32)> = Vec::new();
268
269    for (i, (uuid, _name, ic_weight, salience)) in entity_data.iter().enumerate() {
270        let normalized_salience = salience / total_salience;
271        let salience_boost = SALIENCE_BOOST_FACTOR * normalized_salience;
272        let initial_activation = ic_weight * (1.0 + salience_boost);
273
274        if i % 2 == 0 {
275            forward_seeds.push((*uuid, initial_activation));
276        } else {
277            backward_seeds.push((*uuid, initial_activation));
278        }
279    }
280
281    // If odd number of entities, backward might be empty - add last entity to both
282    if backward_seeds.is_empty() && !forward_seeds.is_empty() {
283        backward_seeds.push(forward_seeds[forward_seeds.len() - 1]);
284    }
285
286    tracing::debug!(
287        "🔀 Bidirectional spread: {} forward seeds, {} backward seeds",
288        forward_seeds.len(),
289        backward_seeds.len()
290    );
291
292    // Spread from both directions with density-adaptive hops
293    let threshold = SPREADING_ACTIVATION_THRESHOLD;
294    let (forward_map, forward_edges) =
295        spread_single_direction(&forward_seeds, graph, hops_per_direction, threshold)?;
296
297    let (backward_map, backward_edges) =
298        spread_single_direction(&backward_seeds, graph, hops_per_direction, threshold)?;
299
300    // Combine maps with intersection boost
301    let mut combined_map: HashMap<Uuid, f32> = HashMap::new();
302    let mut intersection_count = 0;
303
304    // Collect all entities from both directions
305    let all_entities: std::collections::HashSet<Uuid> = forward_map
306        .keys()
307        .chain(backward_map.keys())
308        .cloned()
309        .collect();
310
311    for entity_uuid in all_entities {
312        let forward_activation = forward_map.get(&entity_uuid).cloned().unwrap_or(0.0);
313        let backward_activation = backward_map.get(&entity_uuid).cloned().unwrap_or(0.0);
314
315        // Check if this is an intersection entity (meaningful activation from both)
316        let is_intersection = forward_activation >= BIDIRECTIONAL_INTERSECTION_MIN
317            && backward_activation >= BIDIRECTIONAL_INTERSECTION_MIN;
318
319        let combined_activation = if is_intersection {
320            intersection_count += 1;
321            // Intersection boost: these are "bridge" concepts
322            (forward_activation + backward_activation) * BIDIRECTIONAL_INTERSECTION_BOOST
323        } else {
324            // Non-intersection: just sum the activations
325            forward_activation + backward_activation
326        };
327
328        combined_map.insert(entity_uuid, combined_activation);
329    }
330
331    // Combine traversed edges
332    let mut all_edges = forward_edges;
333    all_edges.extend(backward_edges);
334
335    tracing::debug!(
336        "🔀 Bidirectional result: {} entities ({} intersections), {} edges",
337        combined_map.len(),
338        intersection_count,
339        all_edges.len()
340    );
341
342    Ok((combined_map, all_edges, intersection_count))
343}
344
345/// Spreading activation retrieval (legacy - uses fixed weights)
346///
347/// This is the core algorithm implementing Anderson & Pirolli (1984)
348/// spreading activation model adapted for episodic memory retrieval.
349///
350/// For SHO-26 density-dependent weights, use `spreading_activation_retrieve_with_stats`
351pub fn spreading_activation_retrieve(
352    query_text: &str,
353    query: &Query,
354    graph: &GraphMemory,
355    embedder: &dyn Embedder,
356    episode_to_memory_fn: impl Fn(&EpisodicNode) -> Result<Option<SharedMemory>>,
357) -> Result<Vec<ActivatedMemory>> {
358    // Delegate to enhanced version with default density (uses legacy weights)
359    let (memories, _stats) = spreading_activation_retrieve_with_stats(
360        query_text,
361        query,
362        graph,
363        embedder,
364        None, // No density = use legacy fixed weights
365        episode_to_memory_fn,
366    )?;
367    Ok(memories)
368}
369
370/// Spreading activation retrieval with density-dependent weights (SHO-26)
371///
372/// Enhanced version that:
373/// - Uses density-dependent hybrid weights (graph trust scales with associations)
374/// - Applies importance-weighted decay (important memories decay slower)
375/// - Returns RetrievalStats for observability
376///
377/// # Arguments
378/// - `query_text`: The search query
379/// - `query`: Query parameters including max_results
380/// - `graph`: The knowledge graph for spreading activation
381/// - `embedder`: Embedding model for semantic scoring
382/// - `graph_density`: Optional density (edges/memories). If None, uses fixed weights.
383/// - `episode_to_memory_fn`: Function to convert episodes to memories
384///
385/// # Returns
386/// (Vec<ActivatedMemory>, RetrievalStats)
387pub fn spreading_activation_retrieve_with_stats(
388    query_text: &str,
389    query: &Query,
390    graph: &GraphMemory,
391    embedder: &dyn Embedder,
392    graph_density: Option<f32>,
393    episode_to_memory_fn: impl Fn(&EpisodicNode) -> Result<Option<SharedMemory>>,
394) -> Result<(Vec<ActivatedMemory>, RetrievalStats)> {
395    let start_time = Instant::now();
396    let mut stats = RetrievalStats::default();
397
398    // Determine weights based on density
399    let (semantic_weight, graph_weight, linguistic_weight) = if let Some(density) = graph_density {
400        stats.mode = "associative".to_string();
401        stats.graph_density = density;
402        calculate_density_weights(density)
403    } else {
404        stats.mode = "hybrid".to_string();
405        stats.graph_density = 0.0;
406        (
407            HYBRID_SEMANTIC_WEIGHT,
408            HYBRID_GRAPH_WEIGHT,
409            HYBRID_LINGUISTIC_WEIGHT,
410        )
411    };
412
413    stats.semantic_weight = semantic_weight;
414    stats.graph_weight = graph_weight;
415    stats.linguistic_weight = linguistic_weight;
416
417    // Step 1: Linguistic query analysis (Lioma & Ounis 2006)
418    let analysis = analyze_query(query_text);
419
420    tracing::info!("🔍 Query Analysis:");
421    tracing::info!(
422        "  Focal Entities: {:?}",
423        analysis
424            .focal_entities
425            .iter()
426            .map(|e| &e.text)
427            .collect::<Vec<_>>()
428    );
429    tracing::info!(
430        "  Modifiers: {:?}",
431        analysis
432            .discriminative_modifiers
433            .iter()
434            .map(|m| &m.text)
435            .collect::<Vec<_>>()
436    );
437    tracing::info!(
438        "  Relations: {:?}",
439        analysis
440            .relational_context
441            .iter()
442            .map(|r| &r.text)
443            .collect::<Vec<_>>()
444    );
445    tracing::info!(
446        "  Weights: semantic={:.2}, graph={:.2}, linguistic={:.2}",
447        semantic_weight,
448        graph_weight,
449        linguistic_weight
450    );
451
452    // Step 2: Initialize activation map from focal entities (nouns)
453    // ACT-R inspired: weight initial activation by entity salience (attention budget)
454    let mut activation_map: HashMap<Uuid, f32> = HashMap::new();
455
456    // First pass: collect entities with their salience values
457    let mut entity_data: Vec<(Uuid, String, f32, f32)> = Vec::new(); // (uuid, name, ic_weight, salience)
458
459    for entity in &analysis.focal_entities {
460        if let Some(entity_node) = graph.find_entity_by_name(&entity.text)? {
461            entity_data.push((
462                entity_node.uuid,
463                entity.text.clone(),
464                entity.ic_weight,
465                entity_node.salience,
466            ));
467        } else {
468            tracing::debug!("  ✗ Entity '{}' not found in graph", entity.text);
469        }
470    }
471
472    // Calculate total salience for normalization (attention budget)
473    let total_salience: f32 = entity_data.iter().map(|(_, _, _, s)| s).sum();
474    let total_salience = total_salience.max(0.1); // Prevent division by zero
475
476    // Second pass: apply salience-weighted activation
477    let mut total_boost = 0.0_f32;
478    for (uuid, name, ic_weight, salience) in &entity_data {
479        // Normalize salience across query entities (ACT-R attention budget)
480        let normalized_salience = salience / total_salience;
481
482        // ACT-R inspired: activation = IC_weight × (1 + boost_factor × normalized_salience)
483        let salience_boost = SALIENCE_BOOST_FACTOR * normalized_salience;
484        let initial_activation = ic_weight * (1.0 + salience_boost);
485
486        activation_map.insert(*uuid, initial_activation);
487        stats.entities_activated += 1;
488        total_boost += salience_boost;
489
490        tracing::debug!(
491            "  ✓ Activated '{}' (IC={:.2}, salience={:.2}, norm={:.2}, boost={:.2}, activation={:.2})",
492            name,
493            ic_weight,
494            salience,
495            normalized_salience,
496            salience_boost,
497            initial_activation
498        );
499    }
500
501    // Track average salience boost for observability
502    stats.avg_salience_boost = if !entity_data.is_empty() {
503        total_boost / entity_data.len() as f32
504    } else {
505        0.0
506    };
507
508    if activation_map.is_empty() {
509        tracing::warn!("No entities found in graph, falling back to semantic search");
510        stats.retrieval_time_us = start_time.elapsed().as_micros() as u64;
511        return Ok((Vec::new(), stats)); // Caller should fall back to semantic search
512    }
513
514    // Step 3: Spread activation through graph
515    // PIPE-7: Use bidirectional spreading when 2+ focal entities, else unidirectional
516    let graph_start = Instant::now();
517    let mut traversed_edges: Vec<Uuid>;
518
519    if entity_data.len() >= BIDIRECTIONAL_MIN_ENTITIES {
520        // PIPE-7: Bidirectional spreading activation
521        // Split entities into forward/backward sets, spread from each, boost intersections
522        // Density-adaptive hops: dense (fresh) graphs use fewer hops, sparse (mature) use more
523        let adaptive_hops = calculate_adaptive_hops(graph_density);
524
525        tracing::info!(
526            "🔀 Using bidirectional spreading ({} focal entities, {} hops/direction, density={:.2})",
527            entity_data.len(),
528            adaptive_hops,
529            graph_density.unwrap_or(0.0)
530        );
531
532        let (bidirectional_map, edges, intersection_count) =
533            bidirectional_spread(&entity_data, graph, total_salience, adaptive_hops)?;
534
535        activation_map = bidirectional_map;
536        traversed_edges = edges;
537        stats.entities_activated = activation_map.len();
538        stats.graph_hops = adaptive_hops * 2; // Effective depth (forward + backward)
539
540        tracing::info!(
541            "🔀 Bidirectional complete: {} entities, {} intersections",
542            activation_map.len(),
543            intersection_count
544        );
545    } else {
546        // Standard unidirectional spreading (Anderson & Pirolli 1984)
547        // With importance-weighted decay (SHO-26) and adaptive limits
548        tracing::info!(
549            "📊 Using unidirectional spreading ({} focal entity)",
550            entity_data.len()
551        );
552
553        let mut edges_collected: Vec<Uuid> = Vec::new();
554        let mut current_threshold = SPREADING_ACTIVATION_THRESHOLD;
555
556        for hop in 1..=SPREADING_MAX_HOPS {
557            stats.graph_hops = hop;
558            let count_before = activation_map.len();
559
560            tracing::debug!(
561                "📊 Spreading activation (hop {}/{}, threshold={:.4})",
562                hop,
563                SPREADING_MAX_HOPS,
564                current_threshold
565            );
566
567            // Clone to avoid borrow issues
568            let current_activated: Vec<(Uuid, f32)> =
569                activation_map.iter().map(|(id, act)| (*id, *act)).collect();
570
571            for (entity_uuid, source_activation) in current_activated {
572                // Only spread from entities with sufficient activation
573                if source_activation < current_threshold {
574                    continue;
575                }
576
577                // Get relationships from this entity (limited to prevent blowup)
578                const MAX_EDGES_PER_SPREAD: usize = 100;
579                let edges = graph
580                    .get_entity_relationships_limited(&entity_uuid, Some(MAX_EDGES_PER_SPREAD))?;
581
582                for edge in edges {
583                    // Spread activation to connected entity
584                    let target_uuid = edge.to_entity;
585
586                    // Edge-tier trust weight (SHO-D1, PIPE-4)
587                    let tier_trust = if edge.is_potentiated() {
588                        EDGE_TIER_TRUST_LTP
589                    } else {
590                        match edge.tier {
591                            EdgeTier::L3Semantic => EDGE_TIER_TRUST_L3,
592                            EdgeTier::L2Episodic => EDGE_TIER_TRUST_L2,
593                            EdgeTier::L1Working => EDGE_TIER_TRUST_L1,
594                        }
595                    };
596
597                    // SHO-26: Importance-weighted decay (using effective_strength for time-aware decay)
598                    let effective = edge.effective_strength();
599                    let decay_rate = calculate_importance_weighted_decay(effective);
600                    let decay = (-decay_rate * hop as f32).exp();
601
602                    let spread_amount = source_activation * decay * effective * tier_trust;
603
604                    let new_activation = activation_map.entry(target_uuid).or_insert(0.0);
605                    *new_activation += spread_amount;
606
607                    if spread_amount > 0.01 {
608                        edges_collected.push(edge.uuid);
609                    }
610
611                    if *new_activation >= current_threshold
612                        && *new_activation - spread_amount < current_threshold
613                    {
614                        stats.entities_activated += 1;
615                    }
616                }
617            }
618
619            // Normalize activations to prevent unbounded growth
620            let max_activation = activation_map
621                .values()
622                .cloned()
623                .max_by(|a, b| a.total_cmp(b))
624                .unwrap_or(1.0);
625
626            if max_activation > SPREADING_NORMALIZATION_FACTOR {
627                let scale = SPREADING_NORMALIZATION_FACTOR / max_activation;
628                for activation in activation_map.values_mut() {
629                    *activation *= scale;
630                }
631            }
632
633            // Prune weak activations (ACT-R model)
634            activation_map.retain(|_, activation| *activation > current_threshold);
635
636            let count_after = activation_map.len();
637            let new_activations = count_after.saturating_sub(count_before);
638
639            tracing::debug!(
640                "  Activated entities: {} (+{} new)",
641                count_after,
642                new_activations
643            );
644
645            // Adaptive threshold relaxation
646            if count_after < SPREADING_MIN_CANDIDATES
647                && current_threshold > SPREADING_RELAXED_THRESHOLD
648            {
649                current_threshold = SPREADING_RELAXED_THRESHOLD;
650                tracing::debug!(
651                    "  Relaxing threshold to {:.4} (only {} candidates)",
652                    current_threshold,
653                    count_after
654                );
655            }
656
657            // Early termination checks (only after minimum hops)
658            if hop >= SPREADING_MIN_HOPS {
659                let new_ratio = if count_after > 0 {
660                    new_activations as f32 / count_after as f32
661                } else {
662                    0.0
663                };
664
665                if new_ratio < SPREADING_EARLY_TERMINATION_RATIO && count_after > 0 {
666                    tracing::debug!(
667                        "  Early termination: activation saturated ({:.1}% new)",
668                        new_ratio * 100.0
669                    );
670                    break;
671                }
672
673                if count_after >= SPREADING_EARLY_TERMINATION_CANDIDATES {
674                    tracing::debug!(
675                        "  Early termination: sufficient coverage ({} candidates)",
676                        count_after
677                    );
678                    break;
679                }
680            }
681        }
682
683        traversed_edges = edges_collected;
684    }
685
686    stats.graph_time_us = graph_start.elapsed().as_micros() as u64;
687    tracing::info!("📊 Final activated entities: {}", activation_map.len());
688
689    // Step 4: Retrieve episodic memories connected to activated entities
690    let mut activated_memories: HashMap<Uuid, (f32, EpisodicNode)> = HashMap::new();
691
692    for (entity_uuid, entity_activation) in &activation_map {
693        let episodes = graph.get_episodes_by_entity(entity_uuid)?;
694
695        for episode in episodes {
696            // Accumulate activation for each episode (might be connected to multiple entities)
697            let current = activated_memories
698                .entry(episode.uuid)
699                .or_insert((0.0, episode.clone()));
700
701            current.0 += entity_activation;
702        }
703    }
704
705    stats.graph_candidates = activated_memories.len();
706    tracing::info!(
707        "📊 Retrieved {} episodic memories via graph",
708        activated_memories.len()
709    );
710
711    // Step 5: Convert episodes to memories and calculate scores using UNIFIED scoring
712    let mut scored_memories = Vec::new();
713
714    // Generate query embedding once (for semantic scoring)
715    let embedding_start = Instant::now();
716    let query_embedding = embedder.encode(query_text)?;
717    stats.embedding_time_us = embedding_start.elapsed().as_micros() as u64;
718
719    let now = chrono::Utc::now();
720
721    for (_episode_uuid, (graph_activation, episode)) in activated_memories {
722        // Convert episode to memory
723        if let Some(memory) = episode_to_memory_fn(&episode)? {
724            // Calculate semantic similarity (still needed for ActivatedMemory debug fields)
725            let semantic_score = if let Some(mem_emb) = &memory.experience.embeddings {
726                cosine_similarity(&query_embedding, mem_emb)
727            } else {
728                0.0
729            };
730
731            // Calculate linguistic match score (normalized to 0.0-1.0)
732            let linguistic_raw = calculate_linguistic_match(&memory, &analysis);
733            let linguistic_score = linguistic_raw; // Already normalized in calculate_linguistic_match
734
735            // Memory-tier graph weight multiplier (SHO-D2)
736            // Working memories are dense/noisy → lower graph trust
737            // LongTerm memories are sparse/proven → full graph trust
738            let tier_graph_mult = match memory.tier {
739                MemoryTier::Working => MEMORY_TIER_GRAPH_MULT_WORKING, // 0.3
740                MemoryTier::Session => MEMORY_TIER_GRAPH_MULT_SESSION, // 0.6
741                MemoryTier::LongTerm => MEMORY_TIER_GRAPH_MULT_LONGTERM, // 1.0
742                MemoryTier::Archive => MEMORY_TIER_GRAPH_MULT_ARCHIVE, // 1.2
743            };
744
745            // Unified scoring using density-dependent weights (calculated at function start)
746            // Graph weight is further adjusted by memory tier
747            // Weights are: semantic_weight, graph_weight * tier_mult, linguistic_weight
748            let tier_adjusted_graph_weight = graph_weight * tier_graph_mult;
749            // Renormalize weights to sum to 1.0
750            let weight_sum = semantic_weight + tier_adjusted_graph_weight + linguistic_weight;
751            let norm_semantic = semantic_weight / weight_sum;
752            let norm_graph = tier_adjusted_graph_weight / weight_sum;
753            let norm_linguistic = linguistic_weight / weight_sum;
754
755            let hybrid_score = semantic_score * norm_semantic
756                + graph_activation * norm_graph
757                + linguistic_score * norm_linguistic;
758
759            // Recency decay (10% contribution) - recent memories get boost
760            // λ = 0.01 means ~50% at 70 hours, ~25% at 140 hours
761            const RECENCY_DECAY_RATE: f32 = 0.01;
762            let hours_old = (now - memory.created_at).num_hours().max(0) as f32;
763            let recency_boost = (-RECENCY_DECAY_RATE * hours_old).exp() * 0.1;
764
765            // Emotional arousal boost: high arousal = more salient (5% contribution)
766            let arousal_boost = memory
767                .experience
768                .context
769                .as_ref()
770                .map(|c| c.emotional.arousal * 0.05)
771                .unwrap_or(0.0);
772
773            // Source credibility boost (5% contribution)
774            let credibility_boost = memory
775                .experience
776                .context
777                .as_ref()
778                .map(|c| (c.source.credibility - 0.5).max(0.0) * 0.1)
779                .unwrap_or(0.0);
780
781            let final_score = hybrid_score + recency_boost + arousal_boost + credibility_boost;
782
783            scored_memories.push(ActivatedMemory {
784                memory,
785                activation_score: graph_activation,
786                semantic_score,
787                linguistic_score,
788                final_score,
789            });
790        }
791    }
792
793    // Step 6: Sort by final score (descending)
794    scored_memories.sort_by(|a, b| b.final_score.total_cmp(&a.final_score));
795
796    // Step 7: Apply limit
797    scored_memories.truncate(query.max_results);
798
799    stats.retrieval_time_us = start_time.elapsed().as_micros() as u64;
800
801    // Deduplicate traversed edges (same edge may be traversed multiple times across hops)
802    traversed_edges.sort();
803    traversed_edges.dedup();
804    stats.traversed_edges = traversed_edges;
805
806    tracing::info!(
807        "🎯 Returning {} memories (top scores: {:?}), {} edges traversed",
808        scored_memories.len(),
809        scored_memories
810            .iter()
811            .take(3)
812            .map(|m| m.final_score)
813            .collect::<Vec<_>>(),
814        stats.traversed_edges.len()
815    );
816
817    Ok((scored_memories, stats))
818}
819
820/// Calculate linguistic feature match score
821///
822/// Based on IC-weighted matching:
823/// - Focal entities (nouns): 1.0 point each
824/// - Modifiers (adjectives): 0.5 points each
825/// - Relations (verbs): 0.2 points each
826fn calculate_linguistic_match(memory: &Memory, analysis: &QueryAnalysis) -> f32 {
827    let content_lower = memory.experience.content.to_lowercase();
828    let mut score = 0.0;
829
830    // Entity matches (nouns) - highest weight
831    for entity in &analysis.focal_entities {
832        if content_lower.contains(&entity.text.to_lowercase()) {
833            score += 1.0;
834        }
835    }
836
837    // Modifier matches (adjectives) - medium weight
838    for modifier in &analysis.discriminative_modifiers {
839        if content_lower.contains(&modifier.text.to_lowercase()) {
840            score += 0.5;
841        }
842    }
843
844    // Relation matches (verbs) - low weight (they're "bus stops")
845    for relation in &analysis.relational_context {
846        if content_lower.contains(&relation.text.to_lowercase()) {
847            score += 0.2;
848        }
849    }
850
851    // Normalize by total possible score
852    let max_possible = analysis.focal_entities.len() as f32 * 1.0
853        + analysis.discriminative_modifiers.len() as f32 * 0.5
854        + analysis.relational_context.len() as f32 * 0.2;
855
856    if max_possible > 0.0 {
857        score / max_possible
858    } else {
859        0.0
860    }
861}
862
863#[cfg(test)]
864mod tests {
865    use super::*;
866
867    #[test]
868    fn test_cosine_similarity() {
869        let a = vec![1.0, 0.0, 0.0];
870        let b = vec![1.0, 0.0, 0.0];
871        assert_eq!(cosine_similarity(&a, &b), 1.0);
872
873        let a = vec![1.0, 0.0];
874        let b = vec![0.0, 1.0];
875        assert_eq!(cosine_similarity(&a, &b), 0.0);
876
877        let a = vec![1.0, 1.0];
878        let b = vec![1.0, 1.0];
879        assert!((cosine_similarity(&a, &b) - 1.0).abs() < 0.001);
880    }
881
882    #[test]
883    fn test_density_weights_sparse() {
884        // Sparse graph: density < 0.5 -> MAX graph weight (trust graph)
885        let (semantic, graph, linguistic) = calculate_density_weights(0.3);
886        assert!((graph - DENSITY_GRAPH_WEIGHT_MAX).abs() < 0.001);
887        assert!((linguistic - DENSITY_LINGUISTIC_WEIGHT).abs() < 0.001);
888        assert!((semantic + graph + linguistic - 1.0).abs() < 0.001);
889    }
890
891    #[test]
892    fn test_density_weights_dense() {
893        // Dense graph: density > 2.0 -> MIN graph weight (trust vector)
894        let (semantic, graph, linguistic) = calculate_density_weights(2.5);
895        assert!((graph - DENSITY_GRAPH_WEIGHT_MIN).abs() < 0.001);
896        assert!((linguistic - DENSITY_LINGUISTIC_WEIGHT).abs() < 0.001);
897        assert!((semantic + graph + linguistic - 1.0).abs() < 0.001);
898    }
899
900    #[test]
901    fn test_density_weights_interpolation() {
902        // Medium density: should interpolate
903        let (semantic, graph, linguistic) = calculate_density_weights(1.25);
904        assert!(graph > DENSITY_GRAPH_WEIGHT_MIN);
905        assert!(graph < DENSITY_GRAPH_WEIGHT_MAX);
906        assert!((linguistic - DENSITY_LINGUISTIC_WEIGHT).abs() < 0.001);
907        assert!((semantic + graph + linguistic - 1.0).abs() < 0.001);
908    }
909
910    #[test]
911    fn test_importance_weighted_decay_high() {
912        // High importance -> low decay (preserve signal)
913        let decay = calculate_importance_weighted_decay(1.0);
914        assert!((decay - IMPORTANCE_DECAY_MIN).abs() < 0.001);
915    }
916
917    #[test]
918    fn test_importance_weighted_decay_low() {
919        // Low importance -> high decay (explore)
920        let decay = calculate_importance_weighted_decay(0.0);
921        assert!((decay - IMPORTANCE_DECAY_MAX).abs() < 0.001);
922    }
923
924    #[test]
925    fn test_importance_weighted_decay_mid() {
926        // Medium importance -> intermediate decay
927        let decay = calculate_importance_weighted_decay(0.5);
928        let expected = IMPORTANCE_DECAY_MIN + 0.5 * (IMPORTANCE_DECAY_MAX - IMPORTANCE_DECAY_MIN);
929        assert!((decay - expected).abs() < 0.001);
930    }
931
932    #[test]
933    fn test_activation_decay() {
934        // Test that importance-weighted decay varies correctly
935        let initial_activation = 1.0;
936
937        // High importance = slow decay
938        let high_importance_decay = calculate_importance_weighted_decay(0.9);
939        let high_importance_final = initial_activation * (-high_importance_decay).exp();
940
941        // Low importance = fast decay
942        let low_importance_decay = calculate_importance_weighted_decay(0.1);
943        let low_importance_final = initial_activation * (-low_importance_decay).exp();
944
945        // High importance should retain more activation
946        assert!(high_importance_final > low_importance_final);
947    }
948
949    #[test]
950    fn test_adaptive_constants_valid() {
951        use crate::constants::*;
952
953        // Relaxed threshold must be lower than strict threshold
954        assert!(SPREADING_RELAXED_THRESHOLD < SPREADING_ACTIVATION_THRESHOLD);
955
956        // Min hops must be <= max hops
957        assert!(SPREADING_MIN_HOPS <= SPREADING_MAX_HOPS);
958
959        // Early termination ratio must be in (0, 1)
960        assert!(SPREADING_EARLY_TERMINATION_RATIO > 0.0);
961        assert!(SPREADING_EARLY_TERMINATION_RATIO < 1.0);
962
963        // Normalization factor must be positive
964        assert!(SPREADING_NORMALIZATION_FACTOR > 0.0);
965
966        // Min candidates for relaxation should be reasonable
967        assert!(SPREADING_MIN_CANDIDATES > 0);
968        assert!(SPREADING_MIN_CANDIDATES < SPREADING_EARLY_TERMINATION_CANDIDATES);
969    }
970
971    #[test]
972    fn test_normalization_prevents_explosion() {
973        use crate::constants::SPREADING_NORMALIZATION_FACTOR;
974
975        // Simulate activation accumulation over hops
976        let mut activations: Vec<f32> = vec![1.0, 0.8, 0.5, 0.3];
977
978        // Simulate 5 hops of accumulation (each hop adds to existing)
979        for _ in 0..5 {
980            for activation in &mut activations {
981                *activation += *activation * 0.5; // 50% growth per hop
982            }
983
984            // Normalize
985            let max_activation = activations
986                .iter()
987                .cloned()
988                .max_by(|a, b| a.total_cmp(b))
989                .unwrap_or(1.0);
990
991            if max_activation > SPREADING_NORMALIZATION_FACTOR {
992                let scale = SPREADING_NORMALIZATION_FACTOR / max_activation;
993                for activation in &mut activations {
994                    *activation *= scale;
995                }
996            }
997        }
998
999        // After normalization, max should never exceed factor
1000        let final_max = activations
1001            .iter()
1002            .cloned()
1003            .max_by(|a, b| a.total_cmp(b))
1004            .unwrap_or(0.0);
1005
1006        assert!(final_max <= SPREADING_NORMALIZATION_FACTOR + 0.001);
1007    }
1008
1009    #[test]
1010    fn test_early_termination_ratio() {
1011        use crate::constants::SPREADING_EARLY_TERMINATION_RATIO;
1012
1013        // Simulate saturation detection
1014        let total_before = 50;
1015        let total_after = 52; // Only 2 new activations
1016
1017        let new_activations = total_after - total_before;
1018        let new_ratio = new_activations as f32 / total_after as f32;
1019
1020        // Should trigger early termination (only ~4% new)
1021        assert!(new_ratio < SPREADING_EARLY_TERMINATION_RATIO);
1022
1023        // Simulate rapid growth - should not terminate
1024        let growing_before = 10;
1025        let growing_after = 25; // 15 new activations
1026
1027        let growing_new = growing_after - growing_before;
1028        let growing_ratio = growing_new as f32 / growing_after as f32;
1029
1030        // Should NOT trigger early termination (60% new)
1031        assert!(growing_ratio >= SPREADING_EARLY_TERMINATION_RATIO);
1032    }
1033
1034    // =========================================================================
1035    // PIPE-7: Bidirectional Spreading Activation Tests
1036    // =========================================================================
1037
1038    #[test]
1039    fn test_bidirectional_constants_valid() {
1040        // Minimum entities must be at least 2 for bidirectional to make sense
1041        assert!(BIDIRECTIONAL_MIN_ENTITIES >= 2);
1042
1043        // Intersection boost must be positive
1044        assert!(BIDIRECTIONAL_INTERSECTION_BOOST > 1.0);
1045
1046        // Intersection minimum must be below activation threshold
1047        assert!(BIDIRECTIONAL_INTERSECTION_MIN < SPREADING_ACTIVATION_THRESHOLD);
1048
1049        // Density thresholds must be ordered correctly
1050        assert!(BIDIRECTIONAL_DENSITY_SPARSE < BIDIRECTIONAL_DENSITY_DENSE);
1051
1052        // Hop counts must be ordered: dense < medium < sparse
1053        assert!(BIDIRECTIONAL_HOPS_DENSE < BIDIRECTIONAL_HOPS_MEDIUM);
1054        assert!(BIDIRECTIONAL_HOPS_MEDIUM < BIDIRECTIONAL_HOPS_SPARSE);
1055
1056        // Medium hops × 2 should approximate max hops for unidirectional
1057        assert!(BIDIRECTIONAL_HOPS_MEDIUM * 2 >= SPREADING_MAX_HOPS);
1058    }
1059
1060    #[test]
1061    fn test_adaptive_hops_dense_graph() {
1062        // Dense graph (fresh system): fewer hops to avoid noise
1063        let hops = calculate_adaptive_hops(Some(3.0)); // Above DENSE threshold
1064        assert_eq!(hops, BIDIRECTIONAL_HOPS_DENSE);
1065        assert_eq!(hops, 2);
1066    }
1067
1068    #[test]
1069    fn test_adaptive_hops_sparse_graph() {
1070        // Sparse graph (mature system): more hops for quality edges
1071        let hops = calculate_adaptive_hops(Some(0.3)); // Below SPARSE threshold
1072        assert_eq!(hops, BIDIRECTIONAL_HOPS_SPARSE);
1073        assert_eq!(hops, 4);
1074    }
1075
1076    #[test]
1077    fn test_adaptive_hops_medium_graph() {
1078        // Medium density: balanced exploration
1079        let hops = calculate_adaptive_hops(Some(1.0)); // Between thresholds
1080        assert_eq!(hops, BIDIRECTIONAL_HOPS_MEDIUM);
1081        assert_eq!(hops, 3);
1082    }
1083
1084    #[test]
1085    fn test_adaptive_hops_no_density() {
1086        // No density info: use medium as safe default
1087        let hops = calculate_adaptive_hops(None);
1088        assert_eq!(hops, BIDIRECTIONAL_HOPS_MEDIUM);
1089    }
1090
1091    #[test]
1092    fn test_adaptive_hops_lifecycle() {
1093        // Simulate graph lifecycle: dense → medium → sparse
1094        let fresh_hops = calculate_adaptive_hops(Some(2.5)); // Fresh system
1095        let mid_hops = calculate_adaptive_hops(Some(1.0)); // 6 months in
1096        let mature_hops = calculate_adaptive_hops(Some(0.3)); // Mature system
1097
1098        // Hops should increase as graph matures (gets sparser)
1099        assert!(fresh_hops <= mid_hops);
1100        assert!(mid_hops <= mature_hops);
1101
1102        // Verify actual values
1103        assert_eq!(fresh_hops, 2);
1104        assert_eq!(mid_hops, 3);
1105        assert_eq!(mature_hops, 4);
1106    }
1107
1108    #[test]
1109    fn test_intersection_boost_calculation() {
1110        // Test that intersection entities get boosted
1111        let forward_activation = 0.5;
1112        let backward_activation = 0.3;
1113
1114        // Both exceed minimum threshold
1115        assert!(forward_activation >= BIDIRECTIONAL_INTERSECTION_MIN);
1116        assert!(backward_activation >= BIDIRECTIONAL_INTERSECTION_MIN);
1117
1118        // Intersection boost calculation
1119        let boosted = (forward_activation + backward_activation) * BIDIRECTIONAL_INTERSECTION_BOOST;
1120        let unboosted = forward_activation + backward_activation;
1121
1122        // Boosted should be higher
1123        assert!(boosted > unboosted);
1124
1125        // Boosted should be exactly 1.5× unboosted (with default constants)
1126        let expected_ratio = BIDIRECTIONAL_INTERSECTION_BOOST;
1127        assert!((boosted / unboosted - expected_ratio).abs() < 0.001);
1128    }
1129
1130    #[test]
1131    fn test_non_intersection_no_boost() {
1132        // Test that non-intersection entities don't get boosted
1133        let forward_activation = 0.5;
1134        let backward_activation = 0.0; // Below threshold
1135
1136        // Backward doesn't meet threshold
1137        assert!(backward_activation < BIDIRECTIONAL_INTERSECTION_MIN);
1138
1139        // Non-intersection: just sum
1140        let combined = forward_activation + backward_activation;
1141
1142        // Should equal just forward activation
1143        assert!((combined - forward_activation).abs() < 0.001);
1144    }
1145
1146    #[test]
1147    fn test_bidirectional_entity_split() {
1148        // Test alternating assignment distributes evenly
1149        let entities = vec![
1150            (Uuid::new_v4(), "entity1".to_string(), 1.0, 0.5),
1151            (Uuid::new_v4(), "entity2".to_string(), 1.0, 0.5),
1152            (Uuid::new_v4(), "entity3".to_string(), 1.0, 0.5),
1153            (Uuid::new_v4(), "entity4".to_string(), 1.0, 0.5),
1154        ];
1155
1156        // With 4 entities, split should be 2-2
1157        let mut forward_count = 0;
1158        let mut backward_count = 0;
1159
1160        for (i, _) in entities.iter().enumerate() {
1161            if i % 2 == 0 {
1162                forward_count += 1;
1163            } else {
1164                backward_count += 1;
1165            }
1166        }
1167
1168        assert_eq!(forward_count, 2);
1169        assert_eq!(backward_count, 2);
1170    }
1171
1172    #[test]
1173    fn test_bidirectional_odd_entities() {
1174        // Test odd number of entities doesn't leave backward empty
1175        let entities = vec![
1176            (Uuid::new_v4(), "entity1".to_string(), 1.0, 0.5),
1177            (Uuid::new_v4(), "entity2".to_string(), 1.0, 0.5),
1178            (Uuid::new_v4(), "entity3".to_string(), 1.0, 0.5),
1179        ];
1180
1181        // With 3 entities: indices 0,2 go forward, index 1 goes backward
1182        let mut forward_seeds = Vec::new();
1183        let mut backward_seeds = Vec::new();
1184
1185        for (i, entity) in entities.iter().enumerate() {
1186            if i % 2 == 0 {
1187                forward_seeds.push(entity.0);
1188            } else {
1189                backward_seeds.push(entity.0);
1190            }
1191        }
1192
1193        // 2 forward, 1 backward
1194        assert_eq!(forward_seeds.len(), 2);
1195        assert_eq!(backward_seeds.len(), 1);
1196
1197        // Both sets are non-empty (the actual function duplicates if backward would be empty)
1198        assert!(!forward_seeds.is_empty());
1199        assert!(!backward_seeds.is_empty());
1200    }
1201
1202    #[test]
1203    fn test_bidirectional_threshold_triggers() {
1204        // Test when bidirectional is triggered vs unidirectional
1205
1206        // 1 entity: unidirectional
1207        let single_entity = vec![(Uuid::new_v4(), "entity1".to_string(), 1.0, 0.5)];
1208        assert!(single_entity.len() < BIDIRECTIONAL_MIN_ENTITIES);
1209
1210        // 2 entities: bidirectional
1211        let two_entities = vec![
1212            (Uuid::new_v4(), "entity1".to_string(), 1.0, 0.5),
1213            (Uuid::new_v4(), "entity2".to_string(), 1.0, 0.5),
1214        ];
1215        assert!(two_entities.len() >= BIDIRECTIONAL_MIN_ENTITIES);
1216
1217        // 5 entities: bidirectional
1218        let many_entities = vec![
1219            (Uuid::new_v4(), "entity1".to_string(), 1.0, 0.5),
1220            (Uuid::new_v4(), "entity2".to_string(), 1.0, 0.5),
1221            (Uuid::new_v4(), "entity3".to_string(), 1.0, 0.5),
1222            (Uuid::new_v4(), "entity4".to_string(), 1.0, 0.5),
1223            (Uuid::new_v4(), "entity5".to_string(), 1.0, 0.5),
1224        ];
1225        assert!(many_entities.len() >= BIDIRECTIONAL_MIN_ENTITIES);
1226    }
1227
1228    #[test]
1229    fn test_complexity_improvement() {
1230        // Theoretical complexity comparison
1231        // Let b = branching factor, d = depth
1232
1233        // Unidirectional: O(b^d)
1234        // Bidirectional: O(2 × b^(d/2))
1235
1236        // For b=10, d=6:
1237        // Unidirectional: 10^6 = 1,000,000
1238        // Bidirectional: 2 × 10^3 = 2,000
1239
1240        let b: f64 = 10.0;
1241        let d: f64 = 6.0;
1242
1243        let unidirectional = b.powf(d);
1244        let bidirectional = 2.0 * b.powf(d / 2.0);
1245
1246        // Bidirectional should be significantly smaller
1247        assert!(bidirectional < unidirectional);
1248
1249        // Ratio should be ~500× improvement
1250        let improvement = unidirectional / bidirectional;
1251        assert!(improvement > 100.0);
1252    }
1253
1254    #[test]
1255    fn test_intersection_detection_threshold() {
1256        // Test the minimum threshold for intersection detection
1257        let min_threshold = BIDIRECTIONAL_INTERSECTION_MIN;
1258
1259        // Should be half of activation threshold
1260        let expected = SPREADING_ACTIVATION_THRESHOLD / 2.0;
1261        assert!((min_threshold - expected).abs() < 0.001);
1262
1263        // Should be positive
1264        assert!(min_threshold > 0.0);
1265
1266        // Should be less than typical initial activation
1267        // (IC_NOUN * (1 + SALIENCE_BOOST_FACTOR) = 2.3 * 2 = 4.6 max)
1268        assert!(min_threshold < 1.0);
1269    }
1270}