use anyhow::Result;
use std::collections::HashMap;
use std::time::Instant;
use uuid::Uuid;
use crate::constants::{
BIDIRECTIONAL_DENSITY_DENSE, BIDIRECTIONAL_DENSITY_SPARSE, BIDIRECTIONAL_HOPS_DENSE,
BIDIRECTIONAL_HOPS_MEDIUM, BIDIRECTIONAL_HOPS_SPARSE, BIDIRECTIONAL_INTERSECTION_BOOST,
BIDIRECTIONAL_INTERSECTION_MIN, BIDIRECTIONAL_MIN_ENTITIES, DENSITY_GRAPH_WEIGHT_MAX,
DENSITY_GRAPH_WEIGHT_MIN, DENSITY_LINGUISTIC_WEIGHT, DENSITY_THRESHOLD_MAX,
DENSITY_THRESHOLD_MIN, EDGE_TIER_TRUST_L1, EDGE_TIER_TRUST_L2, EDGE_TIER_TRUST_L3,
EDGE_TIER_TRUST_LTP, HYBRID_GRAPH_WEIGHT, HYBRID_LINGUISTIC_WEIGHT, HYBRID_SEMANTIC_WEIGHT,
IMPORTANCE_DECAY_MAX, IMPORTANCE_DECAY_MIN, MEMORY_TIER_GRAPH_MULT_ARCHIVE,
MEMORY_TIER_GRAPH_MULT_LONGTERM, MEMORY_TIER_GRAPH_MULT_SESSION,
MEMORY_TIER_GRAPH_MULT_WORKING, ONTOLOGICAL_DENSITY_THRESHOLD, ONTOLOGICAL_ENTITY_PENALTY,
ONTOLOGICAL_MIN_CONFIDENCE, ONTOLOGICAL_RELATION_PENALTY, SALIENCE_BOOST_FACTOR,
SPREADING_ACTIVATION_THRESHOLD, SPREADING_DEGREE_NORMALIZATION,
SPREADING_EARLY_TERMINATION_CANDIDATES, SPREADING_EARLY_TERMINATION_RATIO, SPREADING_MAX_HOPS,
SPREADING_MIN_CANDIDATES, SPREADING_MIN_HOPS, SPREADING_NORMALIZATION_FACTOR,
SPREADING_RELAXED_THRESHOLD,
};
use crate::embeddings::Embedder;
use crate::graph_memory::{EdgeTier, EntityLabel, EpisodicNode, GraphMemory, RelationType};
use crate::memory::query_parser::{infer_ontological_intent, OntologicalIntent};
use crate::memory::types::MemoryTier;
use crate::memory::query_parser::{analyze_query, QueryAnalysis};
use crate::memory::types::{Memory, Query, RetrievalStats, SharedMemory};
use crate::similarity::cosine_similarity;
#[derive(Debug, Clone)]
pub struct ActivatedMemory {
pub memory: SharedMemory,
#[allow(dead_code)] pub activation_score: f32,
#[allow(dead_code)] pub semantic_score: f32,
#[allow(dead_code)] pub linguistic_score: f32,
pub final_score: f32,
}
pub fn calculate_density_weights(graph_density: f32) -> (f32, f32, f32) {
let graph_weight = if graph_density <= DENSITY_THRESHOLD_MIN {
DENSITY_GRAPH_WEIGHT_MAX } else if graph_density >= DENSITY_THRESHOLD_MAX {
DENSITY_GRAPH_WEIGHT_MIN } else {
let ratio = (graph_density - DENSITY_THRESHOLD_MIN)
/ (DENSITY_THRESHOLD_MAX - DENSITY_THRESHOLD_MIN);
DENSITY_GRAPH_WEIGHT_MAX - ratio * (DENSITY_GRAPH_WEIGHT_MAX - DENSITY_GRAPH_WEIGHT_MIN)
};
let linguistic_weight = DENSITY_LINGUISTIC_WEIGHT;
let semantic_weight = 1.0 - graph_weight - linguistic_weight;
(semantic_weight, graph_weight, linguistic_weight)
}
pub fn calculate_importance_weighted_decay(importance: f32) -> f32 {
let clamped_importance = importance.clamp(0.0, 1.0);
IMPORTANCE_DECAY_MIN
+ (1.0 - clamped_importance) * (IMPORTANCE_DECAY_MAX - IMPORTANCE_DECAY_MIN)
}
pub fn calculate_adaptive_hops(graph_density: Option<f32>) -> usize {
match graph_density {
Some(density) if density > BIDIRECTIONAL_DENSITY_DENSE => {
BIDIRECTIONAL_HOPS_DENSE
}
Some(density) if density < BIDIRECTIONAL_DENSITY_SPARSE => {
BIDIRECTIONAL_HOPS_SPARSE
}
Some(_) => {
BIDIRECTIONAL_HOPS_MEDIUM
}
None => {
BIDIRECTIONAL_HOPS_MEDIUM
}
}
}
fn spread_single_direction(
seeds: &[(Uuid, f32)],
graph: &GraphMemory,
max_hops: usize,
threshold: f32,
ontological_intent: Option<&OntologicalIntent>,
entity_label_cache: &mut HashMap<Uuid, Option<Vec<EntityLabel>>>,
) -> Result<(HashMap<Uuid, f32>, Vec<Uuid>)> {
let mut activation_map: HashMap<Uuid, f32> = seeds.iter().cloned().collect();
let mut traversed_edges: Vec<Uuid> = Vec::new();
for hop in 1..=max_hops {
let current_activated: Vec<(Uuid, f32)> =
activation_map.iter().map(|(id, act)| (*id, *act)).collect();
for (entity_uuid, source_activation) in current_activated {
if source_activation < threshold {
continue;
}
const MAX_EDGES_PER_SPREAD: usize = 100;
let edges =
graph.get_entity_relationships_limited(&entity_uuid, Some(MAX_EDGES_PER_SPREAD))?;
let degree_norm = if SPREADING_DEGREE_NORMALIZATION {
1.0 / (1.0 + edges.len() as f32).sqrt()
} else {
1.0
};
for edge in edges {
let target_uuid = edge.to_entity;
let tier_trust = if edge.is_potentiated() {
EDGE_TIER_TRUST_LTP
} else {
match edge.tier {
EdgeTier::L3Semantic => EDGE_TIER_TRUST_L3,
EdgeTier::L2Episodic => EDGE_TIER_TRUST_L2,
EdgeTier::L1Working => EDGE_TIER_TRUST_L1,
}
};
let effective = edge.effective_strength();
let decay_rate = calculate_importance_weighted_decay(effective);
let decay = (-decay_rate * hop as f32).exp();
let base_spread = source_activation * decay * effective * tier_trust * degree_norm;
let spread_amount = if let Some(intent) = ontological_intent {
let mut penalty = 1.0_f32;
if !intent.relation_types.is_empty()
&& !matches!(
edge.relation_type,
RelationType::CoOccurs
| RelationType::RelatedTo
| RelationType::CoRetrieved
)
&& !intent.relation_types.contains(&edge.relation_type)
{
penalty *= ONTOLOGICAL_RELATION_PENALTY;
}
if !intent.expected_labels.is_empty() {
let cached_labels =
entity_label_cache.entry(target_uuid).or_insert_with(|| {
graph
.get_entity(&target_uuid)
.ok()
.flatten()
.map(|e| e.labels)
});
if let Some(labels) = cached_labels {
let type_match = labels.iter().any(|l| {
intent
.expected_labels
.iter()
.any(|exp| l.matches_with_hierarchy(exp))
});
if !type_match {
penalty *= ONTOLOGICAL_ENTITY_PENALTY;
}
}
}
base_spread * penalty
} else {
base_spread
};
let new_activation = activation_map.entry(target_uuid).or_insert(0.0);
*new_activation += spread_amount;
if spread_amount > 0.01 {
traversed_edges.push(edge.uuid);
}
}
}
let max_activation = activation_map
.values()
.cloned()
.max_by(|a, b| a.total_cmp(b))
.unwrap_or(1.0);
if max_activation > SPREADING_NORMALIZATION_FACTOR {
let scale = SPREADING_NORMALIZATION_FACTOR / max_activation;
for activation in activation_map.values_mut() {
*activation *= scale;
}
}
activation_map.retain(|_, activation| *activation > threshold);
}
Ok((activation_map, traversed_edges))
}
fn bidirectional_spread(
entity_data: &[(Uuid, String, f32, f32)], graph: &GraphMemory,
total_salience: f32,
hops_per_direction: usize,
ontological_intent: Option<&OntologicalIntent>,
) -> Result<(HashMap<Uuid, f32>, Vec<Uuid>, usize)> {
let mut forward_seeds: Vec<(Uuid, f32)> = Vec::new();
let mut backward_seeds: Vec<(Uuid, f32)> = Vec::new();
for (i, (uuid, _name, ic_weight, salience)) in entity_data.iter().enumerate() {
let normalized_salience = salience / total_salience;
let salience_boost = SALIENCE_BOOST_FACTOR * normalized_salience;
let initial_activation = ic_weight * (1.0 + salience_boost);
if i % 2 == 0 {
forward_seeds.push((*uuid, initial_activation));
} else {
backward_seeds.push((*uuid, initial_activation));
}
}
if backward_seeds.is_empty() && !forward_seeds.is_empty() {
backward_seeds.push(forward_seeds[forward_seeds.len() - 1]);
}
tracing::debug!(
"🔀 Bidirectional spread: {} forward seeds, {} backward seeds",
forward_seeds.len(),
backward_seeds.len()
);
let mut entity_label_cache: HashMap<Uuid, Option<Vec<EntityLabel>>> = HashMap::new();
let threshold = SPREADING_ACTIVATION_THRESHOLD;
let (forward_map, forward_edges) = spread_single_direction(
&forward_seeds,
graph,
hops_per_direction,
threshold,
ontological_intent,
&mut entity_label_cache,
)?;
let (backward_map, backward_edges) = spread_single_direction(
&backward_seeds,
graph,
hops_per_direction,
threshold,
ontological_intent,
&mut entity_label_cache,
)?;
let mut combined_map: HashMap<Uuid, f32> = HashMap::new();
let mut intersection_count = 0;
let all_entities: std::collections::HashSet<Uuid> = forward_map
.keys()
.chain(backward_map.keys())
.cloned()
.collect();
for entity_uuid in all_entities {
let forward_activation = forward_map.get(&entity_uuid).cloned().unwrap_or(0.0);
let backward_activation = backward_map.get(&entity_uuid).cloned().unwrap_or(0.0);
let is_intersection = forward_activation >= BIDIRECTIONAL_INTERSECTION_MIN
&& backward_activation >= BIDIRECTIONAL_INTERSECTION_MIN;
let combined_activation = if is_intersection {
intersection_count += 1;
(forward_activation + backward_activation) * BIDIRECTIONAL_INTERSECTION_BOOST
} else {
forward_activation + backward_activation
};
combined_map.insert(entity_uuid, combined_activation);
}
let mut all_edges = forward_edges;
all_edges.extend(backward_edges);
tracing::debug!(
"🔀 Bidirectional result: {} entities ({} intersections), {} edges",
combined_map.len(),
intersection_count,
all_edges.len()
);
Ok((combined_map, all_edges, intersection_count))
}
pub fn spreading_activation_retrieve(
query_text: &str,
query: &Query,
graph: &GraphMemory,
embedder: &dyn Embedder,
episode_to_memory_fn: impl Fn(&EpisodicNode) -> Result<Option<SharedMemory>>,
) -> Result<Vec<ActivatedMemory>> {
let (memories, _stats) = spreading_activation_retrieve_with_stats(
query_text,
query,
graph,
embedder,
None, None, episode_to_memory_fn,
)?;
Ok(memories)
}
pub fn spreading_activation_retrieve_with_stats(
query_text: &str,
query: &Query,
graph: &GraphMemory,
embedder: &dyn Embedder,
graph_density: Option<f32>,
ontological_intent: Option<&OntologicalIntent>,
episode_to_memory_fn: impl Fn(&EpisodicNode) -> Result<Option<SharedMemory>>,
) -> Result<(Vec<ActivatedMemory>, RetrievalStats)> {
let start_time = Instant::now();
let mut stats = RetrievalStats::default();
let (semantic_weight, graph_weight, linguistic_weight) = if let Some(density) = graph_density {
stats.mode = "associative".to_string();
stats.graph_density = density;
calculate_density_weights(density)
} else {
stats.mode = "hybrid".to_string();
stats.graph_density = 0.0;
(
HYBRID_SEMANTIC_WEIGHT,
HYBRID_GRAPH_WEIGHT,
HYBRID_LINGUISTIC_WEIGHT,
)
};
stats.semantic_weight = semantic_weight;
stats.graph_weight = graph_weight;
stats.linguistic_weight = linguistic_weight;
let analysis = analyze_query(query_text);
let computed_intent;
let ontological_intent_resolved = match ontological_intent {
Some(intent) => intent,
None => {
computed_intent = infer_ontological_intent(query_text, &analysis);
&computed_intent
}
};
let use_ontology = ontological_intent_resolved.confidence >= ONTOLOGICAL_MIN_CONFIDENCE
&& !graph_density.is_some_and(|d| d >= ONTOLOGICAL_DENSITY_THRESHOLD);
let intent_ref = if use_ontology {
Some(ontological_intent_resolved)
} else {
None
};
if use_ontology {
tracing::info!(
"Ontological intent: labels={:?}, relations={:?}, confidence={:.2}",
ontological_intent_resolved.expected_labels,
ontological_intent_resolved
.relation_types
.iter()
.map(|r| r.as_str())
.collect::<Vec<_>>(),
ontological_intent_resolved.confidence
);
}
tracing::info!("🔍 Query Analysis:");
tracing::info!(
" Focal Entities: {:?}",
analysis
.focal_entities
.iter()
.map(|e| &e.text)
.collect::<Vec<_>>()
);
tracing::info!(
" Modifiers: {:?}",
analysis
.discriminative_modifiers
.iter()
.map(|m| &m.text)
.collect::<Vec<_>>()
);
tracing::info!(
" Relations: {:?}",
analysis
.relational_context
.iter()
.map(|r| &r.text)
.collect::<Vec<_>>()
);
tracing::info!(
" Weights: semantic={:.2}, graph={:.2}, linguistic={:.2}",
semantic_weight,
graph_weight,
linguistic_weight
);
let mut activation_map: HashMap<Uuid, f32> = HashMap::new();
let mut entity_data: Vec<(Uuid, String, f32, f32)> = Vec::new();
for entity in &analysis.focal_entities {
if let Some(entity_node) = graph.find_entity_by_name(&entity.text)? {
entity_data.push((
entity_node.uuid,
entity.text.clone(),
entity.ic_weight,
entity_node.salience,
));
} else {
tracing::debug!(" ✗ Entity '{}' not found in graph", entity.text);
}
}
let total_salience: f32 = entity_data.iter().map(|(_, _, _, s)| s).sum();
let total_salience = total_salience.max(0.1);
let mut total_boost = 0.0_f32;
for (uuid, name, ic_weight, salience) in &entity_data {
let normalized_salience = salience / total_salience;
let salience_boost = SALIENCE_BOOST_FACTOR * normalized_salience;
let initial_activation = ic_weight * (1.0 + salience_boost);
activation_map.insert(*uuid, initial_activation);
stats.entities_activated += 1;
total_boost += salience_boost;
tracing::debug!(
" ✓ Activated '{}' (IC={:.2}, salience={:.2}, norm={:.2}, boost={:.2}, activation={:.2})",
name,
ic_weight,
salience,
normalized_salience,
salience_boost,
initial_activation
);
}
stats.avg_salience_boost = if !entity_data.is_empty() {
total_boost / entity_data.len() as f32
} else {
0.0
};
if activation_map.is_empty() {
tracing::warn!("No entities found in graph, falling back to semantic search");
stats.retrieval_time_us = start_time.elapsed().as_micros() as u64;
return Ok((Vec::new(), stats)); }
let graph_start = Instant::now();
let mut traversed_edges: Vec<Uuid>;
if entity_data.len() >= BIDIRECTIONAL_MIN_ENTITIES {
let adaptive_hops = calculate_adaptive_hops(graph_density);
tracing::info!(
"🔀 Using bidirectional spreading ({} focal entities, {} hops/direction, density={:.2})",
entity_data.len(),
adaptive_hops,
graph_density.unwrap_or(0.0)
);
let (bidirectional_map, edges, intersection_count) = bidirectional_spread(
&entity_data,
graph,
total_salience,
adaptive_hops,
intent_ref,
)?;
activation_map = bidirectional_map;
traversed_edges = edges;
stats.entities_activated = activation_map.len();
stats.graph_hops = adaptive_hops * 2;
tracing::info!(
"🔀 Bidirectional complete: {} entities, {} intersections",
activation_map.len(),
intersection_count
);
} else {
tracing::info!(
"📊 Using unidirectional spreading ({} focal entity)",
entity_data.len()
);
let mut edges_collected: Vec<Uuid> = Vec::new();
let mut current_threshold = SPREADING_ACTIVATION_THRESHOLD;
let mut entity_label_cache: HashMap<Uuid, Option<Vec<EntityLabel>>> = HashMap::new();
for hop in 1..=SPREADING_MAX_HOPS {
stats.graph_hops = hop;
let count_before = activation_map.len();
tracing::debug!(
"📊 Spreading activation (hop {}/{}, threshold={:.4})",
hop,
SPREADING_MAX_HOPS,
current_threshold
);
let current_activated: Vec<(Uuid, f32)> =
activation_map.iter().map(|(id, act)| (*id, *act)).collect();
for (entity_uuid, source_activation) in current_activated {
if source_activation < current_threshold {
continue;
}
const MAX_EDGES_PER_SPREAD: usize = 100;
let edges = graph
.get_entity_relationships_limited(&entity_uuid, Some(MAX_EDGES_PER_SPREAD))?;
let degree_norm = if SPREADING_DEGREE_NORMALIZATION {
1.0 / (1.0 + edges.len() as f32).sqrt()
} else {
1.0
};
for edge in edges {
let target_uuid = edge.to_entity;
let tier_trust = if edge.is_potentiated() {
EDGE_TIER_TRUST_LTP
} else {
match edge.tier {
EdgeTier::L3Semantic => EDGE_TIER_TRUST_L3,
EdgeTier::L2Episodic => EDGE_TIER_TRUST_L2,
EdgeTier::L1Working => EDGE_TIER_TRUST_L1,
}
};
let effective = edge.effective_strength();
let decay_rate = calculate_importance_weighted_decay(effective);
let decay = (-decay_rate * hop as f32).exp();
let base_spread =
source_activation * decay * effective * tier_trust * degree_norm;
let spread_amount = if let Some(intent) = intent_ref {
let mut penalty = 1.0_f32;
if !intent.relation_types.is_empty()
&& !matches!(
edge.relation_type,
RelationType::CoOccurs
| RelationType::RelatedTo
| RelationType::CoRetrieved
)
&& !intent.relation_types.contains(&edge.relation_type)
{
penalty *= ONTOLOGICAL_RELATION_PENALTY;
}
if !intent.expected_labels.is_empty() {
let cached_labels =
entity_label_cache.entry(target_uuid).or_insert_with(|| {
graph
.get_entity(&target_uuid)
.ok()
.flatten()
.map(|e| e.labels)
});
if let Some(labels) = cached_labels {
let type_match = labels.iter().any(|l| {
intent
.expected_labels
.iter()
.any(|exp| l.matches_with_hierarchy(exp))
});
if !type_match {
penalty *= ONTOLOGICAL_ENTITY_PENALTY;
}
}
}
base_spread * penalty
} else {
base_spread
};
let new_activation = activation_map.entry(target_uuid).or_insert(0.0);
*new_activation += spread_amount;
if spread_amount > 0.01 {
edges_collected.push(edge.uuid);
}
if *new_activation >= current_threshold
&& *new_activation - spread_amount < current_threshold
{
stats.entities_activated += 1;
}
}
}
let max_activation = activation_map
.values()
.cloned()
.max_by(|a, b| a.total_cmp(b))
.unwrap_or(1.0);
if max_activation > SPREADING_NORMALIZATION_FACTOR {
let scale = SPREADING_NORMALIZATION_FACTOR / max_activation;
for activation in activation_map.values_mut() {
*activation *= scale;
}
}
activation_map.retain(|_, activation| *activation > current_threshold);
let count_after = activation_map.len();
let new_activations = count_after.saturating_sub(count_before);
tracing::debug!(
" Activated entities: {} (+{} new)",
count_after,
new_activations
);
if count_after < SPREADING_MIN_CANDIDATES
&& current_threshold > SPREADING_RELAXED_THRESHOLD
{
current_threshold = SPREADING_RELAXED_THRESHOLD;
tracing::debug!(
" Relaxing threshold to {:.4} (only {} candidates)",
current_threshold,
count_after
);
}
if hop >= SPREADING_MIN_HOPS {
let new_ratio = if count_after > 0 {
new_activations as f32 / count_after as f32
} else {
0.0
};
if new_ratio < SPREADING_EARLY_TERMINATION_RATIO && count_after > 0 {
tracing::debug!(
" Early termination: activation saturated ({:.1}% new)",
new_ratio * 100.0
);
break;
}
if count_after >= SPREADING_EARLY_TERMINATION_CANDIDATES {
tracing::debug!(
" Early termination: sufficient coverage ({} candidates)",
count_after
);
break;
}
}
}
traversed_edges = edges_collected;
}
stats.graph_time_us = graph_start.elapsed().as_micros() as u64;
tracing::info!("📊 Final activated entities: {}", activation_map.len());
let mut activated_memories: HashMap<Uuid, (f32, EpisodicNode)> = HashMap::new();
for (entity_uuid, entity_activation) in &activation_map {
let episodes = graph.get_episodes_by_entity(entity_uuid)?;
for episode in episodes {
let current = activated_memories
.entry(episode.uuid)
.or_insert((0.0, episode.clone()));
current.0 += entity_activation;
}
}
stats.graph_candidates = activated_memories.len();
tracing::info!(
"📊 Retrieved {} episodic memories via graph",
activated_memories.len()
);
let mut scored_memories = Vec::new();
let embedding_start = Instant::now();
let query_embedding = embedder.encode(query_text)?;
stats.embedding_time_us = embedding_start.elapsed().as_micros() as u64;
let now = chrono::Utc::now();
for (_episode_uuid, (graph_activation, episode)) in activated_memories {
if let Some(memory) = episode_to_memory_fn(&episode)? {
let semantic_score = if let Some(mem_emb) = &memory.experience.embeddings {
cosine_similarity(&query_embedding, mem_emb)
} else {
0.0
};
let linguistic_raw = calculate_linguistic_match(&memory, &analysis);
let linguistic_score = linguistic_raw;
let tier_graph_mult = match memory.tier {
MemoryTier::Working => MEMORY_TIER_GRAPH_MULT_WORKING, MemoryTier::Session => MEMORY_TIER_GRAPH_MULT_SESSION, MemoryTier::LongTerm => MEMORY_TIER_GRAPH_MULT_LONGTERM, MemoryTier::Archive => MEMORY_TIER_GRAPH_MULT_ARCHIVE, };
let tier_adjusted_graph_weight = graph_weight * tier_graph_mult;
let weight_sum = semantic_weight + tier_adjusted_graph_weight + linguistic_weight;
let norm_semantic = semantic_weight / weight_sum;
let norm_graph = tier_adjusted_graph_weight / weight_sum;
let norm_linguistic = linguistic_weight / weight_sum;
let hybrid_score = semantic_score * norm_semantic
+ graph_activation * norm_graph
+ linguistic_score * norm_linguistic;
const RECENCY_DECAY_RATE: f32 = 0.01;
let hours_old = (now - memory.created_at).num_hours().max(0) as f32;
let recency_boost = (-RECENCY_DECAY_RATE * hours_old).exp() * 0.1;
let arousal_boost = memory
.experience
.context
.as_ref()
.map(|c| c.emotional.arousal * 0.05)
.unwrap_or(0.0);
let credibility_boost = memory
.experience
.context
.as_ref()
.map(|c| (c.source.credibility - 0.5).max(0.0) * 0.1)
.unwrap_or(0.0);
let final_score = hybrid_score + recency_boost + arousal_boost + credibility_boost;
scored_memories.push(ActivatedMemory {
memory,
activation_score: graph_activation,
semantic_score,
linguistic_score,
final_score,
});
}
}
scored_memories.sort_by(|a, b| b.final_score.total_cmp(&a.final_score));
if scored_memories.len() > 1 {
let penalties = calculate_lateral_inhibition(&scored_memories);
for (i, penalty) in penalties.iter().enumerate() {
scored_memories[i].final_score -= penalty;
}
scored_memories.sort_by(|a, b| b.final_score.total_cmp(&a.final_score));
}
scored_memories.truncate(query.max_results);
stats.retrieval_time_us = start_time.elapsed().as_micros() as u64;
traversed_edges.sort();
traversed_edges.dedup();
stats.traversed_edges = traversed_edges;
if !stats.traversed_edges.is_empty() {
if let Err(e) = graph.batch_strengthen_synapses(&stats.traversed_edges) {
tracing::debug!("Spreading activation edge strengthening failed: {}", e);
}
}
tracing::info!(
"🎯 Returning {} memories (top scores: {:?}), {} edges traversed",
scored_memories.len(),
scored_memories
.iter()
.take(3)
.map(|m| m.final_score)
.collect::<Vec<_>>(),
stats.traversed_edges.len()
);
Ok((scored_memories, stats))
}
fn calculate_linguistic_match(memory: &Memory, analysis: &QueryAnalysis) -> f32 {
let content_lower = memory.experience.content.to_lowercase();
let mut score = 0.0;
for entity in &analysis.focal_entities {
if content_lower.contains(&entity.text.to_lowercase()) {
score += 1.0;
}
}
for modifier in &analysis.discriminative_modifiers {
if content_lower.contains(&modifier.text.to_lowercase()) {
score += 0.5;
}
}
for relation in &analysis.relational_context {
if content_lower.contains(&relation.text.to_lowercase()) {
score += 0.2;
}
}
let max_possible = analysis.focal_entities.len() as f32 * 1.0
+ analysis.discriminative_modifiers.len() as f32 * 0.5
+ analysis.relational_context.len() as f32 * 0.2;
if max_possible > 0.0 {
score / max_possible
} else {
0.0
}
}
fn calculate_lateral_inhibition(scored: &[ActivatedMemory]) -> Vec<f32> {
use crate::constants::{GRAPH_LATERAL_INHIBITION_STRENGTH, GRAPH_LATERAL_INHIBITION_THRESHOLD};
let mut penalties = vec![0.0f32; scored.len()];
for i in 1..scored.len() {
let emb_i = match &scored[i].memory.experience.embeddings {
Some(e) => e,
None => continue,
};
let mut total_penalty = 0.0f32;
for j in 0..i {
let emb_j = match &scored[j].memory.experience.embeddings {
Some(e) => e,
None => continue,
};
let sim = cosine_similarity(emb_i, emb_j);
if sim > GRAPH_LATERAL_INHIBITION_THRESHOLD {
total_penalty += scored[j].final_score * GRAPH_LATERAL_INHIBITION_STRENGTH * sim;
}
}
penalties[i] = total_penalty.min(scored[i].final_score * 0.5);
}
penalties
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cosine_similarity() {
let a = vec![1.0, 0.0, 0.0];
let b = vec![1.0, 0.0, 0.0];
assert_eq!(cosine_similarity(&a, &b), 1.0);
let a = vec![1.0, 0.0];
let b = vec![0.0, 1.0];
assert_eq!(cosine_similarity(&a, &b), 0.0);
let a = vec![1.0, 1.0];
let b = vec![1.0, 1.0];
assert!((cosine_similarity(&a, &b) - 1.0).abs() < 0.001);
}
#[test]
fn test_density_weights_sparse() {
let (semantic, graph, linguistic) = calculate_density_weights(0.3);
assert!((graph - DENSITY_GRAPH_WEIGHT_MAX).abs() < 0.001);
assert!((linguistic - DENSITY_LINGUISTIC_WEIGHT).abs() < 0.001);
assert!((semantic + graph + linguistic - 1.0).abs() < 0.001);
}
#[test]
fn test_density_weights_dense() {
let (semantic, graph, linguistic) = calculate_density_weights(2.5);
assert!((graph - DENSITY_GRAPH_WEIGHT_MIN).abs() < 0.001);
assert!((linguistic - DENSITY_LINGUISTIC_WEIGHT).abs() < 0.001);
assert!((semantic + graph + linguistic - 1.0).abs() < 0.001);
}
#[test]
fn test_density_weights_interpolation() {
let (semantic, graph, linguistic) = calculate_density_weights(1.25);
assert!(graph > DENSITY_GRAPH_WEIGHT_MIN);
assert!(graph < DENSITY_GRAPH_WEIGHT_MAX);
assert!((linguistic - DENSITY_LINGUISTIC_WEIGHT).abs() < 0.001);
assert!((semantic + graph + linguistic - 1.0).abs() < 0.001);
}
#[test]
fn test_importance_weighted_decay_high() {
let decay = calculate_importance_weighted_decay(1.0);
assert!((decay - IMPORTANCE_DECAY_MIN).abs() < 0.001);
}
#[test]
fn test_importance_weighted_decay_low() {
let decay = calculate_importance_weighted_decay(0.0);
assert!((decay - IMPORTANCE_DECAY_MAX).abs() < 0.001);
}
#[test]
fn test_importance_weighted_decay_mid() {
let decay = calculate_importance_weighted_decay(0.5);
let expected = IMPORTANCE_DECAY_MIN + 0.5 * (IMPORTANCE_DECAY_MAX - IMPORTANCE_DECAY_MIN);
assert!((decay - expected).abs() < 0.001);
}
#[test]
fn test_activation_decay() {
let initial_activation = 1.0;
let high_importance_decay = calculate_importance_weighted_decay(0.9);
let high_importance_final = initial_activation * (-high_importance_decay).exp();
let low_importance_decay = calculate_importance_weighted_decay(0.1);
let low_importance_final = initial_activation * (-low_importance_decay).exp();
assert!(high_importance_final > low_importance_final);
}
#[test]
fn test_adaptive_constants_valid() {
use crate::constants::*;
assert!(SPREADING_RELAXED_THRESHOLD < SPREADING_ACTIVATION_THRESHOLD);
assert!(SPREADING_MIN_HOPS <= SPREADING_MAX_HOPS);
assert!(SPREADING_EARLY_TERMINATION_RATIO > 0.0);
assert!(SPREADING_EARLY_TERMINATION_RATIO < 1.0);
assert!(SPREADING_NORMALIZATION_FACTOR > 0.0);
assert!(SPREADING_MIN_CANDIDATES > 0);
assert!(SPREADING_MIN_CANDIDATES < SPREADING_EARLY_TERMINATION_CANDIDATES);
}
#[test]
fn test_normalization_prevents_explosion() {
use crate::constants::SPREADING_NORMALIZATION_FACTOR;
let mut activations: Vec<f32> = vec![1.0, 0.8, 0.5, 0.3];
for _ in 0..5 {
for activation in &mut activations {
*activation += *activation * 0.5; }
let max_activation = activations
.iter()
.cloned()
.max_by(|a, b| a.total_cmp(b))
.unwrap_or(1.0);
if max_activation > SPREADING_NORMALIZATION_FACTOR {
let scale = SPREADING_NORMALIZATION_FACTOR / max_activation;
for activation in &mut activations {
*activation *= scale;
}
}
}
let final_max = activations
.iter()
.cloned()
.max_by(|a, b| a.total_cmp(b))
.unwrap_or(0.0);
assert!(final_max <= SPREADING_NORMALIZATION_FACTOR + 0.001);
}
#[test]
fn test_early_termination_ratio() {
use crate::constants::SPREADING_EARLY_TERMINATION_RATIO;
let total_before = 50;
let total_after = 52;
let new_activations = total_after - total_before;
let new_ratio = new_activations as f32 / total_after as f32;
assert!(new_ratio < SPREADING_EARLY_TERMINATION_RATIO);
let growing_before = 10;
let growing_after = 25;
let growing_new = growing_after - growing_before;
let growing_ratio = growing_new as f32 / growing_after as f32;
assert!(growing_ratio >= SPREADING_EARLY_TERMINATION_RATIO);
}
#[test]
fn test_bidirectional_constants_valid() {
assert!(BIDIRECTIONAL_MIN_ENTITIES >= 2);
assert!(BIDIRECTIONAL_INTERSECTION_BOOST > 1.0);
assert!(BIDIRECTIONAL_INTERSECTION_MIN < SPREADING_ACTIVATION_THRESHOLD);
assert!(BIDIRECTIONAL_DENSITY_SPARSE < BIDIRECTIONAL_DENSITY_DENSE);
assert!(BIDIRECTIONAL_HOPS_DENSE < BIDIRECTIONAL_HOPS_MEDIUM);
assert!(BIDIRECTIONAL_HOPS_MEDIUM < BIDIRECTIONAL_HOPS_SPARSE);
assert!(BIDIRECTIONAL_HOPS_MEDIUM * 2 >= SPREADING_MAX_HOPS);
}
#[test]
fn test_adaptive_hops_dense_graph() {
let hops = calculate_adaptive_hops(Some(3.0)); assert_eq!(hops, BIDIRECTIONAL_HOPS_DENSE);
assert_eq!(hops, 2);
}
#[test]
fn test_adaptive_hops_sparse_graph() {
let hops = calculate_adaptive_hops(Some(0.3)); assert_eq!(hops, BIDIRECTIONAL_HOPS_SPARSE);
assert_eq!(hops, 4);
}
#[test]
fn test_adaptive_hops_medium_graph() {
let hops = calculate_adaptive_hops(Some(1.0)); assert_eq!(hops, BIDIRECTIONAL_HOPS_MEDIUM);
assert_eq!(hops, 3);
}
#[test]
fn test_adaptive_hops_no_density() {
let hops = calculate_adaptive_hops(None);
assert_eq!(hops, BIDIRECTIONAL_HOPS_MEDIUM);
}
#[test]
fn test_adaptive_hops_lifecycle() {
let fresh_hops = calculate_adaptive_hops(Some(2.5)); let mid_hops = calculate_adaptive_hops(Some(1.0)); let mature_hops = calculate_adaptive_hops(Some(0.3));
assert!(fresh_hops <= mid_hops);
assert!(mid_hops <= mature_hops);
assert_eq!(fresh_hops, 2);
assert_eq!(mid_hops, 3);
assert_eq!(mature_hops, 4);
}
#[test]
fn test_intersection_boost_calculation() {
let forward_activation = 0.5;
let backward_activation = 0.3;
assert!(forward_activation >= BIDIRECTIONAL_INTERSECTION_MIN);
assert!(backward_activation >= BIDIRECTIONAL_INTERSECTION_MIN);
let boosted = (forward_activation + backward_activation) * BIDIRECTIONAL_INTERSECTION_BOOST;
let unboosted = forward_activation + backward_activation;
assert!(boosted > unboosted);
let expected_ratio = BIDIRECTIONAL_INTERSECTION_BOOST;
assert!((boosted / unboosted - expected_ratio).abs() < 0.001);
}
#[test]
fn test_non_intersection_no_boost() {
let forward_activation = 0.5;
let backward_activation = 0.0;
assert!(backward_activation < BIDIRECTIONAL_INTERSECTION_MIN);
let combined = forward_activation + backward_activation;
assert!((combined - forward_activation).abs() < 0.001);
}
#[test]
fn test_bidirectional_entity_split() {
let entities = vec![
(Uuid::new_v4(), "entity1".to_string(), 1.0, 0.5),
(Uuid::new_v4(), "entity2".to_string(), 1.0, 0.5),
(Uuid::new_v4(), "entity3".to_string(), 1.0, 0.5),
(Uuid::new_v4(), "entity4".to_string(), 1.0, 0.5),
];
let mut forward_count = 0;
let mut backward_count = 0;
for (i, _) in entities.iter().enumerate() {
if i % 2 == 0 {
forward_count += 1;
} else {
backward_count += 1;
}
}
assert_eq!(forward_count, 2);
assert_eq!(backward_count, 2);
}
#[test]
fn test_bidirectional_odd_entities() {
let entities = vec![
(Uuid::new_v4(), "entity1".to_string(), 1.0, 0.5),
(Uuid::new_v4(), "entity2".to_string(), 1.0, 0.5),
(Uuid::new_v4(), "entity3".to_string(), 1.0, 0.5),
];
let mut forward_seeds = Vec::new();
let mut backward_seeds = Vec::new();
for (i, entity) in entities.iter().enumerate() {
if i % 2 == 0 {
forward_seeds.push(entity.0);
} else {
backward_seeds.push(entity.0);
}
}
assert_eq!(forward_seeds.len(), 2);
assert_eq!(backward_seeds.len(), 1);
assert!(!forward_seeds.is_empty());
assert!(!backward_seeds.is_empty());
}
#[test]
fn test_bidirectional_threshold_triggers() {
let single_entity = vec![(Uuid::new_v4(), "entity1".to_string(), 1.0, 0.5)];
assert!(single_entity.len() < BIDIRECTIONAL_MIN_ENTITIES);
let two_entities = vec![
(Uuid::new_v4(), "entity1".to_string(), 1.0, 0.5),
(Uuid::new_v4(), "entity2".to_string(), 1.0, 0.5),
];
assert!(two_entities.len() >= BIDIRECTIONAL_MIN_ENTITIES);
let many_entities = vec![
(Uuid::new_v4(), "entity1".to_string(), 1.0, 0.5),
(Uuid::new_v4(), "entity2".to_string(), 1.0, 0.5),
(Uuid::new_v4(), "entity3".to_string(), 1.0, 0.5),
(Uuid::new_v4(), "entity4".to_string(), 1.0, 0.5),
(Uuid::new_v4(), "entity5".to_string(), 1.0, 0.5),
];
assert!(many_entities.len() >= BIDIRECTIONAL_MIN_ENTITIES);
}
#[test]
fn test_complexity_improvement() {
let b: f64 = 10.0;
let d: f64 = 6.0;
let unidirectional = b.powf(d);
let bidirectional = 2.0 * b.powf(d / 2.0);
assert!(bidirectional < unidirectional);
let improvement = unidirectional / bidirectional;
assert!(improvement > 100.0);
}
#[test]
fn test_intersection_detection_threshold() {
let min_threshold = BIDIRECTIONAL_INTERSECTION_MIN;
let expected = SPREADING_ACTIVATION_THRESHOLD / 2.0;
assert!((min_threshold - expected).abs() < 0.001);
assert!(min_threshold > 0.0);
assert!(min_threshold < 1.0);
}
}