Skip to main content

nusy_graph_query/
embedding.rs

1//! Embedding infrastructure — provider trait, hash provider, cosine similarity.
2//!
3//! Shared between nusy-kanban (semantic search over work items) and
4//! nusy-codegraph (semantic search over code objects). The embedding
5//! dimension is configurable per consumer.
6
7/// Errors from embedding operations.
8#[derive(Debug, thiserror::Error)]
9pub enum EmbeddingError {
10    #[error("Embedding dimension mismatch: expected {expected}, got {actual}")]
11    DimensionMismatch { expected: usize, actual: usize },
12
13    #[error("Provider error: {0}")]
14    Provider(String),
15}
16
17pub type Result<T> = std::result::Result<T, EmbeddingError>;
18
19/// Trait for embedding providers.
20///
21/// Implementations can use local models (ONNX, sentence-transformers),
22/// remote APIs (Ollama, OpenAI), or deterministic hashing (for testing).
23pub trait EmbeddingProvider {
24    /// Embed a batch of text strings into vectors.
25    fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>>;
26
27    /// Embed a single text string.
28    fn embed(&self, text: &str) -> Result<Vec<f32>> {
29        let results = self.embed_batch(&[text.to_string()])?;
30        results
31            .into_iter()
32            .next()
33            .ok_or_else(|| EmbeddingError::Provider("empty result".to_string()))
34    }
35
36    /// The embedding dimension this provider produces.
37    fn dim(&self) -> usize;
38}
39
40/// Deterministic hash-based embedding provider for testing.
41///
42/// Produces reproducible unit-length vectors by hashing the input text.
43/// Not semantically meaningful but stable across runs, making tests
44/// deterministic.
45pub struct HashEmbeddingProvider {
46    dim: usize,
47}
48
49impl HashEmbeddingProvider {
50    /// Create a hash embedding provider with the given dimension.
51    pub fn new(dim: usize) -> Self {
52        Self { dim }
53    }
54}
55
56impl EmbeddingProvider for HashEmbeddingProvider {
57    fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>> {
58        Ok(texts.iter().map(|t| hash_to_vector(t, self.dim)).collect())
59    }
60
61    fn dim(&self) -> usize {
62        self.dim
63    }
64}
65
66/// Produce a deterministic unit-length vector from a text hash.
67///
68/// Uses SHA-256 chaining to generate enough bytes, then normalizes
69/// to unit length. The same input always produces the same output.
70pub fn hash_to_vector(text: &str, dim: usize) -> Vec<f32> {
71    use sha2::{Digest, Sha256};
72    let mut vec = Vec::with_capacity(dim);
73
74    // Generate enough hash bytes to fill the vector.
75    // Each SHA-256 gives 32 bytes -> 8 floats. Chain hashes for longer vectors.
76    let mut seed = text.to_string();
77    while vec.len() < dim {
78        let mut hasher = Sha256::new();
79        hasher.update(seed.as_bytes());
80        let hash = hasher.finalize();
81        for chunk in hash.chunks(4) {
82            if vec.len() >= dim {
83                break;
84            }
85            let bytes: [u8; 4] = chunk.try_into().expect("4 bytes from sha256 chunk");
86            // Map to [-1, 1] range
87            let val = (u32::from_le_bytes(bytes) as f64 / u32::MAX as f64 * 2.0 - 1.0) as f32;
88            vec.push(val);
89        }
90        seed = format!("{seed}+");
91    }
92
93    // Normalize to unit length
94    let norm: f32 = vec.iter().map(|x| x * x).sum::<f32>().sqrt();
95    if norm > 0.0 {
96        for v in &mut vec {
97            *v /= norm;
98        }
99    }
100
101    vec
102}
103
104/// Cosine similarity between two vectors.
105///
106/// Returns 0.0 for empty vectors or dimension mismatches.
107/// For unit-length vectors, this is equivalent to the dot product.
108pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
109    if a.len() != b.len() || a.is_empty() {
110        return 0.0;
111    }
112
113    let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
114    let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
115    let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
116
117    if norm_a == 0.0 || norm_b == 0.0 {
118        return 0.0;
119    }
120
121    dot / (norm_a * norm_b)
122}
123
124/// An embedded item — ID + vector pair.
125///
126/// Generic container used by both kanban (items) and codegraph (nodes).
127#[derive(Debug, Clone)]
128pub struct EmbeddedItem {
129    pub id: String,
130    pub vector: Vec<f32>,
131}
132
133/// A semantic search result — ID + similarity score.
134#[derive(Debug, Clone)]
135pub struct SearchResult {
136    pub id: String,
137    pub score: f32,
138}
139
140/// Semantic search over a collection of embedded items.
141///
142/// Embeds the query text, computes cosine similarity against all items,
143/// and returns the top-k results sorted by score descending.
144pub fn semantic_search(
145    embeddings: &[EmbeddedItem],
146    query: &str,
147    provider: &dyn EmbeddingProvider,
148    top_k: usize,
149) -> Result<Vec<SearchResult>> {
150    let query_vec = provider.embed(query)?;
151
152    let mut results: Vec<SearchResult> = embeddings
153        .iter()
154        .map(|item| SearchResult {
155            id: item.id.clone(),
156            score: cosine_similarity(&query_vec, &item.vector),
157        })
158        .collect();
159
160    results.sort_by(|a, b| {
161        b.score
162            .partial_cmp(&a.score)
163            .unwrap_or(std::cmp::Ordering::Equal)
164    });
165    results.truncate(top_k);
166
167    Ok(results)
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    fn provider(dim: usize) -> HashEmbeddingProvider {
175        HashEmbeddingProvider::new(dim)
176    }
177
178    #[test]
179    fn test_hash_embedding_deterministic() {
180        let p = provider(384);
181        let v1 = p.embed("hello world").unwrap();
182        let v2 = p.embed("hello world").unwrap();
183        assert_eq!(v1, v2);
184        assert_eq!(v1.len(), 384);
185    }
186
187    #[test]
188    fn test_hash_embedding_configurable_dim() {
189        let p384 = provider(384);
190        let p768 = provider(768);
191        assert_eq!(p384.embed("test").unwrap().len(), 384);
192        assert_eq!(p768.embed("test").unwrap().len(), 768);
193    }
194
195    #[test]
196    fn test_hash_embedding_unit_length() {
197        let p = provider(384);
198        let v = p.embed("test input").unwrap();
199        let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
200        assert!(
201            (norm - 1.0).abs() < 1e-5,
202            "Vector should be unit length, got norm={norm}"
203        );
204    }
205
206    #[test]
207    fn test_hash_embedding_different_inputs_differ() {
208        let p = provider(384);
209        let v1 = p.embed("arrow kanban").unwrap();
210        let v2 = p.embed("signal fusion").unwrap();
211        assert_ne!(v1, v2);
212    }
213
214    #[test]
215    fn test_cosine_similarity_identical() {
216        let v = vec![1.0, 0.0, 0.0];
217        assert!((cosine_similarity(&v, &v) - 1.0).abs() < 1e-6);
218    }
219
220    #[test]
221    fn test_cosine_similarity_orthogonal() {
222        let a = vec![1.0, 0.0, 0.0];
223        let b = vec![0.0, 1.0, 0.0];
224        assert!(cosine_similarity(&a, &b).abs() < 1e-6);
225    }
226
227    #[test]
228    fn test_cosine_similarity_opposite() {
229        let a = vec![1.0, 0.0];
230        let b = vec![-1.0, 0.0];
231        assert!((cosine_similarity(&a, &b) + 1.0).abs() < 1e-6);
232    }
233
234    #[test]
235    fn test_cosine_similarity_empty() {
236        assert_eq!(cosine_similarity(&[], &[]), 0.0);
237    }
238
239    #[test]
240    fn test_cosine_similarity_length_mismatch() {
241        assert_eq!(cosine_similarity(&[1.0], &[1.0, 2.0]), 0.0);
242    }
243
244    #[test]
245    fn test_embed_batch_consistency() {
246        let p = provider(384);
247        let texts = vec!["hello".to_string(), "world".to_string()];
248        let batch_result = p.embed_batch(&texts).unwrap();
249        let single_1 = p.embed("hello").unwrap();
250        let single_2 = p.embed("world").unwrap();
251        assert_eq!(batch_result[0], single_1);
252        assert_eq!(batch_result[1], single_2);
253    }
254
255    #[test]
256    fn test_semantic_search_ranked() {
257        let p = provider(384);
258        let items: Vec<EmbeddedItem> = ["arrow kanban", "signal fusion", "graph query"]
259            .iter()
260            .map(|text| EmbeddedItem {
261                id: text.to_string(),
262                vector: p.embed(text).unwrap(),
263            })
264            .collect();
265
266        let results = semantic_search(&items, "arrow", &p, 3).unwrap();
267        assert_eq!(results.len(), 3);
268        // Sorted descending by score
269        for w in results.windows(2) {
270            assert!(w[0].score >= w[1].score);
271        }
272    }
273
274    #[test]
275    fn test_semantic_search_top_k() {
276        let p = provider(384);
277        let items: Vec<EmbeddedItem> = (0..10)
278            .map(|i| EmbeddedItem {
279                id: format!("item-{i}"),
280                vector: p.embed(&format!("item {i}")).unwrap(),
281            })
282            .collect();
283
284        let results = semantic_search(&items, "test", &p, 3).unwrap();
285        assert_eq!(results.len(), 3);
286    }
287
288    #[test]
289    fn test_semantic_search_empty() {
290        let p = provider(384);
291        let results = semantic_search(&[], "test", &p, 10).unwrap();
292        assert!(results.is_empty());
293    }
294}