Skip to main content

clawft_core/embeddings/
hash_embedder.rs

1//! SimHash-based local embedder.
2//!
3//! Produces deterministic, fixed-dimension embeddings by hashing each word
4//! with [`FnvHasher`] (FNV-1a) and spreading the hash bits across a
5//! float vector. The result is normalized to unit length.
6//!
7//! FNV-1a is deterministic and platform-independent, producing the same
8//! output for the same input regardless of Rust version or target triple.
9//! This is critical for persisted embeddings that must remain stable across
10//! toolchain upgrades.
11//!
12//! This embedder requires no API calls, no model files, and no network access.
13//! It is suitable as a baseline for semantic similarity when true neural
14//! embeddings are not available.
15
16use std::hash::{Hash, Hasher};
17
18use fnv::FnvHasher;
19
20use async_trait::async_trait;
21
22use super::{Embedder, EmbeddingError};
23
24/// SimHash-based embedder that produces deterministic embeddings locally.
25///
26/// # Algorithm
27///
28/// For each word in the input text:
29/// 1. Hash the word with [`FnvHasher`] (FNV-1a, deterministic cross-platform).
30/// 2. For each dimension `i` in 0..dimension, check bit `(i % 64)` of the hash
31///    XORed with `i`. If the bit is set, add +1.0 to that dimension; otherwise
32///    add -1.0.
33/// 3. After processing all words, normalize the vector to unit length (L2 norm).
34///
35/// The default dimension is 384.
36pub struct HashEmbedder {
37    dimension: usize,
38}
39
40impl HashEmbedder {
41    /// Create a new `HashEmbedder` with the specified embedding dimension.
42    pub fn new(dimension: usize) -> Self {
43        Self { dimension }
44    }
45
46    /// Create a `HashEmbedder` with the default dimension of 384.
47    pub fn default_dimension() -> Self {
48        Self::new(384)
49    }
50
51    /// Compute the SimHash embedding synchronously.
52    ///
53    /// This is the underlying synchronous implementation. The async
54    /// [`Embedder::embed`] trait method delegates to this.
55    pub fn compute_embedding(&self, text: &str) -> Vec<f32> {
56        let mut vector = vec![0.0f32; self.dimension];
57
58        let words: Vec<&str> = text.split_whitespace().collect();
59
60        if words.is_empty() {
61            // Return a zero vector for empty input (cannot normalize).
62            return vector;
63        }
64
65        for word in &words {
66            let mut hasher = FnvHasher::default();
67            word.to_lowercase().hash(&mut hasher);
68            let hash = hasher.finish();
69
70            for (i, val) in vector.iter_mut().enumerate() {
71                // Mix the dimension index into the hash to spread bits
72                let mixed = hash ^ (i as u64);
73                let bit = (mixed >> (i % 64)) & 1;
74                if bit == 1 {
75                    *val += 1.0;
76                } else {
77                    *val -= 1.0;
78                }
79            }
80        }
81
82        // Normalize to unit length.
83        let norm: f32 = vector.iter().map(|x| x * x).sum::<f32>().sqrt();
84        if norm > 0.0 {
85            for val in &mut vector {
86                *val /= norm;
87            }
88        }
89
90        vector
91    }
92}
93
94#[async_trait]
95impl Embedder for HashEmbedder {
96    async fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
97        Ok(self.compute_embedding(text))
98    }
99
100    async fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
101        let results: Vec<Vec<f32>> = texts.iter().map(|t| self.compute_embedding(t)).collect();
102        Ok(results)
103    }
104
105    fn dimension(&self) -> usize {
106        self.dimension
107    }
108
109    fn name(&self) -> &str {
110        "hash"
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[tokio::test]
119    async fn deterministic_same_text_same_embedding() {
120        let embedder = HashEmbedder::default_dimension();
121        let e1 = embedder.embed("hello world").await.unwrap();
122        let e2 = embedder.embed("hello world").await.unwrap();
123        assert_eq!(e1, e2, "same text must produce identical embeddings");
124    }
125
126    #[tokio::test]
127    async fn different_text_different_embedding() {
128        let embedder = HashEmbedder::default_dimension();
129        let e1 = embedder.embed("hello world").await.unwrap();
130        let e2 = embedder.embed("goodbye moon").await.unwrap();
131        assert_ne!(e1, e2, "different text should produce different embeddings");
132    }
133
134    #[tokio::test]
135    async fn correct_dimension() {
136        let embedder = HashEmbedder::new(128);
137        let emb = embedder.embed("test text").await.unwrap();
138        assert_eq!(emb.len(), 128);
139        assert_eq!(embedder.dimension(), 128);
140    }
141
142    #[tokio::test]
143    async fn default_dimension_is_384() {
144        let embedder = HashEmbedder::default_dimension();
145        let emb = embedder.embed("test").await.unwrap();
146        assert_eq!(emb.len(), 384);
147        assert_eq!(embedder.dimension(), 384);
148    }
149
150    #[tokio::test]
151    async fn unit_length_norm() {
152        let embedder = HashEmbedder::default_dimension();
153        let emb = embedder.embed("the quick brown fox").await.unwrap();
154        let norm: f32 = emb.iter().map(|x| x * x).sum::<f32>().sqrt();
155        assert!(
156            (norm - 1.0).abs() < 0.01,
157            "L2 norm should be ~1.0, got {norm}"
158        );
159    }
160
161    #[tokio::test]
162    async fn empty_string_handled() {
163        let embedder = HashEmbedder::default_dimension();
164        let emb = embedder.embed("").await.unwrap();
165        assert_eq!(emb.len(), 384);
166        // All zeros for empty input.
167        let sum: f32 = emb.iter().map(|x| x.abs()).sum();
168        assert!(
169            sum < f32::EPSILON,
170            "empty string should produce zero vector, sum={sum}"
171        );
172    }
173
174    #[tokio::test]
175    async fn whitespace_only_handled() {
176        let embedder = HashEmbedder::default_dimension();
177        let emb = embedder.embed("   \t\n  ").await.unwrap();
178        assert_eq!(emb.len(), 384);
179        let sum: f32 = emb.iter().map(|x| x.abs()).sum();
180        assert!(
181            sum < f32::EPSILON,
182            "whitespace-only should produce zero vector"
183        );
184    }
185
186    #[tokio::test]
187    async fn embed_batch_correctness() {
188        let embedder = HashEmbedder::default_dimension();
189        let texts = vec!["hello world".to_string(), "goodbye moon".to_string()];
190        let batch = embedder.embed_batch(&texts).await.unwrap();
191
192        assert_eq!(batch.len(), 2);
193
194        let e1 = embedder.embed("hello world").await.unwrap();
195        let e2 = embedder.embed("goodbye moon").await.unwrap();
196
197        assert_eq!(batch[0], e1);
198        assert_eq!(batch[1], e2);
199    }
200
201    #[tokio::test]
202    async fn embed_batch_empty() {
203        let embedder = HashEmbedder::default_dimension();
204        let batch = embedder.embed_batch(&[]).await.unwrap();
205        assert!(batch.is_empty());
206    }
207
208    #[tokio::test]
209    async fn case_insensitive_hash() {
210        let embedder = HashEmbedder::default_dimension();
211        let e1 = embedder.embed("Hello World").await.unwrap();
212        let e2 = embedder.embed("hello world").await.unwrap();
213        assert_eq!(e1, e2, "hashing should be case-insensitive");
214    }
215
216    #[tokio::test]
217    async fn single_word_produces_unit_vector() {
218        let embedder = HashEmbedder::default_dimension();
219        let emb = embedder.embed("rust").await.unwrap();
220        let norm: f32 = emb.iter().map(|x| x * x).sum::<f32>().sqrt();
221        assert!(
222            (norm - 1.0).abs() < 0.01,
223            "single word norm should be ~1.0, got {norm}"
224        );
225    }
226
227    #[tokio::test]
228    async fn similar_text_higher_cosine() {
229        let embedder = HashEmbedder::default_dimension();
230        let e1 = embedder.embed("the quick brown fox").await.unwrap();
231        let e2 = embedder.embed("the quick brown dog").await.unwrap();
232        let e3 = embedder
233            .embed("quantum computing algorithms")
234            .await
235            .unwrap();
236
237        let sim_close = cosine_similarity(&e1, &e2);
238        let sim_far = cosine_similarity(&e1, &e3);
239
240        assert!(
241            sim_close > sim_far,
242            "similar text should have higher cosine similarity: close={sim_close}, far={sim_far}"
243        );
244    }
245
246    /// Golden test: verify that FNV-1a produces deterministic, platform-independent
247    /// embeddings for a known input. These values were recorded from the FNV-1a
248    /// implementation and should produce identical results on x86_64-linux,
249    /// aarch64-linux, and x86_64-darwin.
250    ///
251    /// If this test fails after a change, it means persisted embeddings are
252    /// silently invalidated and must be re-computed.
253    #[test]
254    fn golden_test_hello_world() {
255        let embedder = HashEmbedder::default_dimension();
256        let emb = embedder.compute_embedding("hello world");
257
258        // Golden values recorded from FNV-1a on x86_64-linux (Rust 1.85).
259        // These must be identical on all platforms.
260        let expected_first_8: [f32; 8] = [
261            -0.07715168, -0.07715168, 0.07715168, 0.0, 0.0, 0.0, 0.0, 0.0,
262        ];
263        let expected_last_8: [f32; 8] = [
264            0.0, 0.0, -0.07715168, 0.0, -0.07715168, 0.07715168, 0.0, 0.07715168,
265        ];
266
267        assert_eq!(
268            &emb[..8], &expected_first_8[..],
269            "first 8 dimensions changed -- FNV-1a determinism broken"
270        );
271        assert_eq!(
272            &emb[376..384], &expected_last_8[..],
273            "last 8 dimensions changed -- FNV-1a determinism broken"
274        );
275
276        // Verify the embedding is non-zero and normalized
277        let norm: f32 = emb.iter().map(|x| x * x).sum::<f32>().sqrt();
278        assert!((norm - 1.0).abs() < 0.01);
279
280        // Verify determinism: calling twice yields identical output.
281        let emb2 = embedder.compute_embedding("hello world");
282        assert_eq!(emb, emb2, "FNV-1a should be deterministic");
283
284        // Verify dimensionality
285        assert_eq!(emb.len(), 384);
286    }
287
288    fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
289        let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
290        let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
291        let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
292        if norm_a == 0.0 || norm_b == 0.0 {
293            return 0.0;
294        }
295        dot / (norm_a * norm_b)
296    }
297}