use std::collections::hash_map::Entry;
use crate::config::Config;
use crate::error::{Error, Result};
use crate::cluster::DuplicateCluster;
use crate::lsh::LshIndex;
use crate::minhash::MinHasher;
use tenshift_core::sample::Sample;
use tracing::{instrument, warn};
pub struct DedupTransformer {
config: Config,
hasher: MinHasher,
index: LshIndex,
buffer: Vec<Sample>,
pub streaming: bool,
next_doc_id: usize,
output_queue: Vec<Sample>,
text_field: String,
mark_duplicates: bool,
bypassed_samples: std::collections::HashMap<Vec<u8>, usize>,
}
impl DedupTransformer {
#[instrument(skip(config), level = "debug")]
pub fn new(config: Config) -> Result<Self> {
let hasher = MinHasher::new(&config)?;
let index = LshIndex::new(&config)?;
Ok(Self {
config,
hasher,
index,
buffer: Vec::new(),
streaming: false,
next_doc_id: 0,
output_queue: Vec::new(),
text_field: "text".to_string(),
mark_duplicates: false,
bypassed_samples: std::collections::HashMap::new(),
})
}
#[must_use]
pub fn with_text_field(mut self, field: impl Into<String>) -> Self {
self.text_field = field.into();
self
}
#[must_use]
pub fn with_streaming(mut self, enabled: bool) -> Self {
self.streaming = enabled;
self
}
#[must_use]
pub fn with_mark_duplicates(mut self, enabled: bool) -> Self {
self.mark_duplicates = enabled;
self
}
#[instrument(skip(self, sample), level = "debug")]
pub fn process_sample(&mut self, sample: &Sample) -> Result<bool> {
let text = self.extract_text(sample)?;
if text.is_empty() {
return Ok(true);
}
let doc_id = self.next_doc_id;
self.next_doc_id = self.next_doc_id.saturating_add(1);
let signature = self.hasher.compute_str(&text, doc_id)?;
let candidates = self.index.insert(signature)?;
let mut is_duplicate = false;
for candidate_id in candidates {
if let Some(sim) = self.index.verify_similarity(candidate_id, doc_id) {
if sim >= self.config.similarity_threshold {
is_duplicate = true;
break;
}
}
}
Ok(!is_duplicate)
}
pub fn push(&mut self, sample: Sample) {
self.buffer.push(sample);
}
pub fn finish_batch(&mut self) -> Vec<Sample> {
if self.buffer.is_empty() {
return Vec::new();
}
let start_doc_id = self.next_doc_id;
let batch_end = start_doc_id.saturating_add(self.buffer.len());
let mut uninserted_docs = Vec::new();
for (i, sample) in self.buffer.iter().enumerate() {
let doc_id = start_doc_id.saturating_add(i);
let mut inserted = false;
if let Ok(text) = self.extract_text(sample) {
if !text.is_empty() {
if let Ok(sig) = self.hasher.compute_str(&text, doc_id) {
if self.index.insert(sig).is_ok() {
inserted = true;
}
}
}
}
if !inserted {
match sample.get(&self.text_field) {
Some(text) => {
let raw_bytes = text.as_bytes().to_vec();
if let Entry::Vacant(slot) = self.bypassed_samples.entry(raw_bytes) {
slot.insert(doc_id);
uninserted_docs.push(doc_id);
}
}
None => {
uninserted_docs.push(doc_id);
}
}
}
}
self.next_doc_id = batch_end;
self.index.find_clusters();
let mut unique_indices = self.index.get_unique_indices();
unique_indices.extend(uninserted_docs);
let mut result = Vec::with_capacity(unique_indices.len());
for doc_id in unique_indices {
if doc_id >= start_doc_id && doc_id < batch_end {
let buf_idx = doc_id - start_doc_id;
result.push(std::mem::take(&mut self.buffer[buf_idx]));
}
}
self.buffer.clear();
result
}
#[must_use]
pub fn clusters(&mut self) -> &[DuplicateCluster] {
self.index.find_clusters()
}
#[must_use]
pub fn stats(&self) -> crate::lsh::LshStats {
self.index.stats()
}
#[must_use]
pub fn unique_count(&self) -> usize {
self.index.doc_count() - self.index.duplicate_count()
}
#[must_use]
pub fn duplicate_count(&self) -> usize {
self.index.duplicate_count()
}
pub fn reset(&mut self) {
self.buffer.clear();
self.output_queue.clear();
self.next_doc_id = 0;
self.bypassed_samples.clear();
self.index.clear();
}
#[instrument(skip(self, sample), level = "trace")]
fn extract_text(&self, sample: &Sample) -> Result<String> {
if let Some(tensor) = sample.get(&self.text_field) {
match tensor.dtype() {
tenshift_core::sample::DType::U8 | tenshift_core::sample::DType::Bytes => {
let bytes = tensor.as_bytes();
match std::str::from_utf8(bytes) {
Ok(s) => Ok(s.to_string()),
Err(_) => Err(Error::InvalidConfig {
reason: format!("field '{}' is not valid UTF-8", self.text_field),
fix: "ensure text fields contain valid UTF-8".to_string(),
}),
}
}
_ => {
warn!(field = %self.text_field, "field is not a text field");
Err(Error::InvalidConfig {
reason: format!("field '{}' is not a text field", self.text_field),
fix: "use U8 or Bytes dtype for text fields".to_string(),
})
}
}
} else {
warn!(field = %self.text_field, "sample missing text field");
Err(Error::InvalidConfig {
reason: format!("sample missing text field '{}'", self.text_field),
fix: format!("ensure samples have a '{}' field", self.text_field),
})
}
}
}
pub mod stateful;
#[cfg(test)]
mod tests;
pub use stateful::StatefulDedupTransform;