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,374 @@
//! Memory module - Core types and data structures
//!
//! Implements the cognitive memory model with:
//! - Knowledge nodes with FSRS-6 scheduling state
//! - Dual-strength model (Bjork & Bjork 1992)
//! - Temporal memory with bi-temporal validity
//! - Semantic embedding metadata
mod node;
mod strength;
mod temporal;
pub use node::{IngestInput, KnowledgeNode, NodeType, RecallInput, SearchMode};
pub use strength::{DualStrength, StrengthDecay};
pub use temporal::{TemporalRange, TemporalValidity};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
// ============================================================================
// GOD TIER 2026: MEMORY SCOPES (Like Mem0)
// ============================================================================
/// Memory scope - controls persistence and sharing behavior
/// Competes with Mem0's User/Session/Agent model
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
#[serde(rename_all = "lowercase")]
pub enum MemoryScope {
/// Per-session memory, cleared on restart (working memory)
Session,
/// Per-user memory, persists across sessions (long-term memory)
#[default]
User,
/// Global agent knowledge, shared across all users (world knowledge)
Agent,
}
impl std::fmt::Display for MemoryScope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MemoryScope::Session => write!(f, "session"),
MemoryScope::User => write!(f, "user"),
MemoryScope::Agent => write!(f, "agent"),
}
}
}
impl std::str::FromStr for MemoryScope {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"session" => Ok(MemoryScope::Session),
"user" => Ok(MemoryScope::User),
"agent" => Ok(MemoryScope::Agent),
_ => Err(format!("Unknown scope: {}", s)),
}
}
}
// ============================================================================
// GOD TIER 2026: MEMORY SYSTEMS (Tulving 1972)
// ============================================================================
/// Memory system classification (based on Tulving's memory systems)
/// - Episodic: Events, conversations, specific moments (decays faster)
/// - Semantic: Facts, concepts, generalizations (stable)
/// - Procedural: How-to knowledge (never decays)
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
#[serde(rename_all = "lowercase")]
pub enum MemorySystem {
/// What happened - events, conversations, specific moments
/// Decays faster than semantic memories
Episodic,
/// What I know - facts, concepts, generalizations
/// More stable, the default for most knowledge
#[default]
Semantic,
/// How-to knowledge - skills, procedures
/// Never decays (like riding a bike)
Procedural,
}
impl std::fmt::Display for MemorySystem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MemorySystem::Episodic => write!(f, "episodic"),
MemorySystem::Semantic => write!(f, "semantic"),
MemorySystem::Procedural => write!(f, "procedural"),
}
}
}
impl std::str::FromStr for MemorySystem {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"episodic" => Ok(MemorySystem::Episodic),
"semantic" => Ok(MemorySystem::Semantic),
"procedural" => Ok(MemorySystem::Procedural),
_ => Err(format!("Unknown memory system: {}", s)),
}
}
}
// ============================================================================
// GOD TIER 2026: KNOWLEDGE GRAPH EDGES (Like Zep's Graphiti)
// ============================================================================
/// Type of relationship between knowledge nodes
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "lowercase")]
pub enum EdgeType {
/// Semantically related (similar meaning/topic)
Semantic,
/// Temporal relationship (happened before/after)
Temporal,
/// Causal relationship (A caused B)
Causal,
/// Derived knowledge (B is derived from A)
Derived,
/// Contradiction (A and B conflict)
Contradiction,
/// Refinement (B is a more specific version of A)
Refinement,
/// Part-of relationship (A is part of B)
PartOf,
/// User-defined relationship
Custom,
}
impl std::fmt::Display for EdgeType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EdgeType::Semantic => write!(f, "semantic"),
EdgeType::Temporal => write!(f, "temporal"),
EdgeType::Causal => write!(f, "causal"),
EdgeType::Derived => write!(f, "derived"),
EdgeType::Contradiction => write!(f, "contradiction"),
EdgeType::Refinement => write!(f, "refinement"),
EdgeType::PartOf => write!(f, "part_of"),
EdgeType::Custom => write!(f, "custom"),
}
}
}
impl std::str::FromStr for EdgeType {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"semantic" => Ok(EdgeType::Semantic),
"temporal" => Ok(EdgeType::Temporal),
"causal" => Ok(EdgeType::Causal),
"derived" => Ok(EdgeType::Derived),
"contradiction" => Ok(EdgeType::Contradiction),
"refinement" => Ok(EdgeType::Refinement),
"part_of" | "partof" => Ok(EdgeType::PartOf),
"custom" => Ok(EdgeType::Custom),
_ => Err(format!("Unknown edge type: {}", s)),
}
}
}
/// A directed edge in the knowledge graph
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KnowledgeEdge {
/// Unique edge ID
pub id: String,
/// Source node ID
pub source_id: String,
/// Target node ID
pub target_id: String,
/// Type of relationship
pub edge_type: EdgeType,
/// Edge weight (strength of relationship)
pub weight: f32,
/// When this relationship started being true
pub valid_from: Option<DateTime<Utc>>,
/// When this relationship stopped being true (None = still valid)
pub valid_until: Option<DateTime<Utc>>,
/// When the edge was created
pub created_at: DateTime<Utc>,
/// Who/what created the edge
pub created_by: Option<String>,
/// Confidence in this relationship (0-1)
pub confidence: f32,
/// Additional metadata as JSON
pub metadata: Option<String>,
}
impl KnowledgeEdge {
/// Create a new knowledge edge
pub fn new(source_id: String, target_id: String, edge_type: EdgeType) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
source_id,
target_id,
edge_type,
weight: 1.0,
valid_from: Some(chrono::Utc::now()),
valid_until: None,
created_at: chrono::Utc::now(),
created_by: None,
confidence: 1.0,
metadata: None,
}
}
/// Check if the edge is currently valid
pub fn is_valid(&self) -> bool {
self.valid_until.is_none()
}
/// Check if the edge was valid at a given time
pub fn was_valid_at(&self, time: DateTime<Utc>) -> bool {
let after_start = self.valid_from.map_or(true, |from| time >= from);
let before_end = self.valid_until.map_or(true, |until| time < until);
after_start && before_end
}
}
// ============================================================================
// MEMORY STATISTICS
// ============================================================================
/// Statistics about the memory system
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MemoryStats {
/// Total number of knowledge nodes
pub total_nodes: i64,
/// Nodes currently due for review
pub nodes_due_for_review: i64,
/// Average retention strength across all nodes
pub average_retention: f64,
/// Average storage strength (Bjork model)
pub average_storage_strength: f64,
/// Average retrieval strength (Bjork model)
pub average_retrieval_strength: f64,
/// Timestamp of the oldest memory
pub oldest_memory: Option<DateTime<Utc>>,
/// Timestamp of the newest memory
pub newest_memory: Option<DateTime<Utc>>,
/// Number of nodes with semantic embeddings
pub nodes_with_embeddings: i64,
/// Embedding model used (if any)
pub embedding_model: Option<String>,
}
impl Default for MemoryStats {
fn default() -> Self {
Self {
total_nodes: 0,
nodes_due_for_review: 0,
average_retention: 0.0,
average_storage_strength: 0.0,
average_retrieval_strength: 0.0,
oldest_memory: None,
newest_memory: None,
nodes_with_embeddings: 0,
embedding_model: None,
}
}
}
// ============================================================================
// CONSOLIDATION RESULT
// ============================================================================
/// Result of a memory consolidation run (sleep-inspired processing)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConsolidationResult {
/// Number of nodes processed
pub nodes_processed: i64,
/// Nodes promoted due to high importance/emotion
pub nodes_promoted: i64,
/// Nodes pruned due to low retention
pub nodes_pruned: i64,
/// Number of nodes with decay applied
pub decay_applied: i64,
/// Processing duration in milliseconds
pub duration_ms: i64,
/// Number of embeddings generated
pub embeddings_generated: i64,
}
impl Default for ConsolidationResult {
fn default() -> Self {
Self {
nodes_processed: 0,
nodes_promoted: 0,
nodes_pruned: 0,
decay_applied: 0,
duration_ms: 0,
embeddings_generated: 0,
}
}
}
// ============================================================================
// SEARCH RESULTS
// ============================================================================
/// Enhanced search result with relevance scores
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchResult {
/// The matched knowledge node
pub node: KnowledgeNode,
/// Keyword (BM25/FTS5) score if matched
pub keyword_score: Option<f32>,
/// Semantic (embedding) similarity if matched
pub semantic_score: Option<f32>,
/// Combined score after RRF fusion
pub combined_score: f32,
/// How the result was matched
pub match_type: MatchType,
}
/// How a search result was matched
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum MatchType {
/// Matched via keyword (BM25/FTS5) search only
Keyword,
/// Matched via semantic (embedding) search only
Semantic,
/// Matched via both keyword and semantic search
Both,
}
/// Semantic similarity search result
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SimilarityResult {
/// The matched knowledge node
pub node: KnowledgeNode,
/// Cosine similarity score (0.0 to 1.0)
pub similarity: f32,
}
// ============================================================================
// EMBEDDING RESULT
// ============================================================================
/// Result of embedding generation
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EmbeddingResult {
/// Successfully generated embeddings
pub successful: i64,
/// Failed embedding generations
pub failed: i64,
/// Skipped (already had embeddings)
pub skipped: i64,
/// Error messages for failures
pub errors: Vec<String>,
}
impl Default for EmbeddingResult {
fn default() -> Self {
Self {
successful: 0,
failed: 0,
skipped: 0,
errors: vec![],
}
}
}

View file

@ -0,0 +1,380 @@
//! Knowledge Node - The fundamental unit of memory
//!
//! Each node represents a discrete piece of knowledge with:
//! - Content and metadata
//! - FSRS-6 scheduling state
//! - Dual-strength retention model
//! - Temporal validity (bi-temporal)
//! - Embedding metadata
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
// ============================================================================
// NODE TYPES
// ============================================================================
/// Types of knowledge nodes
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum NodeType {
/// A discrete fact or piece of information
#[default]
Fact,
/// A concept or abstract idea
Concept,
/// A procedure or how-to knowledge
Procedure,
/// An event or experience
Event,
/// A relationship between entities
Relationship,
/// A quote or verbatim text
Quote,
/// Code or technical snippet
Code,
/// A question to be answered
Question,
/// User insight or reflection
Insight,
}
impl NodeType {
/// Convert to string representation
pub fn as_str(&self) -> &'static str {
match self {
NodeType::Fact => "fact",
NodeType::Concept => "concept",
NodeType::Procedure => "procedure",
NodeType::Event => "event",
NodeType::Relationship => "relationship",
NodeType::Quote => "quote",
NodeType::Code => "code",
NodeType::Question => "question",
NodeType::Insight => "insight",
}
}
/// Parse from string
pub fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"fact" => NodeType::Fact,
"concept" => NodeType::Concept,
"procedure" => NodeType::Procedure,
"event" => NodeType::Event,
"relationship" => NodeType::Relationship,
"quote" => NodeType::Quote,
"code" => NodeType::Code,
"question" => NodeType::Question,
"insight" => NodeType::Insight,
_ => NodeType::Fact,
}
}
}
impl std::fmt::Display for NodeType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
// ============================================================================
// KNOWLEDGE NODE
// ============================================================================
/// A knowledge node in the memory graph
///
/// Combines multiple memory science models:
/// - FSRS-6 for optimal review scheduling
/// - Bjork dual-strength for realistic forgetting
/// - Temporal validity for time-sensitive knowledge
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KnowledgeNode {
/// Unique identifier (UUID v4)
pub id: String,
/// The actual content/knowledge
pub content: String,
/// Type of knowledge (fact, concept, procedure, etc.)
pub node_type: String,
/// When the node was created
pub created_at: DateTime<Utc>,
/// When the node was last modified
pub updated_at: DateTime<Utc>,
/// When the node was last accessed/reviewed
pub last_accessed: DateTime<Utc>,
// ========== FSRS-6 State (21 parameters) ==========
/// Memory stability (days until 90% forgetting probability)
pub stability: f64,
/// Inherent difficulty (1.0 = easy, 10.0 = hard)
pub difficulty: f64,
/// Number of successful reviews
pub reps: i32,
/// Number of lapses (forgotten after learning)
pub lapses: i32,
// ========== Dual-Strength Model (Bjork & Bjork 1992) ==========
/// Storage strength - accumulated with practice, never decays
pub storage_strength: f64,
/// Retrieval strength - current accessibility, decays over time
pub retrieval_strength: f64,
/// Combined retention score (0.0 - 1.0)
pub retention_strength: f64,
// ========== Emotional Memory ==========
/// Sentiment polarity (-1.0 to 1.0)
pub sentiment_score: f64,
/// Sentiment intensity (0.0 to 1.0) - affects stability
pub sentiment_magnitude: f64,
// ========== Scheduling ==========
/// Next scheduled review date
pub next_review: Option<DateTime<Utc>>,
// ========== Provenance ==========
/// Source of the knowledge (URL, file, conversation, etc.)
pub source: Option<String>,
/// Tags for categorization
pub tags: Vec<String>,
// ========== Temporal Memory (Bi-temporal) ==========
/// When this knowledge became valid
#[serde(skip_serializing_if = "Option::is_none")]
pub valid_from: Option<DateTime<Utc>>,
/// When this knowledge stops being valid
#[serde(skip_serializing_if = "Option::is_none")]
pub valid_until: Option<DateTime<Utc>>,
// ========== Semantic Embedding ==========
/// Whether this node has an embedding vector
#[serde(skip_serializing_if = "Option::is_none")]
pub has_embedding: Option<bool>,
/// Which model generated the embedding
#[serde(skip_serializing_if = "Option::is_none")]
pub embedding_model: Option<String>,
}
impl Default for KnowledgeNode {
fn default() -> Self {
let now = Utc::now();
Self {
id: String::new(),
content: String::new(),
node_type: "fact".to_string(),
created_at: now,
updated_at: now,
last_accessed: now,
stability: 2.5,
difficulty: 5.0,
reps: 0,
lapses: 0,
storage_strength: 1.0,
retrieval_strength: 1.0,
retention_strength: 1.0,
sentiment_score: 0.0,
sentiment_magnitude: 0.0,
next_review: None,
source: None,
tags: vec![],
valid_from: None,
valid_until: None,
has_embedding: None,
embedding_model: None,
}
}
}
impl KnowledgeNode {
/// Create a new knowledge node with the given content
pub fn new(content: impl Into<String>) -> Self {
Self {
content: content.into(),
..Default::default()
}
}
/// Check if this node is currently valid (within temporal bounds)
pub fn is_valid_at(&self, time: DateTime<Utc>) -> bool {
let after_start = self.valid_from.map(|t| time >= t).unwrap_or(true);
let before_end = self.valid_until.map(|t| time <= t).unwrap_or(true);
after_start && before_end
}
/// Check if this node is currently valid (now)
pub fn is_currently_valid(&self) -> bool {
self.is_valid_at(Utc::now())
}
/// Check if this node is due for review
pub fn is_due(&self) -> bool {
self.next_review.map(|t| t <= Utc::now()).unwrap_or(true)
}
/// Get the parsed node type
pub fn get_node_type(&self) -> NodeType {
NodeType::from_str(&self.node_type)
}
}
// ============================================================================
// INPUT TYPES
// ============================================================================
/// Input for creating a new memory
///
/// Uses `deny_unknown_fields` to prevent field injection attacks.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct IngestInput {
/// The content to memorize
pub content: String,
/// Type of knowledge (fact, concept, procedure, etc.)
pub node_type: String,
/// Source of the knowledge
pub source: Option<String>,
/// Sentiment polarity (-1.0 to 1.0)
#[serde(default)]
pub sentiment_score: f64,
/// Sentiment intensity (0.0 to 1.0)
#[serde(default)]
pub sentiment_magnitude: f64,
/// Tags for categorization
#[serde(default)]
pub tags: Vec<String>,
/// When this knowledge becomes valid
#[serde(skip_serializing_if = "Option::is_none")]
pub valid_from: Option<DateTime<Utc>>,
/// When this knowledge stops being valid
#[serde(skip_serializing_if = "Option::is_none")]
pub valid_until: Option<DateTime<Utc>>,
}
impl Default for IngestInput {
fn default() -> Self {
Self {
content: String::new(),
node_type: "fact".to_string(),
source: None,
sentiment_score: 0.0,
sentiment_magnitude: 0.0,
tags: vec![],
valid_from: None,
valid_until: None,
}
}
}
/// Search mode for recall queries
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum SearchMode {
/// Keyword search only (FTS5/BM25)
Keyword,
/// Semantic search only (embeddings)
Semantic,
/// Hybrid search with RRF fusion (default, best results)
#[default]
Hybrid,
}
/// Input for recalling memories
///
/// Uses `deny_unknown_fields` to prevent field injection attacks.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct RecallInput {
/// Search query
pub query: String,
/// Maximum results to return
pub limit: i32,
/// Minimum retention strength (0.0 to 1.0)
#[serde(default)]
pub min_retention: f64,
/// Search mode (keyword, semantic, or hybrid)
#[serde(default)]
pub search_mode: SearchMode,
/// Only return results valid at this time
#[serde(skip_serializing_if = "Option::is_none")]
pub valid_at: Option<DateTime<Utc>>,
}
impl Default for RecallInput {
fn default() -> Self {
Self {
query: String::new(),
limit: 10,
min_retention: 0.0,
search_mode: SearchMode::Hybrid,
valid_at: None,
}
}
}
// ============================================================================
// TESTS
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_node_type_roundtrip() {
for node_type in [
NodeType::Fact,
NodeType::Concept,
NodeType::Procedure,
NodeType::Event,
NodeType::Code,
] {
assert_eq!(NodeType::from_str(node_type.as_str()), node_type);
}
}
#[test]
fn test_knowledge_node_default() {
let node = KnowledgeNode::default();
assert!(node.id.is_empty());
assert_eq!(node.node_type, "fact");
assert!(node.is_due());
assert!(node.is_currently_valid());
}
#[test]
fn test_temporal_validity() {
let mut node = KnowledgeNode::default();
let now = Utc::now();
// No bounds = always valid
assert!(node.is_valid_at(now));
// Set future valid_from = not valid now
node.valid_from = Some(now + chrono::Duration::days(1));
assert!(!node.is_valid_at(now));
// Set past valid_from = valid now
node.valid_from = Some(now - chrono::Duration::days(1));
assert!(node.is_valid_at(now));
// Set past valid_until = not valid now
node.valid_until = Some(now - chrono::Duration::hours(1));
assert!(!node.is_valid_at(now));
}
#[test]
fn test_ingest_input_deny_unknown_fields() {
// Valid input should parse
let json = r#"{"content": "test", "nodeType": "fact", "tags": []}"#;
let result: Result<IngestInput, _> = serde_json::from_str(json);
assert!(result.is_ok());
// Unknown field should fail (security feature)
let json_with_unknown =
r#"{"content": "test", "nodeType": "fact", "tags": [], "malicious_field": "attack"}"#;
let result: Result<IngestInput, _> = serde_json::from_str(json_with_unknown);
assert!(result.is_err());
}
}

View file

@ -0,0 +1,256 @@
//! Dual-Strength Memory Model (Bjork & Bjork, 1992)
//!
//! Implements the new theory of disuse which distinguishes between:
//!
//! - **Storage Strength**: How well-encoded the memory is. Increases with
//! each successful retrieval and never decays. Higher storage strength
//! means the memory can be relearned faster if forgotten.
//!
//! - **Retrieval Strength**: How accessible the memory is right now.
//! Decays over time following a power law (FSRS-6 compatible).
//! Higher retrieval strength means easier recall.
//!
//! Key insight: Difficult retrievals (low retrieval strength + high storage
//! strength) lead to larger gains in both strengths ("desirable difficulties").
use serde::{Deserialize, Serialize};
// ============================================================================
// CONSTANTS
// ============================================================================
/// Maximum storage strength (caps accumulation)
pub const MAX_STORAGE_STRENGTH: f64 = 10.0;
/// FSRS-6 decay constant (power law exponent)
/// Slower decay than exponential for short intervals
pub const FSRS_DECAY: f64 = 0.5;
/// FSRS-6 factor (derived from decay optimization)
pub const FSRS_FACTOR: f64 = 9.0;
// ============================================================================
// DUAL STRENGTH MODEL
// ============================================================================
/// Dual-strength memory state
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DualStrength {
/// Storage strength (1.0 - 10.0)
pub storage: f64,
/// Retrieval strength (0.0 - 1.0)
pub retrieval: f64,
}
impl Default for DualStrength {
fn default() -> Self {
Self {
storage: 1.0,
retrieval: 1.0,
}
}
}
impl DualStrength {
/// Create new dual strength with initial values
pub fn new(storage: f64, retrieval: f64) -> Self {
Self {
storage: storage.clamp(0.0, MAX_STORAGE_STRENGTH),
retrieval: retrieval.clamp(0.0, 1.0),
}
}
/// Calculate combined retention strength
///
/// Uses a weighted combination:
/// - 70% retrieval strength (current accessibility)
/// - 30% storage strength (normalized to 0-1 range)
pub fn retention(&self) -> f64 {
(self.retrieval * 0.7) + ((self.storage / MAX_STORAGE_STRENGTH) * 0.3)
}
/// Update strengths after a successful recall
///
/// - Storage strength increases (memory becomes more durable)
/// - Retrieval strength resets to 1.0 (just accessed)
pub fn on_successful_recall(&mut self) {
self.storage = (self.storage + 0.1).min(MAX_STORAGE_STRENGTH);
self.retrieval = 1.0;
}
/// Update strengths after a failed recall (lapse)
///
/// - Storage strength still increases (effort strengthens encoding)
/// - Retrieval strength resets to 1.0 (just relearned)
pub fn on_lapse(&mut self) {
self.storage = (self.storage + 0.3).min(MAX_STORAGE_STRENGTH);
self.retrieval = 1.0;
}
/// Apply time-based decay to retrieval strength
///
/// Uses FSRS-6 power law formula which better matches human forgetting:
/// R = (1 + t/(FACTOR * S))^(-1/DECAY)
pub fn apply_decay(&mut self, days_elapsed: f64, stability: f64) {
if days_elapsed > 0.0 && stability > 0.0 {
self.retrieval = (1.0 + days_elapsed / (FSRS_FACTOR * stability))
.powf(-1.0 / FSRS_DECAY)
.clamp(0.0, 1.0);
}
}
}
// ============================================================================
// STRENGTH DECAY CALCULATOR
// ============================================================================
/// Calculates strength decay over time
pub struct StrengthDecay {
/// FSRS stability (affects decay rate)
stability: f64,
/// Sentiment intensity (emotional memories decay slower)
sentiment_boost: f64,
}
impl StrengthDecay {
/// Create a new decay calculator
pub fn new(stability: f64, sentiment_magnitude: f64) -> Self {
Self {
stability,
sentiment_boost: 1.0 + sentiment_magnitude * 0.5,
}
}
/// Calculate effective stability with sentiment boost
pub fn effective_stability(&self) -> f64 {
self.stability * self.sentiment_boost
}
/// Calculate retrieval strength after elapsed time
///
/// Uses FSRS-6 power law forgetting curve
pub fn retrieval_at(&self, days_elapsed: f64) -> f64 {
if days_elapsed <= 0.0 {
return 1.0;
}
let effective_s = self.effective_stability();
(1.0 + days_elapsed / (FSRS_FACTOR * effective_s))
.powf(-1.0 / FSRS_DECAY)
.clamp(0.0, 1.0)
}
/// Calculate combined retention at a given time
pub fn retention_at(&self, days_elapsed: f64, storage_strength: f64) -> f64 {
let retrieval = self.retrieval_at(days_elapsed);
(retrieval * 0.7) + ((storage_strength / MAX_STORAGE_STRENGTH).min(1.0) * 0.3)
}
}
// ============================================================================
// TESTS
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
fn approx_eq(a: f64, b: f64, epsilon: f64) -> bool {
(a - b).abs() < epsilon
}
#[test]
fn test_dual_strength_default() {
let ds = DualStrength::default();
assert_eq!(ds.storage, 1.0);
assert_eq!(ds.retrieval, 1.0);
// retention = (retrieval * 0.7) + ((storage / MAX_STORAGE_STRENGTH) * 0.3)
// = (1.0 * 0.7) + ((1.0 / 10.0) * 0.3) = 0.7 + 0.03 = 0.73
assert!(approx_eq(ds.retention(), 0.73, 0.01));
}
#[test]
fn test_dual_strength_retention() {
// Full retrieval, low storage
let ds1 = DualStrength::new(1.0, 1.0);
assert!(approx_eq(ds1.retention(), 0.73, 0.01)); // 0.7*1.0 + 0.3*0.1
// Full retrieval, max storage
let ds2 = DualStrength::new(10.0, 1.0);
assert!(approx_eq(ds2.retention(), 1.0, 0.01)); // 0.7*1.0 + 0.3*1.0
// Zero retrieval, max storage
let ds3 = DualStrength::new(10.0, 0.0);
assert!(approx_eq(ds3.retention(), 0.3, 0.01)); // 0.7*0.0 + 0.3*1.0
}
#[test]
fn test_successful_recall() {
let mut ds = DualStrength::new(1.0, 0.5);
ds.on_successful_recall();
assert!(ds.storage > 1.0); // Storage increased
assert_eq!(ds.retrieval, 1.0); // Retrieval reset
}
#[test]
fn test_lapse() {
let mut ds = DualStrength::new(1.0, 0.5);
ds.on_lapse();
assert!(ds.storage > 1.1); // Storage increased more
assert_eq!(ds.retrieval, 1.0); // Retrieval reset
}
#[test]
fn test_storage_cap() {
let mut ds = DualStrength::new(9.9, 1.0);
ds.on_successful_recall();
assert_eq!(ds.storage, MAX_STORAGE_STRENGTH); // Capped at 10.0
}
#[test]
fn test_decay_over_time() {
let mut ds = DualStrength::new(1.0, 1.0);
let stability = 10.0;
// Apply decay for 1 day
ds.apply_decay(1.0, stability);
assert!(ds.retrieval < 1.0);
assert!(ds.retrieval > 0.9);
// Apply decay for 10 days
ds.apply_decay(10.0, stability);
assert!(ds.retrieval < 0.9);
}
#[test]
fn test_strength_decay_calculator() {
let decay = StrengthDecay::new(10.0, 0.0);
// At time 0, full retrieval
assert!(approx_eq(decay.retrieval_at(0.0), 1.0, 0.01));
// Over time, retrieval decreases
let r1 = decay.retrieval_at(1.0);
let r10 = decay.retrieval_at(10.0);
assert!(r1 > r10);
}
#[test]
fn test_sentiment_boost() {
let decay_neutral = StrengthDecay::new(10.0, 0.0);
let decay_emotional = StrengthDecay::new(10.0, 1.0);
// Emotional memories decay slower
let r_neutral = decay_neutral.retrieval_at(10.0);
let r_emotional = decay_emotional.retrieval_at(10.0);
assert!(r_emotional > r_neutral);
}
}

View file

@ -0,0 +1,248 @@
//! Temporal Memory - Bi-temporal knowledge modeling
//!
//! Implements a bi-temporal model for time-sensitive knowledge:
//!
//! - **Transaction Time**: When the fact was recorded (created_at, updated_at)
//! - **Valid Time**: When the fact is/was actually true (valid_from, valid_until)
//!
//! This allows querying:
//! - "What did I know on date X?" (transaction time)
//! - "What was true on date X?" (valid time)
//! - "What did I believe was true on date X, as of date Y?" (bitemporal)
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
// ============================================================================
// TEMPORAL RANGE
// ============================================================================
/// A time range with optional start and end
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TemporalRange {
/// Start of the range (inclusive)
pub start: Option<DateTime<Utc>>,
/// End of the range (inclusive)
pub end: Option<DateTime<Utc>>,
}
impl TemporalRange {
/// Create a range with both bounds
pub fn between(start: DateTime<Utc>, end: DateTime<Utc>) -> Self {
Self {
start: Some(start),
end: Some(end),
}
}
/// Create a range starting from a point
pub fn from(start: DateTime<Utc>) -> Self {
Self {
start: Some(start),
end: None,
}
}
/// Create a range ending at a point
pub fn until(end: DateTime<Utc>) -> Self {
Self {
start: None,
end: Some(end),
}
}
/// Create an unbounded range (all time)
pub fn all() -> Self {
Self {
start: None,
end: None,
}
}
/// Check if a timestamp falls within this range
pub fn contains(&self, time: DateTime<Utc>) -> bool {
let after_start = self.start.map(|s| time >= s).unwrap_or(true);
let before_end = self.end.map(|e| time <= e).unwrap_or(true);
after_start && before_end
}
/// Check if this range overlaps with another
pub fn overlaps(&self, other: &TemporalRange) -> bool {
// Two ranges overlap unless one ends before the other starts
let this_ends_before = match (self.end, other.start) {
(Some(e), Some(s)) => e < s,
_ => false,
};
let other_ends_before = match (other.end, self.start) {
(Some(e), Some(s)) => e < s,
_ => false,
};
!this_ends_before && !other_ends_before
}
/// Get the duration of the range (if bounded)
pub fn duration(&self) -> Option<Duration> {
match (self.start, self.end) {
(Some(s), Some(e)) => Some(e - s),
_ => None,
}
}
}
impl Default for TemporalRange {
fn default() -> Self {
Self::all()
}
}
// ============================================================================
// TEMPORAL VALIDITY
// ============================================================================
/// Temporal validity state for a knowledge node
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TemporalValidity {
/// Always valid (no temporal bounds)
Eternal,
/// Currently valid (within bounds)
Current,
/// Was valid in the past (ended)
Past,
/// Will be valid in the future (not started)
Future,
/// Has both start and end bounds, currently within them
Bounded,
}
impl TemporalValidity {
/// Determine validity state from temporal bounds
pub fn from_bounds(
valid_from: Option<DateTime<Utc>>,
valid_until: Option<DateTime<Utc>>,
) -> Self {
Self::from_bounds_at(valid_from, valid_until, Utc::now())
}
/// Determine validity state at a specific time
pub fn from_bounds_at(
valid_from: Option<DateTime<Utc>>,
valid_until: Option<DateTime<Utc>>,
at_time: DateTime<Utc>,
) -> Self {
match (valid_from, valid_until) {
(None, None) => TemporalValidity::Eternal,
(Some(from), None) => {
if at_time >= from {
TemporalValidity::Current
} else {
TemporalValidity::Future
}
}
(None, Some(until)) => {
if at_time <= until {
TemporalValidity::Current
} else {
TemporalValidity::Past
}
}
(Some(from), Some(until)) => {
if at_time < from {
TemporalValidity::Future
} else if at_time > until {
TemporalValidity::Past
} else {
TemporalValidity::Bounded
}
}
}
}
/// Check if this state represents currently valid knowledge
pub fn is_valid(&self) -> bool {
matches!(
self,
TemporalValidity::Eternal | TemporalValidity::Current | TemporalValidity::Bounded
)
}
}
// ============================================================================
// TESTS
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_temporal_range_contains() {
let now = Utc::now();
let yesterday = now - Duration::days(1);
let tomorrow = now + Duration::days(1);
let range = TemporalRange::between(yesterday, tomorrow);
assert!(range.contains(now));
assert!(range.contains(yesterday));
assert!(range.contains(tomorrow));
assert!(!range.contains(now - Duration::days(2)));
}
#[test]
fn test_temporal_range_overlaps() {
let now = Utc::now();
let r1 = TemporalRange::between(now - Duration::days(2), now);
let r2 = TemporalRange::between(now - Duration::days(1), now + Duration::days(1));
let r3 = TemporalRange::between(now + Duration::days(2), now + Duration::days(3));
assert!(r1.overlaps(&r2)); // They overlap
assert!(!r1.overlaps(&r3)); // No overlap
}
#[test]
fn test_temporal_validity() {
let now = Utc::now();
let yesterday = now - Duration::days(1);
let tomorrow = now + Duration::days(1);
// Eternal
assert_eq!(
TemporalValidity::from_bounds_at(None, None, now),
TemporalValidity::Eternal
);
// Current (started, no end)
assert_eq!(
TemporalValidity::from_bounds_at(Some(yesterday), None, now),
TemporalValidity::Current
);
// Future (not started yet)
assert_eq!(
TemporalValidity::from_bounds_at(Some(tomorrow), None, now),
TemporalValidity::Future
);
// Past (ended)
assert_eq!(
TemporalValidity::from_bounds_at(None, Some(yesterday), now),
TemporalValidity::Past
);
// Bounded (within range)
assert_eq!(
TemporalValidity::from_bounds_at(Some(yesterday), Some(tomorrow), now),
TemporalValidity::Bounded
);
}
#[test]
fn test_validity_is_valid() {
assert!(TemporalValidity::Eternal.is_valid());
assert!(TemporalValidity::Current.is_valid());
assert!(TemporalValidity::Bounded.is_valid());
assert!(!TemporalValidity::Past.is_valid());
assert!(!TemporalValidity::Future.is_valid());
}
}