Initial commit: Vestige v1.0.0 - Cognitive memory MCP server

FSRS-6 spaced repetition, spreading activation, synaptic tagging,
hippocampal indexing, and 130 years of memory research.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-01-25 01:31:03 -06:00
commit f9c60eb5a7
169 changed files with 97206 additions and 0 deletions

View file

@ -0,0 +1,390 @@
//! Test Database Manager
//!
//! Provides isolated database instances for testing:
//! - Temporary databases that are automatically cleaned up
//! - Pre-seeded databases with test data
//! - Database snapshots and restoration
//! - Concurrent test isolation
use vestige_core::{KnowledgeNode, Rating, Storage};
use std::path::PathBuf;
use tempfile::TempDir;
/// Helper to create IngestInput (works around non_exhaustive)
fn make_ingest_input(
content: String,
node_type: String,
tags: Vec<String>,
sentiment_score: f64,
sentiment_magnitude: f64,
source: Option<String>,
valid_from: Option<chrono::DateTime<chrono::Utc>>,
valid_until: Option<chrono::DateTime<chrono::Utc>>,
) -> vestige_core::IngestInput {
let mut input = vestige_core::IngestInput::default();
input.content = content;
input.node_type = node_type;
input.tags = tags;
input.sentiment_score = sentiment_score;
input.sentiment_magnitude = sentiment_magnitude;
input.source = source;
input.valid_from = valid_from;
input.valid_until = valid_until;
input
}
/// Manager for test databases
///
/// Creates isolated database instances for each test to prevent interference.
/// Automatically cleans up temporary databases when dropped.
///
/// # Example
///
/// ```rust,ignore
/// let mut db = TestDatabaseManager::new_temp();
///
/// // Use the storage
/// db.storage.ingest(IngestInput { ... });
///
/// // Database is automatically deleted when `db` goes out of scope
/// ```
pub struct TestDatabaseManager {
/// The storage instance
pub storage: Storage,
/// Temporary directory (kept alive to prevent premature deletion)
_temp_dir: Option<TempDir>,
/// Path to the database file
db_path: PathBuf,
/// Snapshot data for restore operations
snapshot: Option<Vec<KnowledgeNode>>,
}
impl TestDatabaseManager {
/// Create a new test database in a temporary directory
///
/// The database is automatically deleted when the manager is dropped.
pub fn new_temp() -> Self {
let temp_dir = TempDir::new().expect("Failed to create temp directory");
let db_path = temp_dir.path().join("test_vestige.db");
let storage = Storage::new(Some(db_path.clone())).expect("Failed to create test storage");
Self {
storage,
_temp_dir: Some(temp_dir),
db_path,
snapshot: None,
}
}
/// Create a test database at a specific path
///
/// The database is NOT automatically deleted.
pub fn new_at_path(path: PathBuf) -> Self {
let storage = Storage::new(Some(path.clone())).expect("Failed to create test storage");
Self {
storage,
_temp_dir: None,
db_path: path,
snapshot: None,
}
}
/// Get the database path
pub fn path(&self) -> &PathBuf {
&self.db_path
}
/// Check if the database is empty
pub fn is_empty(&self) -> bool {
self.storage
.get_stats()
.map(|s| s.total_nodes == 0)
.unwrap_or(true)
}
/// Get the number of nodes in the database
pub fn node_count(&self) -> i64 {
self.storage
.get_stats()
.map(|s| s.total_nodes)
.unwrap_or(0)
}
// ========================================================================
// SEEDING METHODS
// ========================================================================
/// Seed the database with a specified number of test nodes
pub fn seed_nodes(&mut self, count: usize) -> Vec<String> {
let mut ids = Vec::with_capacity(count);
for i in 0..count {
let input = make_ingest_input(
format!("Test memory content {}", i),
"fact".to_string(),
vec![format!("test-{}", i % 5)],
0.0,
0.0,
None,
None,
None,
);
if let Ok(node) = self.storage.ingest(input) {
ids.push(node.id);
}
}
ids
}
/// Seed with diverse node types
pub fn seed_diverse(&mut self, count_per_type: usize) -> Vec<String> {
let types = ["fact", "concept", "procedure", "event", "code"];
let mut ids = Vec::with_capacity(count_per_type * types.len());
for node_type in types {
for i in 0..count_per_type {
let input = make_ingest_input(
format!("Test {} content {}", node_type, i),
node_type.to_string(),
vec![node_type.to_string()],
0.0,
0.0,
None,
None,
None,
);
if let Ok(node) = self.storage.ingest(input) {
ids.push(node.id);
}
}
}
ids
}
/// Seed with nodes having various retention states
pub fn seed_with_retention_states(&mut self) -> Vec<String> {
let mut ids = Vec::new();
// New node (never reviewed)
let input = make_ingest_input(
"New memory - never reviewed".to_string(),
"fact".to_string(),
vec!["new".to_string()],
0.0,
0.0,
None,
None,
None,
);
if let Ok(node) = self.storage.ingest(input) {
ids.push(node.id);
}
// Well-learned node (multiple good reviews)
let input = make_ingest_input(
"Well-learned memory - reviewed multiple times".to_string(),
"fact".to_string(),
vec!["learned".to_string()],
0.0,
0.0,
None,
None,
None,
);
if let Ok(node) = self.storage.ingest(input) {
let _ = self.storage.mark_reviewed(&node.id, Rating::Good);
let _ = self.storage.mark_reviewed(&node.id, Rating::Good);
let _ = self.storage.mark_reviewed(&node.id, Rating::Easy);
ids.push(node.id);
}
// Struggling node (multiple lapses)
let input = make_ingest_input(
"Struggling memory - has lapses".to_string(),
"fact".to_string(),
vec!["struggling".to_string()],
0.0,
0.0,
None,
None,
None,
);
if let Ok(node) = self.storage.ingest(input) {
let _ = self.storage.mark_reviewed(&node.id, Rating::Again);
let _ = self.storage.mark_reviewed(&node.id, Rating::Hard);
let _ = self.storage.mark_reviewed(&node.id, Rating::Again);
ids.push(node.id);
}
ids
}
/// Seed with emotional memories (different sentiment magnitudes)
pub fn seed_emotional(&mut self, count: usize) -> Vec<String> {
let mut ids = Vec::with_capacity(count);
for i in 0..count {
let magnitude = (i as f64) / (count as f64);
let input = make_ingest_input(
format!("Emotional memory with magnitude {:.2}", magnitude),
"event".to_string(),
vec!["emotional".to_string()],
if i % 2 == 0 { 0.8 } else { -0.8 },
magnitude,
None,
None,
None,
);
if let Ok(node) = self.storage.ingest(input) {
ids.push(node.id);
}
}
ids
}
// ========================================================================
// SNAPSHOT/RESTORE
// ========================================================================
/// Take a snapshot of current database state
pub fn take_snapshot(&mut self) {
let nodes = self
.storage
.get_all_nodes(10000, 0)
.unwrap_or_default();
self.snapshot = Some(nodes);
}
/// Restore from the last snapshot
///
/// Note: This clears the database and re-inserts all nodes from snapshot.
/// IDs will NOT be preserved (new UUIDs are generated).
pub fn restore_snapshot(&mut self) -> bool {
if let Some(nodes) = self.snapshot.take() {
// Clear current data by recreating storage
// Delete the database file first
let _ = std::fs::remove_file(&self.db_path);
self.storage = Storage::new(Some(self.db_path.clone()))
.expect("Failed to recreate storage for restore");
// Re-insert nodes
for node in nodes {
let input = make_ingest_input(
node.content,
node.node_type,
node.tags,
node.sentiment_score,
node.sentiment_magnitude,
node.source,
node.valid_from,
node.valid_until,
);
let _ = self.storage.ingest(input);
}
true
} else {
false
}
}
/// Check if a snapshot exists
pub fn has_snapshot(&self) -> bool {
self.snapshot.is_some()
}
// ========================================================================
// CLEANUP
// ========================================================================
/// Clear all data from the database
pub fn clear(&mut self) {
// Get all node IDs and delete them
if let Ok(nodes) = self.storage.get_all_nodes(10000, 0) {
for node in nodes {
let _ = self.storage.delete_node(&node.id);
}
}
}
/// Recreate the database (useful for testing migrations)
pub fn recreate(&mut self) {
// Delete the database file
let _ = std::fs::remove_file(&self.db_path);
// Recreate storage
self.storage = Storage::new(Some(self.db_path.clone()))
.expect("Failed to recreate storage");
}
}
impl Drop for TestDatabaseManager {
fn drop(&mut self) {
// Storage is dropped automatically
// TempDir (if Some) will clean up the temp directory
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_temp_database_creation() {
let db = TestDatabaseManager::new_temp();
assert!(db.is_empty());
assert!(db.path().exists());
}
#[test]
fn test_seed_nodes() {
let mut db = TestDatabaseManager::new_temp();
let ids = db.seed_nodes(10);
assert_eq!(ids.len(), 10);
assert_eq!(db.node_count(), 10);
}
#[test]
fn test_seed_diverse() {
let mut db = TestDatabaseManager::new_temp();
let ids = db.seed_diverse(3);
// 5 types * 3 each = 15
assert_eq!(ids.len(), 15);
assert_eq!(db.node_count(), 15);
}
#[test]
fn test_clear_database() {
let mut db = TestDatabaseManager::new_temp();
db.seed_nodes(5);
assert_eq!(db.node_count(), 5);
db.clear();
assert!(db.is_empty());
}
#[test]
fn test_snapshot_restore() {
let mut db = TestDatabaseManager::new_temp();
db.seed_nodes(5);
db.take_snapshot();
assert!(db.has_snapshot());
db.clear();
assert!(db.is_empty());
db.restore_snapshot();
assert_eq!(db.node_count(), 5);
}
}

View file

@ -0,0 +1,11 @@
//! Test Harness Module
//!
//! Provides test setup utilities:
//! - `TimeTravelEnvironment` for testing time-dependent behavior (decay, scheduling)
//! - `TestDatabaseManager` for isolated test databases
mod db_manager;
mod time_travel;
pub use db_manager::TestDatabaseManager;
pub use time_travel::TimeTravelEnvironment;

View file

@ -0,0 +1,342 @@
//! Time Travel Environment for Testing Decay
//!
//! Enables testing of time-dependent memory behavior:
//! - FSRS-6 scheduling and intervals
//! - Memory decay (retrieval strength degradation)
//! - Temporal validity periods
//! - Consolidation timing
//!
//! Uses a virtual clock that can be advanced without waiting.
use chrono::{DateTime, Duration, Utc};
use std::cell::RefCell;
/// Environment for testing time-dependent memory behavior
///
/// Provides a virtual clock that can be advanced to test:
/// - Memory decay over time
/// - FSRS-6 scheduling calculations
/// - Temporal validity windows
/// - Consolidation cycles
///
/// # Example
///
/// ```rust,ignore
/// let mut env = TimeTravelEnvironment::new();
///
/// // Start at a known time
/// env.set_time(Utc::now());
///
/// // Advance 30 days to test decay
/// env.advance_days(30);
///
/// // Check retrievability at this point
/// let elapsed = env.days_since(original_time);
/// ```
pub struct TimeTravelEnvironment {
/// Current virtual time
current_time: RefCell<DateTime<Utc>>,
/// Original start time for reference
start_time: DateTime<Utc>,
/// History of time jumps for debugging
time_history: RefCell<Vec<TimeJump>>,
}
/// Record of a time jump for debugging
#[derive(Debug, Clone)]
pub struct TimeJump {
pub from: DateTime<Utc>,
pub to: DateTime<Utc>,
pub reason: String,
}
impl Default for TimeTravelEnvironment {
fn default() -> Self {
Self::new()
}
}
impl TimeTravelEnvironment {
/// Create a new time travel environment starting at the current time
pub fn new() -> Self {
let now = Utc::now();
Self {
current_time: RefCell::new(now),
start_time: now,
time_history: RefCell::new(Vec::new()),
}
}
/// Create environment at a specific starting time
pub fn at(time: DateTime<Utc>) -> Self {
Self {
current_time: RefCell::new(time),
start_time: time,
time_history: RefCell::new(Vec::new()),
}
}
/// Get the current virtual time
pub fn now(&self) -> DateTime<Utc> {
*self.current_time.borrow()
}
/// Get the original start time
pub fn start_time(&self) -> DateTime<Utc> {
self.start_time
}
/// Set the current time to a specific point
pub fn set_time(&self, time: DateTime<Utc>) {
let from = *self.current_time.borrow();
self.time_history.borrow_mut().push(TimeJump {
from,
to: time,
reason: "set_time".to_string(),
});
*self.current_time.borrow_mut() = time;
}
/// Advance time by a duration
pub fn advance(&self, duration: Duration) {
let from = *self.current_time.borrow();
let to = from + duration;
self.time_history.borrow_mut().push(TimeJump {
from,
to,
reason: format!("advance {:?}", duration),
});
*self.current_time.borrow_mut() = to;
}
/// Advance time by the specified number of days
pub fn advance_days(&self, days: i64) {
self.advance(Duration::days(days));
}
/// Advance time by the specified number of hours
pub fn advance_hours(&self, hours: i64) {
self.advance(Duration::hours(hours));
}
/// Advance time by the specified number of minutes
pub fn advance_minutes(&self, minutes: i64) {
self.advance(Duration::minutes(minutes));
}
/// Advance time by the specified number of seconds
pub fn advance_seconds(&self, seconds: i64) {
self.advance(Duration::seconds(seconds));
}
/// Calculate days elapsed since a reference time
pub fn days_since(&self, reference: DateTime<Utc>) -> f64 {
let current = *self.current_time.borrow();
(current - reference).num_seconds() as f64 / 86400.0
}
/// Calculate days elapsed since the start time
pub fn days_since_start(&self) -> f64 {
self.days_since(self.start_time)
}
/// Calculate hours elapsed since a reference time
pub fn hours_since(&self, reference: DateTime<Utc>) -> f64 {
let current = *self.current_time.borrow();
(current - reference).num_seconds() as f64 / 3600.0
}
/// Get time history for debugging
pub fn get_history(&self) -> Vec<TimeJump> {
self.time_history.borrow().clone()
}
/// Clear time history
pub fn clear_history(&self) {
self.time_history.borrow_mut().clear();
}
/// Reset to start time
pub fn reset(&self) {
let from = *self.current_time.borrow();
self.time_history.borrow_mut().push(TimeJump {
from,
to: self.start_time,
reason: "reset".to_string(),
});
*self.current_time.borrow_mut() = self.start_time;
}
// ========================================================================
// DECAY TESTING HELPERS
// ========================================================================
/// Calculate expected retrievability at current time
///
/// Uses FSRS-6 power forgetting curve:
/// R = (1 + factor * t / S)^(-w20)
pub fn expected_retrievability(&self, stability: f64, last_review: DateTime<Utc>) -> f64 {
let elapsed_days = self.days_since(last_review);
vestige_core::retrievability(stability, elapsed_days)
}
/// Calculate expected retrievability with custom decay
pub fn expected_retrievability_with_decay(
&self,
stability: f64,
last_review: DateTime<Utc>,
w20: f64,
) -> f64 {
let elapsed_days = self.days_since(last_review);
vestige_core::retrievability_with_decay(stability, elapsed_days, w20)
}
/// Check if a memory would be due for review at current time
pub fn is_due(&self, next_review: DateTime<Utc>) -> bool {
*self.current_time.borrow() >= next_review
}
/// Calculate how overdue a memory is (negative if not yet due)
pub fn days_overdue(&self, next_review: DateTime<Utc>) -> f64 {
self.days_since(next_review)
}
// ========================================================================
// SCHEDULING HELPERS
// ========================================================================
/// Advance to when a memory would be due
pub fn advance_to_due(&self, next_review: DateTime<Utc>) {
let from = *self.current_time.borrow();
self.time_history.borrow_mut().push(TimeJump {
from,
to: next_review,
reason: "advance_to_due".to_string(),
});
*self.current_time.borrow_mut() = next_review;
}
/// Advance past due date by specified days
pub fn advance_past_due(&self, next_review: DateTime<Utc>, days_overdue: i64) {
let target = next_review + Duration::days(days_overdue);
let from = *self.current_time.borrow();
self.time_history.borrow_mut().push(TimeJump {
from,
to: target,
reason: format!("advance_past_due +{} days", days_overdue),
});
*self.current_time.borrow_mut() = target;
}
// ========================================================================
// TEMPORAL VALIDITY HELPERS
// ========================================================================
/// Check if a time is within a validity window
pub fn is_within_validity(
&self,
valid_from: Option<DateTime<Utc>>,
valid_until: Option<DateTime<Utc>>,
) -> bool {
let current = *self.current_time.borrow();
let after_start = valid_from.map(|t| current >= t).unwrap_or(true);
let before_end = valid_until.map(|t| current <= t).unwrap_or(true);
after_start && before_end
}
/// Advance to just before validity starts
pub fn advance_to_before_validity(&self, valid_from: DateTime<Utc>) {
let target = valid_from - Duration::seconds(1);
self.set_time(target);
}
/// Advance to just after validity ends
pub fn advance_to_after_validity(&self, valid_until: DateTime<Utc>) {
let target = valid_until + Duration::seconds(1);
self.set_time(target);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_time_travel_basic() {
let env = TimeTravelEnvironment::new();
let start = env.now();
env.advance_days(10);
assert!(env.days_since(start) >= 9.99);
assert!(env.days_since(start) <= 10.01);
}
#[test]
fn test_time_travel_reset() {
let env = TimeTravelEnvironment::new();
let start = env.start_time();
env.advance_days(100);
env.reset();
assert_eq!(env.now(), start);
}
#[test]
fn test_retrievability_decay() {
let env = TimeTravelEnvironment::new();
let stability = 10.0;
let last_review = env.now();
// At t=0, retrievability should be ~1.0
let r0 = env.expected_retrievability(stability, last_review);
assert!(r0 > 0.99);
// After 10 days with stability=10, retrievability should be ~0.9
env.advance_days(10);
let r10 = env.expected_retrievability(stability, last_review);
assert!(r10 < r0);
assert!(r10 > 0.85 && r10 < 0.95);
// After 30 days, retrievability should be much lower
env.advance_days(20);
let r30 = env.expected_retrievability(stability, last_review);
assert!(r30 < r10);
}
#[test]
fn test_due_date_checking() {
let env = TimeTravelEnvironment::new();
let next_review = env.now() + Duration::days(5);
// Not due yet
assert!(!env.is_due(next_review));
assert!(env.days_overdue(next_review) < 0.0);
// Advance to due
env.advance_to_due(next_review);
assert!(env.is_due(next_review));
assert!(env.days_overdue(next_review).abs() < 0.01);
// Advance past due
env.advance_days(3);
assert!(env.is_due(next_review));
assert!(env.days_overdue(next_review) > 2.99);
}
#[test]
fn test_history_tracking() {
let env = TimeTravelEnvironment::new();
env.advance_days(1);
env.advance_hours(12);
env.advance_minutes(30);
let history = env.get_history();
assert_eq!(history.len(), 3);
env.clear_history();
assert!(env.get_history().is_empty());
}
}