1pub trait Embedder: Send + Sync {
5 fn dim(&self) -> usize;
7 fn embed(&self, text: &str) -> Vec<f32>;
10 fn try_embed(&self, text: &str) -> Result<Vec<f32>, String> {
13 Ok(self.embed(text))
14 }
15}
16
17#[derive(Debug, Clone)]
25pub struct HashEmbedder {
26 dim: usize,
27}
28
29impl Default for HashEmbedder {
30 fn default() -> Self {
31 Self { dim: 512 }
32 }
33}
34
35impl HashEmbedder {
36 pub fn new(dim: usize) -> Self {
38 Self { dim: dim.max(8) }
39 }
40}
41
42impl Embedder for HashEmbedder {
43 fn dim(&self) -> usize {
44 self.dim
45 }
46
47 fn embed(&self, text: &str) -> Vec<f32> {
48 let mut v = vec![0.0f32; self.dim];
49 let normalized: String = {
51 let mut out = String::with_capacity(text.len());
52 let mut last_ws = false;
53 for ch in text.chars() {
54 if ch.is_whitespace() {
55 if !last_ws && !out.is_empty() {
56 out.push(' ');
57 }
58 last_ws = true;
59 } else {
60 for lower in ch.to_lowercase() {
61 out.push(lower);
62 }
63 last_ws = false;
64 }
65 }
66 if out.ends_with(' ') {
73 out.pop();
74 }
75 out
76 };
77 let chars: Vec<char> = normalized.chars().collect();
78 if chars.is_empty() {
79 return v;
80 }
81 let mut buf = String::with_capacity(8);
82 for n in 3..=5usize {
83 if chars.len() < n {
84 buf.clear();
86 buf.extend(chars.iter());
87 bump(&mut v, &buf, self.dim);
88 continue;
89 }
90 for w in chars.windows(n) {
91 buf.clear();
92 buf.extend(w.iter());
93 bump(&mut v, &buf, self.dim);
94 }
95 }
96 let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
98 if norm > 0.0 {
99 for x in &mut v {
100 *x /= norm;
101 }
102 }
103 v
104 }
105}
106
107fn bump(v: &mut [f32], gram: &str, dim: usize) {
108 let h = av_core::hash::fnv1a(gram.as_bytes());
109 #[allow(clippy::cast_possible_truncation)]
110 let idx = (h % dim as u64) as usize;
111 let sign = if (h >> 63) == 0 { 1.0 } else { -1.0 };
112 if let Some(slot) = v.get_mut(idx) {
113 *slot += sign;
114 }
115}
116
117pub fn cosine(a: &[f32], b: &[f32]) -> f32 {
127 if a.len() != b.len() {
128 return 0.0;
129 }
130 let dot: f64 = a.iter().zip(b).map(|(x, y)| f64::from(*x) * f64::from(*y)).sum();
131 let na: f64 = a
132 .iter()
133 .map(|x| f64::from(*x) * f64::from(*x))
134 .sum::<f64>()
135 .sqrt();
136 let nb: f64 = b
137 .iter()
138 .map(|x| f64::from(*x) * f64::from(*x))
139 .sum::<f64>()
140 .sqrt();
141 if na == 0.0 || nb == 0.0 {
142 return 0.0;
143 }
144 #[allow(clippy::cast_possible_truncation)]
147 let similarity = (dot / (na * nb)) as f32;
148 similarity.clamp(-1.0, 1.0)
149}
150
151#[cfg(test)]
152mod tests {
153 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
154
155 use super::*;
156
157 #[test]
158 fn deterministic() {
159 let e = HashEmbedder::default();
160 assert_eq!(e.embed("the same text"), e.embed("the same text"));
161 }
162
163 #[test]
168 fn dim_len_and_try_embed_contract() {
169 let e = HashEmbedder::default();
170 assert_eq!(e.dim(), 512);
171 assert_eq!(e.embed("x").len(), e.dim());
172 assert_eq!(e.try_embed("some step text").unwrap(), e.embed("some step text"));
173 let small = HashEmbedder::new(64);
174 assert_eq!(small.dim(), 64);
175 assert_eq!(small.embed("x").len(), 64);
176 }
177
178 #[test]
182 fn whitespace_invariance_covers_leading_runs() {
183 let e = HashEmbedder::default();
184 assert_eq!(e.embed(" abc def"), e.embed("abc def"));
185 assert_eq!(e.embed("\t\nabc def \n"), e.embed("abc def"));
186 }
187
188 #[test]
194 fn cosine_normalizes_unnormalized_vectors_and_handles_one_sided_zero() {
195 let value = cosine(&[2.0, 0.0], &[1.0, 1.0]);
196 assert!(
197 (value - std::f32::consts::FRAC_1_SQRT_2).abs() < 1e-6,
198 "expected ~0.7071, got {value}"
199 );
200 assert_eq!(cosine(&[0.0, 0.0], &[1.0, 0.0]), 0.0);
201 assert_eq!(cosine(&[1.0, 0.0], &[0.0, 0.0]), 0.0);
202 }
203
204 #[test]
205 fn normalized_output() {
206 let e = HashEmbedder::default();
207 let v = e.embed("some reasonably long reasoning step about databases");
208 let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
209 assert!((norm - 1.0).abs() < 1e-5, "norm = {norm}");
210 }
211
212 #[test]
213 fn identical_texts_cosine_one() {
214 let e = HashEmbedder::default();
215 let a = e.embed("check the database for pending orders");
216 let b = e.embed("check the database for pending orders");
217 assert!((cosine(&a, &b) - 1.0).abs() < 1e-6);
218 }
219
220 #[test]
221 fn whitespace_and_case_invariant() {
222 let e = HashEmbedder::default();
223 let a = e.embed("Check the Database\nfor pending Orders");
224 let b = e.embed("check the database for pending orders");
225 assert!(cosine(&a, &b) > 0.999, "{}", cosine(&a, &b));
226 }
227
228 #[test]
229 fn paraphrase_loops_are_close_progress_is_far() {
230 let e = HashEmbedder::default();
231 let p1 = e.embed("I should try checking the order database again for the pending records");
233 let p2 = e.embed("Let me try checking the order database again for pending records");
234 let q = e.embed("The API returned 502; switching to the backup endpoint and paging the on-call");
236 let sim_paraphrase = cosine(&p1, &p2);
237 let sim_progress = cosine(&p1, &q);
238 assert!(
239 sim_paraphrase > sim_progress + 0.2,
240 "paraphrase {sim_paraphrase} vs progress {sim_progress}: separation too weak"
241 );
242 }
243
244 #[test]
245 fn empty_and_unicode_do_not_panic() {
246 let e = HashEmbedder::default();
247 assert_eq!(e.embed("").iter().map(|x| x * x).sum::<f32>(), 0.0);
248 let _ = e.embed("日本語のテキスト 🎉 emoji مرحبا");
249 let _ = e.embed("ab"); }
251
252 #[test]
253 fn cosine_edge_cases() {
254 assert_eq!(cosine(&[], &[]), 0.0);
255 assert_eq!(cosine(&[1.0], &[1.0, 2.0]), 0.0); assert_eq!(cosine(&[0.0, 0.0], &[0.0, 0.0]), 0.0); }
258}