Skip to main content

dedup/transform/
mod.rs

1//! tenshift Transform integration for deduplication.
2//!
3//! Provides `DedupTransformer` which implements the tenshift `Transform` trait,
4//! allowing deduplication to be used as a pipeline stage.
5
6use std::collections::hash_map::Entry;
7
8use crate::config::Config;
9use crate::error::{Error, Result};
10use crate::cluster::DuplicateCluster;
11use crate::lsh::LshIndex;
12use crate::minhash::MinHasher;
13
14use tenshift_core::sample::Sample;
15
16use tracing::{instrument, warn};
17
18/// A transform that deduplicates samples using MinHash + LSH.
19///
20/// This transform buffers samples to compute signatures and find duplicates.
21/// It can operate in two modes:
22/// - **Streaming**: Process samples as they arrive, outputting non-duplicates immediately
23/// - **Batch**: Buffer all samples, then output deduplicated set
24///
25/// # Example
26///
27/// ```rust
28/// use dedup::{Config, DedupTransformer};
29/// use tenshift_core::sample::Sample;
30/// use tenshift_core::transform::Transform;
31///
32/// let config = Config::default()
33///     .with_similarity_threshold(0.9);
34///
35/// let mut dedup = DedupTransformer::new(config).unwrap();
36/// ```
37pub struct DedupTransformer {
38    /// Configuration.
39    config: Config,
40    /// MinHash signature computer.
41    hasher: MinHasher,
42    /// LSH index for finding duplicates.
43    index: LshIndex,
44    /// Buffered samples waiting for processing.
45    buffer: Vec<Sample>,
46    /// Whether we're in streaming mode.
47    pub streaming: bool,
48    /// Next document ID.
49    next_doc_id: usize,
50    /// Output queue for streaming mode.
51    output_queue: Vec<Sample>,
52    /// Field name containing text to deduplicate on.
53    text_field: String,
54    /// Whether to mark duplicates instead of filtering.
55    mark_duplicates: bool,
56    /// Track bypassed documents globally across batches.
57    bypassed_samples: std::collections::HashMap<Vec<u8>, usize>,
58}
59
60impl DedupTransformer {
61    /// Create a new deduplication transformer.
62    ///
63    /// # Errors
64    ///
65    /// Returns an error if the configuration is invalid.
66    #[instrument(skip(config), level = "debug")]
67    pub fn new(config: Config) -> Result<Self> {
68        let hasher = MinHasher::new(&config)?;
69        let index = LshIndex::new(&config)?;
70
71        Ok(Self {
72            config,
73            hasher,
74            index,
75            buffer: Vec::new(),
76            streaming: false,
77            next_doc_id: 0,
78            output_queue: Vec::new(),
79            text_field: "text".to_string(),
80            mark_duplicates: false,
81            bypassed_samples: std::collections::HashMap::new(),
82        })
83    }
84
85    /// Set the field name containing text to deduplicate on.
86    #[must_use]
87    pub fn with_text_field(mut self, field: impl Into<String>) -> Self {
88        self.text_field = field.into();
89        self
90    }
91
92    /// Enable streaming mode (output non-duplicates immediately).
93    #[must_use]
94    pub fn with_streaming(mut self, enabled: bool) -> Self {
95        self.streaming = enabled;
96        self
97    }
98
99    /// Enable marking duplicates instead of filtering them.
100    ///
101    /// When enabled, duplicates are tagged with a `is_duplicate` field
102    /// instead of being removed from the output.
103    #[must_use]
104    pub fn with_mark_duplicates(mut self, enabled: bool) -> Self {
105        self.mark_duplicates = enabled;
106        self
107    }
108
109    /// Process a single sample.
110    ///
111    /// Computes the MinHash signature and adds to the LSH index.
112    /// Returns true if the sample is unique, false if it's a duplicate.
113    ///
114    /// # Errors
115    ///
116    /// Returns an error if signature computation fails.
117    #[instrument(skip(self, sample), level = "debug")]
118    pub fn process_sample(&mut self, sample: &Sample) -> Result<bool> {
119        // Extract text from the configured field
120        let text = self.extract_text(sample)?;
121        
122        if text.is_empty() {
123            // Empty documents are considered unique (can't deduplicate)
124            return Ok(true);
125        }
126
127        let doc_id = self.next_doc_id;
128        self.next_doc_id = self.next_doc_id.saturating_add(1);
129
130        // Compute MinHash signature
131        let signature = self.hasher.compute_str(&text, doc_id)?;
132
133        // Add to LSH index and get candidates
134        let candidates = self.index.insert(signature)?;
135
136        // Check if any candidate is actually a duplicate
137        let mut is_duplicate = false;
138        for candidate_id in candidates {
139            if let Some(sim) = self.index.verify_similarity(candidate_id, doc_id) {
140                if sim >= self.config.similarity_threshold {
141                    is_duplicate = true;
142                    break;
143                }
144            }
145        }
146
147        Ok(!is_duplicate)
148    }
149
150    /// Add a sample to the buffer for batch processing, or process immediately
151    /// if streaming mode is enabled.
152    pub fn push(&mut self, sample: Sample) {
153        if self.streaming {
154            let doc_id = self.next_doc_id;
155            self.next_doc_id = self.next_doc_id.saturating_add(1);
156
157            let mut is_dup = false;
158            let mut processed = false;
159
160            if let Ok(text) = self.extract_text(&sample) {
161                if !text.is_empty() {
162                    if let Ok(sig) = self.hasher.compute_str(&text, doc_id) {
163                        if let Ok(candidates) = self.index.insert(sig) {
164                            processed = true;
165                            for candidate_id in candidates {
166                                if let Some(sim) = self.index.verify_similarity(candidate_id, doc_id) {
167                                    if sim >= self.config.similarity_threshold {
168                                        is_dup = true;
169                                        break;
170                                    }
171                                }
172                            }
173                        }
174                    }
175                }
176            }
177
178            if !processed {
179                match sample.get(&self.text_field) {
180                    Some(text) => {
181                        let raw_bytes = text.as_bytes().to_vec();
182                        if let Entry::Vacant(slot) = self.bypassed_samples.entry(raw_bytes) {
183                            slot.insert(doc_id);
184                        } else {
185                            is_dup = true;
186                        }
187                    }
188                    None => {}
189                }
190            }
191
192            if self.mark_duplicates {
193                let tag_val: u8 = if is_dup { 1 } else { 0 };
194                let tagged = sample.with(
195                    "is_duplicate",
196                    tenshift_core::sample::Tensor::u8(vec![tag_val], vec![1]),
197                );
198                self.output_queue.push(tagged);
199            } else if !is_dup {
200                self.output_queue.push(sample);
201            }
202        } else {
203            self.buffer.push(sample);
204        }
205    }
206
207    /// Drain any pending output samples produced in streaming mode.
208    pub fn drain_streaming(&mut self) -> Vec<Sample> {
209        std::mem::take(&mut self.output_queue)
210    }
211
212    /// Process all buffered samples and return deduplicated results.
213    ///
214    /// This computes signatures for all samples, builds the LSH index,
215    /// finds clusters, and returns only unique samples.
216    pub fn finish_batch(&mut self) -> Vec<Sample> {
217        let mut result = std::mem::take(&mut self.output_queue);
218
219        if self.buffer.is_empty() {
220            return result;
221        }
222
223        // Assign document IDs for this batch to avoid collisions with prior inserts
224        let start_doc_id = self.next_doc_id;
225        let batch_end = start_doc_id.saturating_add(self.buffer.len());
226        
227        let mut uninserted_docs = Vec::new();
228
229        // Process all samples and insert signatures with global doc ids
230        for (i, sample) in self.buffer.iter().enumerate() {
231            let doc_id = start_doc_id.saturating_add(i);
232            let mut inserted = false;
233            
234            if let Ok(text) = self.extract_text(sample) {
235                if !text.is_empty() {
236                    // Compute signature; if document is too short, treat as unique
237                    if let Ok(sig) = self.hasher.compute_str(&text, doc_id) {
238                        if self.index.insert(sig).is_ok() {
239                            inserted = true;
240                        }
241                    }
242                }
243            }
244            
245            if !inserted {
246                // Document bypassed LSH (empty text, hash error, or no text field).
247                match sample.get(&self.text_field) {
248                    Some(text) => {
249                        let raw_bytes = text.as_bytes().to_vec();
250                        if let Entry::Vacant(slot) = self.bypassed_samples.entry(raw_bytes) {
251                            slot.insert(doc_id);
252                            uninserted_docs.push(doc_id);
253                        }
254                    }
255                    None => {
256                        uninserted_docs.push(doc_id);
257                    }
258                }
259            }
260        }
261
262        // Advance global doc id counter
263        self.next_doc_id = batch_end;
264
265        // Find clusters first (this populates the index's cluster data)
266        self.index.find_clusters();
267        
268        // Get unique indices from LSH and append our bypassed docs
269        let mut unique_indices = self.index.get_unique_indices();
270        unique_indices.extend(uninserted_docs);
271        
272        if self.mark_duplicates {
273            let unique_set: std::collections::HashSet<usize> = unique_indices.into_iter().collect();
274            result.reserve(self.buffer.len());
275            for i in 0..self.buffer.len() {
276                let doc_id = start_doc_id.saturating_add(i);
277                let is_dup = !unique_set.contains(&doc_id);
278                let tag_val: u8 = if is_dup { 1 } else { 0 };
279                let mut sample = std::mem::take(&mut self.buffer[i]);
280                sample = sample.with(
281                    "is_duplicate",
282                    tenshift_core::sample::Tensor::u8(vec![tag_val], vec![1]),
283                );
284                result.push(sample);
285            }
286        } else {
287            result.reserve(unique_indices.len());
288            for doc_id in unique_indices {
289                if doc_id >= start_doc_id && doc_id < batch_end {
290                    let buf_idx = doc_id - start_doc_id;
291                    result.push(std::mem::take(&mut self.buffer[buf_idx]));
292                }
293            }
294        }
295
296        // Clear buffer since we've processed all samples
297        self.buffer.clear();
298
299        result
300    }
301
302    /// Get duplicate clusters.
303    ///
304    /// Returns all detected duplicate clusters. Call after `finish_batch()`
305    /// for complete results.
306    #[must_use]
307    pub fn clusters(&mut self) -> &[DuplicateCluster] {
308        self.index.find_clusters()
309    }
310
311    /// Get statistics about the deduplication process.
312    #[must_use]
313    pub fn stats(&self) -> crate::lsh::LshStats {
314        self.index.stats()
315    }
316
317    /// Get the number of unique documents found.
318    #[must_use]
319    pub fn unique_count(&self) -> usize {
320        self.index.doc_count() - self.index.duplicate_count()
321    }
322
323    /// Get the number of duplicate documents found.
324    #[must_use]
325    pub fn duplicate_count(&self) -> usize {
326        self.index.duplicate_count()
327    }
328
329    /// Reset the transformer state.
330    pub fn reset(&mut self) {
331        self.buffer.clear();
332        self.output_queue.clear();
333        self.next_doc_id = 0;
334        self.bypassed_samples.clear();
335        // Clear the index in place. This previously rebuilt via `LshIndex::new`
336        // and SILENTLY kept the old populated index when construction errored
337        // (`if let Ok(index) = ...`), leaving a stale index while every other
338        // field was reset -> downstream doc_id collisions against ghost entries.
339        // `clear()` is infallible and cannot leave a stale/half-reset index, so
340        // there is no error to swallow (Law-10).
341        self.index.clear();
342    }
343
344    /// Extract text from a sample's configured field.
345    #[instrument(skip(self, sample), level = "trace")]
346    fn extract_text(&self, sample: &Sample) -> Result<String> {
347        if let Some(tensor) = sample.get(&self.text_field) {
348            // Try to interpret as UTF-8 text
349            match tensor.dtype() {
350                tenshift_core::sample::DType::U8 | tenshift_core::sample::DType::Bytes => {
351                    let bytes = tensor.as_bytes();
352                    match std::str::from_utf8(bytes) {
353                        Ok(s) => Ok(s.to_string()),
354                        Err(_) => Err(Error::InvalidConfig {
355                            reason: format!("field '{}' is not valid UTF-8", self.text_field),
356                            fix: "ensure text fields contain valid UTF-8".to_string(),
357                        }),
358                    }
359                }
360                _ => {
361                    warn!(field = %self.text_field, "field is not a text field");
362                    Err(Error::InvalidConfig {
363                        reason: format!("field '{}' is not a text field", self.text_field),
364                        fix: "use U8 or Bytes dtype for text fields".to_string(),
365                    })
366                }
367            }
368        } else {
369            warn!(field = %self.text_field, "sample missing text field");
370            Err(Error::InvalidConfig {
371                reason: format!("sample missing text field '{}'", self.text_field),
372                fix: format!("ensure samples have a '{}' field", self.text_field),
373            })
374        }
375    }
376
377}
378
379
380pub mod stateful;
381#[cfg(test)]
382mod tests;
383
384pub use stateful::StatefulDedupTransform;