feat(v1.1): consolidate 29 tools → 8 unified tools + CLI

Tool Consolidation:
- search: merges recall, semantic_search, hybrid_search
- memory: merges get_knowledge, delete_knowledge, get_memory_state
- codebase: merges remember_pattern, remember_decision, get_codebase_context
- intention: merges all 5 intention tools into action-based API

New CLI Binary:
- vestige stats [--tagging] [--states]
- vestige health
- vestige consolidate
- vestige restore <file>

Documentation:
- Verify all neuroscience claims against codebase
- Fix Memory States table: "Retention" → "Accessibility" with formula
- Clarify Spreading Activation: embedding similarity vs full network module
- Update Synaptic Tagging: clarify 9h/2h implementation vs biology
- Add comprehensive FAQ with 30+ questions
- Add storage modes: global, per-project, multi-Claude household
- Add CLAUDE.md setup instructions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-01-26 01:31:58 -06:00
parent 29130c3068
commit 8bb6500985
11 changed files with 4152 additions and 90 deletions

View file

@ -0,0 +1,332 @@
//! Unified Codebase Tool
//!
//! Merges remember_pattern, remember_decision, and get_codebase_context into a single
//! `codebase` tool with action-based dispatch.
use serde::Deserialize;
use serde_json::Value;
use std::sync::Arc;
use tokio::sync::Mutex;
use vestige_core::{IngestInput, Storage};
/// Input schema for the unified codebase tool
pub fn schema() -> Value {
serde_json::json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["remember_pattern", "remember_decision", "get_context"],
"description": "Action to perform: 'remember_pattern' stores a code pattern, 'remember_decision' stores an architectural decision, 'get_context' retrieves patterns and decisions for a codebase"
},
// remember_pattern fields
"name": {
"type": "string",
"description": "Name/title for the pattern (required for remember_pattern)"
},
"description": {
"type": "string",
"description": "Detailed description of the pattern (required for remember_pattern)"
},
// remember_decision fields
"decision": {
"type": "string",
"description": "The architectural or design decision made (required for remember_decision)"
},
"rationale": {
"type": "string",
"description": "Why this decision was made (required for remember_decision)"
},
"alternatives": {
"type": "array",
"items": { "type": "string" },
"description": "Alternatives that were considered (optional for remember_decision)"
},
// Shared fields
"files": {
"type": "array",
"items": { "type": "string" },
"description": "Files where this pattern is used or affected by this decision"
},
"codebase": {
"type": "string",
"description": "Codebase/project identifier (e.g., 'vestige-tauri')"
},
// get_context fields
"limit": {
"type": "integer",
"description": "Maximum items per category (default: 10, for get_context)",
"default": 10
}
},
"required": ["action"]
})
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CodebaseArgs {
action: String,
// Pattern fields
name: Option<String>,
description: Option<String>,
// Decision fields
decision: Option<String>,
rationale: Option<String>,
alternatives: Option<Vec<String>>,
// Shared fields
files: Option<Vec<String>>,
codebase: Option<String>,
// Context fields
limit: Option<i32>,
}
/// Execute the unified codebase tool
pub async fn execute(
storage: &Arc<Mutex<Storage>>,
args: Option<Value>,
) -> Result<Value, String> {
let args: CodebaseArgs = match args {
Some(v) => serde_json::from_value(v).map_err(|e| format!("Invalid arguments: {}", e))?,
None => return Err("Missing arguments".to_string()),
};
match args.action.as_str() {
"remember_pattern" => execute_remember_pattern(storage, &args).await,
"remember_decision" => execute_remember_decision(storage, &args).await,
"get_context" => execute_get_context(storage, &args).await,
_ => Err(format!(
"Invalid action '{}'. Must be one of: remember_pattern, remember_decision, get_context",
args.action
)),
}
}
/// Remember a code pattern
async fn execute_remember_pattern(
storage: &Arc<Mutex<Storage>>,
args: &CodebaseArgs,
) -> Result<Value, String> {
let name = args
.name
.as_ref()
.ok_or("'name' is required for remember_pattern action")?;
let description = args
.description
.as_ref()
.ok_or("'description' is required for remember_pattern action")?;
if name.trim().is_empty() {
return Err("Pattern name cannot be empty".to_string());
}
// Build content with structured format
let mut content = format!("# Code Pattern: {}\n\n{}", name, description);
if let Some(ref files) = args.files {
if !files.is_empty() {
content.push_str("\n\n## Files:\n");
for f in files {
content.push_str(&format!("- {}\n", f));
}
}
}
// Build tags
let mut tags = vec!["pattern".to_string(), "codebase".to_string()];
if let Some(ref codebase) = args.codebase {
tags.push(format!("codebase:{}", codebase));
}
let input = IngestInput {
content,
node_type: "pattern".to_string(),
source: args.codebase.clone(),
sentiment_score: 0.0,
sentiment_magnitude: 0.0,
tags,
valid_from: None,
valid_until: None,
};
let mut storage = storage.lock().await;
let node = storage.ingest(input).map_err(|e| e.to_string())?;
Ok(serde_json::json!({
"action": "remember_pattern",
"success": true,
"nodeId": node.id,
"patternName": name,
"message": format!("Pattern '{}' remembered successfully", name),
}))
}
/// Remember an architectural decision
async fn execute_remember_decision(
storage: &Arc<Mutex<Storage>>,
args: &CodebaseArgs,
) -> Result<Value, String> {
let decision = args
.decision
.as_ref()
.ok_or("'decision' is required for remember_decision action")?;
let rationale = args
.rationale
.as_ref()
.ok_or("'rationale' is required for remember_decision action")?;
if decision.trim().is_empty() {
return Err("Decision cannot be empty".to_string());
}
// Build content with structured format (ADR-like)
let mut content = format!(
"# Decision: {}\n\n## Context\n\n{}\n\n## Decision\n\n{}",
&decision[..decision.len().min(50)],
rationale,
decision
);
if let Some(ref alternatives) = args.alternatives {
if !alternatives.is_empty() {
content.push_str("\n\n## Alternatives Considered:\n");
for alt in alternatives {
content.push_str(&format!("- {}\n", alt));
}
}
}
if let Some(ref files) = args.files {
if !files.is_empty() {
content.push_str("\n\n## Affected Files:\n");
for f in files {
content.push_str(&format!("- {}\n", f));
}
}
}
// Build tags
let mut tags = vec![
"decision".to_string(),
"architecture".to_string(),
"codebase".to_string(),
];
if let Some(ref codebase) = args.codebase {
tags.push(format!("codebase:{}", codebase));
}
let input = IngestInput {
content,
node_type: "decision".to_string(),
source: args.codebase.clone(),
sentiment_score: 0.0,
sentiment_magnitude: 0.0,
tags,
valid_from: None,
valid_until: None,
};
let mut storage = storage.lock().await;
let node = storage.ingest(input).map_err(|e| e.to_string())?;
Ok(serde_json::json!({
"action": "remember_decision",
"success": true,
"nodeId": node.id,
"message": "Architectural decision remembered successfully",
}))
}
/// Get codebase context (patterns and decisions)
async fn execute_get_context(
storage: &Arc<Mutex<Storage>>,
args: &CodebaseArgs,
) -> Result<Value, String> {
let limit = args.limit.unwrap_or(10).clamp(1, 50);
let storage = storage.lock().await;
// Build tag filter for codebase
// Tags are stored as: ["pattern", "codebase", "codebase:vestige"]
// We search for the "codebase:{name}" tag
let tag_filter = args
.codebase
.as_ref()
.map(|cb| format!("codebase:{}", cb));
// Query patterns by node_type and tag
let patterns = storage
.get_nodes_by_type_and_tag("pattern", tag_filter.as_deref(), limit)
.unwrap_or_default();
// Query decisions by node_type and tag
let decisions = storage
.get_nodes_by_type_and_tag("decision", tag_filter.as_deref(), limit)
.unwrap_or_default();
let formatted_patterns: Vec<Value> = patterns
.iter()
.map(|n| {
serde_json::json!({
"id": n.id,
"content": n.content,
"tags": n.tags,
"retentionStrength": n.retention_strength,
"createdAt": n.created_at.to_rfc3339(),
})
})
.collect();
let formatted_decisions: Vec<Value> = decisions
.iter()
.map(|n| {
serde_json::json!({
"id": n.id,
"content": n.content,
"tags": n.tags,
"retentionStrength": n.retention_strength,
"createdAt": n.created_at.to_rfc3339(),
})
})
.collect();
Ok(serde_json::json!({
"action": "get_context",
"codebase": args.codebase,
"patterns": {
"count": formatted_patterns.len(),
"items": formatted_patterns,
},
"decisions": {
"count": formatted_decisions.len(),
"items": formatted_decisions,
},
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_schema_structure() {
let schema = schema();
assert!(schema["properties"]["action"].is_object());
assert_eq!(schema["required"], serde_json::json!(["action"]));
// Check action enum values
let action_enum = &schema["properties"]["action"]["enum"];
assert!(action_enum
.as_array()
.unwrap()
.contains(&serde_json::json!("remember_pattern")));
assert!(action_enum
.as_array()
.unwrap()
.contains(&serde_json::json!("remember_decision")));
assert!(action_enum
.as_array()
.unwrap()
.contains(&serde_json::json!("get_context")));
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,223 @@
//! Unified Memory Tool
//!
//! Merges get_knowledge, delete_knowledge, and get_memory_state into a single
//! `memory` tool with action-based dispatch.
use serde::Deserialize;
use serde_json::Value;
use std::sync::Arc;
use tokio::sync::Mutex;
use vestige_core::{MemoryState, Storage};
// Accessibility thresholds based on retention strength
const ACCESSIBILITY_ACTIVE: f64 = 0.7;
const ACCESSIBILITY_DORMANT: f64 = 0.4;
const ACCESSIBILITY_SILENT: f64 = 0.1;
/// Compute accessibility score from memory strengths
/// Combines retention, retrieval, and storage strengths
fn compute_accessibility(retention: f64, retrieval: f64, storage: f64) -> f64 {
// Weighted combination: retention is most important for accessibility
retention * 0.5 + retrieval * 0.3 + storage * 0.2
}
/// Determine memory state from accessibility score
fn state_from_accessibility(accessibility: f64) -> MemoryState {
if accessibility >= ACCESSIBILITY_ACTIVE {
MemoryState::Active
} else if accessibility >= ACCESSIBILITY_DORMANT {
MemoryState::Dormant
} else if accessibility >= ACCESSIBILITY_SILENT {
MemoryState::Silent
} else {
MemoryState::Unavailable
}
}
/// Input schema for the unified memory tool
pub fn schema() -> Value {
serde_json::json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["get", "delete", "state"],
"description": "Action to perform: 'get' retrieves full memory node, 'delete' removes memory, 'state' returns accessibility state"
},
"id": {
"type": "string",
"description": "The ID of the memory node"
}
},
"required": ["action", "id"]
})
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct MemoryArgs {
action: String,
id: String,
}
/// Execute the unified memory tool
pub async fn execute(
storage: &Arc<Mutex<Storage>>,
args: Option<Value>,
) -> Result<Value, String> {
let args: MemoryArgs = 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 format
uuid::Uuid::parse_str(&args.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,
_ => Err(format!(
"Invalid action '{}'. Must be one of: get, delete, state",
args.action
)),
}
}
/// Get full memory node with all metadata
async fn execute_get(storage: &Arc<Mutex<Storage>>, id: &str) -> Result<Value, String> {
let storage = storage.lock().await;
let node = storage.get_node(id).map_err(|e| e.to_string())?;
match node {
Some(n) => Ok(serde_json::json!({
"action": "get",
"found": true,
"node": {
"id": n.id,
"content": n.content,
"nodeType": n.node_type,
"createdAt": n.created_at.to_rfc3339(),
"updatedAt": n.updated_at.to_rfc3339(),
"lastAccessed": n.last_accessed.to_rfc3339(),
"stability": n.stability,
"difficulty": n.difficulty,
"reps": n.reps,
"lapses": n.lapses,
"storageStrength": n.storage_strength,
"retrievalStrength": n.retrieval_strength,
"retentionStrength": n.retention_strength,
"sentimentScore": n.sentiment_score,
"sentimentMagnitude": n.sentiment_magnitude,
"nextReview": n.next_review.map(|d| d.to_rfc3339()),
"source": n.source,
"tags": n.tags,
"hasEmbedding": n.has_embedding,
"embeddingModel": n.embedding_model,
}
})),
None => Ok(serde_json::json!({
"action": "get",
"found": false,
"nodeId": id,
"message": "Memory not found",
})),
}
}
/// Delete a memory and return success status
async fn execute_delete(storage: &Arc<Mutex<Storage>>, id: &str) -> Result<Value, String> {
let mut storage = storage.lock().await;
let deleted = storage.delete_node(id).map_err(|e| e.to_string())?;
Ok(serde_json::json!({
"action": "delete",
"success": deleted,
"nodeId": id,
"message": if deleted { "Memory deleted successfully" } else { "Memory not found" },
}))
}
/// Get accessibility state of a memory (Active/Dormant/Silent/Unavailable)
async fn execute_state(storage: &Arc<Mutex<Storage>>, id: &str) -> Result<Value, String> {
let storage = storage.lock().await;
// Get the memory
let memory = storage
.get_node(id)
.map_err(|e| format!("Error: {}", e))?
.ok_or("Memory not found")?;
// Calculate accessibility score
let accessibility = compute_accessibility(
memory.retention_strength,
memory.retrieval_strength,
memory.storage_strength,
);
// Determine state
let state = state_from_accessibility(accessibility);
let state_description = match state {
MemoryState::Active => "Easily retrievable - this memory is fresh and accessible",
MemoryState::Dormant => "Retrievable with effort - may need cues to recall",
MemoryState::Silent => "Difficult to retrieve - exists but hard to access",
MemoryState::Unavailable => "Cannot be retrieved - needs significant reinforcement",
};
Ok(serde_json::json!({
"action": "state",
"memoryId": id,
"content": memory.content,
"state": format!("{:?}", state),
"accessibility": accessibility,
"description": state_description,
"components": {
"retentionStrength": memory.retention_strength,
"retrievalStrength": memory.retrieval_strength,
"storageStrength": memory.storage_strength
},
"thresholds": {
"active": ACCESSIBILITY_ACTIVE,
"dormant": ACCESSIBILITY_DORMANT,
"silent": ACCESSIBILITY_SILENT
}
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_accessibility_thresholds() {
// Test Active state
let accessibility = compute_accessibility(0.9, 0.8, 0.7);
assert!(accessibility >= ACCESSIBILITY_ACTIVE);
assert!(matches!(state_from_accessibility(accessibility), MemoryState::Active));
// Test Dormant state
let accessibility = compute_accessibility(0.5, 0.5, 0.5);
assert!(accessibility >= ACCESSIBILITY_DORMANT && accessibility < ACCESSIBILITY_ACTIVE);
assert!(matches!(state_from_accessibility(accessibility), MemoryState::Dormant));
// Test Silent state
let accessibility = compute_accessibility(0.2, 0.2, 0.2);
assert!(accessibility >= ACCESSIBILITY_SILENT && accessibility < ACCESSIBILITY_DORMANT);
assert!(matches!(state_from_accessibility(accessibility), MemoryState::Silent));
// Test Unavailable state
let accessibility = compute_accessibility(0.05, 0.05, 0.05);
assert!(accessibility < ACCESSIBILITY_SILENT);
assert!(matches!(state_from_accessibility(accessibility), MemoryState::Unavailable));
}
#[test]
fn test_schema_structure() {
let schema = schema();
assert!(schema["properties"]["action"].is_object());
assert!(schema["properties"]["id"].is_object());
assert_eq!(schema["required"], serde_json::json!(["action", "id"]));
}
}

View file

@ -20,3 +20,9 @@ pub mod tagging;
// Feedback / preference learning
pub mod feedback;
// Unified tools (consolidate multiple operations into single tools)
pub mod codebase_unified;
pub mod intention_unified;
pub mod memory_unified;
pub mod search_unified;

View file

@ -0,0 +1,492 @@
//! Unified Search Tool
//!
//! Merges recall, semantic_search, and hybrid_search into a single `search` tool.
//! Always uses hybrid search internally (keyword + semantic + RRF fusion).
//! Implements Testing Effect (Roediger & Karpicke 2006) by auto-strengthening memories on access.
use serde::Deserialize;
use serde_json::Value;
use std::sync::Arc;
use tokio::sync::Mutex;
use vestige_core::Storage;
/// Input schema for unified search tool
pub fn schema() -> Value {
serde_json::json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query"
},
"limit": {
"type": "integer",
"description": "Maximum number of results (default: 10)",
"default": 10,
"minimum": 1,
"maximum": 100
},
"min_retention": {
"type": "number",
"description": "Minimum retention strength (0.0-1.0, default: 0.0)",
"default": 0.0,
"minimum": 0.0,
"maximum": 1.0
},
"min_similarity": {
"type": "number",
"description": "Minimum similarity threshold (0.0-1.0, default: 0.5)",
"default": 0.5,
"minimum": 0.0,
"maximum": 1.0
}
},
"required": ["query"]
})
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SearchArgs {
query: String,
limit: Option<i32>,
min_retention: Option<f64>,
min_similarity: Option<f32>,
}
/// Execute unified search
///
/// Uses hybrid search (keyword + semantic + RRF fusion) internally.
/// Auto-strengthens memories on access (Testing Effect - Roediger & Karpicke 2006).
pub async fn execute(
storage: &Arc<Mutex<Storage>>,
args: Option<Value>,
) -> Result<Value, String> {
let args: SearchArgs = 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());
}
// Clamp all parameters to valid ranges
let limit = args.limit.unwrap_or(10).clamp(1, 100);
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);
// Use balanced weights for hybrid search (keyword + semantic)
let keyword_weight = 0.5_f32;
let semantic_weight = 0.5_f32;
let storage = storage.lock().await;
// Execute hybrid search
let results = storage
.hybrid_search(&args.query, limit, keyword_weight, semantic_weight)
.map_err(|e| e.to_string())?;
// Filter results by min_retention and min_similarity
let filtered_results: Vec<_> = results
.into_iter()
.filter(|r| {
// Check retention strength
if r.node.retention_strength < min_retention {
return false;
}
// Check similarity if semantic score is available
if let Some(sem_score) = r.semantic_score {
if sem_score < min_similarity {
return false;
}
}
true
})
.collect();
// 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> = filtered_results.iter().map(|r| r.node.id.as_str()).collect();
let _ = storage.strengthen_batch_on_access(&ids); // Ignore errors, don't fail search
// Format results
let formatted: Vec<Value> = filtered_results
.iter()
.map(|r| {
serde_json::json!({
"id": r.node.id,
"content": r.node.content,
"combinedScore": r.combined_score,
"keywordScore": r.keyword_score,
"semanticScore": r.semantic_score,
"nodeType": r.node.node_type,
"tags": r.node.tags,
"retentionStrength": r.node.retention_strength,
})
})
.collect();
Ok(serde_json::json!({
"query": args.query,
"method": "hybrid",
"total": formatted.len(),
"results": formatted,
}))
}
// ============================================================================
// TESTS
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use vestige_core::IngestInput;
/// 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)
}
/// Helper to ingest test content
async fn ingest_test_content(storage: &Arc<Mutex<Storage>>, content: &str) -> String {
let input = IngestInput {
content: content.to_string(),
node_type: "fact".to_string(),
source: None,
sentiment_score: 0.0,
sentiment_magnitude: 0.0,
tags: vec![],
valid_from: None,
valid_until: None,
};
let mut storage_lock = storage.lock().await;
let node = storage_lock.ingest(input).unwrap();
node.id
}
// ========================================================================
// QUERY VALIDATION TESTS
// ========================================================================
#[tokio::test]
async fn test_search_empty_query_fails() {
let (storage, _dir) = test_storage().await;
let args = serde_json::json!({ "query": "" });
let result = execute(&storage, Some(args)).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("empty"));
}
#[tokio::test]
async fn test_search_whitespace_only_query_fails() {
let (storage, _dir) = test_storage().await;
let args = serde_json::json!({ "query": " \t\n " });
let result = execute(&storage, Some(args)).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("empty"));
}
#[tokio::test]
async fn test_search_missing_arguments_fails() {
let (storage, _dir) = test_storage().await;
let result = execute(&storage, None).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("Missing arguments"));
}
#[tokio::test]
async fn test_search_missing_query_field_fails() {
let (storage, _dir) = test_storage().await;
let args = serde_json::json!({ "limit": 10 });
let result = execute(&storage, Some(args)).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid arguments"));
}
// ========================================================================
// LIMIT CLAMPING TESTS
// ========================================================================
#[tokio::test]
async fn test_search_limit_clamped_to_minimum() {
let (storage, _dir) = test_storage().await;
ingest_test_content(&storage, "Test content for limit clamping").await;
// Try with limit 0 - should clamp to 1
let args = serde_json::json!({
"query": "test",
"limit": 0
});
let result = execute(&storage, Some(args)).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_search_limit_clamped_to_maximum() {
let (storage, _dir) = test_storage().await;
ingest_test_content(&storage, "Test content for max limit").await;
// Try with limit 1000 - should clamp to 100
let args = serde_json::json!({
"query": "test",
"limit": 1000
});
let result = execute(&storage, Some(args)).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_search_negative_limit_clamped() {
let (storage, _dir) = test_storage().await;
ingest_test_content(&storage, "Test content for negative limit").await;
let args = serde_json::json!({
"query": "test",
"limit": -5
});
let result = execute(&storage, Some(args)).await;
assert!(result.is_ok());
}
// ========================================================================
// MIN_RETENTION CLAMPING TESTS
// ========================================================================
#[tokio::test]
async fn test_search_min_retention_clamped_to_zero() {
let (storage, _dir) = test_storage().await;
ingest_test_content(&storage, "Test content for retention clamping").await;
let args = serde_json::json!({
"query": "test",
"min_retention": -0.5
});
let result = execute(&storage, Some(args)).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_search_min_retention_clamped_to_one() {
let (storage, _dir) = test_storage().await;
ingest_test_content(&storage, "Test content for max retention").await;
let args = serde_json::json!({
"query": "test",
"min_retention": 1.5
});
let result = execute(&storage, Some(args)).await;
// Should succeed but may return no results (retention > 1.0 clamped to 1.0)
assert!(result.is_ok());
}
// ========================================================================
// MIN_SIMILARITY CLAMPING TESTS
// ========================================================================
#[tokio::test]
async fn test_search_min_similarity_clamped_to_zero() {
let (storage, _dir) = test_storage().await;
ingest_test_content(&storage, "Test content for similarity clamping").await;
let args = serde_json::json!({
"query": "test",
"min_similarity": -0.5
});
let result = execute(&storage, Some(args)).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_search_min_similarity_clamped_to_one() {
let (storage, _dir) = test_storage().await;
ingest_test_content(&storage, "Test content for max similarity").await;
let args = serde_json::json!({
"query": "test",
"min_similarity": 1.5
});
let result = execute(&storage, Some(args)).await;
// Should succeed but may return no results
assert!(result.is_ok());
}
// ========================================================================
// SUCCESSFUL SEARCH TESTS
// ========================================================================
#[tokio::test]
async fn test_search_basic_query_succeeds() {
let (storage, _dir) = test_storage().await;
ingest_test_content(&storage, "The Rust programming language is memory safe.").await;
let args = serde_json::json!({ "query": "rust" });
let result = execute(&storage, Some(args)).await;
assert!(result.is_ok());
let value = result.unwrap();
assert_eq!(value["query"], "rust");
assert_eq!(value["method"], "hybrid");
assert!(value["total"].is_number());
assert!(value["results"].is_array());
}
#[tokio::test]
async fn test_search_returns_matching_content() {
let (storage, _dir) = test_storage().await;
let node_id =
ingest_test_content(&storage, "Python is a dynamic programming language.").await;
let args = serde_json::json!({
"query": "python",
"min_similarity": 0.0
});
let result = execute(&storage, Some(args)).await;
assert!(result.is_ok());
let value = result.unwrap();
let results = value["results"].as_array().unwrap();
assert!(!results.is_empty());
assert_eq!(results[0]["id"], node_id);
}
#[tokio::test]
async fn test_search_with_limit() {
let (storage, _dir) = test_storage().await;
// Ingest multiple items
ingest_test_content(&storage, "Testing content one").await;
ingest_test_content(&storage, "Testing content two").await;
ingest_test_content(&storage, "Testing content three").await;
let args = serde_json::json!({
"query": "testing",
"limit": 2,
"min_similarity": 0.0
});
let result = execute(&storage, Some(args)).await;
assert!(result.is_ok());
let value = result.unwrap();
let results = value["results"].as_array().unwrap();
assert!(results.len() <= 2);
}
#[tokio::test]
async fn test_search_empty_database_returns_empty_array() {
let (storage, _dir) = test_storage().await;
// Don't ingest anything - database is empty
let args = serde_json::json!({ "query": "anything" });
let result = execute(&storage, Some(args)).await;
assert!(result.is_ok());
let value = result.unwrap();
assert_eq!(value["total"], 0);
assert!(value["results"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn test_search_result_contains_expected_fields() {
let (storage, _dir) = test_storage().await;
ingest_test_content(&storage, "Testing field presence in search results.").await;
let args = serde_json::json!({
"query": "testing",
"min_similarity": 0.0
});
let result = execute(&storage, Some(args)).await;
assert!(result.is_ok());
let value = result.unwrap();
let results = value["results"].as_array().unwrap();
if !results.is_empty() {
let first = &results[0];
assert!(first["id"].is_string());
assert!(first["content"].is_string());
assert!(first["combinedScore"].is_number());
// keywordScore and semanticScore may be null if not matched
assert!(first["nodeType"].is_string());
assert!(first["tags"].is_array());
assert!(first["retentionStrength"].is_number());
}
}
// ========================================================================
// DEFAULT VALUES TESTS
// ========================================================================
#[tokio::test]
async fn test_search_default_limit_is_10() {
let (storage, _dir) = test_storage().await;
// Ingest more than 10 items
for i in 0..15 {
ingest_test_content(&storage, &format!("Item number {}", i)).await;
}
let args = serde_json::json!({
"query": "item",
"min_similarity": 0.0
});
let result = execute(&storage, Some(args)).await;
assert!(result.is_ok());
let value = result.unwrap();
let results = value["results"].as_array().unwrap();
assert!(results.len() <= 10);
}
// ========================================================================
// SCHEMA TESTS
// ========================================================================
#[test]
fn test_schema_has_required_fields() {
let schema_value = schema();
assert_eq!(schema_value["type"], "object");
assert!(schema_value["properties"]["query"].is_object());
assert!(schema_value["required"]
.as_array()
.unwrap()
.contains(&serde_json::json!("query")));
}
#[test]
fn test_schema_has_optional_fields() {
let schema_value = schema();
assert!(schema_value["properties"]["limit"].is_object());
assert!(schema_value["properties"]["min_retention"].is_object());
assert!(schema_value["properties"]["min_similarity"].is_object());
}
#[test]
fn test_schema_limit_has_bounds() {
let schema_value = schema();
let limit_schema = &schema_value["properties"]["limit"];
assert_eq!(limit_schema["minimum"], 1);
assert_eq!(limit_schema["maximum"], 100);
assert_eq!(limit_schema["default"], 10);
}
#[test]
fn test_schema_min_retention_has_bounds() {
let schema_value = schema();
let retention_schema = &schema_value["properties"]["min_retention"];
assert_eq!(retention_schema["minimum"], 0.0);
assert_eq!(retention_schema["maximum"], 1.0);
assert_eq!(retention_schema["default"], 0.0);
}
#[test]
fn test_schema_min_similarity_has_bounds() {
let schema_value = schema();
let similarity_schema = &schema_value["properties"]["min_similarity"];
assert_eq!(similarity_schema["minimum"], 0.0);
assert_eq!(similarity_schema["maximum"], 1.0);
assert_eq!(similarity_schema["default"], 0.5);
}
}