feat: v2.0.4 "Deep Reference" — cognitive reasoning engine + 10 bug fixes

New features:
- deep_reference tool (#22): 8-stage cognitive reasoning pipeline with FSRS-6
  trust scoring, intent classification (FactCheck/Timeline/RootCause/Comparison/
  Synthesis), spreading activation expansion, temporal supersession, trust-weighted
  contradiction analysis, relation assessment, dream insight integration, and
  algorithmic reasoning chain generation — all without calling an LLM
- cross_reference (#23): backward-compatible alias for deep_reference
- retrieval_mode parameter on search (precise/balanced/exhaustive)
- get_batch action on memory tool (up to 20 IDs per call)
- Token budget raised from 10K to 100K on search + session_context
- Dates (createdAt/updatedAt) on all search results and session_context lines

Bug fixes (GitHub Issue #25 — all 10 resolved):
- state_transitions empty: wired record_memory_access into strengthen_batch
- chain/bridges no storage fallback: added with edge deduplication
- knowledge_edges dead schema: documented as deprecated
- insights not persisted from dream: wired save_insight after generation
- find_duplicates threshold dropped: serde alias fix
- search min_retention ignored: serde aliases for snake_case params
- intention time triggers null: removed dead trigger_at embedding
- changelog missing dreams: added get_dream_history + event integration
- phantom Related IDs: clarified message text
- fsrs_cards empty: documented as harmless dead schema

Security hardening:
- HTTP transport CORS: permissive() → localhost-only
- Auth token panic guard: &token[..8] → safe min(8) slice
- UTF-8 boundary fix: floor_char_boundary on content truncation
- All unwrap() removed from HTTP transport (unwrap_or_else fallback)
- Dream memory_count capped at 500 (prevents O(N²) hang)
- Dormant state threshold aligned (0.3 → 0.4)

Stats: 23 tools, 758 tests, 0 failures, 0 warnings, 0 unwraps in production

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-04-09 16:15:01 -05:00
parent 61091e06b9
commit 04781a95e2
28 changed files with 1797 additions and 102 deletions

View file

@ -140,6 +140,11 @@ fn execute_system_wide(
.get_recent_state_transitions(limit)
.map_err(|e| e.to_string())?;
// Get dream history (Bug #9 fix — dreams were invisible to audit trail)
let dreams = storage
.get_dream_history(limit)
.unwrap_or_default();
// Build unified event list
let mut events: Vec<(DateTime<Utc>, Value)> = Vec::new();
@ -174,6 +179,20 @@ fn execute_system_wide(
));
}
for d in &dreams {
events.push((
d.dreamed_at,
serde_json::json!({
"type": "dream",
"timestamp": d.dreamed_at.to_rfc3339(),
"durationMs": d.duration_ms,
"memoriesReplayed": d.memories_replayed,
"connectionFound": d.connections_found,
"insightsGenerated": d.insights_generated,
}),
));
}
// Sort by timestamp descending
events.sort_by(|a, b| b.0.cmp(&a.0));

View file

@ -0,0 +1,809 @@
//! Deep Reference Tool (v2.0.4)
//!
//! Cognitive reasoning engine across memories. Combines:
//! 1. Broad retrieval (hybrid search + reranking)
//! 2. Spreading activation expansion (connected memories)
//! 3. FSRS-6 trust scoring (retention, stability, reps, lapses)
//! 4. Temporal supersession (newer = current truth)
//! 5. Contradiction analysis (trust-weighted)
//! 6. Dream insight integration (persisted insights)
//! 7. Structured synthesis (recommended answer + evidence)
//!
//! Research grounding: MAGMA (multi-graph), Kumiho (AGM belief revision),
//! InfMem (System-2 memory control), D-Mem (dual-process retrieval).
//!
//! Replaces cross_reference with full cognitive reasoning. cross_reference
//! is kept as a backward-compatible alias.
use chrono::Utc;
use serde::Deserialize;
use serde_json::Value;
use std::sync::Arc;
use tokio::sync::Mutex;
use crate::cognitive::CognitiveEngine;
use vestige_core::Storage;
/// Input schema for deep_reference / cross_reference tool
pub fn schema() -> Value {
serde_json::json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The question, claim, or topic to reason about across all memories"
},
"depth": {
"type": "integer",
"description": "How many memories to analyze (default: 20, max: 50). Higher = more thorough.",
"default": 20,
"minimum": 5,
"maximum": 50
}
},
"required": ["query"]
})
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DeepRefArgs {
query: String,
depth: Option<i32>,
}
// ============================================================================
// FSRS-6 Trust Score
// ============================================================================
/// Compute trust score from FSRS-6 memory state.
/// Higher = more trustworthy (frequently accessed, high retention, stable, few lapses).
fn compute_trust(retention: f64, stability: f64, reps: i32, lapses: i32) -> f64 {
let retention_factor = retention * 0.4;
let stability_factor = (stability / 30.0).min(1.0) * 0.2;
let reps_factor = (reps as f64 / 10.0).min(1.0) * 0.2;
let lapses_penalty = (1.0 - (lapses as f64 / 5.0)).max(0.0) * 0.2;
(retention_factor + stability_factor + reps_factor + lapses_penalty).clamp(0.0, 1.0)
}
// ============================================================================
// SYSTEM 1: Intent Classification (MAGMA-inspired query routing)
// ============================================================================
#[derive(Debug, Clone, PartialEq)]
enum QueryIntent {
FactCheck, // "Is X true?" → find support/contradiction evidence
Timeline, // "When did X happen?" → temporal ordering + pattern detection
RootCause, // "Why did X happen?" → causal chain backward
Comparison, // "How does X differ from Y?" → diff two memory clusters
Synthesis, // Default: "What do I know about X?" → cluster + best per cluster
}
fn classify_intent(query: &str) -> QueryIntent {
let q = query.to_lowercase();
let patterns: &[(QueryIntent, &[&str])] = &[
(QueryIntent::RootCause, &["why did", "root cause", "what caused", "because of", "reason for", "why is", "why was"]),
(QueryIntent::Timeline, &["when did", "timeline", "history of", "over time", "how has", "evolution of", "sequence of"]),
(QueryIntent::Comparison, &["differ", "compare", "versus", " vs ", "difference between", "changed from"]),
(QueryIntent::FactCheck, &["is it true", "did i", "was there", "verify", "confirm", "is this correct", "should i use", "should we"]),
];
for (intent, keywords) in patterns {
if keywords.iter().any(|kw| q.contains(kw)) {
return intent.clone();
}
}
QueryIntent::Synthesis
}
// ============================================================================
// SYSTEM 2: Relation Assessment (embedding similarity + sentiment + temporal)
// ============================================================================
#[derive(Debug, Clone)]
enum Relation {
Supports,
Contradicts,
Supersedes,
Irrelevant,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
struct RelationAssessment {
relation: Relation,
confidence: f64,
reasoning: String,
}
/// Assess the relationship between two memories using embedding similarity,
/// correction signals, temporal ordering, and trust comparison.
/// No LLM needed — pure algorithmic assessment.
fn assess_relation(a_content: &str, b_content: &str, a_trust: f64, b_trust: f64,
a_date: chrono::DateTime<Utc>, b_date: chrono::DateTime<Utc>,
topic_sim: f32) -> RelationAssessment {
// Irrelevant: different topics
if topic_sim < 0.15 {
return RelationAssessment {
relation: Relation::Irrelevant,
confidence: 1.0 - topic_sim as f64,
reasoning: format!("Different topics (similarity {:.2})", topic_sim),
};
}
let time_delta_days = (b_date - a_date).num_days().abs();
let trust_diff = b_trust - a_trust;
let has_correction = appears_contradictory(a_content, b_content);
// Supersession: same topic + newer + higher trust
if topic_sim > 0.4 && time_delta_days > 0 && trust_diff > 0.05 && !has_correction {
let (newer, older) = if b_date > a_date { ("B", "A") } else { ("A", "B") };
return RelationAssessment {
relation: Relation::Supersedes,
confidence: topic_sim as f64 * (0.5 + trust_diff.min(0.5)),
reasoning: format!("{} supersedes {} (newer by {}d, trust +{:.0}%)",
newer, older, time_delta_days, trust_diff * 100.0),
};
}
// Contradiction: same topic + correction signals detected
if has_correction && topic_sim > 0.15 {
return RelationAssessment {
relation: Relation::Contradicts,
confidence: topic_sim as f64 * 0.8,
reasoning: format!("Contradiction detected (similarity {:.2}, correction signals present)", topic_sim),
};
}
// Support: same topic + no contradiction
if topic_sim > 0.3 {
return RelationAssessment {
relation: Relation::Supports,
confidence: topic_sim as f64,
reasoning: format!("Topically aligned (similarity {:.2}), consistent stance", topic_sim),
};
}
RelationAssessment {
relation: Relation::Irrelevant,
confidence: 0.3,
reasoning: "Weak relationship".to_string(),
}
}
// ============================================================================
// SYSTEM 3: Template Reasoning Chain Generator (no LLM needed)
// ============================================================================
/// Generate a natural language reasoning chain from structured evidence.
/// The AI reads this and validates/extends it — System 1 prepares, System 2 refines.
fn generate_reasoning_chain(
query: &str,
intent: &QueryIntent,
primary: &ScoredMemory,
relations: &[(String, f64, RelationAssessment)], // (preview, trust, relation)
confidence: f64,
) -> String {
let mut chain = String::new();
// Intent-specific opening
match intent {
QueryIntent::FactCheck => {
chain.push_str(&format!(
"FACT CHECK: \"{}\"\n\n", query
));
}
QueryIntent::Timeline => {
chain.push_str(&format!(
"TIMELINE: \"{}\"\n\n", query
));
}
QueryIntent::RootCause => {
chain.push_str(&format!(
"ROOT CAUSE ANALYSIS: \"{}\"\n\n", query
));
}
QueryIntent::Comparison => {
chain.push_str(&format!(
"COMPARISON: \"{}\"\n\n", query
));
}
QueryIntent::Synthesis => {
chain.push_str(&format!(
"SYNTHESIS: \"{}\"\n\n", query
));
}
}
// Primary finding
chain.push_str(&format!(
"PRIMARY FINDING (trust {:.0}%, {}): {}\n",
primary.trust * 100.0,
primary.updated_at.format("%b %d, %Y"),
primary.content.chars().take(150).collect::<String>(),
));
// Superseded memories
let superseded: Vec<_> = relations.iter()
.filter(|(_, _, r)| matches!(r.relation, Relation::Supersedes))
.collect();
for (preview, trust, rel) in &superseded {
chain.push_str(&format!(
" SUPERSEDES (trust {:.0}%): \"{}\" — {}\n",
trust * 100.0,
preview.chars().take(80).collect::<String>(),
rel.reasoning,
));
}
// Supporting evidence
let supporting: Vec<_> = relations.iter()
.filter(|(_, _, r)| matches!(r.relation, Relation::Supports))
.collect();
if !supporting.is_empty() {
chain.push_str(&format!("\nSUPPORTED BY {} MEMOR{}:\n",
supporting.len(),
if supporting.len() == 1 { "Y" } else { "IES" },
));
for (preview, trust, _) in supporting.iter().take(5) {
chain.push_str(&format!(
" + (trust {:.0}%): \"{}\"\n",
trust * 100.0,
preview.chars().take(80).collect::<String>(),
));
}
}
// Contradicting evidence
let contradicting: Vec<_> = relations.iter()
.filter(|(_, _, r)| matches!(r.relation, Relation::Contradicts))
.collect();
if !contradicting.is_empty() {
chain.push_str(&format!("\nCONTRADICTING EVIDENCE ({}):\n", contradicting.len()));
for (preview, trust, rel) in contradicting.iter().take(3) {
chain.push_str(&format!(
" ! (trust {:.0}%): \"{}\" — {}\n",
trust * 100.0,
preview.chars().take(80).collect::<String>(),
rel.reasoning,
));
}
}
// Confidence summary
chain.push_str(&format!("\nOVERALL CONFIDENCE: {:.0}%\n", confidence * 100.0));
chain
}
// ============================================================================
// Contradiction Detection (enhanced with relation assessment)
// ============================================================================
const NEGATION_PAIRS: &[(&str, &str)] = &[
("don't", "do"), ("never", "always"), ("avoid", "use"),
("wrong", "right"), ("incorrect", "correct"),
("deprecated", "recommended"), ("outdated", "current"),
("removed", "added"), ("disabled", "enabled"),
("not ", ""), ("no longer", ""),
];
const CORRECTION_SIGNALS: &[&str] = &[
"actually", "correction", "update:", "updated:", "fixed",
"was wrong", "changed to", "now uses", "replaced by",
"superseded", "no longer", "instead of", "switched to", "migrated to",
];
fn appears_contradictory(a: &str, b: &str) -> bool {
let a_lower = a.to_lowercase();
let b_lower = b.to_lowercase();
let a_words: std::collections::HashSet<&str> = a_lower.split_whitespace().filter(|w| w.len() > 3).collect();
let b_words: std::collections::HashSet<&str> = b_lower.split_whitespace().filter(|w| w.len() > 3).collect();
let shared_words = a_words.intersection(&b_words).count();
if shared_words < 2 { return false; }
for (neg, _) in NEGATION_PAIRS {
if (a_lower.contains(neg) && !b_lower.contains(neg))
|| (b_lower.contains(neg) && !a_lower.contains(neg))
{ return true; }
}
for signal in CORRECTION_SIGNALS {
if a_lower.contains(signal) || b_lower.contains(signal) { return true; }
}
false
}
fn topic_overlap(a: &str, b: &str) -> f32 {
let a_lower = a.to_lowercase();
let b_lower = b.to_lowercase();
let a_words: std::collections::HashSet<&str> = a_lower.split_whitespace().filter(|w| w.len() > 3).collect();
let b_words: std::collections::HashSet<&str> = b_lower.split_whitespace().filter(|w| w.len() > 3).collect();
if a_words.is_empty() || b_words.is_empty() { return 0.0; }
let intersection = a_words.intersection(&b_words).count();
let union = a_words.union(&b_words).count();
if union == 0 { 0.0 } else { intersection as f32 / union as f32 }
}
// ============================================================================
// Scored Memory (used across pipeline stages)
// ============================================================================
#[allow(dead_code)]
struct ScoredMemory {
id: String,
content: String,
tags: Vec<String>,
trust: f64,
updated_at: chrono::DateTime<Utc>,
created_at: chrono::DateTime<Utc>,
retention: f64,
combined_score: f32,
}
// ============================================================================
// Main Execute — 8-Stage Pipeline
// ============================================================================
pub async fn execute(
storage: &Arc<Storage>,
cognitive: &Arc<Mutex<CognitiveEngine>>,
args: Option<Value>,
) -> Result<Value, String> {
let args: DeepRefArgs = match args {
Some(v) => serde_json::from_value(v).map_err(|e| format!("Invalid arguments: {}", e))?,
None => return Err("Missing arguments".to_string()),
};
if args.query.trim().is_empty() {
return Err("Query cannot be empty".to_string());
}
let depth = args.depth.unwrap_or(20).clamp(5, 50) as usize;
// ====================================================================
// STAGE 0: Intent Classification (MAGMA-inspired query routing)
// ====================================================================
let intent = classify_intent(&args.query);
// ====================================================================
// STAGE 1: Broad Retrieval + Reranking
// ====================================================================
let results = storage
.hybrid_search(&args.query, depth as i32, 0.3, 0.7)
.map_err(|e| e.to_string())?;
if results.is_empty() {
return Ok(serde_json::json!({
"query": args.query,
"status": "no_memories",
"confidence": 0.0,
"guidance": "No memories found. Use smart_ingest to add memories.",
"memoriesAnalyzed": 0,
}));
}
let mut ranked = results;
if let Ok(mut cog) = cognitive.try_lock() {
let candidates: Vec<_> = ranked.iter().map(|r| (r.clone(), r.node.content.clone())).collect();
if let Ok(reranked) = cog.reranker.rerank(&args.query, candidates, Some(depth)) {
ranked = reranked.into_iter().map(|rr| rr.item).collect();
}
}
// ====================================================================
// STAGE 2: Spreading Activation Expansion
// ====================================================================
let mut activation_expanded = 0usize;
let existing_ids: std::collections::HashSet<String> = ranked.iter().map(|r| r.node.id.clone()).collect();
if let Ok(mut cog) = cognitive.try_lock() {
let mut expanded_ids = Vec::new();
for r in ranked.iter().take(3) {
let activated = cog.activation_network.activate(&r.node.id, 1.0);
for a in activated.iter().take(3) {
if !existing_ids.contains(&a.memory_id) && !expanded_ids.contains(&a.memory_id) {
expanded_ids.push(a.memory_id.clone());
}
}
}
// Fetch expanded memories from storage
for id in &expanded_ids {
if let Ok(Some(node)) = storage.get_node(id) {
// Create a minimal SearchResult-like entry
ranked.push(vestige_core::SearchResult {
node,
combined_score: 0.3, // lower score since these are expanded, not direct matches
keyword_score: None,
semantic_score: None,
match_type: vestige_core::MatchType::Semantic,
});
activation_expanded += 1;
}
}
}
// ====================================================================
// STAGE 3: FSRS-6 Trust Scoring
// ====================================================================
let scored: Vec<ScoredMemory> = ranked.iter().map(|r| {
let trust = compute_trust(
r.node.retention_strength,
r.node.stability,
r.node.reps,
r.node.lapses,
);
ScoredMemory {
id: r.node.id.clone(),
content: r.node.content.clone(),
tags: r.node.tags.clone(),
trust,
updated_at: r.node.updated_at,
created_at: r.node.created_at,
retention: r.node.retention_strength,
combined_score: r.combined_score,
}
}).collect();
// ====================================================================
// STAGE 4: Temporal Supersession
// ====================================================================
let mut superseded: Vec<Value> = Vec::new();
let mut superseded_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
// Sort by date descending for supersession
let mut by_date = scored.iter().collect::<Vec<_>>();
by_date.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
for i in 0..by_date.len() {
for j in (i + 1)..by_date.len() {
let newer = by_date[i];
let older = by_date[j];
let overlap = topic_overlap(&newer.content, &older.content);
if overlap > 0.3 && newer.trust > older.trust && !superseded_ids.contains(&older.id) {
superseded_ids.insert(older.id.clone());
superseded.push(serde_json::json!({
"id": older.id,
"preview": older.content.chars().take(150).collect::<String>(),
"trust": (older.trust * 100.0).round() / 100.0,
"date": older.updated_at.to_rfc3339(),
"superseded_by": newer.id,
}));
}
}
}
// ====================================================================
// STAGE 5: Trust-Weighted Contradiction Analysis
// ====================================================================
let mut contradictions: Vec<Value> = Vec::new();
for i in 0..scored.len() {
for j in (i + 1)..scored.len() {
let a = &scored[i];
let b = &scored[j];
let overlap = topic_overlap(&a.content, &b.content);
if overlap < 0.15 { continue; }
let is_contradiction = appears_contradictory(&a.content, &b.content);
if !is_contradiction { continue; }
// Only flag as real contradiction if BOTH have decent trust
let min_trust = a.trust.min(b.trust);
if min_trust < 0.3 { continue; } // Low-trust memory isn't worth flagging
let (stronger, weaker) = if a.trust >= b.trust { (a, b) } else { (b, a) };
contradictions.push(serde_json::json!({
"stronger": {
"id": stronger.id,
"preview": stronger.content.chars().take(150).collect::<String>(),
"trust": (stronger.trust * 100.0).round() / 100.0,
"date": stronger.updated_at.to_rfc3339(),
},
"weaker": {
"id": weaker.id,
"preview": weaker.content.chars().take(150).collect::<String>(),
"trust": (weaker.trust * 100.0).round() / 100.0,
"date": weaker.updated_at.to_rfc3339(),
},
"topic_overlap": overlap,
}));
}
}
// ====================================================================
// STAGE 6: Dream Insight Integration
// ====================================================================
let mut related_insights: Vec<Value> = Vec::new();
if let Ok(insights) = storage.get_insights(20) {
let memory_ids: std::collections::HashSet<&str> = scored.iter().map(|s| s.id.as_str()).collect();
for insight in insights {
let overlaps = insight.source_memories.iter()
.any(|src_id| memory_ids.contains(src_id.as_str()));
if overlaps {
related_insights.push(serde_json::json!({
"insight": insight.insight,
"type": insight.insight_type,
"confidence": insight.confidence,
"source_memories": insight.source_memories,
}));
}
}
}
// ====================================================================
// STAGE 7: Relation Assessment (per-pair, using trust + temporal + similarity)
// ====================================================================
let mut pair_relations: Vec<(String, f64, RelationAssessment)> = Vec::new();
if let Some(primary) = scored.iter()
.filter(|s| !superseded_ids.contains(&s.id))
.max_by(|a, b| a.trust.partial_cmp(&b.trust).unwrap_or(std::cmp::Ordering::Equal))
{
for other in scored.iter().filter(|s| s.id != primary.id).take(15) {
let sim = topic_overlap(&primary.content, &other.content);
let rel = assess_relation(
&primary.content, &other.content,
primary.trust, other.trust,
primary.updated_at, other.updated_at,
sim,
);
if !matches!(rel.relation, Relation::Irrelevant) {
pair_relations.push((
other.content.chars().take(100).collect(),
other.trust,
rel,
));
}
}
}
// ====================================================================
// STAGE 8: Synthesis + Reasoning Chain Generation
// ====================================================================
// Find the recommended answer: highest trust, not superseded, most recent
let recommended = scored.iter()
.filter(|s| !superseded_ids.contains(&s.id))
.max_by(|a, b| {
// Primary: trust. Secondary: date.
a.trust.partial_cmp(&b.trust)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.updated_at.cmp(&b.updated_at))
});
// Build evidence list (top memories by trust, not superseded)
let mut non_superseded: Vec<&ScoredMemory> = scored.iter()
.filter(|s| !superseded_ids.contains(&s.id))
.collect();
non_superseded.sort_by(|a, b| b.trust.partial_cmp(&a.trust).unwrap_or(std::cmp::Ordering::Equal));
let evidence: Vec<Value> = non_superseded.iter()
.take(10)
.enumerate()
.map(|(i, s)| serde_json::json!({
"id": s.id,
"preview": s.content.chars().take(200).collect::<String>(),
"trust": (s.trust * 100.0).round() / 100.0,
"date": s.updated_at.to_rfc3339(),
"role": if i == 0 { "primary" } else { "supporting" },
}))
.collect();
// Build evolution timeline
let mut evolution: Vec<Value> = by_date.iter().rev()
.map(|s| serde_json::json!({
"date": s.updated_at.format("%b %d, %Y").to_string(),
"preview": s.content.chars().take(100).collect::<String>(),
"trust": (s.trust * 100.0).round() / 100.0,
}))
.collect();
evolution.truncate(15); // cap timeline length
// Confidence scoring
let base_confidence = recommended.map(|r| r.trust).unwrap_or(0.0);
let agreement_boost = (evidence.len() as f64 * 0.03).min(0.2);
let contradiction_penalty = contradictions.len() as f64 * 0.1;
let confidence = (base_confidence + agreement_boost - contradiction_penalty).clamp(0.0, 1.0);
let status = if contradictions.is_empty() && confidence > 0.7 {
"resolved"
} else if !contradictions.is_empty() {
"contradictions_found"
} else if scored.is_empty() {
"no_evidence"
} else {
"partial_evidence"
};
let guidance = if let Some(rec) = recommended {
if contradictions.is_empty() {
format!(
"High confidence ({:.0}%). Recommended memory (trust {:.0}%, {}) is the most reliable source.",
confidence * 100.0, rec.trust * 100.0, rec.updated_at.format("%b %d, %Y")
)
} else {
format!(
"WARNING: {} contradiction(s) detected. Recommended memory has trust {:.0}% but conflicts exist. Review contradictions below.",
contradictions.len(), rec.trust * 100.0
)
}
} else {
"No strong evidence found. Verify with external sources.".to_string()
};
// Auto-strengthen accessed memories (Testing Effect)
let ids: Vec<&str> = scored.iter().map(|s| s.id.as_str()).collect();
let _ = storage.strengthen_batch_on_access(&ids);
// Generate reasoning chain (the key differentiator — no LLM needed)
let reasoning_chain = if let Some(rec) = recommended {
generate_reasoning_chain(&args.query, &intent, rec, &pair_relations, confidence)
} else {
"No strong evidence found for reasoning.".to_string()
};
// Build response
let mut response = serde_json::json!({
"query": args.query,
"intent": format!("{:?}", intent),
"status": status,
"confidence": (confidence * 100.0).round() / 100.0,
"reasoning": reasoning_chain,
"guidance": guidance,
"memoriesAnalyzed": scored.len(),
"activationExpanded": activation_expanded,
});
if let Some(rec) = recommended {
response["recommended"] = serde_json::json!({
"answer_preview": rec.content.chars().take(300).collect::<String>(),
"memory_id": rec.id,
"trust_score": (rec.trust * 100.0).round() / 100.0,
"date": rec.updated_at.to_rfc3339(),
});
}
if !evidence.is_empty() { response["evidence"] = serde_json::json!(evidence); }
if !contradictions.is_empty() { response["contradictions"] = serde_json::json!(contradictions); }
if !superseded.is_empty() { response["superseded"] = serde_json::json!(superseded); }
if !evolution.is_empty() { response["evolution"] = serde_json::json!(evolution); }
if !related_insights.is_empty() { response["related_insights"] = serde_json::json!(related_insights); }
Ok(response)
}
// ============================================================================
// TESTS
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_schema_structure() {
let s = schema();
assert!(s["properties"]["query"].is_object());
assert!(s["properties"]["depth"].is_object());
assert_eq!(s["required"], serde_json::json!(["query"]));
}
#[test]
fn test_trust_score_high() {
// High retention, high stability, many reps, no lapses → high trust
let trust = compute_trust(0.95, 60.0, 20, 0);
assert!(trust > 0.8, "Expected >0.8, got {}", trust);
}
#[test]
fn test_trust_score_low() {
// Low retention, low stability, few reps, many lapses → low trust
let trust = compute_trust(0.2, 1.0, 1, 10);
assert!(trust < 0.3, "Expected <0.3, got {}", trust);
}
#[test]
fn test_trust_score_medium() {
// Medium everything
let trust = compute_trust(0.6, 15.0, 5, 2);
assert!(trust > 0.4 && trust < 0.7, "Expected 0.4-0.7, got {}", trust);
}
#[test]
fn test_trust_score_clamped() {
// Even extreme values stay in [0, 1]
assert!(compute_trust(1.0, 1000.0, 100, 0) <= 1.0);
assert!(compute_trust(0.0, 0.0, 0, 100) >= 0.0);
}
#[test]
fn test_contradiction_requires_shared_words() {
assert!(!appears_contradictory("not sure about weather", "Rust is fast"));
}
#[test]
fn test_contradiction_with_shared_context() {
assert!(appears_contradictory(
"Don't use FAISS for vector search in production",
"Use FAISS for vector search in production always"
));
}
#[test]
fn test_topic_overlap_similar() {
let overlap = topic_overlap("Vestige uses USearch for vector search", "Vestige vector search powered by USearch HNSW");
assert!(overlap > 0.3);
}
#[test]
fn test_topic_overlap_different() {
let overlap = topic_overlap("The weather is sunny today", "Rust compile times improving");
assert!(overlap < 0.15);
}
#[test]
fn test_depth_clamped() {
let s = schema();
assert_eq!(s["properties"]["depth"]["minimum"], 5);
assert_eq!(s["properties"]["depth"]["maximum"], 50);
}
// === Intent Classification Tests ===
#[test]
fn test_intent_fact_check() {
assert_eq!(classify_intent("Is it true that Vestige uses USearch?"), QueryIntent::FactCheck);
assert_eq!(classify_intent("Did I switch to port 3002?"), QueryIntent::FactCheck);
assert_eq!(classify_intent("Should I use prefix caching?"), QueryIntent::FactCheck);
}
#[test]
fn test_intent_timeline() {
assert_eq!(classify_intent("When did the port change happen?"), QueryIntent::Timeline);
assert_eq!(classify_intent("How has the AIMO3 score evolved over time?"), QueryIntent::Timeline);
}
#[test]
fn test_intent_root_cause() {
assert_eq!(classify_intent("Why did the build fail?"), QueryIntent::RootCause);
assert_eq!(classify_intent("What caused the score regression?"), QueryIntent::RootCause);
}
#[test]
fn test_intent_comparison() {
assert_eq!(classify_intent("How does USearch differ from FAISS?"), QueryIntent::Comparison);
assert_eq!(classify_intent("Compare FSRS versus SM-2"), QueryIntent::Comparison);
}
#[test]
fn test_intent_synthesis_default() {
assert_eq!(classify_intent("Tell me about Sam's projects"), QueryIntent::Synthesis);
assert_eq!(classify_intent("What is Vestige?"), QueryIntent::Synthesis);
}
// === Relation Assessment Tests ===
#[test]
fn test_relation_irrelevant() {
let rel = assess_relation("Rust is fast", "The weather is nice", 0.8, 0.8,
Utc::now(), Utc::now(), 0.05);
assert!(matches!(rel.relation, Relation::Irrelevant));
}
#[test]
fn test_relation_supports() {
let rel = assess_relation(
"Vestige uses USearch for vector search",
"USearch provides fast HNSW indexing for Vestige",
0.8, 0.7, Utc::now(), Utc::now(), 0.6);
assert!(matches!(rel.relation, Relation::Supports));
}
#[test]
fn test_relation_contradicts() {
let rel = assess_relation(
"Don't use FAISS for vector search in production anymore",
"Use FAISS for vector search in production always",
0.8, 0.5, Utc::now(), Utc::now(), 0.7);
assert!(matches!(rel.relation, Relation::Contradicts));
}
}

View file

@ -45,6 +45,7 @@ pub fn schema() -> Value {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DedupArgs {
#[serde(alias = "similarity_threshold")]
similarity_threshold: Option<f64>,
limit: Option<usize>,
tags: Option<Vec<String>>,

View file

@ -6,7 +6,7 @@ use tokio::sync::Mutex;
use chrono::Utc;
use crate::cognitive::CognitiveEngine;
use vestige_core::{DreamHistoryRecord, LinkType, Storage};
use vestige_core::{DreamHistoryRecord, InsightRecord, LinkType, Storage};
pub fn schema() -> serde_json::Value {
serde_json::json!({
@ -30,7 +30,8 @@ pub async fn execute(
.as_ref()
.and_then(|a| a.get("memory_count"))
.and_then(|v| v.as_u64())
.unwrap_or(50) as usize;
.unwrap_or(50)
.min(500) as usize; // Cap at 500 to prevent O(N^2) hang
// v1.9.0: Waking SWR tagging — preferential replay of tagged memories (70/30 split)
let tagged_nodes = storage.get_waking_tagged_memories(memory_count as i32)
@ -94,8 +95,28 @@ pub async fn execute(
let all_connections = cog.dreamer.get_connections();
drop(cog);
// v2.1.0: Persist dream insights to database (Bug #4 fix)
let mut insights_persisted = 0u64;
for insight in &insights {
let record = InsightRecord {
id: insight.id.clone(),
insight: insight.insight.clone(),
source_memories: insight.source_memories.clone(),
confidence: insight.confidence,
novelty_score: insight.novelty_score,
insight_type: format!("{:?}", insight.insight_type),
generated_at: insight.generated_at,
tags: insight.tags.clone(),
feedback: None,
applied_count: 0,
};
if storage.save_insight(&record).is_ok() {
insights_persisted += 1;
}
}
// v1.9.0: Persist only NEW connections from this dream (skip accumulated ones)
let new_connections = &all_connections[pre_dream_count..];
let new_connections = all_connections.get(pre_dream_count..).unwrap_or(&[]);
let mut connections_persisted = 0u64;
{
let now = Utc::now();
@ -197,9 +218,11 @@ pub async fn execute(
"novelty_score": i.novelty_score,
})).collect::<Vec<_>>(),
"connectionsPersisted": connections_persisted,
"insightsPersisted": insights_persisted,
"stats": {
"new_connections_found": dream_result.new_connections_found,
"connections_persisted": connections_persisted,
"insights_persisted": insights_persisted,
"memories_strengthened": dream_result.memories_strengthened,
"memories_compressed": dream_result.memories_compressed,
"insights_generated": dream_result.insights_generated.len(),
@ -532,4 +555,55 @@ mod tests {
persisted
);
}
#[tokio::test]
async fn test_dream_persists_insights() {
let (storage, _dir) = test_storage().await;
// Create diverse tagged memories to encourage insight generation
let topics = [
("Rust borrow checker prevents data races", vec!["rust", "safety"]),
("Rust ownership model ensures memory safety", vec!["rust", "safety"]),
("Cargo manages Rust project dependencies", vec!["rust", "cargo"]),
("Cargo.toml defines project configuration", vec!["rust", "cargo"]),
("Unit tests use the #[test] attribute", vec!["rust", "testing"]),
("Integration tests live in the tests directory", vec!["rust", "testing"]),
("Clippy catches common Rust mistakes", vec!["rust", "tooling"]),
("Rustfmt automatically formats code", vec!["rust", "tooling"]),
];
for (content, tags) in &topics {
storage.ingest(vestige_core::IngestInput {
content: content.to_string(),
node_type: "fact".to_string(),
source: None, sentiment_score: 0.0, sentiment_magnitude: 0.0,
tags: tags.iter().map(|t| t.to_string()).collect(),
valid_from: None, valid_until: None,
}).unwrap();
}
let result = execute(&storage, &test_cognitive(), None).await.unwrap();
assert_eq!(result["status"], "dreamed");
let response_insights = result["insights"].as_array().unwrap();
let persisted_count = result["insightsPersisted"].as_u64().unwrap_or(0);
// If insights were generated, they should be persisted
if !response_insights.is_empty() {
assert!(persisted_count > 0, "Generated insights should be persisted to database");
let stored = storage.get_insights(100).unwrap();
assert_eq!(
stored.len(), persisted_count as usize,
"All {} persisted insights should be retrievable", persisted_count
);
// Verify insight fields
for insight in &stored {
assert!(!insight.id.is_empty(), "Insight ID should not be empty");
assert!(!insight.insight.is_empty(), "Insight text should not be empty");
assert!(insight.confidence >= 0.0 && insight.confidence <= 1.0);
assert!(insight.novelty_score >= 0.0);
assert!(insight.feedback.is_none(), "Fresh insight should have no feedback");
assert_eq!(insight.applied_count, 0);
}
}
}
}

View file

@ -5,6 +5,7 @@ use std::sync::Arc;
use tokio::sync::Mutex;
use crate::cognitive::CognitiveEngine;
use vestige_core::advanced::{Connection, ConnectionType, MemoryChainBuilder, MemoryNode};
use vestige_core::Storage;
pub fn schema() -> serde_json::Value {
@ -50,12 +51,24 @@ pub async fn execute(
match action {
"chain" => {
let to_id = to.ok_or("'to' is required for chain action")?;
match cog.chain_builder.build_chain(from, to_id) {
let chain_result = cog.chain_builder.build_chain(from, to_id);
let from_owned = from.to_string();
let to_owned = to_id.to_string();
drop(cog); // release lock before potential storage fallback
let chain_opt = if chain_result.is_some() {
chain_result
} else {
// Storage fallback: build temporary chain from persisted connections
build_chain_from_storage(storage, &from_owned, &to_owned)
};
match chain_opt {
Some(chain) => {
Ok(serde_json::json!({
"action": "chain",
"from": from,
"to": to_id,
"from": from_owned,
"to": to_owned,
"steps": chain.steps.iter().map(|s| serde_json::json!({
"memory_id": s.memory_id,
"memory_preview": s.memory_preview,
@ -70,8 +83,8 @@ pub async fn execute(
None => {
Ok(serde_json::json!({
"action": "chain",
"from": from,
"to": to_id,
"from": from_owned,
"to": to_owned,
"steps": [],
"message": "No chain found between these memories"
}))
@ -82,6 +95,8 @@ pub async fn execute(
let activation_assocs = cog.activation_network.get_associations(from);
let hippocampal_assocs = cog.hippocampal_index.get_associations(from, 2)
.unwrap_or_default();
let from_owned = from.to_string();
drop(cog); // release lock consistently (matches chain/bridges pattern)
let mut all_associations: Vec<serde_json::Value> = Vec::new();
@ -106,10 +121,9 @@ pub async fn execute(
// Fallback: if in-memory modules are empty, query storage directly
if all_associations.is_empty() {
drop(cog); // release cognitive lock before storage call
if let Ok(connections) = storage.get_connections_for_memory(from) {
if let Ok(connections) = storage.get_connections_for_memory(&from_owned) {
for conn in connections.iter().take(limit) {
let other_id = if conn.source_id == from {
let other_id = if conn.source_id == from_owned {
&conn.target_id
} else {
&conn.source_id
@ -126,7 +140,7 @@ pub async fn execute(
Ok(serde_json::json!({
"action": "associations",
"from": from,
"from": from_owned,
"associations": all_associations,
"count": all_associations.len(),
}))
@ -134,11 +148,23 @@ pub async fn execute(
"bridges" => {
let to_id = to.ok_or("'to' is required for bridges action")?;
let bridges = cog.chain_builder.find_bridge_memories(from, to_id);
let limited: Vec<_> = bridges.iter().take(limit).collect();
let from_owned = from.to_string();
let to_owned = to_id.to_string();
drop(cog); // release lock before potential storage fallback
let final_bridges = if !bridges.is_empty() {
bridges
} else {
// Storage fallback: build temporary graph and find bridges
let temp_builder = build_temp_chain_builder(storage, &from_owned, &to_owned);
temp_builder.find_bridge_memories(&from_owned, &to_owned)
};
let limited: Vec<_> = final_bridges.iter().take(limit).collect();
Ok(serde_json::json!({
"action": "bridges",
"from": from,
"to": to_id,
"from": from_owned,
"to": to_owned,
"bridges": limited,
"count": limited.len(),
}))
@ -147,6 +173,73 @@ pub async fn execute(
}
}
/// Build a temporary MemoryChainBuilder from persisted connections for fallback queries.
fn build_temp_chain_builder(storage: &Arc<Storage>, from_id: &str, to_id: &str) -> MemoryChainBuilder {
let mut builder = MemoryChainBuilder::new();
// Load connections involving either endpoint
let mut all_conns = Vec::new();
if let Ok(conns) = storage.get_connections_for_memory(from_id) {
all_conns.extend(conns);
}
if let Ok(conns) = storage.get_connections_for_memory(to_id) {
all_conns.extend(conns);
}
// Deduplicate edges and load referenced memory nodes
let mut seen_edges = std::collections::HashSet::new();
all_conns.retain(|c| seen_edges.insert((c.source_id.clone(), c.target_id.clone())));
let mut seen_ids = std::collections::HashSet::new();
for conn in &all_conns {
for id in [&conn.source_id, &conn.target_id] {
if seen_ids.insert(id.clone()) {
if let Ok(Some(node)) = storage.get_node(id) {
builder.add_memory(MemoryNode {
id: node.id.clone(),
content_preview: node.content.chars().take(100).collect(),
tags: node.tags.clone(),
connections: vec![],
});
}
}
}
}
// Add edges
for conn in &all_conns {
builder.add_connection(Connection {
from_id: conn.source_id.clone(),
to_id: conn.target_id.clone(),
connection_type: link_type_to_connection_type(&conn.link_type),
strength: conn.strength,
created_at: conn.created_at,
});
}
builder
}
/// Build a chain from storage when in-memory chain_builder is empty.
fn build_chain_from_storage(
storage: &Arc<Storage>,
from_id: &str,
to_id: &str,
) -> Option<vestige_core::advanced::ReasoningChain> {
let builder = build_temp_chain_builder(storage, from_id, to_id);
builder.build_chain(from_id, to_id)
}
/// Convert storage link_type string to ConnectionType enum.
fn link_type_to_connection_type(link_type: &str) -> ConnectionType {
match link_type {
"temporal" => ConnectionType::TemporalProximity,
"causal" => ConnectionType::Causal,
"part_of" => ConnectionType::PartOf,
_ => ConnectionType::SemanticSimilarity,
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -351,4 +444,71 @@ mod tests {
assert_eq!(associations[0]["source"], "persistent_graph");
assert_eq!(associations[0]["memory_id"], id2);
}
#[tokio::test]
async fn test_chain_storage_fallback() {
let (storage, _dir) = test_storage().await;
// Create 3 memories: A -> B -> C
let make = |content: &str| vestige_core::IngestInput {
content: content.to_string(), node_type: "fact".to_string(),
source: None, sentiment_score: 0.0, sentiment_magnitude: 0.0,
tags: vec!["test".to_string()], valid_from: None, valid_until: None,
};
let id_a = storage.ingest(make("Memory A about databases")).unwrap().id;
let id_b = storage.ingest(make("Memory B about indexes")).unwrap().id;
let id_c = storage.ingest(make("Memory C about performance")).unwrap().id;
// Save connections A->B and B->C to storage
let now = chrono::Utc::now();
for (src, tgt) in [(&id_a, &id_b), (&id_b, &id_c)] {
storage.save_connection(&vestige_core::ConnectionRecord {
source_id: src.clone(), target_id: tgt.clone(),
strength: 0.9, link_type: "semantic".to_string(),
created_at: now, last_activated: now, activation_count: 1,
}).unwrap();
}
// Execute chain with empty cognitive engine — should fall back to storage
let args = serde_json::json!({ "action": "chain", "from": id_a, "to": id_c });
let result = execute(&storage, &test_cognitive(), Some(args)).await;
assert!(result.is_ok());
let value = result.unwrap();
assert_eq!(value["action"], "chain");
let steps = value["steps"].as_array().unwrap();
assert!(!steps.is_empty(), "Chain should find path A->B->C via storage fallback");
}
#[tokio::test]
async fn test_bridges_storage_fallback() {
let (storage, _dir) = test_storage().await;
// Create 3 memories: A -> B -> C (B is the bridge)
let make = |content: &str| vestige_core::IngestInput {
content: content.to_string(), node_type: "fact".to_string(),
source: None, sentiment_score: 0.0, sentiment_magnitude: 0.0,
tags: vec!["test".to_string()], valid_from: None, valid_until: None,
};
let id_a = storage.ingest(make("Bridge test memory A")).unwrap().id;
let id_b = storage.ingest(make("Bridge test memory B")).unwrap().id;
let id_c = storage.ingest(make("Bridge test memory C")).unwrap().id;
let now = chrono::Utc::now();
for (src, tgt) in [(&id_a, &id_b), (&id_b, &id_c)] {
storage.save_connection(&vestige_core::ConnectionRecord {
source_id: src.clone(), target_id: tgt.clone(),
strength: 0.9, link_type: "semantic".to_string(),
created_at: now, last_activated: now, activation_count: 1,
}).unwrap();
}
// Execute bridges with empty cognitive engine
let args = serde_json::json!({ "action": "bridges", "from": id_a, "to": id_c });
let result = execute(&storage, &test_cognitive(), Some(args)).await;
assert!(result.is_ok());
let value = result.unwrap();
assert_eq!(value["action"], "bridges");
let bridges = value["bridges"].as_array().unwrap();
assert!(!bridges.is_empty(), "Should find B as bridge between A and C via storage fallback");
}
}

View file

@ -43,12 +43,17 @@ pub fn schema() -> Value {
"properties": {
"action": {
"type": "string",
"enum": ["get", "delete", "state", "promote", "demote", "edit"],
"description": "Action to perform: 'get' retrieves full memory node, 'delete' removes memory, 'state' returns accessibility state, 'promote' increases retrieval strength (thumbs up), 'demote' decreases retrieval strength (thumbs down), 'edit' updates content in-place (preserves FSRS state)"
"enum": ["get", "get_batch", "delete", "state", "promote", "demote", "edit"],
"description": "Action to perform: 'get' retrieves full memory node, 'get_batch' retrieves multiple memories by IDs (use 'ids' array), 'delete' removes memory, 'state' returns accessibility state, 'promote' increases retrieval strength (thumbs up), 'demote' decreases retrieval strength (thumbs down), 'edit' updates content in-place (preserves FSRS state)"
},
"id": {
"type": "string",
"description": "The ID of the memory node"
"description": "The ID of the memory node (for single-memory actions)"
},
"ids": {
"type": "array",
"items": { "type": "string" },
"description": "Array of memory IDs (for get_batch action). Max 20 IDs per call."
},
"reason": {
"type": "string",
@ -59,7 +64,7 @@ pub fn schema() -> Value {
"description": "New content for edit action. Replaces existing content, regenerates embedding, preserves FSRS state."
}
},
"required": ["action", "id"]
"required": ["action"]
})
}
@ -67,7 +72,8 @@ pub fn schema() -> Value {
#[serde(rename_all = "camelCase")]
struct MemoryArgs {
action: String,
id: String,
id: Option<String>,
ids: Option<Vec<String>>,
reason: Option<String>,
content: Option<String>,
}
@ -83,18 +89,34 @@ pub async fn execute(
None => return Err("Missing arguments".to_string()),
};
// Validate UUID format
uuid::Uuid::parse_str(&args.id).map_err(|_| "Invalid memory ID format".to_string())?;
// get_batch uses 'ids' array, all other actions use 'id'
if args.action == "get_batch" {
let ids = args.ids.ok_or("get_batch requires 'ids' array")?;
if ids.is_empty() {
return Err("ids array cannot be empty".to_string());
}
if ids.len() > 20 {
return Err("get_batch supports max 20 IDs per call".to_string());
}
for id in &ids {
uuid::Uuid::parse_str(id).map_err(|_| format!("Invalid memory ID format: {}", id))?;
}
return execute_get_batch(storage, &ids).await;
}
// All other actions require 'id'
let id = args.id.ok_or("This action requires 'id' parameter")?;
uuid::Uuid::parse_str(&id).map_err(|_| "Invalid memory ID format".to_string())?;
match args.action.as_str() {
"get" => execute_get(storage, &args.id).await,
"delete" => execute_delete(storage, &args.id).await,
"state" => execute_state(storage, &args.id).await,
"promote" => execute_promote(storage, cognitive, &args.id, args.reason).await,
"demote" => execute_demote(storage, cognitive, &args.id, args.reason).await,
"edit" => execute_edit(storage, &args.id, args.content).await,
"get" => execute_get(storage, &id).await,
"delete" => execute_delete(storage, &id).await,
"state" => execute_state(storage, &id).await,
"promote" => execute_promote(storage, cognitive, &id, args.reason).await,
"demote" => execute_demote(storage, cognitive, &id, args.reason).await,
"edit" => execute_edit(storage, &id, args.content).await,
_ => Err(format!(
"Invalid action '{}'. Must be one of: get, delete, state, promote, demote, edit",
"Invalid action '{}'. Must be one of: get, get_batch, delete, state, promote, demote, edit",
args.action
)),
}
@ -140,6 +162,49 @@ async fn execute_get(storage: &Arc<Storage>, id: &str) -> Result<Value, String>
}
}
/// Get multiple full memory nodes by ID (batch retrieval for expandable IDs)
async fn execute_get_batch(storage: &Arc<Storage>, ids: &[String]) -> Result<Value, String> {
let mut results = Vec::with_capacity(ids.len());
let mut found_count = 0;
for id in ids {
match storage.get_node(id) {
Ok(Some(n)) => {
found_count += 1;
results.push(serde_json::json!({
"id": n.id,
"content": n.content,
"nodeType": n.node_type,
"createdAt": n.created_at.to_rfc3339(),
"updatedAt": n.updated_at.to_rfc3339(),
"tags": n.tags,
"retentionStrength": n.retention_strength,
"source": n.source,
}));
}
Ok(None) => {
results.push(serde_json::json!({
"id": id,
"found": false,
}));
}
Err(e) => {
results.push(serde_json::json!({
"id": id,
"error": e.to_string(),
}));
}
}
}
Ok(serde_json::json!({
"action": "get_batch",
"requested": ids.len(),
"found": found_count,
"results": results,
}))
}
/// Delete a memory and return success status
async fn execute_delete(storage: &Arc<Storage>, id: &str) -> Result<Value, String> {
let deleted = storage.delete_node(id).map_err(|e| e.to_string())?;
@ -388,10 +453,12 @@ mod tests {
assert!(schema["properties"]["action"].is_object());
assert!(schema["properties"]["id"].is_object());
assert!(schema["properties"]["reason"].is_object());
assert_eq!(schema["required"], serde_json::json!(["action", "id"]));
// Verify all 6 actions are in enum
assert_eq!(schema["required"], serde_json::json!(["action"]));
assert!(schema["properties"]["ids"].is_object()); // get_batch support
// Verify all 7 actions are in enum
let actions = schema["properties"]["action"]["enum"].as_array().unwrap();
assert_eq!(actions.len(), 6);
assert_eq!(actions.len(), 7);
assert!(actions.contains(&serde_json::json!("get_batch")));
assert!(actions.contains(&serde_json::json!("edit")));
assert!(actions.contains(&serde_json::json!("promote")));
assert!(actions.contains(&serde_json::json!("demote")));

View file

@ -37,6 +37,9 @@ pub mod session_context;
pub mod health;
pub mod graph;
// v2.1: Cross-reference (connect the dots)
pub mod cross_reference;
// Deprecated/internal tools — not advertised in the public MCP tools/list,
// but some functions are actively dispatched for backwards compatibility
// and internal cognitive operations. #[allow(dead_code)] suppresses warnings

View file

@ -68,9 +68,15 @@ pub fn schema() -> Value {
},
"token_budget": {
"type": "integer",
"description": "Max tokens for response. Server truncates content to fit budget. Use memory(action='get') for full content of specific IDs.",
"description": "Max tokens for response. Server truncates content to fit budget. Use memory(action='get') for full content of specific IDs. With 1M context models, budgets up to 100K are practical.",
"minimum": 100,
"maximum": 10000
"maximum": 100000
},
"retrieval_mode": {
"type": "string",
"description": "precise: top results only (fast, token-efficient, skips activation/competition). balanced: full 7-stage cognitive pipeline (default). exhaustive: maximum recall with 5x overfetch, deep graph traversal, no competition suppression.",
"enum": ["precise", "balanced", "exhaustive"],
"default": "balanced"
}
},
"required": ["query"]
@ -82,13 +88,18 @@ pub fn schema() -> Value {
struct SearchArgs {
query: String,
limit: Option<i32>,
#[serde(alias = "min_retention")]
min_retention: Option<f64>,
#[serde(alias = "min_similarity")]
min_similarity: Option<f32>,
#[serde(alias = "detail_level")]
detail_level: Option<String>,
#[serde(alias = "context_topics")]
context_topics: Option<Vec<String>>,
#[serde(alias = "token_budget")]
token_budget: Option<i32>,
#[serde(alias = "retrieval_mode")]
retrieval_mode: Option<String>,
}
/// Execute unified search with 7-stage cognitive pipeline.
@ -135,14 +146,32 @@ pub async fn execute(
let min_retention = args.min_retention.unwrap_or(0.0).clamp(0.0, 1.0);
let min_similarity = args.min_similarity.unwrap_or(0.5).clamp(0.0, 1.0);
// Validate retrieval_mode
let retrieval_mode = match args.retrieval_mode.as_deref() {
Some("precise") => "precise",
Some("exhaustive") => "exhaustive",
Some("balanced") | None => "balanced",
Some(invalid) => {
return Err(format!(
"Invalid retrieval_mode '{}'. Must be 'precise', 'balanced', or 'exhaustive'.",
invalid
));
}
};
// Favor semantic search — research shows 0.3/0.7 outperforms equal weights
let keyword_weight = 0.3_f32;
let semantic_weight = 0.7_f32;
// ====================================================================
// STAGE 1: Hybrid search with 3x over-fetch for reranking pool
// STAGE 1: Hybrid search with Nx over-fetch for reranking pool
// ====================================================================
let overfetch_limit = (limit * 3).min(100); // Cap at 100 to avoid excessive DB load
let overfetch_multiplier = match retrieval_mode {
"precise" => 1, // No overfetch — return exactly what's asked
"exhaustive" => 5, // Deep overfetch for maximum recall
_ => 3, // Balanced default
};
let overfetch_limit = (limit * overfetch_multiplier).min(100); // Cap at 100 to avoid excessive DB load
let results = storage
.hybrid_search(&args.query, overfetch_limit, keyword_weight, semantic_weight)
@ -215,7 +244,7 @@ pub async fn execute(
// Determine state from retention strength
lifecycle.state = if result.node.retention_strength > 0.7 {
MemoryState::Active
} else if result.node.retention_strength > 0.3 {
} else if result.node.retention_strength > 0.4 {
MemoryState::Dormant
} else if result.node.retention_strength > 0.1 {
MemoryState::Silent
@ -275,9 +304,11 @@ pub async fn execute(
// ====================================================================
// STAGE 5B: Retrieval competition (Anderson et al. 1994)
// Skipped in precise mode (no need) and exhaustive mode (want all results)
// ====================================================================
let mut suppressed_count = 0_usize;
if filtered_results.len() > 1
if retrieval_mode == "balanced"
&& filtered_results.len() > 1
&& let Ok(mut cog) = cognitive.try_lock()
{
let candidates: Vec<CompetitionCandidate> = filtered_results
@ -321,21 +352,31 @@ pub async fn execute(
// ====================================================================
// STAGE 6: Spreading activation (find associated memories)
// Skipped in precise mode. Deeper (5 results) in exhaustive mode.
// ====================================================================
let associations: Vec<Value> = if let Ok(mut cog) = cognitive.try_lock() {
if let Some(first) = filtered_results.first() {
let activated = cog.activation_network.activate(&first.node.id, 1.0);
activated
.iter()
.take(3)
.map(|a| {
serde_json::json!({
"memoryId": a.memory_id,
"activation": a.activation,
"distance": a.distance,
let activation_take = match retrieval_mode {
"precise" => 0, // Skip entirely
"exhaustive" => 5, // Deeper graph traversal
_ => 3, // Balanced default
};
let associations: Vec<Value> = if activation_take > 0 {
if let Ok(mut cog) = cognitive.try_lock() {
if let Some(first) = filtered_results.first() {
let activated = cog.activation_network.activate(&first.node.id, 1.0);
activated
.iter()
.take(activation_take)
.map(|a| {
serde_json::json!({
"memoryId": a.memory_id,
"activation": a.activation,
"distance": a.distance,
})
})
})
.collect()
.collect()
} else {
vec![]
}
} else {
vec![]
}
@ -401,7 +442,7 @@ pub async fn execute(
let mut budget_expandable: Vec<String> = Vec::new();
let mut budget_tokens_used: Option<usize> = None;
if let Some(budget) = args.token_budget {
let budget = budget.clamp(100, 10000) as usize;
let budget = budget.clamp(100, 100000) as usize;
let budget_chars = budget * 4;
let mut used = 0;
let mut budgeted = Vec::new();
@ -428,11 +469,17 @@ pub async fn execute(
let mut response = serde_json::json!({
"query": args.query,
"method": "hybrid+cognitive",
"retrievalMode": retrieval_mode,
"detailLevel": detail_level,
"total": formatted.len(),
"results": formatted,
});
// Helpful hint when no results found
if formatted.is_empty() {
response["hint"] = serde_json::json!("No memories found. Use smart_ingest to add memories, or try a broader query.");
}
// Include associations if any were found
if !associations.is_empty() {
response["associations"] = serde_json::json!(associations);
@ -499,7 +546,7 @@ fn format_search_result(r: &vestige_core::SearchResult, detail_level: &str) -> V
"validUntil": r.node.valid_until.map(|dt| dt.to_rfc3339()),
"matchType": format!("{:?}", r.match_type),
}),
// "summary" (default) — backwards compatible
// "summary" (default) — includes dates so AI never has to guess when a memory is from
_ => serde_json::json!({
"id": r.node.id,
"content": r.node.content,
@ -509,6 +556,8 @@ fn format_search_result(r: &vestige_core::SearchResult, detail_level: &str) -> V
"nodeType": r.node.node_type,
"tags": r.node.tags,
"retentionStrength": r.node.retention_strength,
"createdAt": r.node.created_at.to_rfc3339(),
"updatedAt": r.node.updated_at.to_rfc3339(),
}),
}
}
@ -1004,10 +1053,11 @@ mod tests {
let results = value["results"].as_array().unwrap();
if !results.is_empty() {
let first = &results[0];
// Summary should have content but not timestamps
// Summary should have content AND timestamps (v2.1: dates always visible)
assert!(first["content"].is_string());
assert!(first["id"].is_string());
assert!(first.get("createdAt").is_none() || first["createdAt"].is_null());
assert!(first["createdAt"].is_string(), "summary must include createdAt");
assert!(first["updatedAt"].is_string(), "summary must include updatedAt");
}
}
@ -1106,6 +1156,6 @@ mod tests {
let tb = &schema_value["properties"]["token_budget"];
assert!(tb.is_object());
assert_eq!(tb["minimum"], 100);
assert_eq!(tb["maximum"], 10000);
assert_eq!(tb["maximum"], 100000);
}
}

View file

@ -27,10 +27,10 @@ pub fn schema() -> Value {
},
"token_budget": {
"type": "integer",
"description": "Max tokens for response (default: 1000). Server truncates content to fit budget.",
"description": "Max tokens for response (default: 1000). Server truncates content to fit budget. With 1M context models, budgets up to 100K are practical.",
"default": 1000,
"minimum": 100,
"maximum": 10000
"maximum": 100000
},
"context": {
"type": "object",
@ -105,7 +105,7 @@ pub async fn execute(
None => SessionContextArgs::default(),
};
let token_budget = args.token_budget.unwrap_or(1000).clamp(100, 10000) as usize;
let token_budget = args.token_budget.unwrap_or(1000).clamp(100, 100000) as usize;
let budget_chars = token_budget * 4;
let include_status = args.include_status.unwrap_or(true);
let include_intentions = args.include_intentions.unwrap_or(true);
@ -132,7 +132,8 @@ pub async fn execute(
continue;
}
let summary = first_sentence(&r.node.content);
let line = format!("- {}", summary);
let date_str = r.node.updated_at.format("%b %d, %Y").to_string();
let line = format!("- ({}) {}", date_str, summary);
let line_len = line.len() + 1; // +1 for newline
if char_count + line_len > budget_chars {
@ -510,7 +511,7 @@ mod tests {
let s = schema();
let tb = &s["properties"]["token_budget"];
assert_eq!(tb["minimum"], 100);
assert_eq!(tb["maximum"], 10000);
assert_eq!(tb["maximum"], 100000);
assert_eq!(tb["default"], 1000);
}