mirror of
https://github.com/samvallad33/vestige.git
synced 2026-05-10 16:22:36 +02:00
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:
commit
f9c60eb5a7
169 changed files with 97206 additions and 0 deletions
374
crates/vestige-core/src/memory/mod.rs
Normal file
374
crates/vestige-core/src/memory/mod.rs
Normal 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![],
|
||||
}
|
||||
}
|
||||
}
|
||||
380
crates/vestige-core/src/memory/node.rs
Normal file
380
crates/vestige-core/src/memory/node.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
256
crates/vestige-core/src/memory/strength.rs
Normal file
256
crates/vestige-core/src/memory/strength.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
248
crates/vestige-core/src/memory/temporal.rs
Normal file
248
crates/vestige-core/src/memory/temporal.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue