diff --git a/cortex.example.toml b/cortex.example.toml index 0a57dfa6..ea162d91 100644 --- a/cortex.example.toml +++ b/cortex.example.toml @@ -29,6 +29,8 @@ enabled = true interval_seconds = 60 # How often to run similarity_threshold = 0.75 # Min cosine similarity for auto-edges max_edges_per_node = 20 # Cap outgoing similarity edges +decay_enabled = true # Set to false for legal/compliance (nothing fades) +decay_rate_per_day = 0.01 # Ignored when decay_enabled = false entity_promote_every_n_cycles = 60 # How often to run entity promotion (cycles) entity_promote_min_agents = 2 # Min agents referencing an entity before promotion diff --git a/crates/cortex-client/src/lib.rs b/crates/cortex-client/src/lib.rs index d08e06ae..0765eff6 100644 --- a/crates/cortex-client/src/lib.rs +++ b/crates/cortex-client/src/lib.rs @@ -4,8 +4,8 @@ //! //! # Example //! ```rust,no_run -//! use cortex_client::CortexClient; -//! use cortex_proto::cortex::v1::CreateNodeRequest; +//! use cortex_memory_client::CortexClient; +//! use cortex_memory_client::proto::CreateNodeRequest; //! //! #[tokio::main] //! async fn main() -> anyhow::Result<()> { diff --git a/crates/cortex-core/examples/auto_linker.rs b/crates/cortex-core/examples/auto_linker.rs index 4eb43188..2a67e2da 100644 --- a/crates/cortex-core/examples/auto_linker.rs +++ b/crates/cortex-core/examples/auto_linker.rs @@ -3,11 +3,11 @@ //! Run with: cargo run --example auto_linker //! Note: First run downloads the embedding model (~30MB) -use cortex_core::graph::GraphEngineImpl; -use cortex_core::linker::{AutoLinker, AutoLinkerConfig}; -use cortex_core::storage::{RedbStorage, Storage}; -use cortex_core::types::*; -use cortex_core::vector::{EmbeddingService, FastEmbedService, HnswIndex, SimilarityConfig}; +use cortex_memory_core::graph::GraphEngineImpl; +use cortex_memory_core::linker::{AutoLinker, AutoLinkerConfig}; +use cortex_memory_core::storage::{RedbStorage, Storage}; +use cortex_memory_core::types::*; +use cortex_memory_core::vector::{EmbeddingService, FastEmbedService, HnswIndex, SimilarityConfig}; use std::sync::{Arc, RwLock}; use tempfile::TempDir; @@ -160,7 +160,7 @@ fn main() { println!("Sample edges created:"); // Collect edges by iterating over all nodes' outgoing edges let all_nodes = storage - .list_nodes(cortex_core::storage::NodeFilter::new()) + .list_nodes(cortex_memory_core::storage::NodeFilter::new()) .unwrap(); let all_edges: Vec<_> = all_nodes .iter() diff --git a/crates/cortex-core/examples/basic_usage.rs b/crates/cortex-core/examples/basic_usage.rs index 9e711c40..b91362d3 100644 --- a/crates/cortex-core/examples/basic_usage.rs +++ b/crates/cortex-core/examples/basic_usage.rs @@ -1,4 +1,4 @@ -use cortex_core::{ +use cortex_memory_core::{ Edge, EdgeProvenance, Node, NodeFilter, NodeKind, RedbStorage, Relation, Source, Storage, }; diff --git a/crates/cortex-core/examples/graph_queries.rs b/crates/cortex-core/examples/graph_queries.rs index c7fcbc5a..2544c363 100644 --- a/crates/cortex-core/examples/graph_queries.rs +++ b/crates/cortex-core/examples/graph_queries.rs @@ -1,4 +1,4 @@ -use cortex_core::{ +use cortex_memory_core::{ Edge, EdgeProvenance, GraphEngine, GraphEngineImpl, Node, NodeKind, PathRequest, RedbStorage, Relation, Source, Storage, TraversalDirection, TraversalRequest, TraversalStrategy, }; diff --git a/crates/cortex-core/examples/vector_search.rs b/crates/cortex-core/examples/vector_search.rs index 59621034..dca2769b 100644 --- a/crates/cortex-core/examples/vector_search.rs +++ b/crates/cortex-core/examples/vector_search.rs @@ -3,9 +3,9 @@ //! Run with: cargo run --example vector_search //! Note: First run downloads the embedding model (~30MB) -use cortex_core::storage::{RedbStorage, Storage}; -use cortex_core::types::*; -use cortex_core::vector::{ +use cortex_memory_core::storage::{RedbStorage, Storage}; +use cortex_memory_core::types::*; +use cortex_memory_core::vector::{ embedding_input, EmbeddingService, FastEmbedService, HnswIndex, VectorIndex, }; use tempfile::TempDir; diff --git a/crates/cortex-core/src/api.rs b/crates/cortex-core/src/api.rs index ea2d103a..ab9163cd 100644 --- a/crates/cortex-core/src/api.rs +++ b/crates/cortex-core/src/api.rs @@ -29,7 +29,7 @@ impl Default for LibraryConfig { /// /// # Example /// ```rust,no_run -/// use cortex_core::{Cortex, LibraryConfig}; +/// use cortex_memory_core::{Cortex, LibraryConfig}; /// /// let cortex = Cortex::open("./memory.redb", LibraryConfig::default()).unwrap(); /// cortex.store(Cortex::fact("The API uses JWT auth", 0.7)).unwrap(); diff --git a/crates/cortex-core/src/linker/auto_linker.rs b/crates/cortex-core/src/linker/auto_linker.rs index e906f60f..0aba4deb 100644 --- a/crates/cortex-core/src/linker/auto_linker.rs +++ b/crates/cortex-core/src/linker/auto_linker.rs @@ -74,6 +74,10 @@ impl AutoLinker let contradiction_detector = ContradictionDetector::new(config.similarity.contradiction_threshold); + if !config.decay.enabled { + log::info!("Edge decay is DISABLED. Edges will never fade or be deleted due to age."); + } + Ok(Self { storage, graph_engine, diff --git a/crates/cortex-core/src/linker/config.rs b/crates/cortex-core/src/linker/config.rs index 300bcf0c..676812b6 100644 --- a/crates/cortex-core/src/linker/config.rs +++ b/crates/cortex-core/src/linker/config.rs @@ -183,6 +183,12 @@ impl AutoLinkerConfig { /// Configuration for edge decay #[derive(Debug, Clone)] pub struct DecayConfig { + /// Set to false to skip edge decay entirely. + /// When disabled, edge weights never change, edges are never pruned or deleted + /// due to age. Use for legal, compliance, or archival deployments. + /// Default: true. + pub enabled: bool, + /// Base decay rate per day. Default: 0.01 (1% per day). pub daily_decay_rate: f32, @@ -208,6 +214,7 @@ pub struct DecayConfig { impl Default for DecayConfig { fn default() -> Self { Self { + enabled: true, daily_decay_rate: 0.01, prune_threshold: 0.1, delete_threshold: 0.05, @@ -223,6 +230,11 @@ impl DecayConfig { Self::default() } + pub fn with_enabled(mut self, enabled: bool) -> Self { + self.enabled = enabled; + self + } + pub fn with_daily_decay_rate(mut self, rate: f32) -> Self { self.daily_decay_rate = rate; self diff --git a/crates/cortex-core/src/linker/decay.rs b/crates/cortex-core/src/linker/decay.rs index a0900194..b8c0468e 100644 --- a/crates/cortex-core/src/linker/decay.rs +++ b/crates/cortex-core/src/linker/decay.rs @@ -19,6 +19,10 @@ impl DecayEngine { /// Apply decay to all edges in the graph /// Returns (pruned_count, deleted_count) pub fn apply_decay(&self, now: DateTime) -> Result<(u64, u64)> { + if !self.config.enabled { + return Ok((0, 0)); + } + let mut pruned_count = 0; let mut deleted_count = 0; @@ -337,6 +341,128 @@ mod tests { let reinforced_edge = storage.get_edge(edge.id).unwrap().unwrap(); assert!(reinforced_edge.updated_at > old_time); } + + #[test] + fn test_decay_skipped_when_disabled() { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("no_decay.redb"); + let storage = Arc::new(RedbStorage::open(&db_path).unwrap()); + + let node1 = Node::new( + NodeKind::new("fact").unwrap(), + "Deposition transcript".into(), + "Key witness testimony from month one".into(), + Source { + agent: "legal".into(), + session: None, + channel: None, + }, + 0.5, + ); + let node2 = Node::new( + NodeKind::new("fact").unwrap(), + "Filing deadline".into(), + "Response due in 30 days".into(), + Source { + agent: "legal".into(), + session: None, + channel: None, + }, + 0.5, + ); + storage.put_node(&node1).unwrap(); + storage.put_node(&node2).unwrap(); + + let mut edge = Edge::new( + node1.id, + node2.id, + Relation::new("related_to").unwrap(), + 0.8, + EdgeProvenance::AutoSimilarity { score: 0.8 }, + ); + // Edge is 365 days old + edge.updated_at = Utc::now() - Duration::days(365); + storage.put_edge(&edge).unwrap(); + + // Decay DISABLED + let config = DecayConfig { + enabled: false, + ..DecayConfig::default() + }; + let engine = DecayEngine::new(storage.clone(), config); + let (pruned, deleted) = engine.apply_decay(Utc::now()).unwrap(); + + assert_eq!(pruned, 0); + assert_eq!(deleted, 0); + + // Edge weight unchanged after a full year + let unchanged = storage.get_edge(edge.id).unwrap().unwrap(); + assert_eq!( + unchanged.weight, 0.8, + "Edge weight must not change when decay is disabled" + ); + } + + #[test] + fn test_decay_still_works_when_enabled() { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("enabled_decay.redb"); + let storage = Arc::new(RedbStorage::open(&db_path).unwrap()); + + let node1 = Node::new( + NodeKind::new("fact").unwrap(), + "Node 1".into(), + "Body 1".into(), + Source { + agent: "test".into(), + session: None, + channel: None, + }, + 0.5, + ); + let node2 = Node::new( + NodeKind::new("fact").unwrap(), + "Node 2".into(), + "Body 2".into(), + Source { + agent: "test".into(), + session: None, + channel: None, + }, + 0.5, + ); + storage.put_node(&node1).unwrap(); + storage.put_node(&node2).unwrap(); + + let mut edge = Edge::new( + node1.id, + node2.id, + Relation::new("related_to").unwrap(), + 0.8, + EdgeProvenance::AutoSimilarity { score: 0.8 }, + ); + edge.updated_at = Utc::now() - Duration::days(365); + storage.put_edge(&edge).unwrap(); + + // Decay ENABLED (default) + let config = DecayConfig::default(); + assert!(config.enabled); + let engine = DecayEngine::new(storage.clone(), config); + engine.apply_decay(Utc::now()).unwrap(); + + // Year-old edge should have decayed significantly + let updated = storage.get_edge(edge.id).unwrap(); + // With default rate 0.01/day over 365 days, weight should be near zero or deleted + // Either the edge was deleted or its weight dropped well below 0.8 + match updated { + None => {} // deleted — expected for a year-old edge + Some(e) => assert!( + e.weight < 0.8, + "Edge weight should have decayed, got {}", + e.weight + ), + } + } } #[cfg(test)] diff --git a/crates/cortex-server/src/config.rs b/crates/cortex-server/src/config.rs index b2423116..b11ada32 100644 --- a/crates/cortex-server/src/config.rs +++ b/crates/cortex-server/src/config.rs @@ -138,6 +138,11 @@ pub struct AutoLinkerTomlConfig { pub interval_seconds: u64, pub similarity_threshold: f32, pub dedup_threshold: f32, + /// Set to false to disable edge weight decay entirely. + /// Edges will never fade, weaken, or be deleted due to age. + /// Use for legal, compliance, or archival deployments. + #[serde(default = "default_true")] + pub decay_enabled: bool, pub decay_rate_per_day: f32, pub max_edges_per_node: usize, /// Whether to run legacy hardcoded structural rules. @@ -154,6 +159,10 @@ pub struct AutoLinkerTomlConfig { pub entity_promote_min_agents: usize, } +fn default_true() -> bool { + true +} + fn default_entity_promote_every_n_cycles() -> u64 { 60 } @@ -169,6 +178,7 @@ impl Default for AutoLinkerTomlConfig { interval_seconds: 60, similarity_threshold: 0.75, dedup_threshold: 0.92, + decay_enabled: true, decay_rate_per_day: 0.01, max_edges_per_node: 50, legacy_rules_enabled: None, @@ -388,6 +398,7 @@ impl CortexConfig { ) .with_decay( cortex_core::DecayConfig::new() + .with_enabled(self.auto_linker.decay_enabled) .with_daily_decay_rate(self.auto_linker.decay_rate_per_day), ) .with_embedding_model(self.embedding.model.clone()) @@ -504,4 +515,53 @@ enabled = true let errors = config.validate(); assert!(errors.is_empty()); } + + #[test] + fn test_decay_enabled_false_parses_from_toml() { + let toml_str = r#" +[auto_linker] +decay_enabled = false +decay_rate_per_day = 0.0 +"#; + let config: CortexConfig = toml::from_str(toml_str).unwrap(); + assert!(!config.auto_linker.decay_enabled); + assert_eq!(config.auto_linker.decay_rate_per_day, 0.0); + } + + #[test] + fn test_decay_enabled_defaults_true_when_missing() { + let toml_str = r#" +[auto_linker] +enabled = true +"#; + let config: CortexConfig = toml::from_str(toml_str).unwrap(); + assert!( + config.auto_linker.decay_enabled, + "decay_enabled must default to true for backward compatibility" + ); + } + + #[test] + fn test_decay_enabled_wired_to_auto_linker_config() { + let toml_str = r#" +[auto_linker] +decay_enabled = false +"#; + let config: CortexConfig = toml::from_str(toml_str).unwrap(); + let linker_config = config.auto_linker_config(); + assert!( + !linker_config.decay.enabled, + "decay_enabled=false in TOML must propagate to DecayConfig.enabled" + ); + } + + #[test] + fn test_decay_enabled_true_wired_to_auto_linker_config() { + let config = CortexConfig::default(); + let linker_config = config.auto_linker_config(); + assert!( + linker_config.decay.enabled, + "Default config must have decay enabled" + ); + } } diff --git a/crates/cortex-server/src/main.rs b/crates/cortex-server/src/main.rs index f55e737b..9931da42 100644 --- a/crates/cortex-server/src/main.rs +++ b/crates/cortex-server/src/main.rs @@ -10,9 +10,6 @@ mod migration; mod observability; mod serve; -#[cfg(feature = "warren")] -mod nats; - use clap::Parser; use cli::{Cli, Commands}; use config::CortexConfig; diff --git a/crates/cortex-server/src/nats/ingest.rs b/crates/cortex-server/src/nats/ingest.rs deleted file mode 100644 index 37116f3b..00000000 --- a/crates/cortex-server/src/nats/ingest.rs +++ /dev/null @@ -1,33 +0,0 @@ -use cortex_core::*; -use std::sync::atomic::AtomicU64; -use std::sync::Arc; -use std::sync::RwLock as StdRwLock; - -/// Thin wrapper around WarrenNatsAdapter for backward compatibility. -pub struct NatsIngest { - inner: warren_adapter::WarrenNatsAdapter, -} - -impl NatsIngest { - pub fn new( - client: async_nats::Client, - storage: Arc, - embedding_service: Arc, - vector_index: Arc>, - graph_version: Arc, - ) -> Self { - Self { - inner: warren_adapter::WarrenNatsAdapter::new( - client, - storage, - embedding_service, - vector_index, - graph_version, - ), - } - } - - pub async fn start(&self) -> Result<()> { - self.inner.start().await - } -} diff --git a/crates/cortex-server/src/nats/mod.rs b/crates/cortex-server/src/nats/mod.rs deleted file mode 100644 index ca43d989..00000000 --- a/crates/cortex-server/src/nats/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! Warren NATS integration — delegates to the `warren-adapter` crate. -//! This module exists for backward compatibility during transition. -mod ingest; - -pub use ingest::NatsIngest; diff --git a/crates/cortex-server/src/serve.rs b/crates/cortex-server/src/serve.rs index f32f2a01..08325041 100644 --- a/crates/cortex-server/src/serve.rs +++ b/crates/cortex-server/src/serve.rs @@ -431,48 +431,11 @@ pub async fn run(config: CortexConfig) -> anyhow::Result<()> { }) }; - // Optionally start NATS consumer - let nats_enabled = config.server.nats_enabled; - let nats_url = config.server.nats_url.clone(); - - let nats_task: Option> = if nats_enabled { - info!("Connecting to NATS at {}...", nats_url); - - #[cfg(feature = "warren")] - { - match async_nats::connect(&nats_url).await { - Ok(client) => { - info!("NATS connected (Warren adapter)"); - let nats_ingest = crate::nats::NatsIngest::new( - client, - storage.clone(), - embedding_service.clone(), - vector_index.clone(), - graph_version.clone(), - ); - Some(tokio::spawn(async move { - if let Err(e) = nats_ingest.start().await { - error!("NATS ingest failed: {}", e); - } - })) - } - Err(e) => { - error!("Failed to connect to NATS: {}", e); - error!("Continuing without NATS consumer"); - None - } - } - } - - #[cfg(not(feature = "warren"))] - { - info!("NATS consumer not available (warren feature disabled)"); - None - } - } else { - info!("NATS consumer disabled"); - None - }; + // NATS consumer (reserved for future generic ingest adapter) + let nats_task: Option> = None; + if config.server.nats_enabled { + info!("NATS ingest configured but no adapter available"); + } info!("Cortex server ready"); diff --git a/crates/cortex-server/tests/integration_test.rs b/crates/cortex-server/tests/integration_test.rs index aeaade9e..a5d4c3ed 100644 --- a/crates/cortex-server/tests/integration_test.rs +++ b/crates/cortex-server/tests/integration_test.rs @@ -686,12 +686,145 @@ fn test_auto_linker_config_defaults_are_sane() { #[test] fn test_decay_config_defaults_are_sane() { let config = DecayConfig::default(); + assert!(config.enabled, "Decay must be enabled by default"); assert!(config.daily_decay_rate > 0.0 && config.daily_decay_rate <= 1.0); assert!(config.prune_threshold > config.delete_threshold); assert!(config.exempt_manual); assert!(config.validate().is_ok()); } +#[test] +fn test_decay_config_with_enabled_builder() { + let config = DecayConfig::new().with_enabled(false); + assert!(!config.enabled); + // Other fields keep defaults + assert_eq!(config.daily_decay_rate, 0.01); + assert!(config.exempt_manual); + assert!(config.validate().is_ok()); +} + +#[test] +fn test_decay_disabled_full_graph_integration() { + let dir = tempdir().unwrap(); + let db_path = dir.path().join("decay_disabled.redb"); + let storage = Arc::new(RedbStorage::open(&db_path).unwrap()); + + // Create two nodes and an old edge + let n1 = Node::new( + NodeKind::new("fact").unwrap(), + "Deposition transcript".into(), + "Key witness testimony".into(), + make_source("legal-agent"), + 0.5, + ); + let n2 = Node::new( + NodeKind::new("fact").unwrap(), + "Exhibit A".into(), + "Primary evidence document".into(), + make_source("legal-agent"), + 0.5, + ); + storage.put_node(&n1).unwrap(); + storage.put_node(&n2).unwrap(); + + let mut edge = Edge::new( + n1.id, + n2.id, + Relation::new("related_to").unwrap(), + 0.9, + EdgeProvenance::AutoSimilarity { score: 0.9 }, + ); + // 18 months old — typical litigation timeline + edge.updated_at = chrono::Utc::now() - chrono::Duration::days(540); + storage.put_edge(&edge).unwrap(); + + // Run decay with enabled=false + let config = DecayConfig::new().with_enabled(false); + let engine = DecayEngine::new(storage.clone(), config); + let (pruned, deleted) = engine.apply_decay(chrono::Utc::now()).unwrap(); + + assert_eq!( + pruned, 0, + "No edges should be pruned when decay is disabled" + ); + assert_eq!( + deleted, 0, + "No edges should be deleted when decay is disabled" + ); + + let after = storage.get_edge(edge.id).unwrap().unwrap(); + assert_eq!( + after.weight, 0.9, + "Edge weight must be exactly preserved when decay is disabled" + ); + assert_eq!( + after.updated_at, edge.updated_at, + "Edge timestamp must not change when decay is disabled" + ); + + // Now run with enabled=true — same edge should decay or be deleted + let config_enabled = DecayConfig::default(); + let engine_enabled = DecayEngine::new(storage.clone(), config_enabled); + engine_enabled.apply_decay(chrono::Utc::now()).unwrap(); + + let after_enabled = storage.get_edge(edge.id).unwrap(); + match after_enabled { + None => {} // deleted after 540 days — expected + Some(e) => assert!( + e.weight < 0.9, + "Edge should have decayed with enabled=true, got {}", + e.weight + ), + } +} + +#[test] +fn test_decay_disabled_multiple_cycles_no_change() { + let dir = tempdir().unwrap(); + let db_path = dir.path().join("multi_cycle.redb"); + let storage = Arc::new(RedbStorage::open(&db_path).unwrap()); + + let n1 = Node::new( + NodeKind::new("fact").unwrap(), + "Regulation".into(), + "SOX compliance requirement".into(), + make_source("compliance"), + 0.8, + ); + let n2 = Node::new( + NodeKind::new("fact").unwrap(), + "Filing".into(), + "Annual filing".into(), + make_source("compliance"), + 0.8, + ); + storage.put_node(&n1).unwrap(); + storage.put_node(&n2).unwrap(); + + let mut edge = Edge::new( + n1.id, + n2.id, + Relation::new("related_to").unwrap(), + 0.75, + EdgeProvenance::AutoSimilarity { score: 0.75 }, + ); + edge.updated_at = chrono::Utc::now() - chrono::Duration::days(180); + storage.put_edge(&edge).unwrap(); + + let config = DecayConfig::new().with_enabled(false); + let engine = DecayEngine::new(storage.clone(), config); + + // Run decay 10 times — nothing should change + for _ in 0..10 { + let (p, d) = engine.apply_decay(chrono::Utc::now()).unwrap(); + assert_eq!(p, 0); + assert_eq!(d, 0); + } + + let after = storage.get_edge(edge.id).unwrap().unwrap(); + assert_eq!(after.weight, 0.75); +} + // ── Write Gate Schema Validation ──────────────────────────────────────────── #[test] diff --git a/docs/concepts/decay-and-memory.md b/docs/concepts/decay-and-memory.md index ded8579f..68e96e59 100644 --- a/docs/concepts/decay-and-memory.md +++ b/docs/concepts/decay-and-memory.md @@ -78,6 +78,32 @@ cortex node create --kind fact --title "Sprint goal: fix auth bug" \ The retention engine sweeps nodes past their `expires_at` during each cycle. +## Disabling Decay + +For domains where knowledge must never fade (legal, compliance, medical, archival), +disable decay entirely: + +```toml +[auto_linker] +decay_enabled = false + +[score_decay] +enabled = false + +[retention] +default_ttl_days = 0 +``` + +When decay is disabled: +- Edge weights never change due to age +- Edges are never pruned or deleted due to low weight +- Search results are ranked by pure relevance, not recency +- Nodes are never expired by the retention engine (when TTL is 0) +- A deposition from month one carries the same weight at trial 18 months later + +The auto-linker still runs (creating new edges, detecting contradictions, +promoting entities). Only the decay pass is skipped. + ## Retention Policies Hard retention limits are separate from decay. See [configuration](../getting-started/configuration.md) for `[retention]` settings.