use super::guard::CompactionGuard;
use super::helpers::{MERGED_CONTENT_CAP, char_safe_prefix, merge_into, now_secs};
use super::*;
use crate::credentials::env_guard::EnvVarGuard;
use crate::memory_core::palace::{Drawer, Palace, PalaceId, RoomType};
use crate::memory_core::retrieval::{PalaceHandle, seed_shared_embedder_with_mock};
use crate::memory_core::store::vector::VectorStore;
use chrono::{Duration as ChronoDuration, Utc};
use serial_test::serial;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
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"
);
assert!(
!cfg.semantic.enabled,
"semantic consolidation must default OFF"
);
assert!(
!cfg.local_model_enabled,
"a local model server must not be permitted 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
}
fn dedup_only_config() -> DreamConfig {
DreamConfig {
semantic: crate::memory_core::semantic_consolidation::SemanticConsolidationConfig {
enabled: false,
..Default::default()
},
..DreamConfig::default()
}
}
#[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(dedup_only_config());
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(dedup_only_config());
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(dedup_only_config());
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 id = handle.id.clone();
let registry = crate::memory_core::PalaceRegistry::new();
registry.register_arc(handle);
let dreamer = Arc::new(Dreamer::new(DreamConfig {
idle_secs: 10,
..dedup_only_config()
}));
let (tx, rx) = tokio::sync::watch::channel(false);
let join = dreamer
.clone()
.start_with_shutdown(registry, id, Duration::ZERO, 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_loop_does_not_pin_palace_handle() {
let handle = open_test_handle("dream-unpin").await;
let id = handle.id.clone();
let weak = Arc::downgrade(&handle);
let registry = crate::memory_core::PalaceRegistry::new();
registry.register_arc(handle);
let dreamer = Arc::new(Dreamer::new(DreamConfig {
idle_secs: 3600,
..dedup_only_config()
}));
let (_tx, rx) = tokio::sync::watch::channel(false);
let _join = dreamer.start_with_shutdown(registry.clone(), id.clone(), Duration::ZERO, rx);
tokio::task::yield_now().await;
registry.remove(&id);
tokio::task::yield_now().await;
assert!(
weak.upgrade().is_none(),
"dream loop must not pin the handle: after registry.remove it must be fully dropped"
);
}
#[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,
..dedup_only_config()
});
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(dedup_only_config());
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(dedup_only_config());
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(dedup_only_config());
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(dedup_only_config());
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(dedup_only_config());
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(dedup_only_config());
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]
#[serial(dotenv_credential_env)]
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]
#[serial(dotenv_credential_env)]
async fn dream_cycle_semantic_consolidation_invalid_model_disables_once() {
let _guard = EnvVarGuard::remove("OPENROUTER_API_KEY");
let handle = open_test_handle("dream-semantic-invalid-model").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,
model: "ollama/anthropic/claude-haiku-4-5".to_string(),
..Default::default()
},
local_model_enabled: true,
openrouter_api_key: String::new(),
..DreamConfig::default()
});
assert!(!dreamer.is_semantic_consolidation_disabled());
let stats1 = dreamer.dream_cycle(&handle).await.unwrap();
assert_eq!(
stats1.semantically_consolidated, 0,
"misconfigured model must not produce any consolidation output"
);
assert_eq!(stats1.semantic_llm_calls, 0);
assert!(
dreamer.is_semantic_consolidation_disabled(),
"invalid model/provider combination must disable the phase after one cycle"
);
let stats2 = dreamer.dream_cycle(&handle).await.unwrap();
assert_eq!(
stats2.semantically_consolidated, 0,
"second cycle must remain a no-op (no retry of the known-bad config)"
);
assert_eq!(stats2.semantic_llm_calls, 0);
assert!(
dreamer.is_semantic_consolidation_disabled(),
"disabled state must persist across cycles"
);
assert_eq!(
handle.drawers.read().len(),
1,
"drawer must survive untouched"
);
}
#[tokio::test]
#[serial(dotenv_credential_env)]
async fn dream_cycle_semantic_consolidation_valid_local_model_not_disabled() {
let _guard = EnvVarGuard::remove("OPENROUTER_API_KEY");
let _host = EnvVarGuard::set(
crate::memory_core::semantic_consolidation::LOCAL_HOST_ENV,
&format!("http://127.0.0.1:{}", closed_loopback_port()),
);
let handle = open_test_handle("dream-semantic-valid-local-model").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,
model: "ollama/llama3.1".to_string(),
..Default::default()
},
local_model_enabled: true,
openrouter_api_key: String::new(),
..DreamConfig::default()
});
let _stats = dreamer.dream_cycle(&handle).await.unwrap();
assert!(
!dreamer.is_semantic_consolidation_disabled(),
"a valid local model id must pass validation and not disable the phase"
);
}
fn closed_loopback_port() -> u16 {
let listener =
std::net::TcpListener::bind("127.0.0.1:0").expect("bind an ephemeral loopback port");
let port = listener
.local_addr()
.expect("ephemeral listener has an address")
.port();
drop(listener);
port
}
#[tokio::test]
#[serial(dotenv_credential_env)]
async fn dream_semantic_no_provider_parks_without_local_fallback() {
crate::credentials::load_env_local_once();
let _key = EnvVarGuard::remove("OPENROUTER_API_KEY");
let listener =
std::net::TcpListener::bind("127.0.0.1:0").expect("bind an ephemeral loopback port");
let addr = listener.local_addr().expect("listener has an address");
listener
.set_nonblocking(true)
.expect("listener can be polled without blocking");
let _host = EnvVarGuard::set(
crate::memory_core::semantic_consolidation::LOCAL_HOST_ENV,
&format!("http://{addr}"),
);
let handle = open_test_handle("dream-semantic-no-provider").await;
handle
.remember(
"a memory the semantic phase must not send anywhere".into(),
RoomType::General,
vec![],
0.5,
)
.await
.unwrap();
let dreamer = Dreamer::new(DreamConfig {
semantic: crate::memory_core::semantic_consolidation::SemanticConsolidationConfig {
enabled: true,
model: "llama3.2".to_string(),
..Default::default()
},
local_model_enabled: true,
openrouter_api_key: String::new(),
..DreamConfig::default()
});
let stats1 = dreamer.dream_cycle(&handle).await.unwrap();
assert_eq!(stats1.semantic_llm_calls, 0, "no provider, no LLM calls");
assert!(
dreamer.is_semantic_consolidation_disabled(),
"an unusable provider must warn once and park, not retry every cycle"
);
let stats2 = dreamer.dream_cycle(&handle).await.unwrap();
assert_eq!(stats2.semantic_llm_calls, 0);
assert!(dreamer.is_semantic_consolidation_disabled());
match listener.accept() {
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
Ok((_, peer)) => panic!(
"semantic consolidation opened a connection to the local model \
endpoint from {peer} — the #5188 fallback is back"
),
Err(e) => panic!("unexpected accept error: {e}"),
}
assert_eq!(
handle.drawers.read().len(),
1,
"drawer must survive untouched"
);
}
#[test]
#[serial(dotenv_credential_env)]
fn dream_cycle_semantic_consolidation_disabled_by_default_builds_nothing() {
crate::credentials::load_env_local_once();
let _guard = EnvVarGuard::remove("OPENROUTER_API_KEY");
assert!(
super::semantic::build_consolidator_from_config(&DreamConfig::default())
.expect("a disabled semantic phase is not an error")
.is_none(),
"an unconfigured daemon must build no inference backend at all"
);
}
#[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, ..dedup_only_config()
});
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(dedup_only_config());
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, ..dedup_only_config()
});
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,
..dedup_only_config()
});
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, ..dedup_only_config()
});
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]
#[serial(dotenv_credential_env)]
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 cfg = DreamConfig {
local_model_enabled: false,
..DreamConfig::default()
};
let stats = consolidate_scoped(&handle, &cfg, None, 7, None)
.await
.unwrap();
assert_eq!(stats, RoomConsolidationStats::default());
}
#[tokio::test]
#[serial(dotenv_credential_env)]
async fn consolidate_scoped_invalid_model_returns_err() {
let _guard = EnvVarGuard::remove("OPENROUTER_API_KEY");
let handle = open_test_handle("scoped-invalid-model").await;
handle
.remember("some aged fact".into(), RoomType::Backend, vec![], 0.5)
.await
.unwrap();
let cfg = DreamConfig {
semantic: crate::memory_core::semantic_consolidation::SemanticConsolidationConfig {
enabled: true,
model: "ollama/anthropic/claude-haiku-4-5".to_string(),
..Default::default()
},
local_model_enabled: true,
..DreamConfig::default()
};
let err = consolidate_scoped(&handle, &cfg, None, 7, None)
.await
.unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("anthropic/"),
"error should name the model: {msg}"
);
assert!(
msg.contains("Ollama"),
"error should name the provider: {msg}"
);
}
#[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"
);
}
#[tokio::test]
async fn apply_consolidation_result_keeps_original_when_kg_write_fails() {
use super::semantic::record_provenance_and_collect_superseded;
use crate::memory_core::store::kg::KnowledgeGraph;
use crate::memory_core::store::kg_redb::KgStoreRedb;
use crate::memory_core::store::vector::UsearchStore;
let dir = tempdir().unwrap();
let kg_path = dir.path().join("kg.redb");
drop(KgStoreRedb::open(&kg_path).unwrap());
let _live = redb::Database::create(&kg_path).unwrap();
let kg = KnowledgeGraph::open(&kg_path).unwrap();
assert!(
kg.is_read_only(),
"precondition: KG must be a read-only snapshot for this test to be valid"
);
let vs = UsearchStore::new(dir.path().join("idx.usearch"), 384).unwrap();
let handle = Arc::new(PalaceHandle::new(
PalaceId::new("kg-write-fail"),
"test".to_string(),
vs,
kg,
));
let canonical_id = Uuid::new_v4();
let orig_id = Uuid::new_v4();
let mut superseded_ids: Vec<Uuid> = Vec::new();
record_provenance_and_collect_superseded(
&handle,
canonical_id,
&[orig_id],
&mut superseded_ids,
)
.await;
assert!(
superseded_ids.is_empty(),
"original must NOT be marked evictable when the superseded_by KG write failed"
);
}
#[test]
#[serial(dotenv_credential_env)]
fn dedup_only_config_ignores_an_ambient_openrouter_key() {
let _guard = EnvVarGuard::set("OPENROUTER_API_KEY", "sk-or-v1-fixture-not-a-real-key");
assert!(
super::semantic::build_consolidator_from_config(&dedup_only_config())
.expect("a disabled semantic phase is not an error")
.is_none(),
"dedup-only config must build no inference backend, so the ambient \
key is never read and no LLM call can be issued"
);
let semantic_on = DreamConfig {
semantic: crate::memory_core::semantic_consolidation::SemanticConsolidationConfig {
enabled: true,
..Default::default()
},
..DreamConfig::default()
};
assert!(
super::semantic::build_consolidator_from_config(&semantic_on)
.expect("a resolvable cloud key passes model validation")
.is_some(),
"an enabled semantic phase turns an ambient OPENROUTER_API_KEY into a \
live backend — the behaviour the dedup tests used to inherit by accident"
);
}
#[tokio::test]
async fn dream_merge_into_caps_multibyte_content_without_panicking() {
let handle = open_test_handle("dream-merge-utf8").await;
let mut survivor_content =
"The Tokyo office rollout review covered staffing and timelines. ".repeat(8);
survivor_content.truncate(490);
assert_eq!(survivor_content.len(), 490);
let loser_content =
"🎉 The 祝賀会 celebration is on Friday evening in the Tokyo office".to_string();
let naive = format!("{survivor_content}\n\nAlso: {loser_content}");
assert!(
naive.len() > MERGED_CONTENT_CAP,
"input must exceed the cap to exercise the truncate at all"
);
assert!(
!naive.is_char_boundary(MERGED_CONTENT_CAP),
"input must straddle the cap — otherwise this test cannot reproduce #5187"
);
let survivor_id = handle
.remember(survivor_content.clone(), RoomType::General, vec![], 0.8)
.await
.unwrap();
let loser_id = handle
.remember(
loser_content.clone(),
RoomType::General,
vec!["祝賀会".into()],
0.5,
)
.await
.unwrap();
let (survivor, loser) = {
let drawers = handle.drawers.read();
let find = |id: Uuid| drawers.iter().find(|d| d.id == id).cloned().unwrap();
(find(survivor_id), find(loser_id))
};
merge_into(&handle, &survivor, &loser);
let merged = {
let drawers = handle.drawers.read();
drawers
.iter()
.find(|d| d.id == survivor_id)
.map(|d| d.content().to_string())
.unwrap()
};
assert!(
merged.len() <= MERGED_CONTENT_CAP,
"merged content must respect the cap, got {} bytes",
merged.len()
);
assert_eq!(
merged,
format!("{survivor_content}\n\nAlso: "),
"merge should cut at the char boundary below the cap, dropping the emoji whole"
);
}
#[test]
fn char_safe_prefix_stops_below_a_multibyte_char() {
let cjk = "東".repeat(30);
assert!(!cjk.is_char_boundary(80), "cap must fall inside a char");
let cut = char_safe_prefix(&cjk, 80);
assert_eq!(cut.len(), 78);
assert!(cjk.starts_with(cut));
let emoji = format!("x{}", "🎉".repeat(25));
assert!(!emoji.is_char_boundary(80));
assert_eq!(char_safe_prefix(&emoji, 80).len(), 77);
let combining = "e\u{301}".repeat(30);
assert!(!combining.is_char_boundary(80));
let cut = char_safe_prefix(&combining, 80);
assert_eq!(cut.len(), 79);
assert!(combining.is_char_boundary(cut.len()));
assert_eq!(char_safe_prefix("短い", 80), "短い");
assert_eq!(char_safe_prefix("", 80), "");
assert_eq!(char_safe_prefix("🎉", 0), "");
}
#[tokio::test]
async fn merge_into_keeps_the_content_hash_in_step() {
use crate::memory_core::content_hash::memory_content_hash;
seed_shared_embedder_with_mock();
let dir = tempdir().unwrap();
let palace = Palace {
id: PalaceId::new("merge-hash"),
name: "merge-hash".to_string(),
description: None,
created_at: Utc::now(),
data_dir: dir.path().join("merge-hash"),
};
std::fs::create_dir_all(&palace.data_dir).unwrap();
let handle = PalaceHandle::open(&palace).unwrap();
let survivor_id = handle
.remember(
"the release tags are per-crate".to_string(),
RoomType::General,
vec![],
0.5,
)
.await
.unwrap();
let loser_id = handle
.remember(
"and each crate versions independently".to_string(),
RoomType::General,
vec![],
0.5,
)
.await
.unwrap();
let (survivor, loser) = {
let drawers = handle.drawers.read();
let find = |id: Uuid| drawers.iter().find(|d| d.id == id).cloned().unwrap();
(find(survivor_id), find(loser_id))
};
let before = survivor.content_hash();
merge_into(&handle, &survivor, &loser);
let merged = {
let drawers = handle.drawers.read();
drawers
.iter()
.find(|d| d.id == survivor_id)
.cloned()
.unwrap()
};
assert_ne!(
merged.content(),
survivor.content(),
"precondition: the merge must actually have rewritten the body"
);
assert_ne!(
merged.content_hash(),
before,
"a rewritten body must mint a new identity"
);
assert_eq!(
merged.content_hash(),
memory_content_hash(merged.content()),
"the digest must describe the body the drawer now holds"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn remember_racing_dream_compact_pass_never_loses_a_vector() {
let handle = open_test_handle("dream-race-compact").await;
let anchor_id = handle
.remember(
"anchor drawer stays put".into(),
RoomType::Backend,
vec![],
0.7,
)
.await
.unwrap();
let drawer_b = Drawer::new(uuid::Uuid::new_v4(), "racing drawer B must survive");
let b_id = drawer_b.id;
let mut b_vec = vec![0.0f32; 384];
b_vec[7] = 1.0;
let write_mutex = handle.write_mutex_for_test();
let guard = write_mutex.lock().await;
handle.vector_store.upsert(b_id, b_vec).await.unwrap();
let compact_handle = handle.clone();
let compact = tokio::spawn(async move {
super::cycle::compact_pass(&compact_handle, Instant::now(), Duration::from_secs(60)).await
});
tokio::time::sleep(Duration::from_millis(150)).await;
handle.drawers.write().push(drawer_b);
drop(guard);
compact.await.unwrap().unwrap();
let ids = handle.vector_store.all_ids();
assert!(
ids.contains(&b_id),
"#6208: drawer B's vector was reclaimed by a dream compact_pass racing \
its remember (ids={ids:?}, want {b_id})"
);
assert!(
ids.contains(&anchor_id),
"the anchor vector must survive too"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn remember_racing_dream_rebuild_never_loses_a_vector() {
let handle = open_test_handle("dream-race-rebuild").await;
let anchor_id = handle
.remember(
"anchor drawer stays put".into(),
RoomType::Backend,
vec![],
0.7,
)
.await
.unwrap();
let drawer_b = Drawer::new(uuid::Uuid::new_v4(), "racing drawer B must survive");
let b_id = drawer_b.id;
let mut b_vec = vec![0.0f32; 384];
b_vec[9] = 1.0;
let write_mutex = handle.write_mutex_for_test();
let guard = write_mutex.lock().await;
handle.vector_store.upsert(b_id, b_vec).await.unwrap();
let rebuild_handle = handle.clone();
let rebuild = tokio::spawn(async move {
super::helpers::rebuild_index_from_drawers(
&rebuild_handle,
Instant::now(),
Duration::from_secs(60),
)
.await
});
tokio::time::sleep(Duration::from_millis(150)).await;
handle.drawers.write().push(drawer_b);
drop(guard);
rebuild.await.unwrap().unwrap();
let ids = handle.vector_store.all_ids();
assert!(
ids.contains(&b_id),
"#6208: drawer B's vector was wiped by a dream index rebuild racing its \
remember (ids={ids:?}, want {b_id})"
);
assert!(
ids.contains(&anchor_id),
"the anchor vector must survive too"
);
}
fn seed_stale_history(handle: &Arc<PalaceHandle>, count: usize, age_days: i64) {
use crate::memory_core::store::kg_store::{
TRIPLES, TripleValue, encode_triple_key, encode_value,
};
let closed_ms = Utc::now().timestamp_millis() - age_days * 86_400_000;
let store = handle.kg.redb_store();
let db = store.db_for_test();
let wtx = db.begin_write().expect("begin history seed");
{
let mut t = wtx.open_table(TRIPLES).expect("open triples");
for i in 0..count {
let core = encode_triple_key("dead", "was", &format!("v{i}"));
let mut key = b"hist:".to_vec();
key.extend_from_slice(&core);
key.extend_from_slice(&(closed_ms - 1000).to_be_bytes());
let value = TripleValue {
object: format!("v{i}"),
valid_from_ms: closed_ms - 1000,
valid_to_ms: Some(closed_ms),
confidence: 1.0,
provenance: None,
};
let bytes = encode_value(&value).expect("encode");
t.insert(key.as_slice(), bytes.as_slice()).expect("insert");
}
}
wtx.commit().expect("commit history seed");
}
fn compact_only_config() -> DreamConfig {
DreamConfig {
compact: true,
prune_history_after_days: 90,
compact_min_bytes: 0,
compact_keep_backup: true,
..dedup_only_config()
}
}
#[tokio::test]
async fn kg_compaction_shrinks_the_file_in_a_dream_cycle() {
let handle = open_test_handle("kg-compact-shrinks").await;
for i in 0..12 {
handle
.remember(
format!("a live drawer number {i} with enough words to survive content prune"),
RoomType::Planning,
vec![],
0.9,
)
.await
.expect("remember");
}
seed_stale_history(&handle, 6_000, 400);
let path = handle.data_dir.as_ref().expect("data_dir").join("kg.redb");
let before = std::fs::metadata(&path).expect("stat").len();
let live_ids: Vec<Uuid> = handle.drawers.read().iter().map(|d| d.id).collect();
let report = kg_compact_pass(&handle, &compact_only_config(), false)
.await
.expect("compact pass");
assert!(report.ran(), "phase was skipped: {}", report.summary());
assert_eq!(report.history_rows_pruned, 6_000);
let after = std::fs::metadata(&path).expect("stat").len();
assert!(
after < before,
"file did not shrink: {before} -> {after} ({})",
report.summary()
);
let survivors = handle.kg.load_drawers().expect("load drawers");
for id in &live_ids {
assert!(
survivors.iter().any(|d| d.id == *id),
"drawer {id} vanished in the rewrite"
);
}
assert_eq!(survivors.len(), live_ids.len());
}
#[tokio::test]
async fn kg_compaction_is_skipped_below_the_size_gate() {
let handle = open_test_handle("kg-compact-gated").await;
handle
.remember(
"one drawer with quite enough words in it".to_string(),
RoomType::Planning,
vec![],
0.9,
)
.await
.expect("remember");
let cfg = DreamConfig {
compact_min_bytes: 1_000_000_000,
..compact_only_config()
};
let report = kg_compact_pass(&handle, &cfg, false).await.expect("pass");
assert!(!report.ran(), "the size gate did not stop the rewrite");
assert!(
report
.skipped
.as_deref()
.is_some_and(|r| r.contains("compact_min_bytes")),
"unexpected skip reason: {:?}",
report.skipped
);
assert_eq!(report.history_rows_pruned, 0);
}
#[tokio::test]
async fn dry_run_prepares_nothing_and_writes_no_bytes() {
let handle = open_test_handle("kg-compact-dry").await;
handle
.remember(
"a drawer that must still be here afterwards".to_string(),
RoomType::Planning,
vec![],
0.9,
)
.await
.expect("remember");
seed_stale_history(&handle, 500, 400);
let data_dir = handle.data_dir.as_ref().expect("data_dir").clone();
let path = data_dir.join("kg.redb");
let bytes_before = std::fs::read(&path).expect("read");
let report = kg_compact_pass(&handle, &compact_only_config(), true)
.await
.expect("dry run");
assert!(report.dry_run);
assert_eq!(report.history_rows_pruned, 0, "a dry run prunes nothing");
assert_eq!(
report.stats.triples_history_stale, 500,
"but it must still report what a real run would prune"
);
assert_eq!(
bytes_before,
std::fs::read(&path).expect("read"),
"the dry run changed kg.redb"
);
assert!(
!data_dir.join("kg.redb.pre-compact.bak").exists(),
"a dry run must not write a backup"
);
assert!(
!data_dir.join("kg.redb.compacting").exists(),
"a dry run must not write a temp file"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_concurrent_reader_never_observes_a_torn_state() {
let handle = open_test_handle("kg-compact-concurrent").await;
for i in 0..10 {
handle
.remember(
format!("concurrent reader fixture drawer number {i} with words"),
RoomType::Planning,
vec![],
0.9,
)
.await
.expect("remember");
}
seed_stale_history(&handle, 4_000, 400);
let expected = handle.drawers.read().len();
let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
let reader_handle = Arc::clone(&handle);
let reader_stop = Arc::clone(&stop);
let reader = tokio::task::spawn_blocking(move || {
let mut polls = 0usize;
while !reader_stop.load(Ordering::Relaxed) {
let drawers = reader_handle
.kg
.load_drawers()
.expect("a read during the rewrite must never fail");
assert_eq!(
drawers.len(),
expected,
"reader saw a torn drawer table mid-rewrite"
);
polls += 1;
}
polls
});
let report = kg_compact_pass(&handle, &compact_only_config(), false)
.await
.expect("compact pass");
stop.store(true, Ordering::Relaxed);
let polls = reader.await.expect("reader task");
assert!(report.ran(), "phase skipped: {}", report.summary());
assert!(polls > 0, "the reader never ran");
assert_eq!(handle.kg.load_drawers().expect("load").len(), expected);
}
#[test]
fn history_prune_days_never_goes_below_the_floor() {
let cfg = DreamConfig {
prune_history_after_days: 1,
..DreamConfig::default()
};
assert_eq!(cfg.effective_prune_history_days(), MIN_PRUNE_HISTORY_DAYS);
let generous = DreamConfig {
prune_history_after_days: 365,
..DreamConfig::default()
};
assert_eq!(generous.effective_prune_history_days(), 365);
}
#[test]
fn kg_compaction_config_defaults() {
let cfg = DreamConfig::default();
assert!(cfg.compact, "kg.redb compaction is on by default");
assert_eq!(cfg.prune_history_after_days, 90);
assert_eq!(cfg.compact_min_bytes, 64 * 1024 * 1024);
assert!(cfg.compact_keep_backup);
assert_eq!(MIN_PRUNE_HISTORY_DAYS, 7);
assert_eq!(COMPACT_MIN_RECLAIM_PERCENT, 10);
}
#[tokio::test]
async fn dream_cycle_records_kg_compaction_stats() {
let handle = open_test_handle("kg-compact-stats").await;
handle
.remember(
"a drawer that survives the cycle with enough words".to_string(),
RoomType::Planning,
vec![],
0.9,
)
.await
.expect("remember");
seed_stale_history(&handle, 3_000, 400);
let dreamer = Dreamer::new(compact_only_config());
let stats = dreamer.dream_cycle(&handle).await.expect("dream cycle");
assert_eq!(
stats.kg_history_rows_pruned, 3_000,
"the cycle did not record what the phase pruned"
);
assert!(
stats.kg_bytes_reclaimed > 0,
"the cycle recorded no reclaimed bytes"
);
assert!(
stats.kg_bytes_after > 0,
"kg_bytes_after must carry the post-cycle file size for the doctor check"
);
assert!(
stats.kg_bytes_after < stats.kg_bytes_after + stats.kg_bytes_reclaimed,
"reclaimed bytes and the post-cycle size must be consistent"
);
}