mirror of
https://github.com/samvallad33/vestige.git
synced 2026-05-09 07:42:37 +02:00
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:
parent
5337efdfa7
commit
bbd1c15b4a
12 changed files with 1705 additions and 10 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -3406,6 +3406,7 @@ dependencies = [
|
||||||
name = "vestige-mcp"
|
name = "vestige-mcp"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
"directories",
|
"directories",
|
||||||
"rmcp",
|
"rmcp",
|
||||||
|
|
|
||||||
|
|
@ -130,10 +130,11 @@ Claude will now have access to persistent, biologically-inspired memory.
|
||||||
| **Prospective Memory** | Future intentions with time/context triggers |
|
| **Prospective Memory** | Future intentions with time/context triggers |
|
||||||
| **Basic Consolidation** | Decay + prune cycles |
|
| **Basic Consolidation** | Decay + prune cycles |
|
||||||
|
|
||||||
### MCP Tools (25 Total)
|
### MCP Tools (26 Total)
|
||||||
|
|
||||||
**Core Memory (7):**
|
**Core Memory (8):**
|
||||||
- `ingest` - Store new memories
|
- `ingest` - Store new memories
|
||||||
|
- `smart_ingest` - Prediction Error Gating (auto-decides create/update/supersede)
|
||||||
- `recall` - Semantic retrieval
|
- `recall` - Semantic retrieval
|
||||||
- `semantic_search` - Pure embedding search
|
- `semantic_search` - Pure embedding search
|
||||||
- `hybrid_search` - BM25 + semantic fusion
|
- `hybrid_search` - BM25 + semantic fusion
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ pub mod cross_project;
|
||||||
pub mod dreams;
|
pub mod dreams;
|
||||||
pub mod importance;
|
pub mod importance;
|
||||||
pub mod intent;
|
pub mod intent;
|
||||||
|
pub mod prediction_error;
|
||||||
pub mod reconsolidation;
|
pub mod reconsolidation;
|
||||||
pub mod speculative;
|
pub mod speculative;
|
||||||
|
|
||||||
|
|
@ -60,4 +61,9 @@ pub use reconsolidation::{
|
||||||
Modification, ReconsolidatedMemory, ReconsolidationManager, ReconsolidationStats,
|
Modification, ReconsolidatedMemory, ReconsolidationManager, ReconsolidationStats,
|
||||||
RelationshipType, RetrievalRecord,
|
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};
|
pub use speculative::{PredictedMemory, PredictionContext, SpeculativeRetriever, UsagePattern};
|
||||||
|
|
|
||||||
852
crates/vestige-core/src/advanced/prediction_error.rs
Normal file
852
crates/vestige-core/src/advanced/prediction_error.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -138,7 +138,8 @@ pub use fsrs::{
|
||||||
|
|
||||||
// Storage layer
|
// Storage layer
|
||||||
pub use storage::{
|
pub use storage::{
|
||||||
ConsolidationHistoryRecord, InsightRecord, IntentionRecord, Result, Storage, StorageError,
|
ConsolidationHistoryRecord, InsightRecord, IntentionRecord, Result, SmartIngestResult, Storage,
|
||||||
|
StorageError,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Consolidation (sleep-inspired memory processing)
|
// Consolidation (sleep-inspired memory processing)
|
||||||
|
|
@ -215,6 +216,18 @@ pub use advanced::{
|
||||||
UsageEvent,
|
UsageEvent,
|
||||||
UsagePattern,
|
UsagePattern,
|
||||||
UserAction,
|
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)
|
// Codebase memory (Vestige's killer differentiator)
|
||||||
|
|
@ -437,6 +450,10 @@ pub mod prelude {
|
||||||
// Reconsolidation
|
// Reconsolidation
|
||||||
ReconsolidationManager,
|
ReconsolidationManager,
|
||||||
SpeculativeRetriever,
|
SpeculativeRetriever,
|
||||||
|
// Prediction Error Gating
|
||||||
|
PredictionErrorGate,
|
||||||
|
GateDecision,
|
||||||
|
EvaluationIntent,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Codebase memory
|
// Codebase memory
|
||||||
|
|
|
||||||
|
|
@ -11,5 +11,6 @@ mod sqlite;
|
||||||
|
|
||||||
pub use migrations::MIGRATIONS;
|
pub use migrations::MIGRATIONS;
|
||||||
pub use sqlite::{
|
pub use sqlite::{
|
||||||
ConsolidationHistoryRecord, InsightRecord, IntentionRecord, Result, Storage, StorageError,
|
ConsolidationHistoryRecord, InsightRecord, IntentionRecord, Result, SmartIngestResult, Storage,
|
||||||
|
StorageError,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,24 @@ pub enum StorageError {
|
||||||
/// Storage result type
|
/// Storage result type
|
||||||
pub type Result<T> = std::result::Result<T, StorageError>;
|
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
|
// STORAGE
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -236,6 +254,244 @@ impl Storage {
|
||||||
.ok_or_else(|| StorageError::NotFound(id))
|
.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
|
/// Generate embedding for a node
|
||||||
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
|
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
|
||||||
fn generate_embedding_for_node(&mut self, node_id: &str, content: &str) -> Result<()> {
|
fn generate_embedding_for_node(&mut self, node_id: &str, content: &str) -> Result<()> {
|
||||||
|
|
@ -372,23 +628,30 @@ impl Storage {
|
||||||
|
|
||||||
/// Recall memories matching a query
|
/// Recall memories matching a query
|
||||||
pub fn recall(&self, input: RecallInput) -> Result<Vec<KnowledgeNode>> {
|
pub fn recall(&self, input: RecallInput) -> Result<Vec<KnowledgeNode>> {
|
||||||
match input.search_mode {
|
let nodes = match input.search_mode {
|
||||||
SearchMode::Keyword => {
|
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"))]
|
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
|
||||||
SearchMode::Semantic => {
|
SearchMode::Semantic => {
|
||||||
let results = self.semantic_search(&input.query, input.limit, 0.3)?;
|
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"))]
|
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
|
||||||
SearchMode::Hybrid => {
|
SearchMode::Hybrid => {
|
||||||
let results = self.hybrid_search(&input.query, input.limit, 0.5, 0.5)?;
|
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")))]
|
#[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
|
/// Keyword search with FTS5
|
||||||
|
|
@ -503,6 +766,76 @@ impl Storage {
|
||||||
.ok_or_else(|| StorageError::NotFound(id.to_string()))
|
.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
|
/// Get memories due for review
|
||||||
pub fn get_review_queue(&self, limit: i32) -> Result<Vec<KnowledgeNode>> {
|
pub fn get_review_queue(&self, limit: i32) -> Result<Vec<KnowledgeNode>> {
|
||||||
let now = Utc::now().to_rfc3339();
|
let now = Utc::now().to_rfc3339();
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@ repository = "https://github.com/samvallad33/vestige"
|
||||||
name = "vestige-mcp"
|
name = "vestige-mcp"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "vestige-restore"
|
||||||
|
path = "src/bin/restore.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# VESTIGE CORE - The cognitive science engine
|
# VESTIGE CORE - The cognitive science engine
|
||||||
|
|
@ -39,6 +43,7 @@ uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
|
||||||
# Error handling
|
# Error handling
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
|
anyhow = "1"
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
|
|
|
||||||
|
|
@ -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()),
|
description: Some("Add new knowledge to memory. Use for facts, concepts, decisions, or any information worth remembering.".to_string()),
|
||||||
input_schema: tools::ingest::schema(),
|
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 {
|
ToolDescription {
|
||||||
name: "recall".to_string(),
|
name: "recall".to_string(),
|
||||||
description: Some("Search and retrieve knowledge from memory. Returns matches ranked by relevance and retention strength.".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()),
|
description: Some("Search memories with context-dependent retrieval. Based on Tulving's Encoding Specificity Principle (1973).".to_string()),
|
||||||
input_schema: tools::context::schema(),
|
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 };
|
let result = ListToolsResult { tools };
|
||||||
|
|
@ -263,6 +284,7 @@ impl McpServer {
|
||||||
let result = match request.name.as_str() {
|
let result = match request.name.as_str() {
|
||||||
// Core memory tools
|
// Core memory tools
|
||||||
"ingest" => tools::ingest::execute(&self.storage, request.arguments).await,
|
"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,
|
"recall" => tools::recall::execute(&self.storage, request.arguments).await,
|
||||||
"semantic_search" => tools::search::execute_semantic(&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,
|
"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,
|
"find_tagged" => tools::tagging::execute_find(&self.storage, request.arguments).await,
|
||||||
"tagging_stats" => tools::tagging::execute_stats(&self.storage).await,
|
"tagging_stats" => tools::tagging::execute_stats(&self.storage).await,
|
||||||
"match_context" => tools::context::execute(&self.storage, request.arguments).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 => {
|
name => {
|
||||||
return Err(JsonRpcError::method_not_found_with_message(&format!(
|
return Err(JsonRpcError::method_not_found_with_message(&format!(
|
||||||
|
|
|
||||||
231
crates/vestige-mcp/src/tools/feedback.rs
Normal file
231
crates/vestige-mcp/src/tools/feedback.rs
Normal 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.)."
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
@ -10,9 +10,13 @@ pub mod knowledge;
|
||||||
pub mod recall;
|
pub mod recall;
|
||||||
pub mod review;
|
pub mod review;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
|
pub mod smart_ingest;
|
||||||
pub mod stats;
|
pub mod stats;
|
||||||
|
|
||||||
// Neuroscience-inspired tools
|
// Neuroscience-inspired tools
|
||||||
pub mod context;
|
pub mod context;
|
||||||
pub mod memory_states;
|
pub mod memory_states;
|
||||||
pub mod tagging;
|
pub mod tagging;
|
||||||
|
|
||||||
|
// Feedback / preference learning
|
||||||
|
pub mod feedback;
|
||||||
|
|
|
||||||
218
crates/vestige-mcp/src/tools/smart_ingest.rs
Normal file
218
crates/vestige-mcp/src/tools/smart_ingest.rs
Normal 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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue