Formatting only. cargo fmt --check was already failing on main (accel SIMD kernels, agent, format, migrate, bench); CI now enforces it. Co-Authored-By: Claude Fable 5.1 <[email protected]>
899 lines
32 KiB
Rust
899 lines
32 KiB
Rust
//! Hippocampal-inspired memory consolidation system.
|
||
//!
|
||
//! Models Working → Episodic → Semantic memory tiers with importance scoring,
|
||
//! exponential decay, and capacity-based eviction.
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Types
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub enum MemorySource {
|
||
User,
|
||
System,
|
||
Tool,
|
||
Retrieval,
|
||
Correction,
|
||
}
|
||
|
||
/// Source classification for content whose true origin is *not*
|
||
/// independently verified by the caller of [`ConsolidationEngine::add_memory`]
|
||
/// — arbitrary text forwarded from a user, a tool's output, or a retrieval
|
||
/// pipeline. This is the only source set `add_memory` accepts; it cannot
|
||
/// claim the `System`/`Correction` importance boost (see [`TrustedSource`]
|
||
/// and [`ConsolidationEngine::add_trusted_memory`]) — a caller passing
|
||
/// through untrusted content has no way to self-report an elevated trust
|
||
/// level through this entry point.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub enum UntrustedSource {
|
||
User,
|
||
Tool,
|
||
Retrieval,
|
||
}
|
||
|
||
impl From<UntrustedSource> for MemorySource {
|
||
fn from(s: UntrustedSource) -> Self {
|
||
match s {
|
||
UntrustedSource::User => MemorySource::User,
|
||
UntrustedSource::Tool => MemorySource::Tool,
|
||
UntrustedSource::Retrieval => MemorySource::Retrieval,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Source classification for content whose elevated trust level has been
|
||
/// independently verified by the caller — e.g. the library's own
|
||
/// system-generated text, or a caller that ran its own correction-cue
|
||
/// detection (as `memory_strategy::SaveOnUserCorrection` does) rather than
|
||
/// forwarding a caller-supplied label verbatim. `MemorySource::System`/
|
||
/// `Correction` get elevated importance weighting in
|
||
/// [`ImportanceScorer::score_correction`]; only reachable through
|
||
/// [`ConsolidationEngine::add_trusted_memory`], a distinct entry point from
|
||
/// the one untrusted content is passed through.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub enum TrustedSource {
|
||
System,
|
||
Correction,
|
||
}
|
||
|
||
impl From<TrustedSource> for MemorySource {
|
||
fn from(s: TrustedSource) -> Self {
|
||
match s {
|
||
TrustedSource::System => MemorySource::System,
|
||
TrustedSource::Correction => MemorySource::Correction,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub enum MemoryTier {
|
||
Working,
|
||
Episodic,
|
||
Semantic,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub struct MemoryRecord {
|
||
pub id: u64,
|
||
pub chunk: String,
|
||
pub embedding: Vec<f32>,
|
||
pub tier: MemoryTier,
|
||
pub importance: f32,
|
||
pub access_count: u32,
|
||
pub last_accessed: f64,
|
||
pub created_at: f64,
|
||
pub source: MemorySource,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Copy)]
|
||
pub struct ImportanceWeights {
|
||
pub surprise: f32,
|
||
pub correction: f32,
|
||
pub length: f32,
|
||
}
|
||
|
||
impl Default for ImportanceWeights {
|
||
fn default() -> Self {
|
||
Self {
|
||
surprise: 0.5,
|
||
correction: 0.3,
|
||
length: 0.2,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub struct ConsolidationConfig {
|
||
pub working_capacity: usize,
|
||
pub episodic_capacity: usize,
|
||
pub episodic_lambda: f64,
|
||
pub semantic_lambda: f64,
|
||
pub working_to_episodic_threshold: f32,
|
||
pub episodic_to_semantic_threshold: u32,
|
||
pub importance_weights: ImportanceWeights,
|
||
}
|
||
|
||
impl Default for ConsolidationConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
working_capacity: 100,
|
||
episodic_capacity: 10_000,
|
||
// ln(2) / 604800 → half-life 7 days in seconds
|
||
episodic_lambda: std::f64::consts::LN_2 / 604_800.0,
|
||
// ln(2) / 2592000 → half-life 30 days in seconds
|
||
semantic_lambda: std::f64::consts::LN_2 / 2_592_000.0,
|
||
working_to_episodic_threshold: 0.6,
|
||
episodic_to_semantic_threshold: 10,
|
||
importance_weights: ImportanceWeights::default(),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default)]
|
||
pub struct ConsolidationStats {
|
||
pub working_count: usize,
|
||
pub episodic_count: usize,
|
||
pub semantic_count: usize,
|
||
pub total_evictions: u64,
|
||
pub total_promotions: u64,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// ImportanceScorer
|
||
// ---------------------------------------------------------------------------
|
||
|
||
pub struct ImportanceScorer;
|
||
|
||
impl ImportanceScorer {
|
||
/// Cosine similarity between two embedding slices.
|
||
/// Returns 0.0 if either norm is zero.
|
||
fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||
let len = a.len().min(b.len());
|
||
if len == 0 {
|
||
return 0.0;
|
||
}
|
||
let dot: f32 = a[..len]
|
||
.iter()
|
||
.zip(b[..len].iter())
|
||
.map(|(x, y)| x * y)
|
||
.sum();
|
||
let norm_a: f32 = a[..len].iter().map(|x| x * x).sum::<f32>().sqrt();
|
||
let norm_b: f32 = b[..len].iter().map(|x| x * x).sum::<f32>().sqrt();
|
||
if norm_a == 0.0 || norm_b == 0.0 {
|
||
return 0.0;
|
||
}
|
||
dot / (norm_a * norm_b)
|
||
}
|
||
|
||
/// Novelty score: 1.0 − max cosine similarity against all existing records.
|
||
/// Returns 1.0 when there are no existing memories.
|
||
pub fn score_surprise(embedding: &[f32], existing_memories: &[&MemoryRecord]) -> f32 {
|
||
if existing_memories.is_empty() {
|
||
return 1.0;
|
||
}
|
||
let max_sim = existing_memories
|
||
.iter()
|
||
.map(|r| Self::cosine_similarity(embedding, &r.embedding))
|
||
.fold(f32::NEG_INFINITY, f32::max);
|
||
(1.0 - max_sim).clamp(0.0, 1.0)
|
||
}
|
||
|
||
/// Returns 1.0 for Correction source, 0.0 otherwise.
|
||
pub fn score_correction(source: &MemorySource) -> f32 {
|
||
if *source == MemorySource::Correction {
|
||
1.0
|
||
} else {
|
||
0.0
|
||
}
|
||
}
|
||
|
||
/// Normalised word-count score, clamped at 1.0 (ceiling = 100 words).
|
||
pub fn score_length(text: &str) -> f32 {
|
||
let word_count = text.split_whitespace().count();
|
||
(word_count as f32 / 100.0).min(1.0)
|
||
}
|
||
|
||
/// Weighted combination of sub-scores, normalised by weight total.
|
||
pub fn score_combined(
|
||
surprise: f32,
|
||
correction: f32,
|
||
length: f32,
|
||
weights: &ImportanceWeights,
|
||
) -> f32 {
|
||
let weight_total = weights.surprise + weights.correction + weights.length;
|
||
if weight_total == 0.0 {
|
||
return 0.0;
|
||
}
|
||
let weighted_sum =
|
||
surprise * weights.surprise + correction * weights.correction + length * weights.length;
|
||
(weighted_sum / weight_total).clamp(0.0, 1.0)
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// DecayCalculator
|
||
// ---------------------------------------------------------------------------
|
||
|
||
pub struct DecayCalculator;
|
||
|
||
impl DecayCalculator {
|
||
/// Exponential decay score for a record.
|
||
///
|
||
/// decay = importance × (access_count + 1) × e^(−λ × elapsed)
|
||
pub fn compute_decay(record: &MemoryRecord, now: f64, lambda: f64) -> f32 {
|
||
let elapsed = (now - record.last_accessed).max(0.0);
|
||
let decay_factor = f64::exp(-lambda * elapsed);
|
||
record.importance * (record.access_count + 1) as f32 * decay_factor as f32
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// ConsolidationEngine
|
||
// ---------------------------------------------------------------------------
|
||
|
||
pub struct ConsolidationEngine {
|
||
pub config: ConsolidationConfig,
|
||
pub records: Vec<MemoryRecord>,
|
||
pub next_id: u64,
|
||
pub stats: ConsolidationStats,
|
||
}
|
||
|
||
impl ConsolidationEngine {
|
||
pub fn new(config: ConsolidationConfig) -> Self {
|
||
Self {
|
||
config,
|
||
records: Vec::new(),
|
||
next_id: 0,
|
||
stats: ConsolidationStats::default(),
|
||
}
|
||
}
|
||
|
||
/// Add a new memory to the Working tier from an untrusted/ordinary origin
|
||
/// (User, Tool, or Retrieval). This is the entry point for arbitrary
|
||
/// caller-supplied content — it cannot claim the elevated System/
|
||
/// Correction importance boost. Use [`Self::add_trusted_memory`] for
|
||
/// content whose elevated trust level the caller has independently
|
||
/// verified.
|
||
///
|
||
/// Importance is scored against existing Working-tier records only.
|
||
pub fn add_memory(
|
||
&mut self,
|
||
chunk: String,
|
||
embedding: Vec<f32>,
|
||
source: UntrustedSource,
|
||
now: f64,
|
||
) -> u64 {
|
||
self.add_memory_with_source(chunk, embedding, source.into(), now)
|
||
}
|
||
|
||
/// Add a new memory tagged System or Correction, which get elevated
|
||
/// importance weighting in [`ImportanceScorer::score_correction`]. Only
|
||
/// call this from code that has independently verified the origin (the
|
||
/// library's own system-generated text, or a caller that ran its own
|
||
/// correction-cue detection) — never from a path that forwards a
|
||
/// caller-supplied trust label verbatim.
|
||
pub fn add_trusted_memory(
|
||
&mut self,
|
||
chunk: String,
|
||
embedding: Vec<f32>,
|
||
source: TrustedSource,
|
||
now: f64,
|
||
) -> u64 {
|
||
self.add_memory_with_source(chunk, embedding, source.into(), now)
|
||
}
|
||
|
||
fn add_memory_with_source(
|
||
&mut self,
|
||
chunk: String,
|
||
embedding: Vec<f32>,
|
||
source: MemorySource,
|
||
now: f64,
|
||
) -> u64 {
|
||
let working: Vec<&MemoryRecord> = self
|
||
.records
|
||
.iter()
|
||
.filter(|r| r.tier == MemoryTier::Working)
|
||
.collect();
|
||
|
||
let surprise = ImportanceScorer::score_surprise(&embedding, &working);
|
||
let correction = ImportanceScorer::score_correction(&source);
|
||
let length = ImportanceScorer::score_length(&chunk);
|
||
let importance = ImportanceScorer::score_combined(
|
||
surprise,
|
||
correction,
|
||
length,
|
||
&self.config.importance_weights,
|
||
);
|
||
|
||
let id = self.next_id;
|
||
self.next_id += 1;
|
||
|
||
self.records.push(MemoryRecord {
|
||
id,
|
||
chunk,
|
||
embedding,
|
||
tier: MemoryTier::Working,
|
||
importance,
|
||
access_count: 0,
|
||
last_accessed: now,
|
||
created_at: now,
|
||
source,
|
||
});
|
||
|
||
id
|
||
}
|
||
|
||
/// Increment access count and update last-accessed timestamp for a record.
|
||
pub fn access_memory(&mut self, id: u64, now: f64) {
|
||
if let Some(rec) = self.records.iter_mut().find(|r| r.id == id) {
|
||
rec.access_count += 1;
|
||
rec.last_accessed = now;
|
||
}
|
||
}
|
||
|
||
/// Run one full consolidation cycle.
|
||
pub fn consolidate(&mut self, now: f64) {
|
||
// ------------------------------------------------------------------
|
||
// Step 1 — Compute decay scores for Working records; sort ascending.
|
||
// ------------------------------------------------------------------
|
||
let working_lambda = self.config.episodic_lambda; // reuse episodic lambda for working
|
||
let mut working_indices: Vec<usize> = self
|
||
.records
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(_, r)| r.tier == MemoryTier::Working)
|
||
.map(|(i, _)| i)
|
||
.collect();
|
||
|
||
working_indices.sort_by(|&a, &b| {
|
||
let da = DecayCalculator::compute_decay(&self.records[a], now, working_lambda);
|
||
let db = DecayCalculator::compute_decay(&self.records[b], now, working_lambda);
|
||
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
|
||
});
|
||
|
||
// ------------------------------------------------------------------
|
||
// Step 2 — Evict lowest-decay Working records until count ≤ capacity.
|
||
// ------------------------------------------------------------------
|
||
let working_count = working_indices.len();
|
||
let capacity = self.config.working_capacity;
|
||
|
||
if working_count > capacity {
|
||
let evict_n = working_count - capacity;
|
||
// Collect the ids of the records to evict (lowest decay = first in sorted list).
|
||
let evict_ids: std::collections::HashSet<u64> = working_indices[..evict_n]
|
||
.iter()
|
||
.map(|&i| self.records[i].id)
|
||
.collect();
|
||
|
||
self.records.retain(|r| !evict_ids.contains(&r.id));
|
||
self.stats.total_evictions += evict_n as u64;
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Step 3 — Promote high-importance Working records → Episodic.
|
||
// ------------------------------------------------------------------
|
||
let threshold = self.config.working_to_episodic_threshold;
|
||
let mut promotions: u64 = 0;
|
||
|
||
for rec in self.records.iter_mut() {
|
||
if rec.tier == MemoryTier::Working && rec.importance > threshold {
|
||
rec.tier = MemoryTier::Episodic;
|
||
promotions += 1;
|
||
}
|
||
}
|
||
self.stats.total_promotions += promotions;
|
||
|
||
// ------------------------------------------------------------------
|
||
// Step 4 — Promote high-access Episodic records → Semantic.
|
||
// ------------------------------------------------------------------
|
||
let semantic_threshold = self.config.episodic_to_semantic_threshold;
|
||
let mut sem_promotions: u64 = 0;
|
||
|
||
for rec in self.records.iter_mut() {
|
||
if rec.tier == MemoryTier::Episodic && rec.access_count > semantic_threshold {
|
||
rec.tier = MemoryTier::Semantic;
|
||
sem_promotions += 1;
|
||
}
|
||
}
|
||
self.stats.total_promotions += sem_promotions;
|
||
|
||
// ------------------------------------------------------------------
|
||
// Step 5 — Evict lowest-decay Episodic records when over capacity.
|
||
// ------------------------------------------------------------------
|
||
let episodic_lambda = self.config.episodic_lambda;
|
||
let episodic_capacity = self.config.episodic_capacity;
|
||
|
||
let mut episodic_indices: Vec<usize> = self
|
||
.records
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(_, r)| r.tier == MemoryTier::Episodic)
|
||
.map(|(i, _)| i)
|
||
.collect();
|
||
|
||
let episodic_count = episodic_indices.len();
|
||
|
||
if episodic_count > episodic_capacity {
|
||
episodic_indices.sort_by(|&a, &b| {
|
||
let da = DecayCalculator::compute_decay(&self.records[a], now, episodic_lambda);
|
||
let db = DecayCalculator::compute_decay(&self.records[b], now, episodic_lambda);
|
||
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
|
||
});
|
||
|
||
let evict_n = episodic_count - episodic_capacity;
|
||
let evict_ids: std::collections::HashSet<u64> = episodic_indices[..evict_n]
|
||
.iter()
|
||
.map(|&i| self.records[i].id)
|
||
.collect();
|
||
|
||
self.records.retain(|r| !evict_ids.contains(&r.id));
|
||
self.stats.total_evictions += evict_n as u64;
|
||
}
|
||
}
|
||
|
||
/// Return live per-tier counts merged with running totals.
|
||
pub fn get_stats(&self) -> ConsolidationStats {
|
||
let mut stats = self.stats.clone();
|
||
stats.working_count = self
|
||
.records
|
||
.iter()
|
||
.filter(|r| r.tier == MemoryTier::Working)
|
||
.count();
|
||
stats.episodic_count = self
|
||
.records
|
||
.iter()
|
||
.filter(|r| r.tier == MemoryTier::Episodic)
|
||
.count();
|
||
stats.semantic_count = self
|
||
.records
|
||
.iter()
|
||
.filter(|r| r.tier == MemoryTier::Semantic)
|
||
.count();
|
||
stats
|
||
}
|
||
|
||
/// Slice over all records.
|
||
pub fn records(&self) -> &[MemoryRecord] {
|
||
&self.records
|
||
}
|
||
|
||
/// Look up a record by id.
|
||
pub fn get_by_id(&self, id: u64) -> Option<&MemoryRecord> {
|
||
self.records.iter().find(|r| r.id == id)
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
// Helper: build a simple normalised embedding of given dimension.
|
||
fn unit_vec(dim: usize, hot: usize) -> Vec<f32> {
|
||
let mut v = vec![0.0f32; dim];
|
||
v[hot % dim] = 1.0;
|
||
v
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 1. Default config values
|
||
// ---------------------------------------------------------------------------
|
||
#[test]
|
||
fn test_memory_tiers_default_config() {
|
||
let cfg = ConsolidationConfig::default();
|
||
assert_eq!(cfg.working_capacity, 100);
|
||
assert_eq!(cfg.episodic_capacity, 10_000);
|
||
assert!((cfg.working_to_episodic_threshold - 0.6_f32).abs() < f32::EPSILON);
|
||
assert_eq!(cfg.episodic_to_semantic_threshold, 10);
|
||
// Verify half-lives roughly: λ = ln2/T → T = ln2/λ
|
||
let working_half_life = std::f64::consts::LN_2 / cfg.episodic_lambda;
|
||
let semantic_half_life = std::f64::consts::LN_2 / cfg.semantic_lambda;
|
||
assert!((working_half_life - 604_800.0).abs() < 1.0);
|
||
assert!((semantic_half_life - 2_592_000.0).abs() < 1.0);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 2. Add memory — basic
|
||
// ---------------------------------------------------------------------------
|
||
/// add_trusted_memory(TrustedSource::Correction) must actually produce a
|
||
/// MemorySource::Correction record — the only way to reach that elevated
|
||
/// classification, since add_memory's UntrustedSource has no such variant.
|
||
#[test]
|
||
fn test_add_trusted_memory_sets_correction_source() {
|
||
let mut engine = ConsolidationEngine::new(ConsolidationConfig::default());
|
||
let id = engine.add_trusted_memory(
|
||
"verified correction".to_string(),
|
||
unit_vec(4, 0),
|
||
TrustedSource::Correction,
|
||
0.0,
|
||
);
|
||
let rec = engine.get_by_id(id).unwrap();
|
||
assert_eq!(rec.source, MemorySource::Correction);
|
||
}
|
||
|
||
/// add_trusted_memory(TrustedSource::System) must produce a
|
||
/// MemorySource::System record.
|
||
#[test]
|
||
fn test_add_trusted_memory_sets_system_source() {
|
||
let mut engine = ConsolidationEngine::new(ConsolidationConfig::default());
|
||
let id = engine.add_trusted_memory(
|
||
"bootstrap text".to_string(),
|
||
unit_vec(4, 0),
|
||
TrustedSource::System,
|
||
0.0,
|
||
);
|
||
let rec = engine.get_by_id(id).unwrap();
|
||
assert_eq!(rec.source, MemorySource::System);
|
||
}
|
||
|
||
#[test]
|
||
fn test_add_memory_basic() {
|
||
let mut engine = ConsolidationEngine::new(ConsolidationConfig::default());
|
||
let id = engine.add_memory(
|
||
"Hello world".to_string(),
|
||
unit_vec(4, 0),
|
||
UntrustedSource::User,
|
||
1_000_000.0,
|
||
);
|
||
assert_eq!(id, 0);
|
||
assert_eq!(engine.records().len(), 1);
|
||
let rec = engine.get_by_id(0).unwrap();
|
||
assert_eq!(rec.tier, MemoryTier::Working);
|
||
assert_eq!(rec.access_count, 0);
|
||
assert!((rec.last_accessed - 1_000_000.0_f64).abs() < f64::EPSILON);
|
||
assert!((rec.created_at - 1_000_000.0_f64).abs() < f64::EPSILON);
|
||
assert_eq!(rec.source, MemorySource::User);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 3. Surprise score — no existing memories
|
||
// ---------------------------------------------------------------------------
|
||
#[test]
|
||
fn test_importance_scorer_surprise_no_memories() {
|
||
let score = ImportanceScorer::score_surprise(&unit_vec(4, 0), &[]);
|
||
assert!((score - 1.0_f32).abs() < f32::EPSILON);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 4. Surprise score — identical embedding
|
||
// ---------------------------------------------------------------------------
|
||
#[test]
|
||
fn test_importance_scorer_surprise_identical() {
|
||
let emb = unit_vec(4, 0);
|
||
let existing = [MemoryRecord {
|
||
id: 0,
|
||
chunk: "existing".to_string(),
|
||
embedding: emb.clone(),
|
||
tier: MemoryTier::Working,
|
||
importance: 0.5,
|
||
access_count: 0,
|
||
last_accessed: 0.0,
|
||
created_at: 0.0,
|
||
source: MemorySource::User,
|
||
}];
|
||
let existing_refs: Vec<&MemoryRecord> = existing.iter().collect();
|
||
let score = ImportanceScorer::score_surprise(&emb, &existing_refs);
|
||
assert!(score < 0.01, "expected ~0.0, got {score}");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 5. Correction score
|
||
// ---------------------------------------------------------------------------
|
||
#[test]
|
||
fn test_importance_scorer_correction() {
|
||
assert!(
|
||
(ImportanceScorer::score_correction(&MemorySource::Correction) - 1.0_f32).abs()
|
||
< f32::EPSILON
|
||
);
|
||
assert!((ImportanceScorer::score_correction(&MemorySource::User)).abs() < f32::EPSILON);
|
||
assert!((ImportanceScorer::score_correction(&MemorySource::System)).abs() < f32::EPSILON);
|
||
assert!((ImportanceScorer::score_correction(&MemorySource::Tool)).abs() < f32::EPSILON);
|
||
assert!(
|
||
(ImportanceScorer::score_correction(&MemorySource::Retrieval)).abs() < f32::EPSILON
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 6. Length score
|
||
// ---------------------------------------------------------------------------
|
||
#[test]
|
||
fn test_importance_scorer_length() {
|
||
assert!((ImportanceScorer::score_length("")).abs() < f32::EPSILON);
|
||
// 50 words → 0.5
|
||
let fifty_words = std::iter::repeat_n("word", 50)
|
||
.collect::<Vec<_>>()
|
||
.join(" ");
|
||
let s50 = ImportanceScorer::score_length(&fifty_words);
|
||
assert!((s50 - 0.5).abs() < 1e-5, "expected 0.5, got {s50}");
|
||
|
||
// 100 words → 1.0
|
||
let hundred_words = std::iter::repeat_n("word", 100)
|
||
.collect::<Vec<_>>()
|
||
.join(" ");
|
||
assert_eq!(ImportanceScorer::score_length(&hundred_words), 1.0);
|
||
|
||
// 200 words → still 1.0 (clamped)
|
||
let two_hundred = std::iter::repeat_n("word", 200)
|
||
.collect::<Vec<_>>()
|
||
.join(" ");
|
||
assert_eq!(ImportanceScorer::score_length(&two_hundred), 1.0);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 7. Combined scorer
|
||
// ---------------------------------------------------------------------------
|
||
#[test]
|
||
fn test_importance_scorer_combined() {
|
||
let weights = ImportanceWeights {
|
||
surprise: 0.5,
|
||
correction: 0.3,
|
||
length: 0.2,
|
||
};
|
||
// All 1.0 → should return 1.0
|
||
assert!(
|
||
(ImportanceScorer::score_combined(1.0, 1.0, 1.0, &weights) - 1.0_f32).abs()
|
||
< f32::EPSILON
|
||
);
|
||
assert!((ImportanceScorer::score_combined(0.0, 0.0, 0.0, &weights)).abs() < f32::EPSILON);
|
||
|
||
// Weighted: 0.5*0.5 + 0.0*0.3 + 1.0*0.2 = 0.25 + 0.0 + 0.20 = 0.45, total=1.0 → 0.45
|
||
let v = ImportanceScorer::score_combined(0.5, 0.0, 1.0, &weights);
|
||
assert!((v - 0.45).abs() < 1e-5, "expected 0.45, got {v}");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 8. Decay calculator
|
||
// ---------------------------------------------------------------------------
|
||
#[test]
|
||
fn test_decay_calculator() {
|
||
let rec = MemoryRecord {
|
||
id: 0,
|
||
chunk: "test".to_string(),
|
||
embedding: vec![1.0],
|
||
tier: MemoryTier::Episodic,
|
||
importance: 1.0,
|
||
access_count: 0,
|
||
last_accessed: 0.0,
|
||
created_at: 0.0,
|
||
source: MemorySource::User,
|
||
};
|
||
// At t=0 → decay = 1.0 * 1 * exp(0) = 1.0
|
||
let lambda = 0.001_f64;
|
||
let d0 = DecayCalculator::compute_decay(&rec, 0.0, lambda);
|
||
assert!((d0 - 1.0).abs() < 1e-5, "expected 1.0 at t=0, got {d0}");
|
||
|
||
// At t=1000 → decay = 1.0 * 1 * exp(-1.0) ≈ 0.3679
|
||
let d1 = DecayCalculator::compute_decay(&rec, 1000.0, lambda);
|
||
let expected = f64::exp(-1.0) as f32;
|
||
assert!(
|
||
(d1 - expected).abs() < 1e-4,
|
||
"expected {expected}, got {d1}"
|
||
);
|
||
|
||
// Higher access_count boosts the score
|
||
let rec2 = MemoryRecord {
|
||
access_count: 9,
|
||
..rec.clone()
|
||
};
|
||
let d2 = DecayCalculator::compute_decay(&rec2, 0.0, lambda);
|
||
assert!(
|
||
(d2 - 10.0).abs() < 1e-4,
|
||
"expected 10.0 with access_count=9, got {d2}"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 9. Consolidate — eviction from Working
|
||
// ---------------------------------------------------------------------------
|
||
#[test]
|
||
fn test_consolidate_eviction_working() {
|
||
let cfg = ConsolidationConfig {
|
||
working_capacity: 3,
|
||
working_to_episodic_threshold: 2.0, // never promote in this test
|
||
..Default::default()
|
||
};
|
||
let mut engine = ConsolidationEngine::new(cfg);
|
||
|
||
// Add 5 records; all have very low importance so none get promoted.
|
||
for i in 0..5_u64 {
|
||
let id = engine.add_memory(
|
||
"x".to_string(),
|
||
unit_vec(4, i as usize),
|
||
UntrustedSource::User,
|
||
i as f64,
|
||
);
|
||
// Force low importance so promotion threshold is not crossed.
|
||
engine
|
||
.records
|
||
.iter_mut()
|
||
.find(|r| r.id == id)
|
||
.unwrap()
|
||
.importance = 0.1;
|
||
}
|
||
assert_eq!(engine.records().len(), 5);
|
||
|
||
engine.consolidate(100.0);
|
||
|
||
// After eviction, working count should be <= 3.
|
||
let working = engine
|
||
.records()
|
||
.iter()
|
||
.filter(|r| r.tier == MemoryTier::Working)
|
||
.count();
|
||
assert!(working <= 3, "working count should be ≤ 3, got {working}");
|
||
assert!(engine.stats.total_evictions >= 2, "expected ≥ 2 evictions");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 10. Consolidate — promotion to Episodic
|
||
// ---------------------------------------------------------------------------
|
||
#[test]
|
||
fn test_consolidate_promotion_to_episodic() {
|
||
let cfg = ConsolidationConfig::default();
|
||
let mut engine = ConsolidationEngine::new(cfg);
|
||
|
||
let id = engine.add_trusted_memory(
|
||
"important memory".to_string(),
|
||
unit_vec(4, 0),
|
||
TrustedSource::Correction,
|
||
0.0,
|
||
);
|
||
// Force importance above threshold.
|
||
engine
|
||
.records
|
||
.iter_mut()
|
||
.find(|r| r.id == id)
|
||
.unwrap()
|
||
.importance = 0.9;
|
||
|
||
engine.consolidate(0.0);
|
||
|
||
let rec = engine.get_by_id(id).unwrap();
|
||
assert_eq!(
|
||
rec.tier,
|
||
MemoryTier::Episodic,
|
||
"record should have been promoted to Episodic"
|
||
);
|
||
assert!(engine.stats.total_promotions >= 1);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 11. Consolidate — promotion to Semantic
|
||
// ---------------------------------------------------------------------------
|
||
#[test]
|
||
fn test_consolidate_promotion_to_semantic() {
|
||
let cfg = ConsolidationConfig::default(); // threshold = 10
|
||
let mut engine = ConsolidationEngine::new(cfg);
|
||
|
||
let id = engine.add_memory(
|
||
"frequently accessed".to_string(),
|
||
unit_vec(4, 0),
|
||
UntrustedSource::User,
|
||
0.0,
|
||
);
|
||
|
||
// Place record directly in Episodic tier with high access count.
|
||
{
|
||
let rec = engine.records.iter_mut().find(|r| r.id == id).unwrap();
|
||
rec.tier = MemoryTier::Episodic;
|
||
rec.access_count = 11; // > threshold of 10
|
||
}
|
||
|
||
engine.consolidate(0.0);
|
||
|
||
let rec = engine.get_by_id(id).unwrap();
|
||
assert_eq!(
|
||
rec.tier,
|
||
MemoryTier::Semantic,
|
||
"record should have been promoted to Semantic"
|
||
);
|
||
assert!(engine.stats.total_promotions >= 1);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 12. access_memory — increments count and timestamp
|
||
// ---------------------------------------------------------------------------
|
||
#[test]
|
||
fn test_access_memory_reactivation() {
|
||
let mut engine = ConsolidationEngine::new(ConsolidationConfig::default());
|
||
let id = engine.add_memory(
|
||
"chunk".to_string(),
|
||
unit_vec(4, 0),
|
||
UntrustedSource::User,
|
||
0.0,
|
||
);
|
||
|
||
engine.access_memory(id, 5000.0);
|
||
let rec = engine.get_by_id(id).unwrap();
|
||
assert_eq!(rec.access_count, 1);
|
||
assert!((rec.last_accessed - 5000.0_f64).abs() < f64::EPSILON);
|
||
|
||
engine.access_memory(id, 9999.0);
|
||
let rec = engine.get_by_id(id).unwrap();
|
||
assert_eq!(rec.access_count, 2);
|
||
assert!((rec.last_accessed - 9999.0_f64).abs() < f64::EPSILON);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 13. get_stats — counts match record tiers
|
||
// ---------------------------------------------------------------------------
|
||
#[test]
|
||
fn test_get_stats() {
|
||
let mut engine = ConsolidationEngine::new(ConsolidationConfig::default());
|
||
|
||
// 2 Working
|
||
engine.add_memory("w1".to_string(), unit_vec(4, 0), UntrustedSource::User, 0.0);
|
||
engine.add_memory("w2".to_string(), unit_vec(4, 1), UntrustedSource::User, 0.0);
|
||
|
||
// 1 Episodic (manually set)
|
||
let id_e = engine.add_memory("e1".to_string(), unit_vec(4, 2), UntrustedSource::User, 0.0);
|
||
engine
|
||
.records
|
||
.iter_mut()
|
||
.find(|r| r.id == id_e)
|
||
.unwrap()
|
||
.tier = MemoryTier::Episodic;
|
||
|
||
// 1 Semantic (manually set)
|
||
let id_s = engine.add_memory("s1".to_string(), unit_vec(4, 3), UntrustedSource::User, 0.0);
|
||
engine
|
||
.records
|
||
.iter_mut()
|
||
.find(|r| r.id == id_s)
|
||
.unwrap()
|
||
.tier = MemoryTier::Semantic;
|
||
|
||
let stats = engine.get_stats();
|
||
assert_eq!(stats.working_count, 2);
|
||
assert_eq!(stats.episodic_count, 1);
|
||
assert_eq!(stats.semantic_count, 1);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 14. Consolidate — Episodic eviction over capacity
|
||
// ---------------------------------------------------------------------------
|
||
#[test]
|
||
fn test_consolidate_episodic_eviction() {
|
||
let cfg = ConsolidationConfig {
|
||
episodic_capacity: 3,
|
||
working_to_episodic_threshold: 2.0, // never auto-promote from Working
|
||
..Default::default()
|
||
};
|
||
let mut engine = ConsolidationEngine::new(cfg);
|
||
|
||
// Seed 5 records directly in Episodic.
|
||
for i in 0..5_u64 {
|
||
let id = engine.add_memory(
|
||
"episodic chunk".to_string(),
|
||
unit_vec(4, i as usize),
|
||
UntrustedSource::User,
|
||
i as f64,
|
||
);
|
||
let rec = engine.records.iter_mut().find(|r| r.id == id).unwrap();
|
||
rec.tier = MemoryTier::Episodic;
|
||
rec.importance = 0.5;
|
||
rec.access_count = 1;
|
||
}
|
||
|
||
assert_eq!(engine.records().len(), 5);
|
||
engine.consolidate(100_000.0);
|
||
|
||
let episodic = engine
|
||
.records()
|
||
.iter()
|
||
.filter(|r| r.tier == MemoryTier::Episodic)
|
||
.count();
|
||
assert!(
|
||
episodic <= 3,
|
||
"episodic count should be ≤ 3, got {episodic}"
|
||
);
|
||
assert!(
|
||
engine.stats.total_evictions >= 2,
|
||
"expected ≥ 2 episodic evictions"
|
||
);
|
||
}
|
||
}
|