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

1
Cargo.lock generated
View file

@ -3406,6 +3406,7 @@ dependencies = [
name = "vestige-mcp"
version = "1.0.0"
dependencies = [
"anyhow",
"chrono",
"directories",
"rmcp",

View file

@ -130,10 +130,11 @@ Claude will now have access to persistent, biologically-inspired memory.
| **Prospective Memory** | Future intentions with time/context triggers |
| **Basic Consolidation** | Decay + prune cycles |
### MCP Tools (25 Total)
### MCP Tools (26 Total)
**Core Memory (7):**
**Core Memory (8):**
- `ingest` - Store new memories
- `smart_ingest` - Prediction Error Gating (auto-decides create/update/supersede)
- `recall` - Semantic retrieval
- `semantic_search` - Pure embedding search
- `hybrid_search` - BM25 + semantic fusion

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();

View file

@ -13,6 +13,10 @@ repository = "https://github.com/samvallad33/vestige"
name = "vestige-mcp"
path = "src/main.rs"
[[bin]]
name = "vestige-restore"
path = "src/bin/restore.rs"
[dependencies]
# ============================================================================
# VESTIGE CORE - The cognitive science engine
@ -39,6 +43,7 @@ uuid = { version = "1", features = ["v4", "serde"] }
# Error handling
thiserror = "2"
anyhow = "1"
# Logging
tracing = "0.1"

View file

@ -120,6 +120,11 @@ impl McpServer {
description: Some("Add new knowledge to memory. Use for facts, concepts, decisions, or any information worth remembering.".to_string()),
input_schema: tools::ingest::schema(),
},
ToolDescription {
name: "smart_ingest".to_string(),
description: Some("INTELLIGENT memory ingestion with Prediction Error Gating. Automatically decides whether to CREATE new, UPDATE existing, or SUPERSEDE outdated memories based on semantic similarity. Solves the 'bad vs good similar memory' problem.".to_string()),
input_schema: tools::smart_ingest::schema(),
},
ToolDescription {
name: "recall".to_string(),
description: Some("Search and retrieve knowledge from memory. Returns matches ranked by relevance and retention strength.".to_string()),
@ -244,6 +249,22 @@ impl McpServer {
description: Some("Search memories with context-dependent retrieval. Based on Tulving's Encoding Specificity Principle (1973).".to_string()),
input_schema: tools::context::schema(),
},
// Feedback / preference learning
ToolDescription {
name: "promote_memory".to_string(),
description: Some("Promote a memory (thumbs up). Use when a memory led to a good outcome. Increases retrieval strength so it surfaces more often.".to_string()),
input_schema: tools::feedback::promote_schema(),
},
ToolDescription {
name: "demote_memory".to_string(),
description: Some("Demote a memory (thumbs down). Use when a memory led to a bad outcome or was wrong. Decreases retrieval strength so better alternatives surface. Does NOT delete.".to_string()),
input_schema: tools::feedback::demote_schema(),
},
ToolDescription {
name: "request_feedback".to_string(),
description: Some("Ask the user if a memory was helpful. Use after applying advice from a memory. Returns options for the user to choose: helpful (promote), wrong (demote), or skip.".to_string()),
input_schema: tools::feedback::request_feedback_schema(),
},
];
let result = ListToolsResult { tools };
@ -263,6 +284,7 @@ impl McpServer {
let result = match request.name.as_str() {
// Core memory tools
"ingest" => tools::ingest::execute(&self.storage, request.arguments).await,
"smart_ingest" => tools::smart_ingest::execute(&self.storage, request.arguments).await,
"recall" => tools::recall::execute(&self.storage, request.arguments).await,
"semantic_search" => tools::search::execute_semantic(&self.storage, request.arguments).await,
"hybrid_search" => tools::search::execute_hybrid(&self.storage, request.arguments).await,
@ -291,6 +313,10 @@ impl McpServer {
"find_tagged" => tools::tagging::execute_find(&self.storage, request.arguments).await,
"tagging_stats" => tools::tagging::execute_stats(&self.storage).await,
"match_context" => tools::context::execute(&self.storage, request.arguments).await,
// Feedback / preference learning
"promote_memory" => tools::feedback::execute_promote(&self.storage, request.arguments).await,
"demote_memory" => tools::feedback::execute_demote(&self.storage, request.arguments).await,
"request_feedback" => tools::feedback::execute_request_feedback(&self.storage, request.arguments).await,
name => {
return Err(JsonRpcError::method_not_found_with_message(&format!(

View file

@ -0,0 +1,231 @@
//! Feedback Tools
//!
//! Promote and demote memories based on outcome quality.
//! Implements preference learning for Vestige.
use serde::Deserialize;
use serde_json::Value;
use std::sync::Arc;
use tokio::sync::Mutex;
use vestige_core::Storage;
/// Input schema for promote_memory tool
pub fn promote_schema() -> Value {
serde_json::json!({
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "The ID of the memory to promote"
},
"reason": {
"type": "string",
"description": "Why this memory was helpful (optional, for logging)"
}
},
"required": ["id"]
})
}
/// Input schema for demote_memory tool
pub fn demote_schema() -> Value {
serde_json::json!({
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "The ID of the memory to demote"
},
"reason": {
"type": "string",
"description": "Why this memory was unhelpful or wrong (optional, for logging)"
}
},
"required": ["id"]
})
}
#[derive(Debug, Deserialize)]
struct FeedbackArgs {
id: String,
reason: Option<String>,
}
/// Promote a memory (thumbs up) - it led to a good outcome
pub async fn execute_promote(
storage: &Arc<Mutex<Storage>>,
args: Option<Value>,
) -> Result<Value, String> {
let args: FeedbackArgs = match args {
Some(v) => serde_json::from_value(v).map_err(|e| format!("Invalid arguments: {}", e))?,
None => return Err("Missing arguments".to_string()),
};
// Validate UUID
uuid::Uuid::parse_str(&args.id).map_err(|_| "Invalid node ID format".to_string())?;
let storage = storage.lock().await;
// Get node before for comparison
let before = storage.get_node(&args.id).map_err(|e| e.to_string())?
.ok_or_else(|| format!("Node not found: {}", args.id))?;
let node = storage.promote_memory(&args.id).map_err(|e| e.to_string())?;
Ok(serde_json::json!({
"success": true,
"action": "promoted",
"nodeId": node.id,
"reason": args.reason,
"changes": {
"retrievalStrength": {
"before": before.retrieval_strength,
"after": node.retrieval_strength,
"delta": "+0.20"
},
"retentionStrength": {
"before": before.retention_strength,
"after": node.retention_strength,
"delta": "+0.10"
},
"stability": {
"before": before.stability,
"after": node.stability,
"multiplier": "1.5x"
}
},
"message": format!("Memory promoted. It will now surface more often in searches. Retrieval: {:.2} -> {:.2}",
before.retrieval_strength, node.retrieval_strength),
}))
}
/// Demote a memory (thumbs down) - it led to a bad outcome
pub async fn execute_demote(
storage: &Arc<Mutex<Storage>>,
args: Option<Value>,
) -> Result<Value, String> {
let args: FeedbackArgs = match args {
Some(v) => serde_json::from_value(v).map_err(|e| format!("Invalid arguments: {}", e))?,
None => return Err("Missing arguments".to_string()),
};
// Validate UUID
uuid::Uuid::parse_str(&args.id).map_err(|_| "Invalid node ID format".to_string())?;
let storage = storage.lock().await;
// Get node before for comparison
let before = storage.get_node(&args.id).map_err(|e| e.to_string())?
.ok_or_else(|| format!("Node not found: {}", args.id))?;
let node = storage.demote_memory(&args.id).map_err(|e| e.to_string())?;
Ok(serde_json::json!({
"success": true,
"action": "demoted",
"nodeId": node.id,
"reason": args.reason,
"changes": {
"retrievalStrength": {
"before": before.retrieval_strength,
"after": node.retrieval_strength,
"delta": "-0.30"
},
"retentionStrength": {
"before": before.retention_strength,
"after": node.retention_strength,
"delta": "-0.15"
},
"stability": {
"before": before.stability,
"after": node.stability,
"multiplier": "0.5x"
}
},
"message": format!("Memory demoted. Better alternatives will now surface instead. Retrieval: {:.2} -> {:.2}",
before.retrieval_strength, node.retrieval_strength),
"note": "Memory is NOT deleted - it remains searchable but ranks lower."
}))
}
/// Input schema for request_feedback tool
pub fn request_feedback_schema() -> Value {
serde_json::json!({
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "The ID of the memory to request feedback on"
},
"context": {
"type": "string",
"description": "What the memory was used for (e.g., 'error handling advice')"
}
},
"required": ["id"]
})
}
#[derive(Debug, Deserialize)]
struct RequestFeedbackArgs {
id: String,
context: Option<String>,
}
/// Request feedback from the user about a memory's usefulness
/// Returns a structured prompt for Claude to ask the user
pub async fn execute_request_feedback(
storage: &Arc<Mutex<Storage>>,
args: Option<Value>,
) -> Result<Value, String> {
let args: RequestFeedbackArgs = match args {
Some(v) => serde_json::from_value(v).map_err(|e| format!("Invalid arguments: {}", e))?,
None => return Err("Missing arguments".to_string()),
};
// Validate UUID
uuid::Uuid::parse_str(&args.id).map_err(|_| "Invalid node ID format".to_string())?;
let storage = storage.lock().await;
let node = storage.get_node(&args.id).map_err(|e| e.to_string())?
.ok_or_else(|| format!("Node not found: {}", args.id))?;
// Truncate content for display
let preview: String = node.content.chars().take(100).collect();
let preview = if node.content.len() > 100 {
format!("{}...", preview)
} else {
preview
};
Ok(serde_json::json!({
"action": "request_feedback",
"nodeId": node.id,
"memoryPreview": preview,
"context": args.context,
"prompt": "Was this memory helpful?",
"options": [
{
"key": "A",
"label": "Yes, helpful",
"action": "promote",
"description": "Memory will surface more often"
},
{
"key": "B",
"label": "No, wrong/outdated",
"action": "demote",
"description": "Better alternatives will surface instead"
},
{
"key": "C",
"label": "Ask Claude...",
"action": "custom",
"description": "Give Claude a custom instruction (e.g., 'update this memory', 'merge with X', 'add tag Y')"
}
],
"instruction": "PRESENT THESE OPTIONS TO THE USER. If they choose A, call promote_memory. If B, call demote_memory. If C, they will provide a custom instruction - execute it (could be: update the memory content, delete it, merge it, add tags, research something, etc.)."
}))
}

View file

@ -10,9 +10,13 @@ pub mod knowledge;
pub mod recall;
pub mod review;
pub mod search;
pub mod smart_ingest;
pub mod stats;
// Neuroscience-inspired tools
pub mod context;
pub mod memory_states;
pub mod tagging;
// Feedback / preference learning
pub mod feedback;

View file

@ -0,0 +1,218 @@
//! Smart Ingest Tool
//!
//! Intelligent memory ingestion with Prediction Error Gating.
//! Automatically decides whether to create, update, or supersede memories
//! based on semantic similarity to existing content.
//!
//! This solves the "bad vs good similar memory" problem by:
//! - Detecting when new content is similar to existing memories
//! - Updating existing memories when appropriate (low prediction error)
//! - Creating new memories when content is substantially different (high PE)
//! - Superseding demoted/outdated memories with better alternatives
use serde::Deserialize;
use serde_json::Value;
use std::sync::Arc;
use tokio::sync::Mutex;
use vestige_core::{IngestInput, Storage};
/// Input schema for smart_ingest tool
pub fn schema() -> Value {
serde_json::json!({
"type": "object",
"properties": {
"content": {
"type": "string",
"description": "The content to remember. Will be compared against existing memories."
},
"node_type": {
"type": "string",
"description": "Type of knowledge: fact, concept, event, person, place, note, pattern, decision",
"default": "fact"
},
"tags": {
"type": "array",
"items": { "type": "string" },
"description": "Tags for categorization"
},
"source": {
"type": "string",
"description": "Source or reference for this knowledge"
},
"forceCreate": {
"type": "boolean",
"description": "Force creation of a new memory even if similar content exists",
"default": false
}
},
"required": ["content"]
})
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SmartIngestArgs {
content: String,
node_type: Option<String>,
tags: Option<Vec<String>>,
source: Option<String>,
force_create: Option<bool>,
}
pub async fn execute(
storage: &Arc<Mutex<Storage>>,
args: Option<Value>,
) -> Result<Value, String> {
let args: SmartIngestArgs = match args {
Some(v) => serde_json::from_value(v).map_err(|e| format!("Invalid arguments: {}", e))?,
None => return Err("Missing arguments".to_string()),
};
// Validate content
if args.content.trim().is_empty() {
return Err("Content cannot be empty".to_string());
}
if args.content.len() > 1_000_000 {
return Err("Content too large (max 1MB)".to_string());
}
let input = IngestInput {
content: args.content,
node_type: args.node_type.unwrap_or_else(|| "fact".to_string()),
source: args.source,
sentiment_score: 0.0,
sentiment_magnitude: 0.0,
tags: args.tags.unwrap_or_default(),
valid_from: None,
valid_until: None,
};
let mut storage = storage.lock().await;
// Check if force_create is enabled
if args.force_create.unwrap_or(false) {
// Use regular ingest
let node = storage.ingest(input).map_err(|e| e.to_string())?;
return Ok(serde_json::json!({
"success": true,
"decision": "create",
"nodeId": node.id,
"message": "Memory created (force_create=true)",
"hasEmbedding": node.has_embedding.unwrap_or(false),
"predictionError": 1.0,
"reason": "Forced creation - skipped similarity check"
}));
}
// Use smart ingest with prediction error gating
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
{
let result = storage.smart_ingest(input).map_err(|e| e.to_string())?;
Ok(serde_json::json!({
"success": true,
"decision": result.decision,
"nodeId": result.node.id,
"message": format!("Smart ingest complete: {}", result.reason),
"hasEmbedding": result.node.has_embedding.unwrap_or(false),
"similarity": result.similarity,
"predictionError": result.prediction_error,
"supersededId": result.superseded_id,
"reason": result.reason,
"explanation": match result.decision.as_str() {
"create" => "Created new memory - content was different enough from existing memories",
"update" => "Updated existing memory - content was similar to an existing memory",
"reinforce" => "Reinforced existing memory - content was nearly identical",
"supersede" => "Superseded old memory - new content is an improvement/correction",
"merge" => "Merged with related memories - content connects multiple topics",
"replace" => "Replaced existing memory content entirely",
"add_context" => "Added new content as context to existing memory",
_ => "Memory processed successfully"
}
}))
}
#[cfg(not(all(feature = "embeddings", feature = "vector-search")))]
{
// Fall back to regular ingest if features not available
let node = storage.ingest(input).map_err(|e| e.to_string())?;
Ok(serde_json::json!({
"success": true,
"decision": "create",
"nodeId": node.id,
"message": "Memory created (smart ingest requires embeddings feature)",
"hasEmbedding": false,
"predictionError": 1.0,
"reason": "Embeddings not available - used regular ingest"
}))
}
}
// ============================================================================
// TESTS
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
/// Create a test storage instance with a temporary database
async fn test_storage() -> (Arc<Mutex<Storage>>, TempDir) {
let dir = TempDir::new().unwrap();
let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap();
(Arc::new(Mutex::new(storage)), dir)
}
#[tokio::test]
async fn test_smart_ingest_empty_content_fails() {
let (storage, _dir) = test_storage().await;
let args = serde_json::json!({ "content": "" });
let result = execute(&storage, Some(args)).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("empty"));
}
#[tokio::test]
async fn test_smart_ingest_basic_content_succeeds() {
let (storage, _dir) = test_storage().await;
let args = serde_json::json!({
"content": "This is a test fact to remember."
});
let result = execute(&storage, Some(args)).await;
assert!(result.is_ok());
let value = result.unwrap();
assert_eq!(value["success"], true);
assert!(value["nodeId"].is_string());
assert!(value["decision"].is_string());
}
#[tokio::test]
async fn test_smart_ingest_force_create() {
let (storage, _dir) = test_storage().await;
let args = serde_json::json!({
"content": "Force create test content.",
"forceCreate": true
});
let result = execute(&storage, Some(args)).await;
assert!(result.is_ok());
let value = result.unwrap();
assert_eq!(value["success"], true);
assert_eq!(value["decision"], "create");
assert!(value["reason"].as_str().unwrap().contains("Forced") ||
value["reason"].as_str().unwrap().contains("Embeddings not available"));
}
#[test]
fn test_schema_has_required_fields() {
let schema_value = schema();
assert_eq!(schema_value["type"], "object");
assert!(schema_value["properties"]["content"].is_object());
assert!(schema_value["properties"]["forceCreate"].is_object());
assert!(schema_value["required"].as_array().unwrap().contains(&serde_json::json!("content")));
}
}