use std::io;
use std::path::Path;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone)]
pub struct SearchResult {
pub id: String,
pub score: f32,
pub metadata: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Entry {
id: String,
embedding: Vec<f32>,
metadata: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProgressiveSearch {
entries: Vec<Entry>,
}
impl ProgressiveSearch {
pub fn new() -> Self {
Self {
entries: Vec::new(),
}
}
pub fn insert(&mut self, id: &str, embedding: Vec<f32>, metadata: serde_json::Value) {
self.entries.push(Entry {
id: id.to_owned(),
embedding,
metadata,
});
}
pub fn search(&self, query: &[f32], k: usize) -> Vec<SearchResult> {
if self.entries.is_empty() || k == 0 {
return Vec::new();
}
let mut scored: Vec<SearchResult> = self
.entries
.iter()
.map(|entry| SearchResult {
id: entry.id.clone(),
score: cosine_similarity(query, &entry.embedding),
metadata: entry.metadata.clone(),
})
.collect();
scored.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
scored.truncate(k);
scored
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn save(&self, path: &Path) -> io::Result<()> {
let json = serde_json::to_string(self).map_err(|e| {
io::Error::new(io::ErrorKind::InvalidData, format!("serialize failed: {e}"))
})?;
std::fs::write(path, json)
}
pub fn load(path: &Path) -> io::Result<Self> {
let json = std::fs::read_to_string(path)?;
serde_json::from_str(&json).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("deserialize failed: {e}"),
)
})
}
}
impl Default for ProgressiveSearch {
fn default() -> Self {
Self::new()
}
}
fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
if a.len() != b.len() {
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)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn insert_and_search_returns_correct_top_k() {
let mut idx = ProgressiveSearch::new();
idx.insert("a", vec![1.0, 0.0, 0.0], serde_json::json!({"tag": "a"}));
idx.insert("b", vec![0.0, 1.0, 0.0], serde_json::json!({"tag": "b"}));
idx.insert("c", vec![0.7, 0.7, 0.0], serde_json::json!({"tag": "c"}));
let results = idx.search(&[1.0, 0.0, 0.0], 2);
assert_eq!(results.len(), 2);
assert_eq!(results[0].id, "a");
assert!((results[0].score - 1.0).abs() < 0.01);
assert_eq!(results[1].id, "c");
}
#[test]
fn cosine_similarity_identical_vectors() {
let score = cosine_similarity(&[0.6, 0.8], &[0.6, 0.8]);
assert!(
(score - 1.0).abs() < 0.001,
"identical vectors should score ~1.0, got {score}"
);
}
#[test]
fn cosine_similarity_orthogonal_vectors() {
let score = cosine_similarity(&[1.0, 0.0], &[0.0, 1.0]);
assert!(
score.abs() < 0.001,
"orthogonal vectors should score ~0.0, got {score}"
);
}
#[test]
fn cosine_similarity_opposite_vectors() {
let score = cosine_similarity(&[1.0, 0.0], &[-1.0, 0.0]);
assert!(
(score - (-1.0)).abs() < 0.001,
"opposite vectors should score ~-1.0, got {score}"
);
}
#[test]
fn cosine_similarity_different_lengths() {
let score = cosine_similarity(&[1.0, 0.0], &[1.0]);
assert!(
score.abs() < f32::EPSILON,
"mismatched lengths should return 0.0"
);
}
#[test]
fn cosine_similarity_zero_vector() {
let score = cosine_similarity(&[0.0, 0.0], &[1.0, 0.0]);
assert!(score.abs() < f32::EPSILON, "zero norm should return 0.0");
}
#[test]
fn save_and_load_round_trip() {
let mut idx = ProgressiveSearch::new();
idx.insert("x", vec![1.0, 2.0, 3.0], serde_json::json!({"v": 42}));
idx.insert("y", vec![4.0, 5.0, 6.0], serde_json::json!(null));
let dir = std::env::temp_dir().join("clawft_progressive_test");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("test_index.json");
idx.save(&path).unwrap();
let loaded = ProgressiveSearch::load(&path).unwrap();
assert_eq!(loaded.len(), 2);
let orig_results = idx.search(&[1.0, 2.0, 3.0], 1);
let loaded_results = loaded.search(&[1.0, 2.0, 3.0], 1);
assert_eq!(orig_results[0].id, loaded_results[0].id);
assert!((orig_results[0].score - loaded_results[0].score).abs() < f32::EPSILON);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn empty_search_returns_empty() {
let idx = ProgressiveSearch::new();
assert!(idx.search(&[1.0, 0.0], 5).is_empty());
}
#[test]
fn search_with_k_zero_returns_empty() {
let mut idx = ProgressiveSearch::new();
idx.insert("a", vec![1.0], serde_json::json!(null));
assert!(idx.search(&[1.0], 0).is_empty());
}
#[test]
fn len_and_is_empty() {
let mut idx = ProgressiveSearch::new();
assert!(idx.is_empty());
assert_eq!(idx.len(), 0);
idx.insert("a", vec![1.0], serde_json::json!(null));
assert!(!idx.is_empty());
assert_eq!(idx.len(), 1);
}
#[test]
fn default_creates_empty() {
let idx = ProgressiveSearch::default();
assert!(idx.is_empty());
}
#[test]
fn search_preserves_metadata() {
let mut idx = ProgressiveSearch::new();
let meta = serde_json::json!({"source": "test", "priority": 5});
idx.insert("m", vec![1.0, 0.0], meta.clone());
let results = idx.search(&[1.0, 0.0], 1);
assert_eq!(results[0].metadata, meta);
}
#[test]
fn search_top_k_larger_than_store() {
let mut idx = ProgressiveSearch::new();
idx.insert("only", vec![1.0], serde_json::json!(null));
let results = idx.search(&[1.0], 100);
assert_eq!(results.len(), 1);
}
#[test]
fn load_nonexistent_file_errors() {
let result = ProgressiveSearch::load(Path::new("/nonexistent/path/index.json"));
assert!(result.is_err());
}
}