clawft_core/embeddings/
hash_embedder.rs1use std::hash::{Hash, Hasher};
17
18use fnv::FnvHasher;
19
20use async_trait::async_trait;
21
22use super::{Embedder, EmbeddingError};
23
24pub struct HashEmbedder {
37 dimension: usize,
38}
39
40impl HashEmbedder {
41 pub fn new(dimension: usize) -> Self {
43 Self { dimension }
44 }
45
46 pub fn default_dimension() -> Self {
48 Self::new(384)
49 }
50
51 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 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 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 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 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 #[test]
254 fn golden_test_hello_world() {
255 let embedder = HashEmbedder::default_dimension();
256 let emb = embedder.compute_embedding("hello world");
257
258 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 let norm: f32 = emb.iter().map(|x| x * x).sum::<f32>().sqrt();
278 assert!((norm - 1.0).abs() < 0.01);
279
280 let emb2 = embedder.compute_embedding("hello world");
282 assert_eq!(emb, emb2, "FNV-1a should be deterministic");
283
284 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}