Skip to main content

av_loopdetect/
embed.rs

1//! Embedding abstraction + the deterministic feature-hashing default.
2
3/// Text → fixed-dim L2-normalized vector.
4pub trait Embedder: Send + Sync {
5    /// Embedding dimensionality.
6    fn dim(&self) -> usize;
7    /// Embed `text`. Must return an L2-normalized vector of length `dim()`
8    /// (the zero vector is allowed for empty/degenerate input).
9    fn embed(&self, text: &str) -> Vec<f32>;
10    /// Fallible embedding path used by production workers. Deterministic
11    /// embedders use the infallible default implementation.
12    fn try_embed(&self, text: &str) -> Result<Vec<f32>, String> {
13        Ok(self.embed(text))
14    }
15}
16
17/// Character-n-gram feature-hashing embedder.
18///
19/// Deterministic, dependency-free, CPU-cheap (~µs per step). N-grams (3..=5)
20/// of the lowercased, whitespace-collapsed text are FNV-hashed into `dim`
21/// buckets with a signed hash trick, then L2-normalized. Near-duplicate
22/// paraphrases share most n-grams → cosine ≈ 1; genuinely progressing content
23/// (new entities, numbers, code) diverges.
24#[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    /// Create with an explicit dimension (≥ 64 recommended).
37    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        // Normalize: lowercase, collapse whitespace runs.
50        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            out
67        };
68        let chars: Vec<char> = normalized.chars().collect();
69        if chars.is_empty() {
70            return v;
71        }
72        let mut buf = String::with_capacity(8);
73        for n in 3..=5usize {
74            if chars.len() < n {
75                // Short text: hash the whole string once per n to keep signal.
76                buf.clear();
77                buf.extend(chars.iter());
78                bump(&mut v, &buf, self.dim);
79                continue;
80            }
81            for w in chars.windows(n) {
82                buf.clear();
83                buf.extend(w.iter());
84                bump(&mut v, &buf, self.dim);
85            }
86        }
87        // L2 normalize.
88        let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
89        if norm > 0.0 {
90            for x in &mut v {
91                *x /= norm;
92            }
93        }
94        v
95    }
96}
97
98fn bump(v: &mut [f32], gram: &str, dim: usize) {
99    let h = av_core::hash::fnv1a(gram.as_bytes());
100    #[allow(clippy::cast_possible_truncation)]
101    let idx = (h % dim as u64) as usize;
102    let sign = if (h >> 63) == 0 { 1.0 } else { -1.0 };
103    if let Some(slot) = v.get_mut(idx) {
104        *slot += sign;
105    }
106}
107
108/// Cosine similarity of two equal-length vectors (0 when either is zero).
109pub fn cosine(a: &[f32], b: &[f32]) -> f32 {
110    if a.len() != b.len() {
111        return 0.0;
112    }
113    let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
114    let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
115    let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
116    if na == 0.0 || nb == 0.0 {
117        return 0.0;
118    }
119    (dot / (na * nb)).clamp(-1.0, 1.0)
120}
121
122#[cfg(test)]
123mod tests {
124    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
125
126    use super::*;
127
128    #[test]
129    fn deterministic() {
130        let e = HashEmbedder::default();
131        assert_eq!(e.embed("the same text"), e.embed("the same text"));
132    }
133
134    #[test]
135    fn normalized_output() {
136        let e = HashEmbedder::default();
137        let v = e.embed("some reasonably long reasoning step about databases");
138        let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
139        assert!((norm - 1.0).abs() < 1e-5, "norm = {norm}");
140    }
141
142    #[test]
143    fn identical_texts_cosine_one() {
144        let e = HashEmbedder::default();
145        let a = e.embed("check the database for pending orders");
146        let b = e.embed("check the database for pending orders");
147        assert!((cosine(&a, &b) - 1.0).abs() < 1e-6);
148    }
149
150    #[test]
151    fn whitespace_and_case_invariant() {
152        let e = HashEmbedder::default();
153        let a = e.embed("Check   the Database\nfor pending Orders");
154        let b = e.embed("check the database for pending orders");
155        assert!(cosine(&a, &b) > 0.999, "{}", cosine(&a, &b));
156    }
157
158    #[test]
159    fn paraphrase_loops_are_close_progress_is_far() {
160        let e = HashEmbedder::default();
161        // Paraphrase family (loop-like): high overlap expected.
162        let p1 = e.embed("I should try checking the order database again for the pending records");
163        let p2 = e.embed("Let me try checking the order database again for pending records");
164        // Progress: same domain but genuinely new content.
165        let q = e.embed("The API returned 502; switching to the backup endpoint and paging the on-call");
166        let sim_paraphrase = cosine(&p1, &p2);
167        let sim_progress = cosine(&p1, &q);
168        assert!(
169            sim_paraphrase > sim_progress + 0.2,
170            "paraphrase {sim_paraphrase} vs progress {sim_progress}: separation too weak"
171        );
172    }
173
174    #[test]
175    fn empty_and_unicode_do_not_panic() {
176        let e = HashEmbedder::default();
177        assert_eq!(e.embed("").iter().map(|x| x * x).sum::<f32>(), 0.0);
178        let _ = e.embed("日本語のテキスト 🎉 emoji مرحبا");
179        let _ = e.embed("ab"); // shorter than smallest n-gram
180    }
181
182    #[test]
183    fn cosine_edge_cases() {
184        assert_eq!(cosine(&[], &[]), 0.0);
185        assert_eq!(cosine(&[1.0], &[1.0, 2.0]), 0.0); // length mismatch
186        assert_eq!(cosine(&[0.0, 0.0], &[0.0, 0.0]), 0.0); // zero vectors
187    }
188}