Skip to main content

llm_kernel/dlp/
fingerprint.rs

1//! L2 — fingerprint matching of registered sensitive documents.
2//!
3//! Registers sensitive documents by embedding and cosine-matches outbound
4//! content against them, via any
5//! [`EmbeddingProvider`](crate::embedding::EmbeddingProvider) (e.g. the
6//! kernel's BGE-M3 fastembed backend).
7//!
8//! `match_content` returns `Result<Option<..>>` rather than a bare `Option`
9//! deliberately: embedding is fallible (model load, ONNX runtime), and an
10//! error silently flattened to `None` would read as a false "clean" verdict
11//! on a DLP path.
12
13use crate::embedding::{EmbeddingProvider, SearchHit, cosine_similarity};
14use crate::error::Result;
15use std::sync::Arc;
16
17/// Default cosine threshold for [`FingerprintIndex::new`].
18pub const DEFAULT_THRESHOLD: f64 = 0.85;
19
20/// Cosine-matches outbound content against registered sensitive documents.
21///
22/// Registration embeds with the **document** prefix; matching embeds with the
23/// **query** prefix (see `EmbeddingProvider::embed` / `embed_document` for
24/// the asymmetric-model rationale).
25// ponytail: linear cosine scan — μs at hundreds of docs, ~ms at 10k
26// (1024-dim). Switch to `TurbovecIndex` (feature `vector-index`) past ~10k
27// registered documents.
28pub struct FingerprintIndex {
29    provider: Arc<dyn EmbeddingProvider>,
30    docs: Vec<(u64, Vec<f32>)>,
31    threshold: f64,
32}
33
34impl FingerprintIndex {
35    /// New index with the default match threshold (0.85).
36    pub fn new(provider: Arc<dyn EmbeddingProvider>) -> Self {
37        Self::with_threshold(provider, DEFAULT_THRESHOLD)
38    }
39
40    /// New index with an explicit cosine threshold (higher = stricter).
41    pub fn with_threshold(provider: Arc<dyn EmbeddingProvider>, threshold: f64) -> Self {
42        Self {
43            provider,
44            docs: Vec::new(),
45            threshold,
46        }
47    }
48
49    /// Register (or re-register — appends) a sensitive document under
50    /// `doc_id`.
51    pub fn register(&mut self, doc_id: u64, text: &str) -> Result<()> {
52        let vector = self.provider.embed_document(text)?.vector;
53        self.docs.push((doc_id, vector));
54        Ok(())
55    }
56
57    /// Number of registered document vectors.
58    pub fn len(&self) -> usize {
59        self.docs.len()
60    }
61
62    /// Whether nothing is registered.
63    pub fn is_empty(&self) -> bool {
64        self.docs.is_empty()
65    }
66
67    /// Embed `content` (query prefix) and return the best registered document
68    /// at or above the threshold, or `Ok(None)` when nothing matches.
69    pub fn match_content(&self, content: &str) -> Result<Option<SearchHit>> {
70        if self.docs.is_empty() {
71            return Ok(None);
72        }
73        let query = self.provider.embed(content)?.vector;
74        let best = self
75            .docs
76            .iter()
77            .filter_map(|&(id, ref v)| {
78                let score = cosine_similarity(&query, v);
79                (score >= self.threshold).then_some(SearchHit {
80                    id,
81                    score: score as f32,
82                })
83            })
84            .max_by(|a, b| {
85                a.score
86                    .partial_cmp(&b.score)
87                    .expect("scores are finite (cosine of finite vectors)")
88            });
89        Ok(best)
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use crate::embedding::EmbeddingResult;
97    use crate::embedding::types::normalize;
98
99    /// Deterministic fake: bag-of-characters vector over 8 dims — no model
100    /// download, similar texts → similar vectors.
101    struct FakeProvider;
102
103    fn fake_vector(text: &str) -> Vec<f32> {
104        let mut v = vec![0f32; 8];
105        for b in text.bytes() {
106            v[(b % 8) as usize] += 1.0;
107        }
108        normalize(&mut v);
109        v
110    }
111
112    impl EmbeddingProvider for FakeProvider {
113        fn dim(&self) -> usize {
114            8
115        }
116        fn embed(&self, text: &str) -> Result<EmbeddingResult> {
117            Ok(EmbeddingResult {
118                vector: fake_vector(text),
119                text_preview: text.chars().take(16).collect(),
120            })
121        }
122        fn name(&self) -> &str {
123            "fake"
124        }
125    }
126
127    #[test]
128    fn near_copy_matches_registered_doc() {
129        let mut index = FingerprintIndex::new(Arc::new(FakeProvider));
130        index.register(1, "confidential merger memo draft").unwrap();
131        index.register(2, "public weather forecast notes").unwrap();
132
133        let hit = index
134            .match_content("confidential merger memo final")
135            .unwrap()
136            .expect("near-copy should match");
137        assert_eq!(hit.id, 1);
138        assert!(hit.score >= DEFAULT_THRESHOLD as f32);
139    }
140
141    #[test]
142    fn unrelated_content_returns_none() {
143        let mut index = FingerprintIndex::new(Arc::new(FakeProvider));
144        index.register(1, "confidential merger memo draft").unwrap();
145        assert!(index.match_content("zzz qqq xxx www").unwrap().is_none());
146    }
147
148    #[test]
149    fn empty_index_returns_none() {
150        let index = FingerprintIndex::new(Arc::new(FakeProvider));
151        assert!(index.is_empty());
152        assert_eq!(index.len(), 0);
153        assert!(index.match_content("anything").unwrap().is_none());
154    }
155
156    #[test]
157    fn stricter_threshold_blocks_even_identical_text() {
158        let mut index = FingerprintIndex::with_threshold(Arc::new(FakeProvider), 2.0);
159        index.register(1, "confidential merger memo draft").unwrap();
160        // Cosine can never reach 2.0 — even the identical text is excluded.
161        assert!(
162            index
163                .match_content("confidential merger memo draft")
164                .unwrap()
165                .is_none()
166        );
167    }
168
169    #[test]
170    fn zero_threshold_returns_best_doc() {
171        let mut index = FingerprintIndex::with_threshold(Arc::new(FakeProvider), 0.0);
172        index.register(7, "alpha").unwrap();
173        let hit = index
174            .match_content("beta")
175            .unwrap()
176            .expect("zero threshold admits any nonzero-overlap candidate");
177        assert_eq!(hit.id, 7);
178    }
179
180    #[test]
181    fn fingerprint_index_is_send_sync() {
182        fn assert_send_sync<T: Send + Sync>() {}
183        assert_send_sync::<FingerprintIndex>();
184    }
185}