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            // The loop pushes the collapsed separator eagerly, so a
67            // trailing whitespace run leaves a dangling ' ' — making
68            // embed("abc") != embed("abc ") and breaking the documented
69            // whitespace-collapse invariance (a large delta for short
70            // steps). Trim it so trailing runs collapse to nothing,
71            // symmetric with the leading-run suppression above.
72            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                // Short text: hash the whole string once per n to keep signal.
85                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        // L2 normalize.
97        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
117/// Cosine similarity of two equal-length vectors (0 when either is zero).
118///
119/// Accumulates in f64: an f32 sum-of-squares underflows to exactly 0.0
120/// for vectors of tiny finite components (all ≲ 5e-23), which made
121/// `cosine(v, v)` return 0 for such vectors — maximum "novelty" for a
122/// byte-identical repeat, defeating the loop breaker whenever a
123/// degenerate embedder emitted tiny activations. The breaker's hostile-
124/// embedding gate only catches exact zeros and non-finite values, so
125/// the underflow case must be correct here.
126pub 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    // Truncating f64→f32 is intentional: the ratio is in [-1, 1] where
145    // the conversion only rounds, never overflows.
146    #[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    /// Mutation-run hardening: pin the embedder's shape contract — the
164    /// default dimension, `embed()` output length == `dim()`, and the
165    /// `try_embed` default forwarding to `embed` (the production worker
166    /// path). Mutants returning fixed dims/vectors survived without these.
167    #[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    /// The documented whitespace-collapse invariance covers LEADING runs
179    /// too — a mutant dropping the `!out.is_empty()` guard kept a leading
180    /// space and made `embed("  abc def")` diverge from `embed("abc def")`.
181    #[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    /// `cosine` must divide by the norm product — verified on NON-unit
189    /// vectors where the correct value is strictly inside (-1, 1), so a
190    /// `/`→`*` (or `*`→`/`) mutant cannot hide behind the final clamp.
191    /// Also pin the zero-vector arm for EACH side independently (the
192    /// `||`→`&&` mutant produced NaN when only one side was zero).
193    #[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        // Paraphrase family (loop-like): high overlap expected.
232        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        // Progress: same domain but genuinely new content.
235        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"); // shorter than smallest n-gram
250    }
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); // length mismatch
256        assert_eq!(cosine(&[0.0, 0.0], &[0.0, 0.0]), 0.0); // zero vectors
257    }
258}