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) {
if self.streaming {
let doc_id = self.next_doc_id;
self.next_doc_id = self.next_doc_id.saturating_add(1);
let mut is_dup = false;
let mut processed = 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 let Ok(candidates) = self.index.insert(sig) {
processed = true;
for candidate_id in candidates {
if let Some(sim) = self.index.verify_similarity(candidate_id, doc_id) {
if sim >= self.config.similarity_threshold {
is_dup = true;
break;
}
}
}
}
}
}
}
if !processed {
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);
} else {
is_dup = true;
}
}
None => {}
}
}
if self.mark_duplicates {
let tag_val: u8 = if is_dup { 1 } else { 0 };
let tagged = sample.with(
"is_duplicate",
tenshift_core::sample::Tensor::u8(vec![tag_val], vec![1]),
);
self.output_queue.push(tagged);
} else if !is_dup {
self.output_queue.push(sample);
}
} else {
self.buffer.push(sample);
}
}
pub fn drain_streaming(&mut self) -> Vec<Sample> {
std::mem::take(&mut self.output_queue)
}
pub fn finish_batch(&mut self) -> Vec<Sample> {
let mut result = std::mem::take(&mut self.output_queue);
if self.buffer.is_empty() {
return result;
}
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);
if self.mark_duplicates {
let unique_set: std::collections::HashSet<usize> = unique_indices.into_iter().collect();
result.reserve(self.buffer.len());
for i in 0..self.buffer.len() {
let doc_id = start_doc_id.saturating_add(i);
let is_dup = !unique_set.contains(&doc_id);
let tag_val: u8 = if is_dup { 1 } else { 0 };
let mut sample = std::mem::take(&mut self.buffer[i]);
sample = sample.with(
"is_duplicate",
tenshift_core::sample::Tensor::u8(vec![tag_val], vec![1]),
);
result.push(sample);
}
} else {
result.reserve(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;