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;