Add Prediction Error Gating and smart_ingest tool (26 tools total)

Implements neuroscience-inspired memory gating based on prediction error:
- New smart_ingest MCP tool that auto-decides CREATE/UPDATE/SUPERSEDE
- PredictionErrorGate evaluates semantic similarity vs existing memories
- Automatically supersedes demoted memories with similar new content
- Reinforces near-identical memories instead of creating duplicates
- Adds promote_memory/demote_memory/request_feedback tools

Thresholds:
- >0.92 similarity = Reinforce existing
- >0.75 similarity = Update/Merge
- <0.75 similarity = Create new
- Demoted + similar = Auto-supersede

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-01-25 13:30:03 -06:00
parent 5337efdfa7
commit bbd1c15b4a
12 changed files with 1705 additions and 10 deletions

View file

@ -23,6 +23,7 @@ pub mod cross_project;
pub mod dreams;
pub mod importance;
pub mod intent;
pub mod prediction_error;
pub mod reconsolidation;
pub mod speculative;
@ -60,4 +61,9 @@ pub use reconsolidation::{
Modification, ReconsolidatedMemory, ReconsolidationManager, ReconsolidationStats,
RelationshipType, RetrievalRecord,
};
pub use prediction_error::{
CandidateMemory, CreateReason, EvaluationIntent, GateDecision, GateStats, MergeStrategy,
PredictionErrorConfig, PredictionErrorGate, SimilarityResult, SupersedeReason, UpdateType,
cosine_similarity,
};
pub use speculative::{PredictedMemory, PredictionContext, SpeculativeRetriever, UsagePattern};

View file

@ -0,0 +1,852 @@
//! # Prediction Error Gating
//!
//! Implements neuroscience-inspired prediction error gating for intelligent memory updates.
//!
//! Based on research showing that prediction error (PE) determines whether memories are:
//! - **Updated** (small PE): New info is similar enough to existing memory
//! - **Created** (large PE): New info is different enough to warrant new memory
//!
//! This solves the "bad vs good similar memory" problem by:
//! 1. Detecting when new content is similar to existing memories
//! 2. Calculating the prediction error (semantic distance)
//! 3. Deciding whether to update existing or create new
//! 4. Optionally superseding outdated memories
//!
//! ## Scientific Background
//!
//! Based on:
//! - Sinclair & Bhavnani (2020): "The Reconsolidation Dilemma"
//! - Lee et al. (2017): Prediction error and memory updating
//! - Google Titans (2025): Surprise-based storage
//!
//! ## Example
//!
//! ```rust,ignore
//! use vestige_core::advanced::prediction_error::PredictionErrorGate;
//!
//! let gate = PredictionErrorGate::new();
//!
//! // New content arrives
//! let decision = gate.evaluate(
//! "Use async/await for better performance",
//! &existing_memories,
//! &embeddings,
//! );
//!
//! match decision {
//! GateDecision::Update { target, .. } => {
//! // Update existing memory with new info
//! }
//! GateDecision::Create { .. } => {
//! // Create new memory
//! }
//! GateDecision::Supersede { old, .. } => {
//! // New memory supersedes old (demote old)
//! }
//! }
//! ```
use serde::{Deserialize, Serialize};
// ============================================================================
// CONSTANTS
// ============================================================================
/// Default similarity threshold for considering memories as "similar"
/// Above this = potential update candidate
const DEFAULT_SIMILARITY_THRESHOLD: f32 = 0.75;
/// Threshold for considering content as "nearly identical"
/// Above this = definitely update, not create
const NEAR_IDENTICAL_THRESHOLD: f32 = 0.92;
/// Threshold for "correction" detection
/// When new content contradicts existing with high similarity
const CORRECTION_THRESHOLD: f32 = 0.70;
/// Maximum candidates to consider for update
const MAX_UPDATE_CANDIDATES: usize = 5;
// ============================================================================
// GATE DECISION
// ============================================================================
/// Decision made by the prediction error gate
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum GateDecision {
/// Create a new memory (high prediction error)
Create {
/// Reason for creating new
reason: CreateReason,
/// Prediction error score (0.0 = identical, 1.0 = completely different)
prediction_error: f32,
/// Related memories that were considered
related_memory_ids: Vec<String>,
},
/// Update an existing memory (low prediction error)
Update {
/// ID of memory to update
target_id: String,
/// How similar the content is (0.0 - 1.0)
similarity: f32,
/// Type of update to perform
update_type: UpdateType,
/// Prediction error score
prediction_error: f32,
},
/// Supersede an existing memory (correction/improvement)
Supersede {
/// ID of memory being superseded
old_memory_id: String,
/// Similarity to old memory
similarity: f32,
/// Why this supersedes the old one
supersede_reason: SupersedeReason,
/// Prediction error score
prediction_error: f32,
},
/// Merge with multiple existing memories
Merge {
/// IDs of memories to merge with
memory_ids: Vec<String>,
/// Average similarity
avg_similarity: f32,
/// Merge strategy
strategy: MergeStrategy,
},
}
impl GateDecision {
/// Get the prediction error score
pub fn prediction_error(&self) -> f32 {
match self {
Self::Create { prediction_error, .. } => *prediction_error,
Self::Update { prediction_error, .. } => *prediction_error,
Self::Supersede { prediction_error, .. } => *prediction_error,
Self::Merge { avg_similarity, .. } => 1.0 - avg_similarity,
}
}
/// Check if this is a create decision
pub fn is_create(&self) -> bool {
matches!(self, Self::Create { .. })
}
/// Check if this is an update decision
pub fn is_update(&self) -> bool {
matches!(self, Self::Update { .. })
}
/// Get target ID if updating or superseding
pub fn target_id(&self) -> Option<&str> {
match self {
Self::Update { target_id, .. } => Some(target_id),
Self::Supersede { old_memory_id, .. } => Some(old_memory_id),
_ => None,
}
}
}
/// Reasons for creating a new memory
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum CreateReason {
/// No similar memories exist
NoSimilarMemories,
/// Content is substantially different from all candidates
HighPredictionError,
/// Different domain/topic despite surface similarity
DifferentDomain,
/// Explicitly requested new memory (not update)
ExplicitCreate,
/// First memory in the system
FirstMemory,
}
/// Types of updates to existing memories
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum UpdateType {
/// Append new information
Append,
/// Replace content entirely
Replace,
/// Merge content intelligently
Merge,
/// Add as related context
AddContext,
/// Strengthen existing memory (same content, reinforcement)
Reinforce,
}
/// Reasons for superseding an existing memory
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum SupersedeReason {
/// New content is a correction of old
Correction,
/// New content is an improvement/update
Improvement,
/// Old content is marked as outdated
Outdated,
/// User explicitly indicated this is better
UserIndicated,
/// New content has higher confidence/authority
HigherConfidence,
}
/// Strategies for merging multiple memories
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum MergeStrategy {
/// Combine all content
Combine,
/// Keep most recent, link to older
KeepRecent,
/// Create summary of all
Summarize,
/// Create hierarchy (parent with children)
Hierarchical,
}
// ============================================================================
// CANDIDATE MEMORY
// ============================================================================
/// A candidate memory for update consideration
#[derive(Debug, Clone)]
pub struct CandidateMemory {
/// Memory ID
pub id: String,
/// Memory content
pub content: String,
/// Embedding vector
pub embedding: Vec<f32>,
/// Current retrieval strength
pub retrieval_strength: f64,
/// Current retention strength
pub retention_strength: f64,
/// Tags on the memory
pub tags: Vec<String>,
/// Source of the memory
pub source: Option<String>,
/// Whether this memory was previously demoted
pub was_demoted: bool,
/// Whether this memory was previously promoted
pub was_promoted: bool,
}
/// Result of similarity comparison
#[derive(Debug, Clone)]
pub struct SimilarityResult {
/// Memory ID
pub memory_id: String,
/// Cosine similarity score (0.0 - 1.0)
pub similarity: f32,
/// Prediction error (1.0 - similarity)
pub prediction_error: f32,
/// Semantic overlap (estimated shared concepts)
pub semantic_overlap: f32,
/// Whether contents appear contradictory
pub appears_contradictory: bool,
}
// ============================================================================
// PREDICTION ERROR GATE
// ============================================================================
/// Configuration for the prediction error gate
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PredictionErrorConfig {
/// Similarity threshold for update consideration
pub similarity_threshold: f32,
/// Threshold for near-identical detection
pub near_identical_threshold: f32,
/// Threshold for correction detection
pub correction_threshold: f32,
/// Maximum candidates to consider
pub max_candidates: usize,
/// Whether to auto-supersede demoted memories
pub auto_supersede_demoted: bool,
/// Whether to prefer updates over creates
pub prefer_updates: bool,
}
impl Default for PredictionErrorConfig {
fn default() -> Self {
Self {
similarity_threshold: DEFAULT_SIMILARITY_THRESHOLD,
near_identical_threshold: NEAR_IDENTICAL_THRESHOLD,
correction_threshold: CORRECTION_THRESHOLD,
max_candidates: MAX_UPDATE_CANDIDATES,
auto_supersede_demoted: true,
prefer_updates: true,
}
}
}
/// The Prediction Error Gate
///
/// Evaluates new content against existing memories to determine
/// whether to create, update, or supersede.
#[derive(Debug)]
pub struct PredictionErrorGate {
/// Configuration
config: PredictionErrorConfig,
/// Statistics
stats: GateStats,
}
impl Default for PredictionErrorGate {
fn default() -> Self {
Self::new()
}
}
impl PredictionErrorGate {
/// Create a new prediction error gate with default config
pub fn new() -> Self {
Self {
config: PredictionErrorConfig::default(),
stats: GateStats::default(),
}
}
/// Create with custom config
pub fn with_config(config: PredictionErrorConfig) -> Self {
Self {
config,
stats: GateStats::default(),
}
}
/// Get the configuration
pub fn config(&self) -> &PredictionErrorConfig {
&self.config
}
/// Get mutable configuration
pub fn config_mut(&mut self) -> &mut PredictionErrorConfig {
&mut self.config
}
/// Evaluate new content against candidates
///
/// Returns a decision on whether to create, update, or supersede.
pub fn evaluate(
&mut self,
new_content: &str,
new_embedding: &[f32],
candidates: &[CandidateMemory],
) -> GateDecision {
self.stats.total_evaluations += 1;
// No candidates = definitely create
if candidates.is_empty() {
self.stats.creates += 1;
return GateDecision::Create {
reason: CreateReason::FirstMemory,
prediction_error: 1.0,
related_memory_ids: vec![],
};
}
// Calculate similarities
let mut similarities: Vec<SimilarityResult> = candidates
.iter()
.map(|c| {
let similarity = cosine_similarity(new_embedding, &c.embedding);
let appears_contradictory = self.detect_contradiction(new_content, &c.content);
SimilarityResult {
memory_id: c.id.clone(),
similarity,
prediction_error: 1.0 - similarity,
semantic_overlap: similarity, // Simplified; could use more sophisticated measure
appears_contradictory,
}
})
.collect();
// Sort by similarity (highest first)
similarities.sort_by(|a, b| b.similarity.partial_cmp(&a.similarity).unwrap_or(std::cmp::Ordering::Equal));
// Take top candidates
let top_candidates: Vec<_> = similarities
.iter()
.take(self.config.max_candidates)
.collect();
// Check for near-identical match
if let Some(best) = top_candidates.first() {
if best.similarity >= self.config.near_identical_threshold {
// Nearly identical - reinforce existing
self.stats.updates += 1;
return GateDecision::Update {
target_id: best.memory_id.clone(),
similarity: best.similarity,
update_type: UpdateType::Reinforce,
prediction_error: best.prediction_error,
};
}
// Check for potential supersede
let candidate = candidates.iter().find(|c| c.id == best.memory_id);
if let Some(c) = candidate {
// If similar and the existing memory was demoted, supersede it
if best.similarity >= self.config.similarity_threshold
&& c.was_demoted
&& self.config.auto_supersede_demoted {
self.stats.supersedes += 1;
return GateDecision::Supersede {
old_memory_id: c.id.clone(),
similarity: best.similarity,
supersede_reason: SupersedeReason::Improvement,
prediction_error: best.prediction_error,
};
}
// Check for correction (similar but contradictory)
if best.similarity >= self.config.correction_threshold
&& best.appears_contradictory {
self.stats.supersedes += 1;
return GateDecision::Supersede {
old_memory_id: c.id.clone(),
similarity: best.similarity,
supersede_reason: SupersedeReason::Correction,
prediction_error: best.prediction_error,
};
}
// Regular update for similar content
if best.similarity >= self.config.similarity_threshold && self.config.prefer_updates {
self.stats.updates += 1;
return GateDecision::Update {
target_id: best.memory_id.clone(),
similarity: best.similarity,
update_type: UpdateType::Merge,
prediction_error: best.prediction_error,
};
}
}
}
// Check for merge opportunity (multiple similar memories)
let merge_candidates: Vec<_> = top_candidates
.iter()
.filter(|s| s.similarity >= self.config.similarity_threshold * 0.9)
.collect();
if merge_candidates.len() >= 2 {
let avg_similarity = merge_candidates.iter().map(|s| s.similarity).sum::<f32>()
/ merge_candidates.len() as f32;
self.stats.merges += 1;
return GateDecision::Merge {
memory_ids: merge_candidates.iter().map(|s| s.memory_id.clone()).collect(),
avg_similarity,
strategy: MergeStrategy::Combine,
};
}
// Default: create new (high prediction error)
let best_pe = top_candidates
.first()
.map(|s| s.prediction_error)
.unwrap_or(1.0);
self.stats.creates += 1;
GateDecision::Create {
reason: if candidates.is_empty() {
CreateReason::NoSimilarMemories
} else {
CreateReason::HighPredictionError
},
prediction_error: best_pe,
related_memory_ids: top_candidates.iter().map(|s| s.memory_id.clone()).collect(),
}
}
/// Evaluate with explicit intent
///
/// Use when the user has indicated intent (e.g., "update X" or "this is better than Y")
pub fn evaluate_with_intent(
&mut self,
new_content: &str,
new_embedding: &[f32],
candidates: &[CandidateMemory],
intent: EvaluationIntent,
) -> GateDecision {
match intent {
EvaluationIntent::ForceCreate => {
self.stats.creates += 1;
GateDecision::Create {
reason: CreateReason::ExplicitCreate,
prediction_error: 1.0,
related_memory_ids: vec![],
}
}
EvaluationIntent::ForceUpdate { target_id } => {
// Find the target candidate
if let Some(c) = candidates.iter().find(|c| c.id == target_id) {
let similarity = cosine_similarity(new_embedding, &c.embedding);
self.stats.updates += 1;
GateDecision::Update {
target_id: target_id.clone(),
similarity,
update_type: UpdateType::Replace,
prediction_error: 1.0 - similarity,
}
} else {
// Target not found, evaluate normally
self.evaluate(new_content, new_embedding, candidates)
}
}
EvaluationIntent::Supersede { old_memory_id, reason } => {
if let Some(c) = candidates.iter().find(|c| c.id == old_memory_id) {
let similarity = cosine_similarity(new_embedding, &c.embedding);
self.stats.supersedes += 1;
GateDecision::Supersede {
old_memory_id,
similarity,
supersede_reason: reason,
prediction_error: 1.0 - similarity,
}
} else {
self.evaluate(new_content, new_embedding, candidates)
}
}
EvaluationIntent::Auto => {
self.evaluate(new_content, new_embedding, candidates)
}
}
}
/// Detect if two pieces of content appear contradictory
///
/// Uses simple heuristics; could be enhanced with NLI model
fn detect_contradiction(&self, new_content: &str, old_content: &str) -> bool {
let new_lower = new_content.to_lowercase();
let old_lower = old_content.to_lowercase();
// Check for explicit negation patterns
let negation_pairs = [
("don't", "do"),
("never", "always"),
("avoid", "use"),
("wrong", "right"),
("bad", "good"),
("incorrect", "correct"),
("deprecated", "recommended"),
("outdated", "current"),
("instead of", ""),
("rather than", ""),
("not ", ""),
];
for (neg, _pos) in negation_pairs.iter() {
if new_lower.contains(neg) && !old_lower.contains(neg) {
return true;
}
}
// Check for correction phrases
let correction_phrases = [
"actually",
"correction",
"update:",
"fixed",
"was wrong",
"should be",
"better approach",
"improved",
"the right way",
];
for phrase in correction_phrases.iter() {
if new_lower.contains(phrase) {
return true;
}
}
false
}
/// Get statistics
pub fn stats(&self) -> &GateStats {
&self.stats
}
/// Reset statistics
pub fn reset_stats(&mut self) {
self.stats = GateStats::default();
}
}
// ============================================================================
// EVALUATION INTENT
// ============================================================================
/// Explicit intent for evaluation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum EvaluationIntent {
/// Automatically determine best action
Auto,
/// Force creation of new memory
ForceCreate,
/// Force update of specific memory
ForceUpdate { target_id: String },
/// Force supersede of specific memory
Supersede { old_memory_id: String, reason: SupersedeReason },
}
// ============================================================================
// STATISTICS
// ============================================================================
/// Statistics about gate decisions
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GateStats {
/// Total evaluations performed
pub total_evaluations: usize,
/// Decisions to create new
pub creates: usize,
/// Decisions to update existing
pub updates: usize,
/// Decisions to supersede
pub supersedes: usize,
/// Decisions to merge
pub merges: usize,
}
impl GateStats {
/// Get create rate
pub fn create_rate(&self) -> f64 {
if self.total_evaluations > 0 {
self.creates as f64 / self.total_evaluations as f64
} else {
0.0
}
}
/// Get update rate
pub fn update_rate(&self) -> f64 {
if self.total_evaluations > 0 {
self.updates as f64 / self.total_evaluations as f64
} else {
0.0
}
}
/// Get supersede rate
pub fn supersede_rate(&self) -> f64 {
if self.total_evaluations > 0 {
self.supersedes as f64 / self.total_evaluations as f64
} else {
0.0
}
}
}
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
/// Calculate cosine similarity between two vectors
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
if a.len() != b.len() || a.is_empty() {
return 0.0;
}
let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm_a == 0.0 || norm_b == 0.0 {
return 0.0;
}
(dot / (norm_a * norm_b)).clamp(0.0, 1.0)
}
// ============================================================================
// TESTS
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
fn make_embedding(seed: f32) -> Vec<f32> {
// Create embeddings with controlled similarity based on seed
// Seeds close to each other = similar vectors
// Seeds far apart = different vectors
(0..384).map(|i| {
let base = (i as f32 / 384.0) * std::f32::consts::PI * 2.0;
(base * seed).sin()
}).collect()
}
fn make_orthogonal_embedding() -> Vec<f32> {
// Create an embedding that's orthogonal to seed=1.0
(0..384).map(|i| {
let base = (i as f32 / 384.0) * std::f32::consts::PI * 2.0;
(base + std::f32::consts::PI / 2.0).sin() // 90 degree phase shift
}).collect()
}
fn make_candidate(id: &str, seed: f32) -> CandidateMemory {
CandidateMemory {
id: id.to_string(),
content: format!("Content for {}", id),
embedding: make_embedding(seed),
retrieval_strength: 0.8,
retention_strength: 0.7,
tags: vec![],
source: None,
was_demoted: false,
was_promoted: false,
}
}
#[test]
fn test_cosine_similarity() {
let a = vec![1.0, 0.0, 0.0];
let b = vec![1.0, 0.0, 0.0];
assert!((cosine_similarity(&a, &b) - 1.0).abs() < 0.001);
let c = vec![0.0, 1.0, 0.0];
assert!((cosine_similarity(&a, &c) - 0.0).abs() < 0.001);
let d = vec![-1.0, 0.0, 0.0];
assert!(cosine_similarity(&a, &d) <= 0.0);
}
#[test]
fn test_empty_candidates() {
let mut gate = PredictionErrorGate::new();
let embedding = make_embedding(1.0);
let decision = gate.evaluate("New content", &embedding, &[]);
assert!(matches!(decision, GateDecision::Create { reason: CreateReason::FirstMemory, .. }));
}
#[test]
fn test_high_similarity_update() {
let mut gate = PredictionErrorGate::new();
let embedding = make_embedding(1.0);
// Create candidate with identical embedding
let mut candidate = make_candidate("mem-1", 1.0);
candidate.embedding = embedding.clone();
let decision = gate.evaluate("Same content", &embedding, &[candidate]);
assert!(decision.is_update());
if let GateDecision::Update { update_type, .. } = decision {
assert_eq!(update_type, UpdateType::Reinforce);
}
}
#[test]
fn test_demoted_memory_supersede() {
let mut gate = PredictionErrorGate::new();
let embedding = make_embedding(1.0);
// Use similar embedding (seed 1.05) - close enough to be above similarity threshold
let mut candidate = make_candidate("mem-1", 1.0);
candidate.embedding = make_embedding(1.05);
candidate.was_demoted = true;
let decision = gate.evaluate("Better solution", &embedding, &[candidate]);
// Should supersede the demoted memory if similarity is above threshold
// If not superseding, it should at least update
assert!(matches!(decision, GateDecision::Supersede { .. } | GateDecision::Update { .. }));
}
#[test]
fn test_different_content_create() {
let mut gate = PredictionErrorGate::new();
let new_embedding = make_embedding(1.0);
// Use orthogonal embedding for truly different content
let mut candidate = make_candidate("mem-1", 1.0);
candidate.embedding = make_orthogonal_embedding();
let decision = gate.evaluate("Completely different topic", &new_embedding, &[candidate]);
assert!(matches!(decision, GateDecision::Create { .. }));
}
#[test]
fn test_contradiction_detection() {
let gate = PredictionErrorGate::new();
assert!(gate.detect_contradiction(
"Don't use synchronous code",
"Use synchronous code for simplicity"
));
assert!(gate.detect_contradiction(
"Actually, the correct approach is...",
"The approach is to..."
));
assert!(!gate.detect_contradiction(
"Use async/await for performance",
"Use async patterns when needed"
));
}
#[test]
fn test_force_create_intent() {
let mut gate = PredictionErrorGate::new();
let embedding = make_embedding(1.0);
let candidate = make_candidate("mem-1", 1.0);
let decision = gate.evaluate_with_intent(
"New content",
&embedding,
&[candidate],
EvaluationIntent::ForceCreate,
);
assert!(matches!(decision, GateDecision::Create { reason: CreateReason::ExplicitCreate, .. }));
}
#[test]
fn test_force_update_intent() {
let mut gate = PredictionErrorGate::new();
let embedding = make_embedding(1.0);
let candidate = make_candidate("mem-1", 5.0);
let decision = gate.evaluate_with_intent(
"Updated content",
&embedding,
&[candidate],
EvaluationIntent::ForceUpdate { target_id: "mem-1".to_string() },
);
assert!(matches!(decision, GateDecision::Update { .. }));
}
#[test]
fn test_stats() {
let mut gate = PredictionErrorGate::new();
let embedding = make_embedding(1.0);
// Create (empty candidates)
gate.evaluate("Content", &embedding, &[]);
// Update (identical)
let mut candidate = make_candidate("mem-1", 1.0);
candidate.embedding = embedding.clone();
gate.evaluate("Content", &embedding, &[candidate.clone()]);
let stats = gate.stats();
assert_eq!(stats.total_evaluations, 2);
assert_eq!(stats.creates, 1);
assert_eq!(stats.updates, 1);
}
}

View file

@ -138,7 +138,8 @@ pub use fsrs::{
// Storage layer
pub use storage::{
ConsolidationHistoryRecord, InsightRecord, IntentionRecord, Result, Storage, StorageError,
ConsolidationHistoryRecord, InsightRecord, IntentionRecord, Result, SmartIngestResult, Storage,
StorageError,
};
// Consolidation (sleep-inspired memory processing)
@ -215,6 +216,18 @@ pub use advanced::{
UsageEvent,
UsagePattern,
UserAction,
// Prediction Error Gating (solves bad vs good similar memory problem)
CandidateMemory,
CreateReason,
EvaluationIntent,
GateDecision,
GateStats,
MergeStrategy,
PredictionErrorConfig,
PredictionErrorGate,
SimilarityResult as PredictionSimilarityResult,
SupersedeReason,
UpdateType,
};
// Codebase memory (Vestige's killer differentiator)
@ -437,6 +450,10 @@ pub mod prelude {
// Reconsolidation
ReconsolidationManager,
SpeculativeRetriever,
// Prediction Error Gating
PredictionErrorGate,
GateDecision,
EvaluationIntent,
};
// Codebase memory

View file

@ -11,5 +11,6 @@ mod sqlite;
pub use migrations::MIGRATIONS;
pub use sqlite::{
ConsolidationHistoryRecord, InsightRecord, IntentionRecord, Result, Storage, StorageError,
ConsolidationHistoryRecord, InsightRecord, IntentionRecord, Result, SmartIngestResult, Storage,
StorageError,
};

View file

@ -52,6 +52,24 @@ pub enum StorageError {
/// Storage result type
pub type Result<T> = std::result::Result<T, StorageError>;
/// Result of smart ingest with prediction error gating
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SmartIngestResult {
/// Decision made: "create", "update", "supersede", "merge", "reinforce", etc.
pub decision: String,
/// The resulting node (new or updated)
pub node: KnowledgeNode,
/// ID of superseded memory (if any)
pub superseded_id: Option<String>,
/// Similarity to closest existing memory (0.0 - 1.0)
pub similarity: Option<f32>,
/// Prediction error (1.0 - similarity)
pub prediction_error: Option<f32>,
/// Human-readable explanation of the decision
pub reason: String,
}
// ============================================================================
// STORAGE
// ============================================================================
@ -236,6 +254,244 @@ impl Storage {
.ok_or_else(|| StorageError::NotFound(id))
}
/// Smart ingest with Prediction Error Gating
///
/// Uses neuroscience-inspired prediction error to decide whether to:
/// - Create a new memory (high prediction error)
/// - Update an existing memory (low prediction error)
/// - Supersede a demoted/outdated memory (correction)
///
/// This solves the "bad vs good similar memory" problem.
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
pub fn smart_ingest(
&mut self,
input: IngestInput,
) -> Result<SmartIngestResult> {
use crate::advanced::prediction_error::{
CandidateMemory, GateDecision, PredictionErrorGate, UpdateType,
};
// Generate embedding for new content
if !self.embedding_service.is_ready() {
// Fall back to regular ingest if embeddings not available
let node = self.ingest(input)?;
return Ok(SmartIngestResult {
decision: "create".to_string(),
node,
superseded_id: None,
similarity: None,
prediction_error: Some(1.0),
reason: "Embeddings not available, falling back to regular ingest".to_string(),
});
}
let new_embedding = self
.embedding_service
.embed(&input.content)
.map_err(|e| StorageError::Init(format!("Embedding failed: {}", e)))?;
// Find similar memories using semantic search
let similar = self.semantic_search_raw(&input.content, 10)?;
// Build candidate memories
let mut candidates: Vec<CandidateMemory> = Vec::new();
for (node_id, similarity) in similar.iter() {
if let Some(node) = self.get_node(node_id)? {
// Get embedding for this node
if let Some(emb) = self.get_node_embedding(node_id)? {
// Check if this memory was previously demoted (low retrieval strength)
let was_demoted = node.retrieval_strength < 0.3;
let was_promoted = node.retrieval_strength > 0.85;
candidates.push(CandidateMemory {
id: node.id.clone(),
content: node.content.clone(),
embedding: emb,
retrieval_strength: node.retrieval_strength,
retention_strength: node.retention_strength,
tags: node.tags.clone(),
source: node.source.clone(),
was_demoted,
was_promoted,
});
}
}
}
// Evaluate with prediction error gate
let mut gate = PredictionErrorGate::new();
let decision = gate.evaluate(&input.content, &new_embedding.vector, &candidates);
match decision {
GateDecision::Create { prediction_error, related_memory_ids, reason, .. } => {
// Create new memory
let node = self.ingest(input)?;
Ok(SmartIngestResult {
decision: "create".to_string(),
node,
superseded_id: None,
similarity: None,
prediction_error: Some(prediction_error),
reason: format!("Created new memory: {:?}. Related: {:?}", reason, related_memory_ids),
})
}
GateDecision::Update { target_id, similarity, update_type, prediction_error } => {
match update_type {
UpdateType::Reinforce => {
// Just strengthen the existing memory
self.strengthen_on_access(&target_id)?;
let node = self.get_node(&target_id)?
.ok_or_else(|| StorageError::NotFound(target_id.clone()))?;
Ok(SmartIngestResult {
decision: "reinforce".to_string(),
node,
superseded_id: None,
similarity: Some(similarity),
prediction_error: Some(prediction_error),
reason: "Content nearly identical - reinforced existing memory".to_string(),
})
}
UpdateType::Merge | UpdateType::Append => {
// Update the existing memory with merged content
let existing = self.get_node(&target_id)?
.ok_or_else(|| StorageError::NotFound(target_id.clone()))?;
let merged_content = format!(
"{}\n\n[Updated {}]\n{}",
existing.content,
chrono::Utc::now().format("%Y-%m-%d"),
input.content
);
self.update_node_content(&target_id, &merged_content)?;
self.strengthen_on_access(&target_id)?;
let node = self.get_node(&target_id)?
.ok_or_else(|| StorageError::NotFound(target_id.clone()))?;
Ok(SmartIngestResult {
decision: "update".to_string(),
node,
superseded_id: None,
similarity: Some(similarity),
prediction_error: Some(prediction_error),
reason: "Merged with existing similar memory".to_string(),
})
}
UpdateType::Replace => {
// Replace content entirely
self.update_node_content(&target_id, &input.content)?;
let node = self.get_node(&target_id)?
.ok_or_else(|| StorageError::NotFound(target_id.clone()))?;
Ok(SmartIngestResult {
decision: "replace".to_string(),
node,
superseded_id: None,
similarity: Some(similarity),
prediction_error: Some(prediction_error),
reason: "Replaced existing memory with new content".to_string(),
})
}
UpdateType::AddContext => {
// Add as context without modifying main content
let existing = self.get_node(&target_id)?
.ok_or_else(|| StorageError::NotFound(target_id.clone()))?;
let merged_content = format!(
"{}\n\n---\nContext: {}",
existing.content,
input.content
);
self.update_node_content(&target_id, &merged_content)?;
let node = self.get_node(&target_id)?
.ok_or_else(|| StorageError::NotFound(target_id.clone()))?;
Ok(SmartIngestResult {
decision: "add_context".to_string(),
node,
superseded_id: None,
similarity: Some(similarity),
prediction_error: Some(prediction_error),
reason: "Added new content as context to existing memory".to_string(),
})
}
}
}
GateDecision::Supersede { old_memory_id, similarity, supersede_reason, prediction_error } => {
// Demote the old memory and create new
self.demote_memory(&old_memory_id)?;
// Create the new improved memory
let node = self.ingest(input)?;
Ok(SmartIngestResult {
decision: "supersede".to_string(),
node,
superseded_id: Some(old_memory_id),
similarity: Some(similarity),
prediction_error: Some(prediction_error),
reason: format!("New memory supersedes old: {:?}", supersede_reason),
})
}
GateDecision::Merge { memory_ids, avg_similarity, strategy } => {
// For now, create new and link to existing
let node = self.ingest(input)?;
Ok(SmartIngestResult {
decision: "merge".to_string(),
node,
superseded_id: None,
similarity: Some(avg_similarity),
prediction_error: Some(1.0 - avg_similarity),
reason: format!("Created new memory linked to {} similar memories ({:?})", memory_ids.len(), strategy),
})
}
}
}
/// Get the embedding vector for a node
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
fn get_node_embedding(&self, node_id: &str) -> Result<Option<Vec<f32>>> {
let mut stmt = self.conn.prepare(
"SELECT embedding FROM node_embeddings WHERE node_id = ?1"
)?;
let embedding_bytes: Option<Vec<u8>> = stmt
.query_row(params![node_id], |row| row.get(0))
.optional()?;
Ok(embedding_bytes.and_then(|bytes| {
crate::embeddings::Embedding::from_bytes(&bytes).map(|e| e.vector)
}))
}
/// Update the content of an existing node
pub fn update_node_content(&mut self, id: &str, new_content: &str) -> Result<()> {
let now = Utc::now();
self.conn.execute(
"UPDATE knowledge_nodes SET content = ?1, updated_at = ?2 WHERE id = ?3",
params![new_content, now.to_rfc3339(), id],
)?;
// Regenerate embedding for updated content
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
{
// Remove old embedding from index
if let Ok(mut index) = self.vector_index.lock() {
let _ = index.remove(id);
}
// Generate new embedding
if let Err(e) = self.generate_embedding_for_node(id, new_content) {
tracing::warn!("Failed to regenerate embedding for {}: {}", id, e);
}
}
Ok(())
}
/// Generate embedding for a node
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
fn generate_embedding_for_node(&mut self, node_id: &str, content: &str) -> Result<()> {
@ -372,23 +628,30 @@ impl Storage {
/// Recall memories matching a query
pub fn recall(&self, input: RecallInput) -> Result<Vec<KnowledgeNode>> {
match input.search_mode {
let nodes = match input.search_mode {
SearchMode::Keyword => {
self.keyword_search(&input.query, input.limit, input.min_retention)
self.keyword_search(&input.query, input.limit, input.min_retention)?
}
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
SearchMode::Semantic => {
let results = self.semantic_search(&input.query, input.limit, 0.3)?;
Ok(results.into_iter().map(|r| r.node).collect())
results.into_iter().map(|r| r.node).collect()
}
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
SearchMode::Hybrid => {
let results = self.hybrid_search(&input.query, input.limit, 0.5, 0.5)?;
Ok(results.into_iter().map(|r| r.node).collect())
results.into_iter().map(|r| r.node).collect()
}
#[cfg(not(all(feature = "embeddings", feature = "vector-search")))]
_ => self.keyword_search(&input.query, input.limit, input.min_retention),
}
_ => self.keyword_search(&input.query, input.limit, input.min_retention)?,
};
// Auto-strengthen memories on access (Testing Effect - Roediger & Karpicke 2006)
// This implements "use it or lose it" - accessed memories get stronger
let ids: Vec<&str> = nodes.iter().map(|n| n.id.as_str()).collect();
let _ = self.strengthen_batch_on_access(&ids); // Ignore errors, don't fail recall
Ok(nodes)
}
/// Keyword search with FTS5
@ -503,6 +766,76 @@ impl Storage {
.ok_or_else(|| StorageError::NotFound(id.to_string()))
}
/// Passively strengthen a memory when it's accessed (recalled/searched)
/// This implements the "use it or lose it" principle - memories that are
/// accessed get a small boost, those that aren't decay naturally.
/// Based on Testing Effect (Roediger & Karpicke 2006)
pub fn strengthen_on_access(&self, id: &str) -> Result<()> {
let now = Utc::now();
// Small retrieval strength boost (0.05) on each access
// This is much smaller than a full review but compounds over time
self.conn.execute(
"UPDATE knowledge_nodes SET
last_accessed = ?1,
retrieval_strength = MIN(1.0, retrieval_strength + 0.05),
retention_strength = MIN(1.0, retention_strength + 0.02)
WHERE id = ?2",
params![now.to_rfc3339(), id],
)?;
Ok(())
}
/// Batch strengthen multiple memories on access
pub fn strengthen_batch_on_access(&self, ids: &[&str]) -> Result<()> {
for id in ids {
self.strengthen_on_access(id)?;
}
Ok(())
}
/// Promote a memory (thumbs up) - used when a memory led to a good outcome
/// Significantly boosts retrieval strength so it surfaces more often
pub fn promote_memory(&self, id: &str) -> Result<KnowledgeNode> {
let now = Utc::now();
// Strong boost: +0.2 retrieval, +0.1 retention
self.conn.execute(
"UPDATE knowledge_nodes SET
last_accessed = ?1,
retrieval_strength = MIN(1.0, retrieval_strength + 0.20),
retention_strength = MIN(1.0, retention_strength + 0.10),
stability = stability * 1.5
WHERE id = ?2",
params![now.to_rfc3339(), id],
)?;
self.get_node(id)?
.ok_or_else(|| StorageError::NotFound(id.to_string()))
}
/// Demote a memory (thumbs down) - used when a memory led to a bad outcome
/// Significantly reduces retrieval strength so better alternatives surface
/// Does NOT delete - the memory stays for reference but ranks lower
pub fn demote_memory(&self, id: &str) -> Result<KnowledgeNode> {
let now = Utc::now();
// Strong penalty: -0.3 retrieval, -0.15 retention, halve stability
self.conn.execute(
"UPDATE knowledge_nodes SET
last_accessed = ?1,
retrieval_strength = MAX(0.05, retrieval_strength - 0.30),
retention_strength = MAX(0.05, retention_strength - 0.15),
stability = stability * 0.5
WHERE id = ?2",
params![now.to_rfc3339(), id],
)?;
self.get_node(id)?
.ok_or_else(|| StorageError::NotFound(id.to_string()))
}
/// Get memories due for review
pub fn get_review_queue(&self, limit: i32) -> Result<Vec<KnowledgeNode>> {
let now = Utc::now().to_rfc3339();