use super::guard::CompactionGuard;
use super::helpers::now_secs;
use super::*;
use crate::memory_core::palace::{Palace, PalaceId, RoomType};
use crate::memory_core::retrieval::{PalaceHandle, seed_shared_embedder_with_mock};
use chrono::{Duration as ChronoDuration, Utc};
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::Duration;
use tempfile::tempdir;
use uuid::Uuid;
#[test]
fn dream_config_defaults() {
let cfg = DreamConfig::default();
assert_eq!(cfg.idle_secs, 300);
assert!((cfg.dedup_threshold - 0.95).abs() < 1e-6);
assert!((cfg.prune_importance - 0.05).abs() < 1e-6);
assert_eq!(cfg.max_cycle_ms, 60_000);
assert!(
cfg.content_prune_enabled,
"content-quality pruning is on by default"
);
assert_eq!(cfg.content_prune_min_words, 4);
assert!(
cfg.recall_benchmark_enabled,
"recall benchmark is enabled by default"
);
}
#[test]
fn dreamer_touch_resets_idle() {
let dreamer = Dreamer::new(DreamConfig {
idle_secs: 2,
..DreamConfig::default()
});
assert!(!dreamer.is_idle(), "fresh dreamer should not be idle yet");
dreamer
.last_activity
.store(now_secs().saturating_sub(10), Ordering::Relaxed);
assert!(dreamer.is_idle(), "should be idle after 10s simulated wait");
dreamer.touch();
assert!(!dreamer.is_idle(), "touch should reset idle clock");
}
async fn open_test_handle(name: &str) -> Arc<PalaceHandle> {
seed_shared_embedder_with_mock();
let dir = tempdir().unwrap();
let palace = Palace {
id: PalaceId::new(name),
name: name.into(),
description: None,
created_at: Utc::now(),
data_dir: dir.path().join(name),
};
std::fs::create_dir_all(&palace.data_dir).unwrap();
let handle = PalaceHandle::open(&palace).unwrap();
std::mem::forget(dir);
handle
}
#[tokio::test]
async fn dream_cycle_merges_duplicates() {
let handle = open_test_handle("dream-merge").await;
handle
.remember(
"Rust uses HNSW for vector search".into(),
RoomType::Backend,
vec!["rust".into()],
0.7,
)
.await
.unwrap();
handle
.remember(
"Rust uses HNSW for vector search".into(),
RoomType::Backend,
vec!["rust".into()],
0.6,
)
.await
.unwrap();
assert_eq!(handle.drawers.read().len(), 2);
let dreamer = Dreamer::new(DreamConfig::default());
let stats = dreamer.dream_cycle(&handle).await.unwrap();
assert_eq!(stats.merged, 1, "expected exactly one merge");
assert_eq!(handle.drawers.read().len(), 1, "expected dedup to 1 drawer");
}
#[tokio::test]
async fn dream_cycle_prunes_low_importance() {
let handle = open_test_handle("dream-prune").await;
handle
.remember(
"very stale fact nobody cares about".into(),
RoomType::General,
vec![],
0.01,
)
.await
.unwrap();
{
let mut drawers = handle.drawers.write();
for d in drawers.iter_mut() {
d.created_at = Utc::now() - ChronoDuration::days(60);
}
}
assert_eq!(handle.drawers.read().len(), 1);
let dreamer = Dreamer::new(DreamConfig::default());
let stats = dreamer.dream_cycle(&handle).await.unwrap();
assert_eq!(stats.pruned, 1, "expected exactly one prune");
assert!(
handle.drawers.read().is_empty(),
"low-importance aged drawer should be removed"
);
}
#[tokio::test]
async fn dream_cycle_prunes_at_floor_importance() {
let handle = open_test_handle("dream-prune-floor").await;
handle
.remember(
"drawer that decays to the floor".into(),
RoomType::General,
vec![],
0.05,
)
.await
.unwrap();
{
let mut drawers = handle.drawers.write();
for d in drawers.iter_mut() {
d.created_at = Utc::now() - ChronoDuration::days(60);
}
}
assert_eq!(handle.drawers.read().len(), 1);
let dreamer = Dreamer::new(DreamConfig::default());
let stats = dreamer.dream_cycle(&handle).await.unwrap();
assert_eq!(
stats.pruned, 1,
"drawer at floor importance + aged > 30d must be prunable (was unsatisfiable under strict `<`)"
);
assert!(handle.drawers.read().is_empty());
}
#[tokio::test]
async fn dreamer_shutdown_terminates_loop() {
let handle = open_test_handle("dream-shutdown").await;
let dreamer = Arc::new(Dreamer::new(DreamConfig {
idle_secs: 10,
..DreamConfig::default()
}));
let (tx, rx) = tokio::sync::watch::channel(false);
let join = dreamer.clone().start_with_shutdown(handle, rx);
tokio::task::yield_now().await;
tx.send(true).expect("send shutdown signal");
let outcome = tokio::time::timeout(Duration::from_secs(2), join).await;
assert!(
outcome.is_ok(),
"dream loop did not exit within 2s of shutdown"
);
outcome.unwrap().expect("join handle clean exit");
}
#[tokio::test]
async fn dream_cycle_compacts_orphaned_vectors() {
let handle = open_test_handle("dream-compact").await;
let id_keep = handle
.remember(
"alpha drawer about HNSW".into(),
RoomType::Backend,
vec![],
0.7,
)
.await
.unwrap();
let id_orphan_a = handle
.remember(
"beta drawer about something else".into(),
RoomType::General,
vec![],
0.5,
)
.await
.unwrap();
let id_orphan_b = handle
.remember(
"gamma drawer about yet another topic".into(),
RoomType::General,
vec![],
0.5,
)
.await
.unwrap();
assert_eq!(handle.drawers.read().len(), 3);
let before_idx = handle.vector_store.index_size();
let before_ids = handle.vector_store.all_ids().len();
assert_eq!(before_ids, 3, "key_map should track all three upserts");
{
let mut drawers = handle.drawers.write();
drawers.retain(|d| d.id == id_keep);
}
let _ = handle.kg.delete_drawer(id_orphan_a).await;
let _ = handle.kg.delete_drawer(id_orphan_b).await;
let dreamer = Dreamer::new(DreamConfig {
dedup_threshold: 0.999,
..DreamConfig::default()
});
let stats = dreamer.dream_cycle(&handle).await.unwrap();
assert_eq!(
stats.compacted, 2,
"expected exactly two orphan vectors removed; got stats={stats:?}"
);
let after_ids = handle.vector_store.all_ids().len();
assert_eq!(
after_ids, 1,
"key_map should only track the surviving drawer (before={before_ids}, before_idx={before_idx})"
);
assert!(
handle.vector_store.all_ids().contains(&id_keep),
"compaction must not remove the live drawer's vector"
);
}
#[tokio::test]
async fn dream_stats_persisted_after_cycle() {
let handle = open_test_handle("dream-persist").await;
handle
.remember(
"non-duplicate baseline drawer".into(),
RoomType::General,
vec![],
0.5,
)
.await
.unwrap();
let dreamer = Dreamer::new(DreamConfig::default());
let stats = dreamer.dream_cycle(&handle).await.unwrap();
let data_dir = handle.data_dir.clone().expect("data_dir set");
let loaded = PersistedDreamStats::load(&data_dir)
.unwrap()
.expect("dream_stats.json should exist after a cycle");
assert_eq!(
loaded.stats, stats,
"persisted stats must match cycle output"
);
let age = chrono::Utc::now().signed_duration_since(loaded.last_run_at);
assert!(
age.num_seconds().abs() < 5,
"last_run_at must be within a few seconds of now; got {age}"
);
}
#[tokio::test]
async fn closet_refresh_builds_index() {
let handle = open_test_handle("dream-closets").await;
let id = handle
.remember(
"Quokkas are the happiest marsupials in Australia".into(),
RoomType::General,
vec![],
0.5,
)
.await
.unwrap();
let dreamer = Dreamer::new(DreamConfig::default());
let stats = dreamer.dream_cycle(&handle).await.unwrap();
assert!(
stats.closets_updated > 0,
"closet index should be non-empty"
);
let closets = handle.closets.read();
let entry = closets.get("quokkas").expect("expected `quokkas` keyword");
assert!(
entry.contains(&id),
"closet entry must reference the source drawer"
);
}
#[tokio::test]
async fn dream_cycle_toggles_is_compacting() {
let handle = open_test_handle("dream-compacting-flag").await;
assert!(!handle.is_compacting(), "flag must start cleared");
{
let _g = CompactionGuard::new(handle.is_compacting.clone());
assert!(handle.is_compacting(), "guard must set the flag");
}
assert!(!handle.is_compacting(), "guard must clear on drop");
let dreamer = Dreamer::new(DreamConfig::default());
let _stats = dreamer.dream_cycle(&handle).await.unwrap();
assert!(
!handle.is_compacting(),
"flag must be cleared after dream_cycle returns"
);
}
#[tokio::test]
async fn dream_content_prune_drops_blocklist_drawer() {
let handle = open_test_handle("dream-content-blocklist").await;
handle
.remember_with_options(
"Tool use: Bash".into(),
RoomType::General,
vec![],
0.5,
crate::memory_core::retrieval::RememberOptions::forced(),
)
.await
.unwrap();
let keep_id = handle
.remember(
"Refactor the dream loop to add a content-quality prune pass.".into(),
RoomType::Backend,
vec!["dream".into()],
0.7,
)
.await
.unwrap();
assert_eq!(handle.drawers.read().len(), 2);
let dreamer = Dreamer::new(DreamConfig::default());
let stats = dreamer.dream_cycle(&handle).await.unwrap();
assert_eq!(
stats.content_pruned, 1,
"expected exactly one blocklist-pruned drawer; got stats={stats:?}"
);
let surviving: Vec<Uuid> = handle.drawers.read().iter().map(|d| d.id).collect();
assert_eq!(surviving, vec![keep_id], "noise drawer must be gone");
}
#[tokio::test]
async fn dream_content_prune_drops_short_drawer() {
let handle = open_test_handle("dream-content-short").await;
handle
.remember_with_options(
"hello world".into(),
RoomType::General,
vec![],
0.5,
crate::memory_core::retrieval::RememberOptions::forced(),
)
.await
.unwrap();
let keep_id = handle
.remember(
"This drawer has more than four words and should survive.".into(),
RoomType::General,
vec![],
0.6,
)
.await
.unwrap();
assert_eq!(handle.drawers.read().len(), 2);
let dreamer = Dreamer::new(DreamConfig::default());
let stats = dreamer.dream_cycle(&handle).await.unwrap();
assert_eq!(
stats.content_pruned, 1,
"expected exactly one short drawer pruned; got stats={stats:?}"
);
let surviving: Vec<Uuid> = handle.drawers.read().iter().map(|d| d.id).collect();
assert_eq!(surviving, vec![keep_id], "short drawer must be gone");
}
#[tokio::test]
async fn dream_content_prune_keeps_good_drawer() {
let handle = open_test_handle("dream-content-keep").await;
let keep_id = handle
.remember(
"Dreaming runs a content-quality prune pass before dedup. \
It enforces the same rule the write path uses."
.into(),
RoomType::Backend,
vec!["dream".into()],
0.7,
)
.await
.unwrap();
assert_eq!(handle.drawers.read().len(), 1);
let dreamer = Dreamer::new(DreamConfig::default());
let stats = dreamer.dream_cycle(&handle).await.unwrap();
assert_eq!(
stats.content_pruned, 0,
"well-formed drawer must not be content-pruned; got stats={stats:?}"
);
let surviving: Vec<Uuid> = handle.drawers.read().iter().map(|d| d.id).collect();
assert_eq!(surviving, vec![keep_id], "good drawer must survive");
}
#[tokio::test]
async fn dream_cycle_semantic_consolidation_with_mock() {
use crate::memory_core::semantic_consolidation::{
ConsolidationAction, MockInference, SemanticConsolidationConfig, SemanticConsolidator,
};
let handle = open_test_handle("dream-semantic-mock").await;
let id1 = handle
.remember(
"ts is the search tool used for code navigation".into(),
RoomType::Backend,
vec!["ts".into()],
0.7,
)
.await
.unwrap();
let id2 = handle
.remember(
"trusty-search provides hybrid BM25 and vector retrieval".into(),
RoomType::Backend,
vec!["trusty-search".into()],
0.6,
)
.await
.unwrap();
assert_eq!(handle.drawers.read().len(), 2);
let canonical_text = "trusty-search (alias: ts) provides hybrid BM25 + vector code search";
let actions = vec![ConsolidationAction::Merge {
canonical_content: canonical_text.to_string(),
superseded_ids: vec![id1, id2],
}];
let mock = std::sync::Arc::new(MockInference::new(actions));
let call_count = mock.call_count.clone();
let cfg = SemanticConsolidationConfig {
enabled: true,
max_batch_size: 8,
max_calls_per_cycle: 20,
..Default::default()
};
let consolidator = std::sync::Arc::new(SemanticConsolidator::new(mock, cfg));
let dreamer = Dreamer::with_consolidator(
DreamConfig {
dedup_threshold: 0.999,
semantic: SemanticConsolidationConfig {
enabled: true,
..Default::default()
},
..DreamConfig::default()
},
consolidator,
);
let stats = dreamer.dream_cycle(&handle).await.unwrap();
assert_eq!(
stats.semantically_consolidated, 1,
"expected one canonical drawer; got stats={stats:?}"
);
assert_eq!(
call_count.load(std::sync::atomic::Ordering::Relaxed),
1,
"expected exactly one LLM call"
);
assert_eq!(stats.semantic_llm_calls, 1);
let drawer_ids: Vec<Uuid> = handle.drawers.read().iter().map(|d| d.id).collect();
assert!(
drawer_ids.contains(&id1),
"original drawer 1 must be preserved"
);
assert!(
drawer_ids.contains(&id2),
"original drawer 2 must be preserved"
);
let has_canonical = handle
.drawers
.read()
.iter()
.any(|d| d.content == canonical_text);
assert!(has_canonical, "canonical drawer must be present");
}
#[tokio::test]
async fn dream_cycle_semantic_consolidation_skips_task_drawers() {
use crate::memory_core::palace::DrawerType;
use crate::memory_core::retrieval::RememberOptions;
use crate::memory_core::semantic_consolidation::{
ConsolidationAction, MockInference, SemanticConsolidationConfig, SemanticConsolidator,
};
let handle = open_test_handle("dream-semantic-task-skip").await;
let task_opts = || RememberOptions {
force: true,
classify_as: Some(DrawerType::Task),
..RememberOptions::default()
};
let id1 = handle
.remember_with_options(
"Goal: migrate the chat store to redb".into(),
RoomType::Planning,
vec![],
0.7,
task_opts(),
)
.await
.unwrap();
let id2 = handle
.remember_with_options(
"Milestone: ship the MCP chat-session tools".into(),
RoomType::Planning,
vec![],
0.6,
task_opts(),
)
.await
.unwrap();
assert_eq!(handle.drawers.read().len(), 2);
let actions = vec![ConsolidationAction::Merge {
canonical_content: "tasks should NOT be merged".to_string(),
superseded_ids: vec![id1, id2],
}];
let mock = std::sync::Arc::new(MockInference::new(actions));
let call_count = mock.call_count.clone();
let cfg = SemanticConsolidationConfig {
enabled: true,
..Default::default()
};
let consolidator = std::sync::Arc::new(SemanticConsolidator::new(mock, cfg));
let dreamer = Dreamer::with_consolidator(
DreamConfig {
dedup_threshold: 0.999,
semantic: SemanticConsolidationConfig {
enabled: true,
..Default::default()
},
..DreamConfig::default()
},
consolidator,
);
let stats = dreamer.dream_cycle(&handle).await.unwrap();
assert_eq!(
stats.semantically_consolidated, 0,
"Task drawers must not be consolidated"
);
assert_eq!(
call_count.load(std::sync::atomic::Ordering::Relaxed),
0,
"consolidator must never be called when only Task drawers exist"
);
let ids: Vec<Uuid> = handle.drawers.read().iter().map(|d| d.id).collect();
assert!(
ids.contains(&id1) && ids.contains(&id2),
"both tasks survive"
);
assert_eq!(handle.drawers.read().len(), 2, "no canonical drawer added");
}
#[tokio::test]
async fn dream_cycle_semantic_consolidation_no_inference() {
let _guard = EnvVarGuard::remove("OPENROUTER_API_KEY");
let handle = open_test_handle("dream-semantic-no-inference").await;
handle
.remember(
"some memory that should not be semantically consolidated".into(),
RoomType::General,
vec![],
0.5,
)
.await
.unwrap();
let dreamer = Dreamer::new(DreamConfig {
semantic: crate::memory_core::semantic_consolidation::SemanticConsolidationConfig {
enabled: true,
..Default::default()
},
local_model_enabled: false,
openrouter_api_key: String::new(),
..DreamConfig::default()
});
let stats = dreamer.dream_cycle(&handle).await.unwrap();
assert_eq!(
stats.semantically_consolidated, 0,
"no inference available → semantic phase must be no-op"
);
assert_eq!(
stats.semantic_llm_calls, 0,
"no LLM calls when inference unavailable"
);
assert_eq!(
handle.drawers.read().len(),
1,
"drawer must survive untouched"
);
}
#[tokio::test]
async fn dream_cycle_semantic_consolidation_disabled_by_config() {
use crate::memory_core::semantic_consolidation::{
MockInference, SemanticConsolidationConfig, SemanticConsolidator,
};
let handle = open_test_handle("dream-semantic-disabled").await;
handle
.remember(
"this drawer should not be touched by semantic phase".into(),
RoomType::General,
vec![],
0.5,
)
.await
.unwrap();
let mock = std::sync::Arc::new(MockInference::no_op());
let call_count = mock.call_count.clone();
let consolidator = std::sync::Arc::new(SemanticConsolidator::new(
mock,
SemanticConsolidationConfig::default(),
));
let dreamer = Dreamer::with_consolidator(
DreamConfig {
semantic: SemanticConsolidationConfig {
enabled: false, ..Default::default()
},
..DreamConfig::default()
},
consolidator,
);
let stats = dreamer.dream_cycle(&handle).await.unwrap();
assert_eq!(stats.semantically_consolidated, 0);
assert_eq!(
call_count.load(std::sync::atomic::Ordering::Relaxed),
0,
"mock must not be called when semantic phase is disabled"
);
}
#[test]
fn dream_compression_ratio_math() {
let mut stats = DreamStats {
drawers_before: 10,
drawers_after: 7,
..DreamStats::default()
};
stats.update_compression_ratio();
let diff = (stats.compression_ratio - 0.3_f64).abs();
assert!(
diff < 1e-10,
"expected 0.3, got {}",
stats.compression_ratio
);
}
#[test]
fn dream_compression_ratio_zero_drawers() {
let mut stats = DreamStats {
drawers_before: 0,
drawers_after: 0,
..DreamStats::default()
};
stats.update_compression_ratio();
assert_eq!(
stats.compression_ratio, 0.0,
"zero drawers_before must produce 0.0 compression_ratio"
);
}
#[test]
fn dream_compression_ratio_net_growth() {
let mut stats = DreamStats {
drawers_before: 5,
drawers_after: 8, ..DreamStats::default()
};
stats.update_compression_ratio();
assert_eq!(
stats.compression_ratio, 0.0,
"net palace growth (after > before) must clamp compression_ratio to 0.0"
);
}
#[test]
fn dream_stats_serde_roundtrip_new_fields() {
let original = DreamStats {
merged: 2,
pruned: 1,
drawers_before: 8,
drawers_after: 5,
compression_ratio: 0.375,
recall_score_before: Some(0.72),
recall_score_after: Some(0.81),
duration_ms: 1200,
..DreamStats::default()
};
let json = serde_json::to_string(&original).expect("serialize");
let decoded: DreamStats = serde_json::from_str(&json).expect("deserialize");
assert_eq!(decoded.drawers_before, 8);
assert_eq!(decoded.drawers_after, 5);
let cr_diff = (decoded.compression_ratio - 0.375_f64).abs();
assert!(cr_diff < 1e-10, "compression_ratio round-trip failed");
assert_eq!(decoded.recall_score_before, Some(0.72));
assert_eq!(decoded.recall_score_after, Some(0.81));
assert_eq!(decoded.merged, 2);
assert_eq!(decoded.duration_ms, 1200);
}
#[test]
fn dream_stats_backward_compat() {
let legacy_json = r#"{
"merged": 3,
"pruned": 1,
"closets_updated": 12,
"compacted": 0,
"content_pruned": 2,
"semantically_consolidated": 0,
"semantic_llm_calls": 0,
"semantic_cache_hits": 0,
"duration_ms": 800
}"#;
let decoded: DreamStats =
serde_json::from_str(legacy_json).expect("backward-compat deserialize must succeed");
assert_eq!(decoded.merged, 3);
assert_eq!(decoded.drawers_before, 0, "missing field must default to 0");
assert_eq!(decoded.drawers_after, 0, "missing field must default to 0");
assert_eq!(
decoded.compression_ratio, 0.0,
"missing field must default to 0.0"
);
assert_eq!(
decoded.recall_score_before, None,
"missing Option field must default to None"
);
assert_eq!(
decoded.recall_score_after, None,
"missing Option field must default to None"
);
}
#[tokio::test]
async fn dream_cycle_records_drawer_counts() {
let handle = open_test_handle("dream-drawer-counts").await;
handle
.remember(
"drawer count baseline for effectiveness metrics".into(),
RoomType::General,
vec![],
0.6,
)
.await
.unwrap();
let dreamer = Dreamer::new(DreamConfig {
dedup_threshold: 0.999, ..DreamConfig::default()
});
let stats = dreamer.dream_cycle(&handle).await.unwrap();
assert_eq!(stats.drawers_before, 1, "expected 1 drawer before");
assert_eq!(
stats.drawers_after, 1,
"expected 1 drawer after (none removed)"
);
assert_eq!(
stats.compression_ratio, 0.0,
"no drawers removed → ratio 0.0"
);
}
#[tokio::test]
async fn dream_cycle_compression_ratio_nonzero_after_dedup() {
let handle = open_test_handle("dream-compress-nonzero").await;
handle
.remember(
"duplicate drawer for compression test".into(),
RoomType::General,
vec![],
0.7,
)
.await
.unwrap();
handle
.remember(
"duplicate drawer for compression test".into(),
RoomType::General,
vec![],
0.6,
)
.await
.unwrap();
assert_eq!(handle.drawers.read().len(), 2);
let dreamer = Dreamer::new(DreamConfig::default());
let stats = dreamer.dream_cycle(&handle).await.unwrap();
assert_eq!(stats.drawers_before, 2, "two drawers before cycle");
assert_eq!(stats.drawers_after, 1, "one remaining after dedup");
let diff = (stats.compression_ratio - 0.5_f64).abs();
assert!(
diff < 1e-10,
"expected compression_ratio=0.5, got {}",
stats.compression_ratio
);
}
#[tokio::test]
async fn dream_recall_benchmark_empty_palace_returns_none() {
let handle = open_test_handle("dream-bench-empty").await;
let result = super::recall_benchmark::run_benchmark(&handle).await;
assert_eq!(
result, None,
"empty palace must yield None from recall benchmark"
);
}
#[tokio::test]
async fn dream_recall_benchmark_returns_score_with_drawers() {
let handle = open_test_handle("dream-bench-score").await;
handle
.remember(
"cargo build and test commands for the Rust workspace".into(),
RoomType::Backend,
vec!["cargo".into()],
0.8,
)
.await
.unwrap();
handle
.remember(
"error handling with thiserror for libraries and anyhow for binaries".into(),
RoomType::Backend,
vec!["errors".into()],
0.7,
)
.await
.unwrap();
let result = super::recall_benchmark::run_benchmark(&handle).await;
let score = result.expect("expected Some(score) with seeded drawers");
assert!(
score.is_finite() && score >= 0.0,
"recall benchmark score must be finite and non-negative; got {score}"
);
}
#[tokio::test]
async fn dream_cycle_records_recall_scores() {
let handle = open_test_handle("dream-recall-scores").await;
handle
.remember(
"HNSW vector search and batch embedding performance patterns".into(),
RoomType::Backend,
vec!["hnsw".into(), "embedding".into()],
0.8,
)
.await
.unwrap();
let dreamer = Dreamer::new(DreamConfig {
dedup_threshold: 0.999, ..DreamConfig::default()
});
let stats = dreamer.dream_cycle(&handle).await.unwrap();
assert!(
stats.recall_score_before.is_some(),
"recall_score_before must be Some with a seeded palace"
);
assert!(
stats.recall_score_after.is_some(),
"recall_score_after must be Some with a seeded palace"
);
let before = stats.recall_score_before.unwrap();
let after = stats.recall_score_after.unwrap();
assert!(
before.is_finite() && before >= 0.0,
"recall_score_before={before} must be finite and non-negative"
);
assert!(
after.is_finite() && after >= 0.0,
"recall_score_after={after} must be finite and non-negative"
);
}
#[tokio::test]
async fn dream_stats_effectiveness_fields_persisted() {
let handle = open_test_handle("dream-persist-eff").await;
handle
.remember(
"unit test patterns and mock usage in Rust tests".into(),
RoomType::General,
vec!["testing".into()],
0.7,
)
.await
.unwrap();
let dreamer = Dreamer::new(DreamConfig {
dedup_threshold: 0.999,
..DreamConfig::default()
});
let stats = dreamer.dream_cycle(&handle).await.unwrap();
let data_dir = handle.data_dir.clone().expect("data_dir set");
let loaded = PersistedDreamStats::load(&data_dir)
.unwrap()
.expect("dream_stats.json must exist after cycle");
assert_eq!(
loaded.stats.drawers_before, stats.drawers_before,
"drawers_before must be persisted"
);
assert_eq!(
loaded.stats.drawers_after, stats.drawers_after,
"drawers_after must be persisted"
);
let cr_diff = (loaded.stats.compression_ratio - stats.compression_ratio).abs();
assert!(cr_diff < 1e-10, "compression_ratio must be persisted");
assert_eq!(
loaded.stats.recall_score_before, stats.recall_score_before,
"recall_score_before must be persisted"
);
assert_eq!(
loaded.stats.recall_score_after, stats.recall_score_after,
"recall_score_after must be persisted"
);
}
#[tokio::test]
async fn dream_cycle_recall_benchmark_disabled() {
let handle = open_test_handle("dream-bench-disabled").await;
handle
.remember(
"recall benchmark opt-out test drawer".into(),
RoomType::General,
vec![],
0.6,
)
.await
.unwrap();
let dreamer = Dreamer::new(DreamConfig {
recall_benchmark_enabled: false,
dedup_threshold: 0.999, ..DreamConfig::default()
});
let stats = dreamer.dream_cycle(&handle).await.unwrap();
assert_eq!(
stats.recall_score_before, None,
"recall_score_before must be None when benchmark is disabled"
);
assert_eq!(
stats.recall_score_after, None,
"recall_score_after must be None when benchmark is disabled"
);
assert_eq!(
stats.drawers_before, 1,
"drawers_before must still be counted"
);
assert_eq!(
stats.drawers_after, 1,
"drawers_after must still be counted"
);
}
#[tokio::test]
async fn consolidate_scoped_filters_by_room() {
use crate::memory_core::semantic_consolidation::{
ConsolidationAction, MockInference, SemanticConsolidationConfig, SemanticConsolidator,
};
use std::sync::atomic::Ordering;
let handle = open_test_handle("scoped-room-filter").await;
let p1 = handle
.remember(
"planning note one about the roadmap".into(),
RoomType::Planning,
vec![],
0.5,
)
.await
.unwrap();
let p2 = handle
.remember(
"planning note two about the roadmap".into(),
RoomType::Planning,
vec![],
0.5,
)
.await
.unwrap();
{
let mut drawers = handle.drawers.write();
for d in drawers.iter_mut() {
d.created_at = Utc::now() - ChronoDuration::days(30);
}
}
let mock = std::sync::Arc::new(MockInference::new(vec![ConsolidationAction::Merge {
canonical_content: "roadmap planning summary".to_string(),
superseded_ids: vec![p1, p2],
}]));
let call_count = mock.call_count.clone();
let consolidator = std::sync::Arc::new(SemanticConsolidator::new(
mock,
SemanticConsolidationConfig {
enabled: true,
..Default::default()
},
));
let cfg = DreamConfig::default();
let backend = consolidate_scoped(
&handle,
&cfg,
Some(RoomType::Backend),
7,
Some(consolidator.clone()),
)
.await
.unwrap();
assert_eq!(backend, RoomConsolidationStats::default());
assert_eq!(
call_count.load(Ordering::Relaxed),
0,
"wrong room must not consolidate"
);
assert_eq!(handle.drawers.read().len(), 2);
let planning = consolidate_scoped(
&handle,
&cfg,
Some(RoomType::Planning),
7,
Some(consolidator),
)
.await
.unwrap();
assert_eq!(planning.summary_facts_created, 1);
assert_eq!(planning.facts_evicted, 2);
assert_eq!(
call_count.load(Ordering::Relaxed),
1,
"consolidator ran once for Planning"
);
let ids: Vec<Uuid> = handle.drawers.read().iter().map(|d| d.id).collect();
assert!(
!ids.contains(&p1) && !ids.contains(&p2),
"superseded originals evicted"
);
}
#[tokio::test]
async fn consolidate_scoped_skips_task_drawers() {
use crate::memory_core::palace::DrawerType;
use crate::memory_core::retrieval::RememberOptions;
use crate::memory_core::semantic_consolidation::{
ConsolidationAction, MockInference, SemanticConsolidationConfig, SemanticConsolidator,
};
use std::sync::atomic::Ordering;
let handle = open_test_handle("scoped-task-skip").await;
let task_opts = || RememberOptions {
force: true,
classify_as: Some(DrawerType::Task),
..RememberOptions::default()
};
let t1 = handle
.remember_with_options(
"Goal: keep this forever".into(),
RoomType::Planning,
vec![],
0.5,
task_opts(),
)
.await
.unwrap();
let t2 = handle
.remember_with_options(
"Goal: and this one too".into(),
RoomType::Planning,
vec![],
0.5,
task_opts(),
)
.await
.unwrap();
{
let mut drawers = handle.drawers.write();
for d in drawers.iter_mut() {
d.created_at = Utc::now() - ChronoDuration::days(30);
}
}
let mock = std::sync::Arc::new(MockInference::new(vec![ConsolidationAction::Merge {
canonical_content: "tasks must NOT merge".to_string(),
superseded_ids: vec![t1, t2],
}]));
let call_count = mock.call_count.clone();
let consolidator = std::sync::Arc::new(SemanticConsolidator::new(
mock,
SemanticConsolidationConfig {
enabled: true,
..Default::default()
},
));
let stats = consolidate_scoped(
&handle,
&DreamConfig::default(),
None,
7,
Some(consolidator),
)
.await
.unwrap();
assert_eq!(
stats,
RoomConsolidationStats::default(),
"task-only palace yields no work"
);
assert_eq!(
call_count.load(Ordering::Relaxed),
0,
"consolidator never sees Task drawers"
);
let ids: Vec<Uuid> = handle.drawers.read().iter().map(|d| d.id).collect();
assert!(ids.contains(&t1) && ids.contains(&t2), "both tasks survive");
}
#[tokio::test]
async fn consolidate_scoped_no_inference_is_noop() {
let _guard = EnvVarGuard::remove("OPENROUTER_API_KEY");
let handle = open_test_handle("scoped-no-inference").await;
handle
.remember("some aged fact".into(), RoomType::Backend, vec![], 0.5)
.await
.unwrap();
let stats = consolidate_scoped(&handle, &DreamConfig::default(), None, 7, None)
.await
.unwrap();
assert_eq!(stats, RoomConsolidationStats::default());
}
#[tokio::test]
async fn consolidate_scoped_non_positive_age_is_noop() {
use crate::memory_core::semantic_consolidation::{
ConsolidationAction, MockInference, SemanticConsolidationConfig, SemanticConsolidator,
};
let handle = open_test_handle("scoped-non-positive-age").await;
let r1 = handle
.remember(
"recent planning note one".into(),
RoomType::Planning,
vec![],
0.5,
)
.await
.unwrap();
let r2 = handle
.remember(
"recent planning note two".into(),
RoomType::Planning,
vec![],
0.5,
)
.await
.unwrap();
let mock = std::sync::Arc::new(MockInference::new(vec![ConsolidationAction::Merge {
canonical_content: "must NOT merge recent drawers".to_string(),
superseded_ids: vec![r1, r2],
}]));
let call_count = mock.call_count.clone();
let consolidator = std::sync::Arc::new(SemanticConsolidator::new(
mock,
SemanticConsolidationConfig {
enabled: true,
..Default::default()
},
));
let cfg = DreamConfig::default();
for age in [0_i64, -5] {
let stats = consolidate_scoped(
&handle,
&cfg,
Some(RoomType::Planning),
age,
Some(consolidator.clone()),
)
.await
.unwrap();
assert_eq!(
stats,
RoomConsolidationStats::default(),
"max_age_days={age} must consolidate/evict nothing"
);
}
assert_eq!(
call_count.load(Ordering::Relaxed),
0,
"consolidator must never run for a non-positive age window"
);
let ids: Vec<Uuid> = handle.drawers.read().iter().map(|d| d.id).collect();
assert!(
ids.contains(&r1) && ids.contains(&r2),
"recent drawers must survive a non-positive age window"
);
}
struct EnvVarGuard {
key: &'static str,
previous: Option<String>,
}
impl EnvVarGuard {
fn remove(key: &'static str) -> Self {
let previous = std::env::var(key).ok();
unsafe { std::env::remove_var(key) };
Self { key, previous }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match &self.previous {
Some(v) => unsafe { std::env::set_var(self.key, v) },
None => unsafe { std::env::remove_var(self.key) },
}
}
}