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.
151    pub fn push(&mut self, sample: Sample) {
152        self.buffer.push(sample);
153    }
154
155    /// Process all buffered samples and return deduplicated results.
156    ///
157    /// This computes signatures for all samples, builds the LSH index,
158    /// finds clusters, and returns only unique samples.
159    pub fn finish_batch(&mut self) -> Vec<Sample> {
160        if self.buffer.is_empty() {
161            return Vec::new();
162        }
163
164        // Assign document IDs for this batch to avoid collisions with prior inserts
165        let start_doc_id = self.next_doc_id;
166        // Saturating like `process_sample`: a counter pinned at usize::MAX
167        // must not panic or wrap doc ids into already-issued ones.
168        let batch_end = start_doc_id.saturating_add(self.buffer.len());
169        
170        let mut uninserted_docs = Vec::new();
171
172        // Process all samples and insert signatures with global doc ids
173        for (i, sample) in self.buffer.iter().enumerate() {
174            let doc_id = start_doc_id.saturating_add(i);
175            let mut inserted = false;
176            
177            if let Ok(text) = self.extract_text(sample) {
178                if !text.is_empty() {
179                    // Compute signature; if document is too short, treat as unique
180                    if let Ok(sig) = self.hasher.compute_str(&text, doc_id) {
181                        // Law-10: do NOT swallow an insert error and mark the doc
182                        // inserted. A failed LSH insert (e.g. length-validated
183                        // reject) must not silently drop the document. Only mark
184                        // inserted on success; on error the doc falls through to
185                        // the byte-exact bypass path below (preserved + content-
186                        // deduped), never lost.
187                        if self.index.insert(sig).is_ok() {
188                            inserted = true;
189                        }
190                    }
191                }
192            }
193            
194            if !inserted {
195                // Document bypassed LSH (empty text, hash error, or no text field).
196                match sample.get(&self.text_field) {
197                    // Field present (possibly empty): dedup by its exact bytes so
198                    // identical bypassed content collapses to a single unique doc.
199                    Some(text) => {
200                        let raw_bytes = text.as_bytes().to_vec();
201                        if let Entry::Vacant(slot) = self.bypassed_samples.entry(raw_bytes) {
202                            slot.insert(doc_id);
203                            uninserted_docs.push(doc_id);
204                        }
205                    }
206                    // Field absent: there is nothing to compare on. Collapsing
207                    // these under one empty key silently dropped every field-less
208                    // sample after the first (they are distinct documents that
209                    // merely lack the text field). Keep each one as unique.
210                    None => {
211                        uninserted_docs.push(doc_id);
212                    }
213                }
214            }
215        }
216
217        // Advance global doc id counter
218        self.next_doc_id = batch_end;
219
220        // Find clusters first (this populates the index's cluster data)
221        self.index.find_clusters();
222        
223        // Get unique indices from LSH and append our bypassed docs
224        let mut unique_indices = self.index.get_unique_indices();
225        unique_indices.extend(uninserted_docs);
226        
227        // Collect unique samples that belong to this batch
228        let mut result = Vec::with_capacity(unique_indices.len());
229        for doc_id in unique_indices {
230            if doc_id >= start_doc_id && doc_id < batch_end {
231                let buf_idx = doc_id - start_doc_id;
232                result.push(std::mem::take(&mut self.buffer[buf_idx]));
233            }
234        }
235
236        // Clear buffer since we've processed all samples
237        self.buffer.clear();
238
239        result
240    }
241
242    /// Get duplicate clusters.
243    ///
244    /// Returns all detected duplicate clusters. Call after `finish_batch()`
245    /// for complete results.
246    #[must_use]
247    pub fn clusters(&mut self) -> &[DuplicateCluster] {
248        self.index.find_clusters()
249    }
250
251    /// Get statistics about the deduplication process.
252    #[must_use]
253    pub fn stats(&self) -> crate::lsh::LshStats {
254        self.index.stats()
255    }
256
257    /// Get the number of unique documents found.
258    #[must_use]
259    pub fn unique_count(&self) -> usize {
260        self.index.doc_count() - self.index.duplicate_count()
261    }
262
263    /// Get the number of duplicate documents found.
264    #[must_use]
265    pub fn duplicate_count(&self) -> usize {
266        self.index.duplicate_count()
267    }
268
269    /// Reset the transformer state.
270    pub fn reset(&mut self) {
271        self.buffer.clear();
272        self.output_queue.clear();
273        self.next_doc_id = 0;
274        self.bypassed_samples.clear();
275        // Clear the index in place. This previously rebuilt via `LshIndex::new`
276        // and SILENTLY kept the old populated index when construction errored
277        // (`if let Ok(index) = ...`), leaving a stale index while every other
278        // field was reset -> downstream doc_id collisions against ghost entries.
279        // `clear()` is infallible and cannot leave a stale/half-reset index, so
280        // there is no error to swallow (Law-10).
281        self.index.clear();
282    }
283
284    /// Extract text from a sample's configured field.
285    #[instrument(skip(self, sample), level = "trace")]
286    fn extract_text(&self, sample: &Sample) -> Result<String> {
287        if let Some(tensor) = sample.get(&self.text_field) {
288            // Try to interpret as UTF-8 text
289            match tensor.dtype() {
290                tenshift_core::sample::DType::U8 | tenshift_core::sample::DType::Bytes => {
291                    let bytes = tensor.as_bytes();
292                    match std::str::from_utf8(bytes) {
293                        Ok(s) => Ok(s.to_string()),
294                        Err(_) => Err(Error::InvalidConfig {
295                            reason: format!("field '{}' is not valid UTF-8", self.text_field),
296                            fix: "ensure text fields contain valid UTF-8".to_string(),
297                        }),
298                    }
299                }
300                _ => {
301                    warn!(field = %self.text_field, "field is not a text field");
302                    Err(Error::InvalidConfig {
303                        reason: format!("field '{}' is not a text field", self.text_field),
304                        fix: "use U8 or Bytes dtype for text fields".to_string(),
305                    })
306                }
307            }
308        } else {
309            warn!(field = %self.text_field, "sample missing text field");
310            Err(Error::InvalidConfig {
311                reason: format!("sample missing text field '{}'", self.text_field),
312                fix: format!("ensure samples have a '{}' field", self.text_field),
313            })
314        }
315    }
316
317}
318
319
320pub mod stateful;
321#[cfg(test)]
322mod tests;
323
324pub use stateful::StatefulDedupTransform;