vestige/tests/e2e/tests/cognitive/spreading_activation_tests.rs
Sam Valladares 8178beb961 feat(v2.0.5): Intentional Amnesia — active forgetting via top-down inhibitory control
First AI memory system to model forgetting as a neuroscience-grounded
PROCESS rather than passive decay. Adds the `suppress` MCP tool (#24),
Rac1 cascade worker, migration V10, and dashboard forgetting indicators.

Based on:
- Anderson, Hanslmayr & Quaegebeur (2025), Nat Rev Neurosci — right
  lateral PFC as the domain-general inhibitory controller; SIF
  compounds with each stopping attempt.
- Cervantes-Sandoval et al. (2020), Front Cell Neurosci PMC7477079 —
  Rac1 GTPase as the active synaptic destabilization mechanism.

What's new:
* `suppress` MCP tool — each call compounds `suppression_count` and
  subtracts a `0.15 × count` penalty (saturating at 80%) from
  retrieval scores during hybrid search. Distinct from delete
  (removes) and demote (one-shot).
* Rac1 cascade worker — background sweep piggybacks the 6h
  consolidation loop, walks `memory_connections` edges from
  recently-suppressed seeds, applies attenuated FSRS decay to
  co-activated neighbors. You don't just forget Jake — you fade
  the café, the roommate, the birthday.
* 24h labile window — reversible via `suppress({id, reverse: true})`
  within 24 hours. Matches Nader reconsolidation semantics.
* Migration V10 — additive-only (`suppression_count`, `suppressed_at`
  + partial indices). All v2.0.x DBs upgrade seamlessly on first launch.
* Dashboard: `ForgettingIndicator.svelte` pulses when suppressions
  are active. 3D graph nodes dim to 20% opacity when suppressed.
  New WebSocket events: `MemorySuppressed`, `MemoryUnsuppressed`,
  `Rac1CascadeSwept`. Heartbeat carries `suppressed_count`.
* Search pipeline: SIF penalty inserted into the accessibility stage
  so it stacks on top of passive FSRS decay.
* Tool count bumped 23 → 24. Cognitive modules 29 → 30.

Memories persist — they are INHIBITED, not erased. `memory.get(id)`
returns full content through any number of suppressions. The 24h
labile window is a grace period for regret.

Also fixes issue #31 (dashboard graph view buggy) as a companion UI
bug discovered during the v2.0.5 audit cycle:

* Root cause: node glow `SpriteMaterial` had no `map`, so
  `THREE.Sprite` rendered as a solid-coloured 1×1 plane. Additive
  blending + `UnrealBloomPass(0.8, 0.4, 0.85)` amplified the square
  edges into hard-edged glowing cubes.
* Fix: shared 128×128 radial-gradient `CanvasTexture` singleton used
  as the sprite map. Retuned bloom to `(0.55, 0.6, 0.2)`. Halved fog
  density (0.008 → 0.0035). Edges bumped from dark navy `0x4a4a7a`
  to brand violet `0x8b5cf6` with higher opacity. Added explicit
  `scene.background` and a 2000-point starfield for depth.
* 21 regression tests added in `ui-fixes.test.ts` locking every
  invariant in (shared texture singleton, depthWrite:false, scale
  ×6, bloom magic numbers via source regex, starfield presence).

Tests: 1,284 Rust (+47) + 171 Vitest (+21) = 1,455 total, 0 failed
Clippy: clean across all targets, zero warnings
Release binary: 22.6MB, `cargo build --release -p vestige-mcp` green
Versions: workspace aligned at 2.0.5 across all 6 crates/packages

Closes #31
2026-04-14 17:30:30 -05:00

878 lines
27 KiB
Rust

//! # Spreading Activation E2E Tests (Phase 7.4)
//!
//! Comprehensive tests proving spreading activation finds connections
//! that pure similarity search CANNOT find.
//!
//! Based on Collins & Loftus (1975) spreading activation theory.
use std::collections::HashSet;
use vestige_core::neuroscience::spreading_activation::{
ActivationConfig, ActivationNetwork, LinkType,
};
// ============================================================================
// MULTI-HOP ASSOCIATION TESTS (6 tests)
// ============================================================================
/// Test that spreading activation finds hidden chains that similarity search misses.
///
/// Scenario: A -> B -> C where A and C have NO direct similarity.
/// Similarity search from A would never find C, but spreading activation does.
#[test]
fn test_spreading_finds_hidden_chains() {
let mut network = ActivationNetwork::new();
// Create a chain: "rust_async" -> "tokio_runtime" -> "green_threads"
// These concepts are related through association, not direct similarity
network.add_edge(
"rust_async".to_string(),
"tokio_runtime".to_string(),
LinkType::Semantic,
0.9,
);
network.add_edge(
"tokio_runtime".to_string(),
"green_threads".to_string(),
LinkType::Semantic,
0.8,
);
// Activate from "rust_async"
let results = network.activate("rust_async", 1.0);
// Should find "green_threads" through the chain
let found_green_threads = results.iter().any(|r| r.memory_id == "green_threads");
assert!(
found_green_threads,
"Spreading activation should find 'green_threads' through the chain, \
even though it has no direct similarity to 'rust_async'"
);
// Verify the path was tracked correctly
let green_threads_result = results
.iter()
.find(|r| r.memory_id == "green_threads")
.unwrap();
assert_eq!(green_threads_result.distance, 2, "Should be 2 hops away");
}
/// Test 3-hop discovery - finding concepts 3 links away.
#[test]
fn test_spreading_3_hop_discovery() {
let config = ActivationConfig {
decay_factor: 0.8,
max_hops: 4,
min_threshold: 0.05,
allow_cycles: false,
};
let mut network = ActivationNetwork::with_config(config);
// Create a 3-hop chain: A -> B -> C -> D
network.add_edge(
"memory_a".to_string(),
"memory_b".to_string(),
LinkType::Semantic,
0.9,
);
network.add_edge(
"memory_b".to_string(),
"memory_c".to_string(),
LinkType::Semantic,
0.9,
);
network.add_edge(
"memory_c".to_string(),
"memory_d".to_string(),
LinkType::Semantic,
0.9,
);
let results = network.activate("memory_a", 1.0);
// Find memory_d at distance 3
let found_d = results.iter().find(|r| r.memory_id == "memory_d");
assert!(found_d.is_some(), "Should find memory at 3 hops");
assert_eq!(found_d.unwrap().distance, 3, "Distance should be 3 hops");
}
/// Test that spreading activation beats pure similarity search.
///
/// Creates a network where the most semantically relevant memory
/// is only reachable through association, not direct similarity.
#[test]
fn test_spreading_beats_similarity_search() {
let mut network = ActivationNetwork::new();
// Scenario: User asks about "memory leaks in Rust"
// Direct similarity might find: "rust_ownership" (similar keywords)
// But the ACTUAL solution is in "arc_weak_patterns" which is only
// reachable through: memory_leaks -> reference_counting -> arc_weak_patterns
network.add_edge(
"memory_leaks".to_string(),
"rust_ownership".to_string(),
LinkType::Semantic,
0.5, // Weak direct connection
);
network.add_edge(
"memory_leaks".to_string(),
"reference_counting".to_string(),
LinkType::Causal,
0.9,
);
network.add_edge(
"reference_counting".to_string(),
"arc_weak_patterns".to_string(),
LinkType::Semantic,
0.95,
);
let results = network.activate("memory_leaks", 1.0);
// Find both results
let _ownership_activation = results
.iter()
.find(|r| r.memory_id == "rust_ownership")
.map(|r| r.activation)
.unwrap_or(0.0);
let arc_weak_activation = results
.iter()
.find(|r| r.memory_id == "arc_weak_patterns")
.map(|r| r.activation)
.unwrap_or(0.0);
// The arc_weak_patterns should be found even though it requires 2 hops
assert!(
arc_weak_activation > 0.0,
"Should find arc_weak_patterns through spreading activation"
);
// Both should be in results - spreading activation surfaces hidden connections
let memory_ids: HashSet<_> = results.iter().map(|r| r.memory_id.as_str()).collect();
assert!(memory_ids.contains("arc_weak_patterns"));
assert!(memory_ids.contains("reference_counting"));
}
/// Test that activation paths are correctly tracked.
#[test]
fn test_spreading_path_tracking() {
let mut network = ActivationNetwork::new();
network.add_edge(
"start".to_string(),
"middle".to_string(),
LinkType::Semantic,
0.9,
);
network.add_edge(
"middle".to_string(),
"end".to_string(),
LinkType::Semantic,
0.9,
);
let results = network.activate("start", 1.0);
let end_result = results.iter().find(|r| r.memory_id == "end").unwrap();
// Path should be: start -> middle -> end
assert_eq!(end_result.path.len(), 3);
assert_eq!(end_result.path[0], "start");
assert_eq!(end_result.path[1], "middle");
assert_eq!(end_result.path[2], "end");
}
/// Test convergent activation - when multiple paths lead to the same node.
#[test]
fn test_spreading_convergent_activation() {
let mut network = ActivationNetwork::new();
// Create convergent paths: A -> B -> D and A -> C -> D
network.add_edge(
"source".to_string(),
"path1".to_string(),
LinkType::Semantic,
0.8,
);
network.add_edge(
"source".to_string(),
"path2".to_string(),
LinkType::Semantic,
0.8,
);
network.add_edge(
"path1".to_string(),
"target".to_string(),
LinkType::Semantic,
0.8,
);
network.add_edge(
"path2".to_string(),
"target".to_string(),
LinkType::Semantic,
0.8,
);
let results = network.activate("source", 1.0);
// Target should receive activation from both paths
let target_results: Vec<_> = results.iter().filter(|r| r.memory_id == "target").collect();
// Should have at least one result for target
assert!(!target_results.is_empty(), "Target should be activated");
// The activation should reflect receiving from multiple sources
// (implementation may aggregate or keep separate - test that it's found)
let total_target_activation: f64 = target_results.iter().map(|r| r.activation).sum();
assert!(
total_target_activation > 0.0,
"Target should have positive activation from convergent paths"
);
}
/// Test semantic vs temporal link types have different effects.
#[test]
fn test_spreading_semantic_vs_temporal_links() {
let mut network = ActivationNetwork::new();
// Create two parallel paths with different link types
network.add_edge(
"event".to_string(),
"semantic_related".to_string(),
LinkType::Semantic,
0.9,
);
network.add_edge(
"event".to_string(),
"temporal_related".to_string(),
LinkType::Temporal,
0.9,
);
let results = network.activate("event", 1.0);
// Both should be found
let semantic = results.iter().find(|r| r.memory_id == "semantic_related");
let temporal = results.iter().find(|r| r.memory_id == "temporal_related");
assert!(semantic.is_some(), "Should find semantically linked memory");
assert!(temporal.is_some(), "Should find temporally linked memory");
// Verify link types are preserved
assert_eq!(semantic.unwrap().link_type, LinkType::Semantic);
assert_eq!(temporal.unwrap().link_type, LinkType::Temporal);
}
// ============================================================================
// ACTIVATION DECAY TESTS (5 tests)
// ============================================================================
/// Test that activation decays with each hop.
#[test]
fn test_activation_decay_per_hop() {
let config = ActivationConfig {
decay_factor: 0.7,
max_hops: 3,
min_threshold: 0.01,
allow_cycles: false,
};
let mut network = ActivationNetwork::with_config(config);
// Chain with uniform strength
network.add_edge("a".to_string(), "b".to_string(), LinkType::Semantic, 1.0);
network.add_edge("b".to_string(), "c".to_string(), LinkType::Semantic, 1.0);
network.add_edge("c".to_string(), "d".to_string(), LinkType::Semantic, 1.0);
let results = network.activate("a", 1.0);
let b_activation = results
.iter()
.find(|r| r.memory_id == "b")
.map(|r| r.activation)
.unwrap_or(0.0);
let c_activation = results
.iter()
.find(|r| r.memory_id == "c")
.map(|r| r.activation)
.unwrap_or(0.0);
let d_activation = results
.iter()
.find(|r| r.memory_id == "d")
.map(|r| r.activation)
.unwrap_or(0.0);
// Each hop should reduce activation by decay factor (0.7)
assert!(
b_activation > c_activation,
"Activation should decay: b ({}) > c ({})",
b_activation,
c_activation
);
assert!(
c_activation > d_activation,
"Activation should decay: c ({}) > d ({})",
c_activation,
d_activation
);
// Verify approximate decay rate (allowing for floating point)
let ratio_bc = c_activation / b_activation;
assert!(
(ratio_bc - 0.7).abs() < 0.1,
"Decay ratio b->c should be ~0.7, got {}",
ratio_bc
);
}
/// Test that decay factor is configurable.
#[test]
fn test_activation_decay_factor_configurable() {
// Test with high decay (0.9 - slow decay)
let high_config = ActivationConfig {
decay_factor: 0.9,
max_hops: 3,
min_threshold: 0.01,
allow_cycles: false,
};
let mut high_network = ActivationNetwork::with_config(high_config);
high_network.add_edge("a".to_string(), "b".to_string(), LinkType::Semantic, 1.0);
high_network.add_edge("b".to_string(), "c".to_string(), LinkType::Semantic, 1.0);
// Test with low decay (0.3 - fast decay)
let low_config = ActivationConfig {
decay_factor: 0.3,
max_hops: 3,
min_threshold: 0.01,
allow_cycles: false,
};
let mut low_network = ActivationNetwork::with_config(low_config);
low_network.add_edge("a".to_string(), "b".to_string(), LinkType::Semantic, 1.0);
low_network.add_edge("b".to_string(), "c".to_string(), LinkType::Semantic, 1.0);
let high_results = high_network.activate("a", 1.0);
let low_results = low_network.activate("a", 1.0);
let high_c = high_results
.iter()
.find(|r| r.memory_id == "c")
.map(|r| r.activation)
.unwrap_or(0.0);
let low_c = low_results
.iter()
.find(|r| r.memory_id == "c")
.map(|r| r.activation)
.unwrap_or(0.0);
assert!(
high_c > low_c,
"Higher decay factor should preserve more activation: {} > {}",
high_c,
low_c
);
}
/// Test activation follows inverse distance law.
#[test]
fn test_activation_distance_law() {
let config = ActivationConfig {
decay_factor: 0.7,
max_hops: 5,
min_threshold: 0.001,
allow_cycles: false,
};
let mut network = ActivationNetwork::with_config(config);
// Create a longer chain
network.add_edge("n0".to_string(), "n1".to_string(), LinkType::Semantic, 1.0);
network.add_edge("n1".to_string(), "n2".to_string(), LinkType::Semantic, 1.0);
network.add_edge("n2".to_string(), "n3".to_string(), LinkType::Semantic, 1.0);
network.add_edge("n3".to_string(), "n4".to_string(), LinkType::Semantic, 1.0);
let results = network.activate("n0", 1.0);
// Collect activations by distance
let mut activations_by_distance: Vec<(u32, f64)> =
results.iter().map(|r| (r.distance, r.activation)).collect();
activations_by_distance.sort_by_key(|(d, _)| *d);
// Verify monotonic decrease with distance
for i in 1..activations_by_distance.len() {
let (prev_dist, prev_act) = activations_by_distance[i - 1];
let (curr_dist, curr_act) = activations_by_distance[i];
if prev_dist < curr_dist {
assert!(
prev_act >= curr_act,
"Activation should decrease with distance: d{} ({}) >= d{} ({})",
prev_dist,
prev_act,
curr_dist,
curr_act
);
}
}
}
/// Test minimum activation threshold stops propagation.
#[test]
fn test_activation_minimum_threshold() {
let config = ActivationConfig {
decay_factor: 0.5,
max_hops: 10,
min_threshold: 0.2, // High threshold
allow_cycles: false,
};
let mut network = ActivationNetwork::with_config(config);
// Create a long chain
network.add_edge("a".to_string(), "b".to_string(), LinkType::Semantic, 1.0);
network.add_edge("b".to_string(), "c".to_string(), LinkType::Semantic, 1.0);
network.add_edge("c".to_string(), "d".to_string(), LinkType::Semantic, 1.0);
network.add_edge("d".to_string(), "e".to_string(), LinkType::Semantic, 1.0);
network.add_edge("e".to_string(), "f".to_string(), LinkType::Semantic, 1.0);
let results = network.activate("a", 1.0);
// With 0.5 decay and 0.2 threshold:
// b: 1.0 * 0.5 = 0.5 (above threshold)
// c: 0.5 * 0.5 = 0.25 (above threshold)
// d: 0.25 * 0.5 = 0.125 (below threshold - should not propagate)
// So d might be found but e and f should NOT be found
let found_e = results.iter().any(|r| r.memory_id == "e");
let found_f = results.iter().any(|r| r.memory_id == "f");
assert!(
!found_e && !found_f,
"Nodes beyond threshold should not be found. Found e: {}, f: {}",
found_e,
found_f
);
}
/// Test maximum hops limit is enforced.
#[test]
fn test_activation_max_hops_limit() {
let config = ActivationConfig {
decay_factor: 0.99, // Almost no decay
max_hops: 2, // But strict hop limit
min_threshold: 0.01,
allow_cycles: false,
};
let mut network = ActivationNetwork::with_config(config);
// Create a chain of 5 nodes
network.add_edge("a".to_string(), "b".to_string(), LinkType::Semantic, 1.0);
network.add_edge("b".to_string(), "c".to_string(), LinkType::Semantic, 1.0);
network.add_edge("c".to_string(), "d".to_string(), LinkType::Semantic, 1.0);
network.add_edge("d".to_string(), "e".to_string(), LinkType::Semantic, 1.0);
let results = network.activate("a", 1.0);
// Should find b (1 hop) and c (2 hops) but NOT d or e
let found_b = results.iter().any(|r| r.memory_id == "b");
let found_c = results.iter().any(|r| r.memory_id == "c");
let found_d = results.iter().any(|r| r.memory_id == "d");
let found_e = results.iter().any(|r| r.memory_id == "e");
assert!(found_b, "Should find b at 1 hop");
assert!(found_c, "Should find c at 2 hops");
assert!(!found_d, "Should NOT find d at 3 hops (exceeds max_hops=2)");
assert!(!found_e, "Should NOT find e at 4 hops");
}
// ============================================================================
// EDGE REINFORCEMENT TESTS (5 tests)
// ============================================================================
/// Test Hebbian reinforcement - "neurons that fire together wire together".
#[test]
fn test_hebbian_reinforcement() {
let mut network = ActivationNetwork::new();
// Initial weak connection
network.add_edge(
"concept_a".to_string(),
"concept_b".to_string(),
LinkType::Semantic,
0.3,
);
// Get initial strength
let initial_associations = network.get_associations("concept_a");
let initial_strength = initial_associations
.iter()
.find(|a| a.memory_id == "concept_b")
.map(|a| a.association_strength)
.unwrap_or(0.0);
// Reinforce the connection (simulating co-activation)
network.reinforce_edge("concept_a", "concept_b", 0.2);
// Get reinforced strength
let reinforced_associations = network.get_associations("concept_a");
let reinforced_strength = reinforced_associations
.iter()
.find(|a| a.memory_id == "concept_b")
.map(|a| a.association_strength)
.unwrap_or(0.0);
assert!(
reinforced_strength > initial_strength,
"Reinforcement should increase edge strength: {} > {}",
reinforced_strength,
initial_strength
);
}
/// Test that edge strength increases with repeated use.
#[test]
fn test_edge_strength_increases_with_use() {
let mut network = ActivationNetwork::new();
network.add_edge(
"frequently_used".to_string(),
"target".to_string(),
LinkType::Semantic,
0.2,
);
let mut strengths = vec![];
// Record initial strength
let assoc = network.get_associations("frequently_used");
strengths.push(assoc[0].association_strength);
// Reinforce multiple times
for _ in 0..5 {
network.reinforce_edge("frequently_used", "target", 0.1);
let assoc = network.get_associations("frequently_used");
strengths.push(assoc[0].association_strength);
}
// Verify monotonic increase (until capped at 1.0)
for i in 1..strengths.len() {
assert!(
strengths[i] >= strengths[i - 1],
"Strength should increase with use: {} >= {}",
strengths[i],
strengths[i - 1]
);
}
// Final strength should be significantly higher than initial
assert!(
strengths.last().unwrap() > &0.5,
"After multiple reinforcements, strength should be high"
);
}
/// Test that traversal count is tracked on edges.
#[test]
fn test_traversal_count_tracking() {
let mut network = ActivationNetwork::new();
network.add_edge(
"source".to_string(),
"target".to_string(),
LinkType::Semantic,
0.8,
);
// Reinforce multiple times (each reinforcement increments activation_count)
for _ in 0..3 {
network.reinforce_edge("source", "target", 0.05);
}
// The edge should have been reinforced 3 times
// Note: We verify this through the association strength increasing
let associations = network.get_associations("source");
let final_strength = associations
.iter()
.find(|a| a.memory_id == "target")
.map(|a| a.association_strength)
.unwrap_or(0.0);
// Should be 0.8 + 3*0.05 = 0.95
assert!(
(final_strength - 0.95).abs() < 0.01,
"Strength should reflect 3 reinforcements: expected 0.95, got {}",
final_strength
);
}
/// Test that different link types can have different weights.
#[test]
fn test_link_type_weights() {
let mut network = ActivationNetwork::new();
// Create edges with different link types and strengths
network.add_edge(
"event".to_string(),
"semantic_link".to_string(),
LinkType::Semantic,
0.9,
);
network.add_edge(
"event".to_string(),
"temporal_link".to_string(),
LinkType::Temporal,
0.5,
);
network.add_edge(
"event".to_string(),
"causal_link".to_string(),
LinkType::Causal,
0.7,
);
let results = network.activate("event", 1.0);
// Verify different activations based on edge strength
let semantic_act = results
.iter()
.find(|r| r.memory_id == "semantic_link")
.map(|r| r.activation)
.unwrap_or(0.0);
let temporal_act = results
.iter()
.find(|r| r.memory_id == "temporal_link")
.map(|r| r.activation)
.unwrap_or(0.0);
let causal_act = results
.iter()
.find(|r| r.memory_id == "causal_link")
.map(|r| r.activation)
.unwrap_or(0.0);
// Semantic (0.9) > Causal (0.7) > Temporal (0.5)
assert!(
semantic_act > causal_act && causal_act > temporal_act,
"Activation should reflect edge strengths: semantic ({}) > causal ({}) > temporal ({})",
semantic_act,
causal_act,
temporal_act
);
}
/// Test edge decay without use (edges weaken over time if not reinforced).
#[test]
fn test_edge_decay_without_use() {
let mut network = ActivationNetwork::new();
network.add_edge(
"forgotten".to_string(),
"target".to_string(),
LinkType::Semantic,
0.8,
);
// Get initial associations
let initial = network.get_associations("forgotten");
let initial_strength = initial[0].association_strength;
// Note: The current implementation doesn't have automatic time-based decay
// But we can test the apply_decay method through edge manipulation
// For now, we verify the initial state is correct
assert!(
(initial_strength - 0.8).abs() < 0.01,
"Initial strength should be 0.8"
);
// Test that edges can be retrieved and have correct properties
assert_eq!(initial.len(), 1);
assert_eq!(initial[0].memory_id, "target");
assert_eq!(initial[0].link_type, LinkType::Semantic);
}
// ============================================================================
// NETWORK BUILDING TESTS (4 tests)
// ============================================================================
/// Test network builds from semantic similarity.
#[test]
fn test_network_builds_from_semantic_similarity() {
let mut network = ActivationNetwork::new();
// Build a network representing semantic relationships in code
// These would typically be built from embedding similarity
// Rust async ecosystem
network.add_edge(
"async_rust".to_string(),
"tokio".to_string(),
LinkType::Semantic,
0.9,
);
network.add_edge(
"async_rust".to_string(),
"async_await".to_string(),
LinkType::Semantic,
0.95,
);
network.add_edge(
"tokio".to_string(),
"runtime".to_string(),
LinkType::Semantic,
0.8,
);
network.add_edge(
"tokio".to_string(),
"spawn".to_string(),
LinkType::Semantic,
0.85,
);
assert_eq!(network.node_count(), 5);
assert_eq!(network.edge_count(), 4);
// Verify associations are retrievable
let async_associations = network.get_associations("async_rust");
assert_eq!(async_associations.len(), 2);
// Highest association should be async_await (0.95)
assert_eq!(async_associations[0].memory_id, "async_await");
}
/// Test network builds from temporal proximity.
#[test]
fn test_network_builds_from_temporal_proximity() {
let mut network = ActivationNetwork::new();
// Build a network from temporal co-occurrence
// Events that happened close in time
// Morning standup sequence
network.add_edge(
"standup".to_string(),
"jira_update".to_string(),
LinkType::Temporal,
0.9,
);
network.add_edge(
"jira_update".to_string(),
"code_review".to_string(),
LinkType::Temporal,
0.85,
);
network.add_edge(
"code_review".to_string(),
"merge_pr".to_string(),
LinkType::Temporal,
0.8,
);
// Verify temporal chain
let results = network.activate("standup", 1.0);
// Should find the whole workflow sequence
let found_merge = results.iter().any(|r| r.memory_id == "merge_pr");
assert!(found_merge, "Should find temporally linked merge_pr");
// Verify link types are temporal
for result in &results {
assert_eq!(
result.link_type,
LinkType::Temporal,
"All links should be temporal"
);
}
}
/// Test that semantic and temporal link types are differentiated.
#[test]
fn test_network_link_types_differentiated() {
let mut network = ActivationNetwork::new();
// Same nodes, different link types
network.add_edge(
"feature_a".to_string(),
"feature_b".to_string(),
LinkType::Semantic,
0.7,
);
network.add_edge(
"feature_a".to_string(),
"feature_c".to_string(),
LinkType::Temporal,
0.7,
);
network.add_edge(
"feature_a".to_string(),
"feature_d".to_string(),
LinkType::Causal,
0.7,
);
network.add_edge(
"feature_a".to_string(),
"feature_e".to_string(),
LinkType::PartOf,
0.7,
);
let associations = network.get_associations("feature_a");
// Collect link types
let link_types: HashSet<LinkType> = associations.iter().map(|a| a.link_type).collect();
assert!(link_types.contains(&LinkType::Semantic));
assert!(link_types.contains(&LinkType::Temporal));
assert!(link_types.contains(&LinkType::Causal));
assert!(link_types.contains(&LinkType::PartOf));
assert_eq!(link_types.len(), 4, "Should have 4 different link types");
}
/// Test batch construction of network.
#[test]
fn test_network_batch_construction() {
let mut network = ActivationNetwork::new();
// Simulate batch construction from a knowledge graph
let edges = vec![
("rust", "cargo", LinkType::Semantic, 0.9),
("rust", "ownership", LinkType::Semantic, 0.95),
("rust", "traits", LinkType::Semantic, 0.9),
("cargo", "dependencies", LinkType::Semantic, 0.85),
("cargo", "build", LinkType::PartOf, 0.8),
("ownership", "borrowing", LinkType::Semantic, 0.9),
("ownership", "lifetimes", LinkType::Semantic, 0.85),
("traits", "generics", LinkType::Semantic, 0.8),
("traits", "impl", LinkType::PartOf, 0.9),
];
for (source, target, link_type, strength) in edges {
network.add_edge(source.to_string(), target.to_string(), link_type, strength);
}
// Verify network structure
assert_eq!(network.node_count(), 10, "Should have 10 unique nodes");
assert_eq!(network.edge_count(), 9, "Should have 9 edges");
// Test spreading from rust
let results = network.activate("rust", 1.0);
// Should reach multiple concepts
let reached_nodes: HashSet<_> = results.iter().map(|r| r.memory_id.as_str()).collect();
assert!(reached_nodes.contains("cargo"));
assert!(reached_nodes.contains("ownership"));
assert!(reached_nodes.contains("traits"));
assert!(reached_nodes.contains("borrowing")); // 2 hops: rust -> ownership -> borrowing
// Count nodes at each distance
let distance_1: Vec<_> = results.iter().filter(|r| r.distance == 1).collect();
let distance_2: Vec<_> = results.iter().filter(|r| r.distance == 2).collect();
assert_eq!(
distance_1.len(),
3,
"Should have 3 nodes at distance 1 (cargo, ownership, traits)"
);
assert!(
distance_2.len() >= 4,
"Should have at least 4 nodes at distance 2"
);
}