vestige/crates/vestige-core/src/consolidation/phases.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

1256 lines
44 KiB
Rust

//! 4-Phase Biologically-Accurate Dream Cycle
//!
//! Implements a neuroscience-grounded sleep cycle based on:
//! - **NREM1 (Light Sleep / Triage)**: Score & categorize memories, build replay queue
//! - **NREM3 (Deep Sleep / Consolidation)**: SO-spindle-ripple coupling, FSRS decay, synaptic downscaling
//! - **REM (Dreaming / Creative)**: Cross-domain pairing, pattern extraction, emotional processing
//! - **Integration (Pre-Wake)**: Validate insights, store new nodes, generate report
//!
//! References:
//! - Diekelmann & Born (2010): Active system consolidation during NREM
//! - Stickgold & Walker (2013): REM creativity and abstraction
//! - Tononi & Cirelli (2006): Synaptic homeostasis (downscaling)
//! - Frey & Morris (1997): Synaptic tag-and-capture
use std::collections::{HashMap, HashSet};
use std::time::Instant;
use chrono::{DateTime, Utc};
use crate::memory::KnowledgeNode;
use crate::neuroscience::emotional_memory::{EmotionCategory, EmotionalMemory};
use crate::neuroscience::importance_signals::ImportanceSignals;
use crate::neuroscience::synaptic_tagging::SynapticTaggingSystem;
// ============================================================================
// PHASE RESULTS
// ============================================================================
/// Which dream phase
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DreamPhase {
/// Light sleep — triage and scoring
Nrem1,
/// Deep sleep — consolidation and replay
Nrem3,
/// REM sleep — creative connections and emotional processing
Rem,
/// Pre-wake — validate and integrate
Integration,
}
impl DreamPhase {
pub fn as_str(&self) -> &'static str {
match self {
DreamPhase::Nrem1 => "NREM1_Triage",
DreamPhase::Nrem3 => "NREM3_Consolidation",
DreamPhase::Rem => "REM_Creative",
DreamPhase::Integration => "Integration",
}
}
}
impl std::fmt::Display for DreamPhase {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
/// Result from a single dream phase
#[derive(Debug, Clone)]
pub struct PhaseResult {
pub phase: DreamPhase,
pub duration_ms: u64,
pub memories_processed: usize,
pub actions: Vec<String>,
}
/// Memory categorized during NREM1 triage
#[derive(Debug, Clone)]
pub struct TriagedMemory {
pub id: String,
pub content: String,
pub importance: f64,
pub category: TriageCategory,
pub tags: Vec<String>,
pub created_at: DateTime<Utc>,
pub retention_strength: f64,
pub emotional_valence: f64,
pub is_flashbulb: bool,
}
/// Categories assigned during NREM1 triage
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TriageCategory {
/// High emotional content (bug fixes, breakthroughs, frustrations)
Emotional,
/// Future-relevant (intentions, plans, TODOs)
FutureRelevant,
/// User-promoted or high-reward memories
Rewarded,
/// High prediction error / novel content
Novel,
/// Standard memory, no special category
Standard,
}
/// A creative connection discovered during REM
#[derive(Debug, Clone)]
pub struct CreativeConnection {
pub memory_a_id: String,
pub memory_b_id: String,
pub insight: String,
pub confidence: f64,
pub connection_type: CreativeConnectionType,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CreativeConnectionType {
/// Memories from different domains share an abstract pattern
CrossDomain,
/// Memories together suggest a causal relationship
Causal,
/// Memories complement each other (fill knowledge gaps)
Complementary,
/// Memories contradict — needs resolution
Contradictory,
}
/// A validated insight from the Integration phase
#[derive(Debug, Clone)]
pub struct DreamInsight {
pub insight: String,
pub source_memory_ids: Vec<String>,
pub confidence: f64,
pub novelty: f64,
pub insight_type: String,
}
/// Complete result from the 4-phase dream cycle
#[derive(Debug, Clone)]
pub struct FourPhaseDreamResult {
pub phases: Vec<PhaseResult>,
pub total_duration_ms: u64,
pub memories_replayed: usize,
pub insights: Vec<DreamInsight>,
pub creative_connections: Vec<CreativeConnection>,
pub memories_strengthened: usize,
pub memories_downscaled: usize,
pub emotional_processed: usize,
pub replay_queue_size: usize,
}
// ============================================================================
// 4-PHASE DREAM ENGINE
// ============================================================================
/// Orchestrates the 4-phase biologically-accurate dream cycle
pub struct DreamEngine {
/// NREM1: 70% high-value, 30% random noise floor
high_value_ratio: f64,
/// NREM3: batch size for oscillation waves
wave_batch_size: usize,
/// NREM3: synaptic downscaling factor for unreplayed low-importance memories
downscale_factor: f64,
/// REM: minimum confidence for cross-domain insights
min_insight_confidence: f64,
/// Integration: minimum confidence to keep an insight
validation_threshold: f64,
}
impl Default for DreamEngine {
fn default() -> Self {
Self {
high_value_ratio: 0.7,
wave_batch_size: 15,
downscale_factor: 0.95,
min_insight_confidence: 0.3,
validation_threshold: 0.4,
}
}
}
impl DreamEngine {
pub fn new() -> Self {
Self::default()
}
/// Run the complete 4-phase dream cycle
pub fn run(
&self,
memories: &[KnowledgeNode],
emotional_memory: &mut EmotionalMemory,
importance_signals: &ImportanceSignals,
synaptic_tagging: &mut SynapticTaggingSystem,
) -> FourPhaseDreamResult {
let total_start = Instant::now();
let mut phases = Vec::with_capacity(4);
// ==================== PHASE 1: NREM1 (Triage) ====================
let (triaged, replay_queue, phase1) =
self.phase_nrem1(memories, emotional_memory, importance_signals);
phases.push(phase1);
// ==================== PHASE 2: NREM3 (Consolidation) ====================
let (strengthened_ids, downscaled_count, phase2) =
self.phase_nrem3(&replay_queue, &triaged, synaptic_tagging);
phases.push(phase2);
// ==================== PHASE 3: REM (Creative) ====================
let (connections, emotional_processed, phase3) = self.phase_rem(&triaged, emotional_memory);
phases.push(phase3);
// ==================== PHASE 4: Integration ====================
let (insights, phase4) = self.phase_integration(&connections, &triaged);
phases.push(phase4);
FourPhaseDreamResult {
total_duration_ms: total_start.elapsed().as_millis() as u64,
memories_replayed: replay_queue.len(),
replay_queue_size: replay_queue.len(),
insights,
creative_connections: connections,
memories_strengthened: strengthened_ids.len(),
memories_downscaled: downscaled_count,
emotional_processed,
phases,
}
}
// ========================================================================
// PHASE 1: NREM1 — Light Sleep / Triage
// ========================================================================
//
// Score all memories with importance signals, categorize them, and build
// the replay queue with 70% high-value + 30% random noise floor.
fn phase_nrem1(
&self,
memories: &[KnowledgeNode],
emotional_memory: &mut EmotionalMemory,
importance_signals: &ImportanceSignals,
) -> (Vec<TriagedMemory>, Vec<String>, PhaseResult) {
let start = Instant::now();
let mut triaged = Vec::with_capacity(memories.len());
let mut actions = Vec::new();
for node in memories {
// Score importance using 4-channel model
let ctx = crate::neuroscience::importance_signals::Context::current();
let score = importance_signals.compute_importance(&node.content, &ctx);
let importance = score.composite;
// Evaluate emotional content
let emotional = emotional_memory.evaluate_content(&node.content);
// Categorize
let category = self.categorize_memory(node, importance, &emotional.category);
triaged.push(TriagedMemory {
id: node.id.clone(),
content: node.content.clone(),
importance,
category,
tags: node.tags.clone(),
created_at: node.created_at,
retention_strength: node.retention_strength,
emotional_valence: emotional.valence,
is_flashbulb: emotional.is_flashbulb,
});
}
// Sort by importance (highest first)
triaged.sort_by(|a, b| {
b.importance
.partial_cmp(&a.importance)
.unwrap_or(std::cmp::Ordering::Equal)
});
// Build replay queue: 70% high-value, 30% random noise floor
let high_value_count = (triaged.len() as f64 * self.high_value_ratio).ceil() as usize;
let random_count = triaged.len().saturating_sub(high_value_count);
let mut replay_queue: Vec<String> = triaged
.iter()
.take(high_value_count)
.map(|m| m.id.clone())
.collect();
// Add random noise floor from the remaining memories
if random_count > 0 {
let remaining: Vec<&TriagedMemory> = triaged.iter().skip(high_value_count).collect();
// Simple deterministic shuffle using content hash
let mut noise: Vec<&TriagedMemory> = remaining;
noise.sort_by_key(|m| {
let hash: u64 =
m.id.bytes()
.fold(0u64, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u64));
hash
});
for m in noise.iter().take(random_count) {
replay_queue.push(m.id.clone());
}
}
// Count categories
let mut cat_counts: HashMap<&str, usize> = HashMap::new();
for t in &triaged {
let label = match t.category {
TriageCategory::Emotional => "emotional",
TriageCategory::FutureRelevant => "future_relevant",
TriageCategory::Rewarded => "rewarded",
TriageCategory::Novel => "novel",
TriageCategory::Standard => "standard",
};
*cat_counts.entry(label).or_insert(0) += 1;
}
actions.push(format!("Scored {} memories", triaged.len()));
actions.push(format!("Categories: {:?}", cat_counts));
actions.push(format!(
"Replay queue: {} high-value + {} noise = {} total",
high_value_count.min(triaged.len()),
replay_queue
.len()
.saturating_sub(high_value_count.min(triaged.len())),
replay_queue.len()
));
let flashbulb_count = triaged.iter().filter(|m| m.is_flashbulb).count();
if flashbulb_count > 0 {
actions.push(format!("Flashbulb memories detected: {}", flashbulb_count));
}
let phase = PhaseResult {
phase: DreamPhase::Nrem1,
duration_ms: start.elapsed().as_millis() as u64,
memories_processed: triaged.len(),
actions,
};
(triaged, replay_queue, phase)
}
fn categorize_memory(
&self,
node: &KnowledgeNode,
importance: f64,
emotion: &EmotionCategory,
) -> TriageCategory {
// High emotional content
if matches!(
emotion,
EmotionCategory::Frustration
| EmotionCategory::Urgency
| EmotionCategory::Joy
| EmotionCategory::Surprise
) && node.sentiment_magnitude > 0.4
{
return TriageCategory::Emotional;
}
// Future-relevant (intentions, TODOs)
let content_lower = node.content.to_lowercase();
if content_lower.contains("todo")
|| content_lower.contains("remind")
|| content_lower.contains("intention")
|| content_lower.contains("next time")
|| content_lower.contains("plan to")
{
return TriageCategory::FutureRelevant;
}
// Rewarded (promoted or high utility)
if node.utility_score.unwrap_or(0.0) > 0.5 || node.reps >= 5 {
return TriageCategory::Rewarded;
}
// Novel (high importance score)
if importance > 0.6 {
return TriageCategory::Novel;
}
TriageCategory::Standard
}
// ========================================================================
// PHASE 2: NREM3 — Deep Sleep / Consolidation
// ========================================================================
//
// Process in oscillation-like waves (batches of 10-20):
// - SO phase: Select cluster from replay queue
// - Spindle phase: Strengthen connections via synaptic tagging
// - Ripple phase: Replay sequences, find causal links
// - Synaptic downscaling for unreplayed low-importance memories
fn phase_nrem3(
&self,
replay_queue: &[String],
triaged: &[TriagedMemory],
synaptic_tagging: &mut SynapticTaggingSystem,
) -> (Vec<String>, usize, PhaseResult) {
let start = Instant::now();
let mut actions = Vec::new();
let mut strengthened_ids = Vec::new();
let replay_set: HashSet<&String> = replay_queue.iter().collect();
// Process replay queue in oscillation waves
let wave_count = replay_queue.len().div_ceil(self.wave_batch_size);
for wave_idx in 0..wave_count {
let wave_start = wave_idx * self.wave_batch_size;
let wave_end = (wave_start + self.wave_batch_size).min(replay_queue.len());
let wave = &replay_queue[wave_start..wave_end];
// SO phase: The wave IS the selected cluster
// Spindle phase: Tag memories for consolidation via synaptic tagging
for id in wave {
// Tag this memory in the synaptic tagging system
synaptic_tagging.tag_memory(id);
strengthened_ids.push(id.clone());
}
// Ripple phase: Find sequential pairs within the wave for causal linking
// (Adjacent memories in replay order represent temporal associations)
}
actions.push(format!(
"Processed {} waves of {} memories",
wave_count,
replay_queue.len()
));
actions.push(format!(
"Strengthened {} memories via synaptic tagging",
strengthened_ids.len()
));
// Synaptic downscaling: reduce retention on unreplayed low-importance memories
let mut downscaled_count = 0;
for tm in triaged {
if !replay_set.contains(&tm.id) && tm.importance < 0.4 {
// This memory wasn't replayed and has low importance
// In the actual DB update, we'd multiply retrieval_strength by downscale_factor
downscaled_count += 1;
}
}
if downscaled_count > 0 {
actions.push(format!(
"Synaptic downscaling: {} unreplayed low-importance memories marked for {}x decay",
downscaled_count, self.downscale_factor
));
}
let phase = PhaseResult {
phase: DreamPhase::Nrem3,
duration_ms: start.elapsed().as_millis() as u64,
memories_processed: replay_queue.len(),
actions,
};
(strengthened_ids, downscaled_count, phase)
}
// ========================================================================
// PHASE 3: REM — Creative Connections & Emotional Processing
// ========================================================================
//
// - Cross-domain pairing: match memories from different tags/categories
// - Extract abstract patterns
// - Reduce emotional intensity of error memories (extract lesson)
// - Generate creative hypotheses
fn phase_rem(
&self,
triaged: &[TriagedMemory],
emotional_memory: &mut EmotionalMemory,
) -> (Vec<CreativeConnection>, usize, PhaseResult) {
let start = Instant::now();
let mut connections = Vec::new();
let mut actions = Vec::new();
let mut emotional_processed = 0;
// Group memories by primary tag for cross-domain pairing
let mut tag_groups: HashMap<String, Vec<&TriagedMemory>> = HashMap::new();
for tm in triaged {
let primary_tag = tm
.tags
.first()
.cloned()
.unwrap_or_else(|| "untagged".to_string());
tag_groups.entry(primary_tag).or_default().push(tm);
}
let tag_keys: Vec<String> = tag_groups.keys().cloned().collect();
// Cross-domain pairing: compare memories between different tag groups
for i in 0..tag_keys.len() {
for j in (i + 1)..tag_keys.len() {
let group_a = &tag_groups[&tag_keys[i]];
let group_b = &tag_groups[&tag_keys[j]];
// Sample pairs (max 5 per group pair to keep bounded)
let max_pairs = 5;
let mut pair_count = 0;
for mem_a in group_a.iter().take(3) {
for mem_b in group_b.iter().take(3) {
if pair_count >= max_pairs {
break;
}
// Check for shared words (simple content similarity)
let similarity = self.content_similarity(&mem_a.content, &mem_b.content);
if similarity > self.min_insight_confidence {
let conn_type = self.classify_connection(mem_a, mem_b, similarity);
let insight = self.generate_connection_insight(
mem_a,
mem_b,
&tag_keys[i],
&tag_keys[j],
conn_type,
);
connections.push(CreativeConnection {
memory_a_id: mem_a.id.clone(),
memory_b_id: mem_b.id.clone(),
insight,
confidence: similarity,
connection_type: conn_type,
});
pair_count += 1;
}
}
}
}
}
actions.push(format!(
"Cross-domain pairing: {} tag groups, {} connections found",
tag_keys.len(),
connections.len()
));
// Emotional processing: reduce intensity of error/frustration memories
for tm in triaged {
if tm.category == TriageCategory::Emotional && tm.emotional_valence < -0.3 {
// Process negative emotional memories — extract the lesson, reduce raw emotion
// In practice: the insight extraction above captures the lesson,
// and we record the emotional processing for the engine
emotional_memory.record_encoding(&tm.id, tm.emotional_valence * 0.7, 0.3);
emotional_processed += 1;
}
}
if emotional_processed > 0 {
actions.push(format!(
"Emotional processing: {} negative memories had intensity reduced",
emotional_processed
));
}
// Pattern extraction: find repeated patterns across memories
let pattern_count = self.extract_patterns(triaged, &mut connections);
if pattern_count > 0 {
actions.push(format!(
"Pattern extraction: {} shared patterns found",
pattern_count
));
}
let phase = PhaseResult {
phase: DreamPhase::Rem,
duration_ms: start.elapsed().as_millis() as u64,
memories_processed: triaged.len(),
actions,
};
(connections, emotional_processed, phase)
}
fn content_similarity(&self, a: &str, b: &str) -> f64 {
let words_a: HashSet<&str> = a
.split_whitespace()
.map(|w| w.trim_matches(|c: char| !c.is_alphanumeric()))
.filter(|w| w.len() > 3)
.collect();
let words_b: HashSet<&str> = b
.split_whitespace()
.map(|w| w.trim_matches(|c: char| !c.is_alphanumeric()))
.filter(|w| w.len() > 3)
.collect();
if words_a.is_empty() || words_b.is_empty() {
return 0.0;
}
let intersection = words_a.intersection(&words_b).count() as f64;
let union = words_a.union(&words_b).count() as f64;
intersection / union // Jaccard similarity
}
fn classify_connection(
&self,
a: &TriagedMemory,
b: &TriagedMemory,
similarity: f64,
) -> CreativeConnectionType {
// Check for contradiction (opposing sentiments about similar content)
if (a.emotional_valence - b.emotional_valence).abs() > 1.0 && similarity > 0.4 {
return CreativeConnectionType::Contradictory;
}
// Check for causal (temporal ordering + one references the other's topic)
if a.created_at < b.created_at && similarity > 0.3 {
let time_gap = (b.created_at - a.created_at).num_hours();
if time_gap < 24 {
return CreativeConnectionType::Causal;
}
}
// Cross-domain if different primary tags
if a.tags.first() != b.tags.first() {
return CreativeConnectionType::CrossDomain;
}
CreativeConnectionType::Complementary
}
fn generate_connection_insight(
&self,
a: &TriagedMemory,
b: &TriagedMemory,
tag_a: &str,
tag_b: &str,
conn_type: CreativeConnectionType,
) -> String {
let a_summary = if a.content.len() > 60 {
&a.content[..60]
} else {
&a.content
};
let b_summary = if b.content.len() > 60 {
&b.content[..60]
} else {
&b.content
};
match conn_type {
CreativeConnectionType::CrossDomain => {
format!(
"Cross-domain pattern between [{}] and [{}]: '{}...' connects to '{}...'",
tag_a, tag_b, a_summary, b_summary
)
}
CreativeConnectionType::Causal => {
format!(
"Possible causal link: '{}...' may have led to '{}...'",
a_summary, b_summary
)
}
CreativeConnectionType::Complementary => {
format!(
"Complementary knowledge: '{}...' and '{}...' fill gaps in each other",
a_summary, b_summary
)
}
CreativeConnectionType::Contradictory => {
format!(
"Contradiction detected: '{}...' vs '{}...' — may need resolution",
a_summary, b_summary
)
}
}
}
fn extract_patterns(
&self,
triaged: &[TriagedMemory],
connections: &mut Vec<CreativeConnection>,
) -> usize {
// Find memories that share common n-word sequences (patterns)
let mut bigram_index: HashMap<(String, String), Vec<usize>> = HashMap::new();
for (idx, tm) in triaged.iter().enumerate() {
let words: Vec<String> = tm
.content
.split_whitespace()
.map(|w| w.to_lowercase())
.filter(|w| w.len() > 3)
.collect();
for window in words.windows(2) {
let key = (window[0].clone(), window[1].clone());
bigram_index.entry(key).or_default().push(idx);
}
}
// Find bigrams shared by 3+ memories (indicates a pattern)
let mut pattern_count = 0;
for (bigram, indices) in &bigram_index {
if indices.len() >= 3 && indices.len() <= 10 {
pattern_count += 1;
// Create a connection between the first and last memory sharing this pattern
if let (Some(&first), Some(&last)) = (indices.first(), indices.last())
&& first != last
{
connections.push(CreativeConnection {
memory_a_id: triaged[first].id.clone(),
memory_b_id: triaged[last].id.clone(),
insight: format!(
"Shared pattern '{} {}' found across {} memories",
bigram.0,
bigram.1,
indices.len()
),
confidence: (indices.len() as f64 / triaged.len() as f64).min(1.0),
connection_type: CreativeConnectionType::CrossDomain,
});
}
}
}
pattern_count
}
// ========================================================================
// PHASE 4: Integration — Pre-Wake
// ========================================================================
//
// - Validate REM insights against memory graph
// - Filter low-confidence connections
// - Generate dream report
fn phase_integration(
&self,
connections: &[CreativeConnection],
triaged: &[TriagedMemory],
) -> (Vec<DreamInsight>, PhaseResult) {
let start = Instant::now();
let mut insights = Vec::new();
let mut actions = Vec::new();
// Validate connections: keep only those above threshold
let valid_connections: Vec<&CreativeConnection> = connections
.iter()
.filter(|c| c.confidence >= self.validation_threshold)
.collect();
actions.push(format!(
"Validated {}/{} connections (threshold: {})",
valid_connections.len(),
connections.len(),
self.validation_threshold
));
// Convert validated connections to insights
for conn in &valid_connections {
insights.push(DreamInsight {
insight: conn.insight.clone(),
source_memory_ids: vec![conn.memory_a_id.clone(), conn.memory_b_id.clone()],
confidence: conn.confidence,
novelty: self.estimate_novelty(conn, triaged),
insight_type: match conn.connection_type {
CreativeConnectionType::CrossDomain => "CrossDomain".to_string(),
CreativeConnectionType::Causal => "Causal".to_string(),
CreativeConnectionType::Complementary => "Complementary".to_string(),
CreativeConnectionType::Contradictory => "Contradiction".to_string(),
},
});
}
// Deduplicate insights involving the same memory pairs
let mut seen_pairs: HashSet<(String, String)> = HashSet::new();
insights.retain(|i| {
if i.source_memory_ids.len() >= 2 {
let (a, b) = (&i.source_memory_ids[0], &i.source_memory_ids[1]);
let pair = if a <= b {
(a.clone(), b.clone())
} else {
(b.clone(), a.clone())
};
seen_pairs.insert(pair)
} else {
true
}
});
// Sort by confidence * novelty (most interesting first)
insights.sort_by(|a, b| {
let score_a = a.confidence * a.novelty;
let score_b = b.confidence * b.novelty;
score_b
.partial_cmp(&score_a)
.unwrap_or(std::cmp::Ordering::Equal)
});
// Cap at 20 insights
insights.truncate(20);
actions.push(format!("Generated {} dream insights", insights.len()));
// Summary statistics
let avg_retention: f64 = if triaged.is_empty() {
0.0
} else {
triaged.iter().map(|m| m.retention_strength).sum::<f64>() / triaged.len() as f64
};
actions.push(format!(
"Average retention across dreamed memories: {:.2}",
avg_retention
));
let phase = PhaseResult {
phase: DreamPhase::Integration,
duration_ms: start.elapsed().as_millis() as u64,
memories_processed: triaged.len(),
actions,
};
(insights, phase)
}
fn estimate_novelty(&self, conn: &CreativeConnection, triaged: &[TriagedMemory]) -> f64 {
// Novelty is higher when:
// 1. The memories are from different time periods
// 2. The memories have different tags
// 3. Cross-domain connections are inherently more novel
let mem_a = triaged.iter().find(|m| m.id == conn.memory_a_id);
let mem_b = triaged.iter().find(|m| m.id == conn.memory_b_id);
let mut novelty: f64 = match conn.connection_type {
CreativeConnectionType::CrossDomain => 0.7,
CreativeConnectionType::Contradictory => 0.8,
CreativeConnectionType::Causal => 0.5,
CreativeConnectionType::Complementary => 0.4,
};
if let (Some(a), Some(b)) = (mem_a, mem_b) {
// Time distance bonus
let time_gap_days = (a.created_at - b.created_at).num_days().unsigned_abs();
if time_gap_days > 7 {
novelty += 0.1;
}
// Tag diversity bonus
let tags_a: HashSet<&String> = a.tags.iter().collect();
let tags_b: HashSet<&String> = b.tags.iter().collect();
if tags_a.is_disjoint(&tags_b) {
novelty += 0.1;
}
}
novelty.min(1.0)
}
}
// ============================================================================
// TESTS
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
fn make_test_node(id: &str, content: &str, tags: &[&str]) -> KnowledgeNode {
let now = Utc::now();
KnowledgeNode {
id: id.to_string(),
content: content.to_string(),
node_type: "fact".to_string(),
created_at: now - Duration::hours(1),
updated_at: now,
last_accessed: now,
stability: 5.0,
difficulty: 5.0,
reps: 2,
lapses: 0,
storage_strength: 3.0,
retrieval_strength: 0.8,
retention_strength: 0.7,
sentiment_score: 0.0,
sentiment_magnitude: 0.0,
next_review: None,
source: None,
tags: tags.iter().map(|s| s.to_string()).collect(),
valid_from: None,
valid_until: None,
utility_score: None,
times_retrieved: None,
times_useful: None,
emotional_valence: None,
flashbulb: None,
temporal_level: None,
has_embedding: None,
embedding_model: None,
suppression_count: 0,
suppressed_at: None,
}
}
fn make_emotional_node(id: &str, content: &str, sentiment_mag: f64) -> KnowledgeNode {
let mut node = make_test_node(id, content, &["bug-fix"]);
node.sentiment_magnitude = sentiment_mag;
node
}
#[test]
fn test_dream_engine_creation() {
let engine = DreamEngine::new();
assert!((engine.high_value_ratio - 0.7).abs() < f64::EPSILON);
assert_eq!(engine.wave_batch_size, 15);
}
#[test]
fn test_full_dream_cycle_runs() {
let engine = DreamEngine::new();
let mut emotional = EmotionalMemory::new();
let importance = ImportanceSignals::new();
let mut synaptic = SynapticTaggingSystem::new();
let memories: Vec<KnowledgeNode> = (0..10)
.map(|i| {
make_test_node(
&format!("mem-{}", i),
&format!("Test memory content for dream cycle number {}", i),
&["test"],
)
})
.collect();
let result = engine.run(&memories, &mut emotional, &importance, &mut synaptic);
assert_eq!(result.phases.len(), 4);
assert_eq!(result.phases[0].phase, DreamPhase::Nrem1);
assert_eq!(result.phases[1].phase, DreamPhase::Nrem3);
assert_eq!(result.phases[2].phase, DreamPhase::Rem);
assert_eq!(result.phases[3].phase, DreamPhase::Integration);
assert!(result.total_duration_ms < 5000); // Should be fast
assert_eq!(result.memories_replayed, 10); // All 10 in replay queue
}
#[test]
fn test_nrem1_triage_categories() {
let engine = DreamEngine::new();
let mut emotional = EmotionalMemory::new();
let importance = ImportanceSignals::new();
let memories = vec![
make_emotional_node("emo-1", "Critical production crash error panic!", 0.9),
make_test_node(
"future-1",
"TODO: remind me to add caching next time",
&["planning"],
),
make_test_node("standard-1", "The function returns a string", &["docs"]),
];
let (triaged, _queue, phase) = engine.phase_nrem1(&memories, &mut emotional, &importance);
assert_eq!(triaged.len(), 3);
assert_eq!(phase.phase, DreamPhase::Nrem1);
assert!(phase.memories_processed == 3);
// Emotional memory should be categorized
let emo = triaged.iter().find(|m| m.id == "emo-1").unwrap();
assert_eq!(emo.category, TriageCategory::Emotional);
// Future-relevant should be categorized
let future = triaged.iter().find(|m| m.id == "future-1").unwrap();
assert_eq!(future.category, TriageCategory::FutureRelevant);
}
#[test]
fn test_replay_queue_70_30_split() {
let engine = DreamEngine::new();
let mut emotional = EmotionalMemory::new();
let importance = ImportanceSignals::new();
let memories: Vec<KnowledgeNode> = (0..20)
.map(|i| {
make_test_node(
&format!("mem-{}", i),
&format!("Memory with varying importance content {}", i),
&["test"],
)
})
.collect();
let (_triaged, queue, _phase) = engine.phase_nrem1(&memories, &mut emotional, &importance);
// All 20 should be in the queue (70% + 30% = 100%)
assert_eq!(queue.len(), 20);
}
#[test]
fn test_nrem3_consolidation_waves() {
let engine = DreamEngine::new();
let mut synaptic = SynapticTaggingSystem::new();
let triaged: Vec<TriagedMemory> = (0..10)
.map(|i| TriagedMemory {
id: format!("mem-{}", i),
content: format!("Test memory {}", i),
importance: 0.5,
category: TriageCategory::Standard,
tags: vec!["test".to_string()],
created_at: Utc::now(),
retention_strength: 0.7,
emotional_valence: 0.0,
is_flashbulb: false,
})
.collect();
let replay_queue: Vec<String> = triaged.iter().map(|m| m.id.clone()).collect();
let (strengthened, _downscaled, phase) =
engine.phase_nrem3(&replay_queue, &triaged, &mut synaptic);
assert_eq!(phase.phase, DreamPhase::Nrem3);
assert_eq!(strengthened.len(), 10);
}
#[test]
fn test_synaptic_downscaling() {
let engine = DreamEngine::new();
let mut synaptic = SynapticTaggingSystem::new();
let triaged: Vec<TriagedMemory> = vec![
TriagedMemory {
id: "replayed".to_string(),
content: "Important replayed memory".to_string(),
importance: 0.8,
category: TriageCategory::Novel,
tags: vec![],
created_at: Utc::now(),
retention_strength: 0.9,
emotional_valence: 0.0,
is_flashbulb: false,
},
TriagedMemory {
id: "unreplayed".to_string(),
content: "Low importance unreplayed memory".to_string(),
importance: 0.2,
category: TriageCategory::Standard,
tags: vec![],
created_at: Utc::now(),
retention_strength: 0.3,
emotional_valence: 0.0,
is_flashbulb: false,
},
];
// Only replay the important one
let replay_queue = vec!["replayed".to_string()];
let (_strengthened, downscaled, _phase) =
engine.phase_nrem3(&replay_queue, &triaged, &mut synaptic);
// The unreplayed low-importance memory should be marked for downscaling
assert_eq!(downscaled, 1);
}
#[test]
fn test_rem_cross_domain_connections() {
let engine = DreamEngine::new();
let mut emotional = EmotionalMemory::new();
let triaged = vec![
TriagedMemory {
id: "rust-1".to_string(),
content: "Implemented error handling with Result type pattern".to_string(),
importance: 0.6,
category: TriageCategory::Standard,
tags: vec!["rust".to_string()],
created_at: Utc::now(),
retention_strength: 0.7,
emotional_valence: 0.3,
is_flashbulb: false,
},
TriagedMemory {
id: "typescript-1".to_string(),
content: "Used error handling with try-catch pattern for API errors".to_string(),
importance: 0.5,
category: TriageCategory::Standard,
tags: vec!["typescript".to_string()],
created_at: Utc::now(),
retention_strength: 0.6,
emotional_valence: 0.0,
is_flashbulb: false,
},
];
let (connections, _emotional_processed, phase) = engine.phase_rem(&triaged, &mut emotional);
assert_eq!(phase.phase, DreamPhase::Rem);
// Should find connection via shared "error handling" and "pattern" words
assert!(
!connections.is_empty(),
"Should find cross-domain error handling pattern"
);
}
#[test]
fn test_rem_emotional_processing() {
let engine = DreamEngine::new();
let mut emotional = EmotionalMemory::new();
let triaged = vec![TriagedMemory {
id: "angry-1".to_string(),
content: "Critical production error crashed the entire system".to_string(),
importance: 0.8,
category: TriageCategory::Emotional,
tags: vec!["incident".to_string()],
created_at: Utc::now(),
retention_strength: 0.9,
emotional_valence: -0.8,
is_flashbulb: false,
}];
let (_connections, emotional_processed, _phase) =
engine.phase_rem(&triaged, &mut emotional);
assert_eq!(
emotional_processed, 1,
"Negative emotional memory should be processed"
);
}
#[test]
fn test_integration_validates_insights() {
let engine = DreamEngine::new();
let connections = vec![
CreativeConnection {
memory_a_id: "a".to_string(),
memory_b_id: "b".to_string(),
insight: "Strong connection".to_string(),
confidence: 0.8,
connection_type: CreativeConnectionType::CrossDomain,
},
CreativeConnection {
memory_a_id: "c".to_string(),
memory_b_id: "d".to_string(),
insight: "Weak connection".to_string(),
confidence: 0.1, // Below validation threshold
connection_type: CreativeConnectionType::Complementary,
},
];
let triaged = vec![
TriagedMemory {
id: "a".to_string(),
content: "Memory A".to_string(),
importance: 0.5,
category: TriageCategory::Standard,
tags: vec!["tag-a".to_string()],
created_at: Utc::now() - Duration::days(10),
retention_strength: 0.7,
emotional_valence: 0.0,
is_flashbulb: false,
},
TriagedMemory {
id: "b".to_string(),
content: "Memory B".to_string(),
importance: 0.5,
category: TriageCategory::Standard,
tags: vec!["tag-b".to_string()],
created_at: Utc::now(),
retention_strength: 0.8,
emotional_valence: 0.0,
is_flashbulb: false,
},
];
let (insights, phase) = engine.phase_integration(&connections, &triaged);
assert_eq!(phase.phase, DreamPhase::Integration);
// Only the strong connection should survive validation
assert_eq!(insights.len(), 1);
assert_eq!(insights[0].insight, "Strong connection");
}
#[test]
fn test_content_similarity() {
let engine = DreamEngine::new();
let sim = engine.content_similarity(
"error handling with Result type pattern",
"error handling with try-catch pattern",
);
assert!(
sim > 0.2,
"Similar content should have >0.2 Jaccard: {}",
sim
);
let dissim = engine.content_similarity(
"Rust memory management with ownership",
"Python web framework for HTTP endpoints",
);
assert!(dissim < sim, "Dissimilar content should score lower");
}
#[test]
fn test_empty_memories_returns_empty_results() {
let engine = DreamEngine::new();
let mut emotional = EmotionalMemory::new();
let importance = ImportanceSignals::new();
let mut synaptic = SynapticTaggingSystem::new();
let result = engine.run(&[], &mut emotional, &importance, &mut synaptic);
assert_eq!(result.phases.len(), 4);
assert_eq!(result.memories_replayed, 0);
assert_eq!(result.insights.len(), 0);
assert_eq!(result.memories_strengthened, 0);
}
#[test]
fn test_phase_durations_are_recorded() {
let engine = DreamEngine::new();
let mut emotional = EmotionalMemory::new();
let importance = ImportanceSignals::new();
let mut synaptic = SynapticTaggingSystem::new();
let memories: Vec<KnowledgeNode> = (0..5)
.map(|i| make_test_node(&format!("m{}", i), &format!("Content {}", i), &["test"]))
.collect();
let result = engine.run(&memories, &mut emotional, &importance, &mut synaptic);
for phase in &result.phases {
// Duration should be non-negative (might be 0ms for fast operations)
assert!(phase.duration_ms < 10000);
assert!(
!phase.actions.is_empty(),
"Each phase should report actions"
);
}
}
#[test]
fn test_flashbulb_detected_in_triage() {
let engine = DreamEngine::new();
let mut emotional = EmotionalMemory::new();
let importance = ImportanceSignals::new();
let mut node = make_test_node(
"flash-1",
"CRITICAL: Production server crash! Emergency rollback needed immediately!",
&["incident"],
);
node.sentiment_magnitude = 0.9;
let (triaged, _queue, phase) = engine.phase_nrem1(&[node], &mut emotional, &importance);
// Check that the triage processed the memory
assert_eq!(triaged.len(), 1);
// Flashbulb detection depends on importance signals — just verify triage runs
assert_eq!(phase.phase, DreamPhase::Nrem1);
}
}