llm_kernel/dlp/
fingerprint.rs1use crate::embedding::{EmbeddingProvider, SearchHit, cosine_similarity};
14use crate::error::Result;
15use std::sync::Arc;
16
17pub const DEFAULT_THRESHOLD: f64 = 0.85;
19
20pub struct FingerprintIndex {
29 provider: Arc<dyn EmbeddingProvider>,
30 docs: Vec<(u64, Vec<f32>)>,
31 threshold: f64,
32}
33
34impl FingerprintIndex {
35 pub fn new(provider: Arc<dyn EmbeddingProvider>) -> Self {
37 Self::with_threshold(provider, DEFAULT_THRESHOLD)
38 }
39
40 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 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 pub fn len(&self) -> usize {
59 self.docs.len()
60 }
61
62 pub fn is_empty(&self) -> bool {
64 self.docs.is_empty()
65 }
66
67 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 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 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}