Skip to main content

clawft_core/embeddings/
progressive.rs

1//! Progressive search with three-tier recall strategy.
2//!
3//! Provides a [`ProgressiveSearch`] index that (in its final form) routes
4//! through three layers:
5//!
6//! - **Layer A** -- Coarse routing (~70 % recall, microseconds)
7//! - **Layer B** -- Hot-region refinement (~85 % recall)
8//! - **Layer C** -- Full graph traversal (~95 % recall)
9//!
10//! The current implementation is a brute-force cosine similarity search that
11//! serves as a drop-in stub. When the `rvf-index` crate becomes available the
12//! internals will be swapped for a real HNSW graph while keeping this public
13//! API stable.
14//!
15//! This module is gated behind the `rvf` feature flag.
16
17use std::io;
18use std::path::Path;
19
20use serde::{Deserialize, Serialize};
21
22// ── Types ──────────────────────────────────────────────────────────────
23
24/// A single search result returned by [`ProgressiveSearch::search`].
25#[derive(Debug, Clone)]
26pub struct SearchResult {
27    /// Identifier of the matched entry.
28    pub id: String,
29    /// Cosine similarity score (higher is better).
30    pub score: f32,
31    /// Arbitrary metadata attached to the entry.
32    pub metadata: serde_json::Value,
33}
34
35/// Serializable representation of one entry (used for persistence).
36#[derive(Debug, Clone, Serialize, Deserialize)]
37struct Entry {
38    id: String,
39    embedding: Vec<f32>,
40    metadata: serde_json::Value,
41}
42
43// ── ProgressiveSearch ──────────────────────────────────────────────────
44
45/// Three-tier progressive search index.
46///
47/// See the [module-level documentation](self) for tier descriptions.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct ProgressiveSearch {
50    entries: Vec<Entry>,
51}
52
53impl ProgressiveSearch {
54    /// Create a new, empty index.
55    pub fn new() -> Self {
56        Self {
57            entries: Vec::new(),
58        }
59    }
60
61    /// Insert an entry into the index.
62    pub fn insert(&mut self, id: &str, embedding: Vec<f32>, metadata: serde_json::Value) {
63        self.entries.push(Entry {
64            id: id.to_owned(),
65            embedding,
66            metadata,
67        });
68    }
69
70    /// Search for the `k` nearest entries to `query`.
71    ///
72    /// Returns results sorted by descending cosine similarity.
73    ///
74    /// # Stub behaviour
75    ///
76    /// The current implementation performs brute-force cosine similarity over
77    /// all entries. This will be replaced by a real HNSW graph when
78    /// `rvf-index` is available.
79    pub fn search(&self, query: &[f32], k: usize) -> Vec<SearchResult> {
80        if self.entries.is_empty() || k == 0 {
81            return Vec::new();
82        }
83
84        let mut scored: Vec<SearchResult> = self
85            .entries
86            .iter()
87            .map(|entry| SearchResult {
88                id: entry.id.clone(),
89                score: cosine_similarity(query, &entry.embedding),
90                metadata: entry.metadata.clone(),
91            })
92            .collect();
93
94        scored.sort_by(|a, b| {
95            b.score
96                .partial_cmp(&a.score)
97                .unwrap_or(std::cmp::Ordering::Equal)
98        });
99
100        scored.truncate(k);
101        scored
102    }
103
104    /// Return the number of entries in the index.
105    pub fn len(&self) -> usize {
106        self.entries.len()
107    }
108
109    /// Return `true` when the index contains no entries.
110    pub fn is_empty(&self) -> bool {
111        self.entries.is_empty()
112    }
113
114    /// Persist the index to `path` as JSON.
115    pub fn save(&self, path: &Path) -> io::Result<()> {
116        let json = serde_json::to_string(self).map_err(|e| {
117            io::Error::new(io::ErrorKind::InvalidData, format!("serialize failed: {e}"))
118        })?;
119        std::fs::write(path, json)
120    }
121
122    /// Load a previously saved index from `path`.
123    pub fn load(path: &Path) -> io::Result<Self> {
124        let json = std::fs::read_to_string(path)?;
125        serde_json::from_str(&json).map_err(|e| {
126            io::Error::new(
127                io::ErrorKind::InvalidData,
128                format!("deserialize failed: {e}"),
129            )
130        })
131    }
132}
133
134impl Default for ProgressiveSearch {
135    fn default() -> Self {
136        Self::new()
137    }
138}
139
140// ── Helpers ────────────────────────────────────────────────────────────
141
142/// Compute cosine similarity between two vectors.
143///
144/// Returns `0.0` when either vector has zero norm or when lengths differ.
145fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
146    if a.len() != b.len() {
147        return 0.0;
148    }
149
150    let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
151    let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
152    let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
153
154    if norm_a == 0.0 || norm_b == 0.0 {
155        return 0.0;
156    }
157
158    dot / (norm_a * norm_b)
159}
160
161// ── Tests ──────────────────────────────────────────────────────────────
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn insert_and_search_returns_correct_top_k() {
169        let mut idx = ProgressiveSearch::new();
170        idx.insert("a", vec![1.0, 0.0, 0.0], serde_json::json!({"tag": "a"}));
171        idx.insert("b", vec![0.0, 1.0, 0.0], serde_json::json!({"tag": "b"}));
172        idx.insert("c", vec![0.7, 0.7, 0.0], serde_json::json!({"tag": "c"}));
173
174        let results = idx.search(&[1.0, 0.0, 0.0], 2);
175        assert_eq!(results.len(), 2);
176        assert_eq!(results[0].id, "a");
177        assert!((results[0].score - 1.0).abs() < 0.01);
178        // Second should be "c" (partial match), not "b" (orthogonal)
179        assert_eq!(results[1].id, "c");
180    }
181
182    #[test]
183    fn cosine_similarity_identical_vectors() {
184        let score = cosine_similarity(&[0.6, 0.8], &[0.6, 0.8]);
185        assert!(
186            (score - 1.0).abs() < 0.001,
187            "identical vectors should score ~1.0, got {score}"
188        );
189    }
190
191    #[test]
192    fn cosine_similarity_orthogonal_vectors() {
193        let score = cosine_similarity(&[1.0, 0.0], &[0.0, 1.0]);
194        assert!(
195            score.abs() < 0.001,
196            "orthogonal vectors should score ~0.0, got {score}"
197        );
198    }
199
200    #[test]
201    fn cosine_similarity_opposite_vectors() {
202        let score = cosine_similarity(&[1.0, 0.0], &[-1.0, 0.0]);
203        assert!(
204            (score - (-1.0)).abs() < 0.001,
205            "opposite vectors should score ~-1.0, got {score}"
206        );
207    }
208
209    #[test]
210    fn cosine_similarity_different_lengths() {
211        let score = cosine_similarity(&[1.0, 0.0], &[1.0]);
212        assert!(
213            score.abs() < f32::EPSILON,
214            "mismatched lengths should return 0.0"
215        );
216    }
217
218    #[test]
219    fn cosine_similarity_zero_vector() {
220        let score = cosine_similarity(&[0.0, 0.0], &[1.0, 0.0]);
221        assert!(score.abs() < f32::EPSILON, "zero norm should return 0.0");
222    }
223
224    #[test]
225    fn save_and_load_round_trip() {
226        let mut idx = ProgressiveSearch::new();
227        idx.insert("x", vec![1.0, 2.0, 3.0], serde_json::json!({"v": 42}));
228        idx.insert("y", vec![4.0, 5.0, 6.0], serde_json::json!(null));
229
230        let dir = std::env::temp_dir().join("clawft_progressive_test");
231        std::fs::create_dir_all(&dir).unwrap();
232        let path = dir.join("test_index.json");
233
234        idx.save(&path).unwrap();
235        let loaded = ProgressiveSearch::load(&path).unwrap();
236
237        assert_eq!(loaded.len(), 2);
238
239        // Verify search still works identically.
240        let orig_results = idx.search(&[1.0, 2.0, 3.0], 1);
241        let loaded_results = loaded.search(&[1.0, 2.0, 3.0], 1);
242
243        assert_eq!(orig_results[0].id, loaded_results[0].id);
244        assert!((orig_results[0].score - loaded_results[0].score).abs() < f32::EPSILON);
245
246        // Clean up.
247        let _ = std::fs::remove_dir_all(&dir);
248    }
249
250    #[test]
251    fn empty_search_returns_empty() {
252        let idx = ProgressiveSearch::new();
253        assert!(idx.search(&[1.0, 0.0], 5).is_empty());
254    }
255
256    #[test]
257    fn search_with_k_zero_returns_empty() {
258        let mut idx = ProgressiveSearch::new();
259        idx.insert("a", vec![1.0], serde_json::json!(null));
260        assert!(idx.search(&[1.0], 0).is_empty());
261    }
262
263    #[test]
264    fn len_and_is_empty() {
265        let mut idx = ProgressiveSearch::new();
266        assert!(idx.is_empty());
267        assert_eq!(idx.len(), 0);
268
269        idx.insert("a", vec![1.0], serde_json::json!(null));
270        assert!(!idx.is_empty());
271        assert_eq!(idx.len(), 1);
272    }
273
274    #[test]
275    fn default_creates_empty() {
276        let idx = ProgressiveSearch::default();
277        assert!(idx.is_empty());
278    }
279
280    #[test]
281    fn search_preserves_metadata() {
282        let mut idx = ProgressiveSearch::new();
283        let meta = serde_json::json!({"source": "test", "priority": 5});
284        idx.insert("m", vec![1.0, 0.0], meta.clone());
285
286        let results = idx.search(&[1.0, 0.0], 1);
287        assert_eq!(results[0].metadata, meta);
288    }
289
290    #[test]
291    fn search_top_k_larger_than_store() {
292        let mut idx = ProgressiveSearch::new();
293        idx.insert("only", vec![1.0], serde_json::json!(null));
294        let results = idx.search(&[1.0], 100);
295        assert_eq!(results.len(), 1);
296    }
297
298    #[test]
299    fn load_nonexistent_file_errors() {
300        let result = ProgressiveSearch::load(Path::new("/nonexistent/path/index.json"));
301        assert!(result.is_err());
302    }
303}