use crate::embedding::{EmbeddingProvider, SearchHit, cosine_similarity};
use crate::error::Result;
use std::sync::Arc;
pub const DEFAULT_THRESHOLD: f64 = 0.85;
pub struct FingerprintIndex {
provider: Arc<dyn EmbeddingProvider>,
docs: Vec<(u64, Vec<f32>)>,
threshold: f64,
}
impl FingerprintIndex {
pub fn new(provider: Arc<dyn EmbeddingProvider>) -> Self {
Self::with_threshold(provider, DEFAULT_THRESHOLD)
}
pub fn with_threshold(provider: Arc<dyn EmbeddingProvider>, threshold: f64) -> Self {
Self {
provider,
docs: Vec::new(),
threshold,
}
}
pub fn register(&mut self, doc_id: u64, text: &str) -> Result<()> {
let vector = self.provider.embed_document(text)?.vector;
self.docs.push((doc_id, vector));
Ok(())
}
pub fn len(&self) -> usize {
self.docs.len()
}
pub fn is_empty(&self) -> bool {
self.docs.is_empty()
}
pub fn match_content(&self, content: &str) -> Result<Option<SearchHit>> {
if self.docs.is_empty() {
return Ok(None);
}
let query = self.provider.embed(content)?.vector;
let best = self
.docs
.iter()
.filter_map(|&(id, ref v)| {
let score = cosine_similarity(&query, v);
(score >= self.threshold).then_some(SearchHit {
id,
score: score as f32,
})
})
.max_by(|a, b| {
a.score
.partial_cmp(&b.score)
.expect("scores are finite (cosine of finite vectors)")
});
Ok(best)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::embedding::EmbeddingResult;
use crate::embedding::types::normalize;
struct FakeProvider;
fn fake_vector(text: &str) -> Vec<f32> {
let mut v = vec![0f32; 8];
for b in text.bytes() {
v[(b % 8) as usize] += 1.0;
}
normalize(&mut v);
v
}
impl EmbeddingProvider for FakeProvider {
fn dim(&self) -> usize {
8
}
fn embed(&self, text: &str) -> Result<EmbeddingResult> {
Ok(EmbeddingResult {
vector: fake_vector(text),
text_preview: text.chars().take(16).collect(),
})
}
fn name(&self) -> &str {
"fake"
}
}
#[test]
fn near_copy_matches_registered_doc() {
let mut index = FingerprintIndex::new(Arc::new(FakeProvider));
index.register(1, "confidential merger memo draft").unwrap();
index.register(2, "public weather forecast notes").unwrap();
let hit = index
.match_content("confidential merger memo final")
.unwrap()
.expect("near-copy should match");
assert_eq!(hit.id, 1);
assert!(hit.score >= DEFAULT_THRESHOLD as f32);
}
#[test]
fn unrelated_content_returns_none() {
let mut index = FingerprintIndex::new(Arc::new(FakeProvider));
index.register(1, "confidential merger memo draft").unwrap();
assert!(index.match_content("zzz qqq xxx www").unwrap().is_none());
}
#[test]
fn empty_index_returns_none() {
let index = FingerprintIndex::new(Arc::new(FakeProvider));
assert!(index.is_empty());
assert_eq!(index.len(), 0);
assert!(index.match_content("anything").unwrap().is_none());
}
#[test]
fn stricter_threshold_blocks_even_identical_text() {
let mut index = FingerprintIndex::with_threshold(Arc::new(FakeProvider), 2.0);
index.register(1, "confidential merger memo draft").unwrap();
assert!(
index
.match_content("confidential merger memo draft")
.unwrap()
.is_none()
);
}
#[test]
fn zero_threshold_returns_best_doc() {
let mut index = FingerprintIndex::with_threshold(Arc::new(FakeProvider), 0.0);
index.register(7, "alpha").unwrap();
let hit = index
.match_content("beta")
.unwrap()
.expect("zero threshold admits any nonzero-overlap candidate");
assert_eq!(hit.id, 7);
}
#[test]
fn fingerprint_index_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<FingerprintIndex>();
}
}