1use 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
18pub struct DedupTransformer {
38 config: Config,
40 hasher: MinHasher,
42 index: LshIndex,
44 buffer: Vec<Sample>,
46 pub streaming: bool,
48 next_doc_id: usize,
50 output_queue: Vec<Sample>,
52 text_field: String,
54 mark_duplicates: bool,
56 bypassed_samples: std::collections::HashMap<Vec<u8>, usize>,
58}
59
60impl DedupTransformer {
61 #[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 #[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 #[must_use]
94 pub fn with_streaming(mut self, enabled: bool) -> Self {
95 self.streaming = enabled;
96 self
97 }
98
99 #[must_use]
104 pub fn with_mark_duplicates(mut self, enabled: bool) -> Self {
105 self.mark_duplicates = enabled;
106 self
107 }
108
109 #[instrument(skip(self, sample), level = "debug")]
118 pub fn process_sample(&mut self, sample: &Sample) -> Result<bool> {
119 let text = self.extract_text(sample)?;
121
122 if text.is_empty() {
123 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 let signature = self.hasher.compute_str(&text, doc_id)?;
132
133 let candidates = self.index.insert(signature)?;
135
136 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 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 pub fn drain_streaming(&mut self) -> Vec<Sample> {
209 std::mem::take(&mut self.output_queue)
210 }
211
212 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 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 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 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 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 self.next_doc_id = batch_end;
264
265 self.index.find_clusters();
267
268 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 self.buffer.clear();
298
299 result
300 }
301
302 #[must_use]
307 pub fn clusters(&mut self) -> &[DuplicateCluster] {
308 self.index.find_clusters()
309 }
310
311 #[must_use]
313 pub fn stats(&self) -> crate::lsh::LshStats {
314 self.index.stats()
315 }
316
317 #[must_use]
319 pub fn unique_count(&self) -> usize {
320 self.index.doc_count() - self.index.duplicate_count()
321 }
322
323 #[must_use]
325 pub fn duplicate_count(&self) -> usize {
326 self.index.duplicate_count()
327 }
328
329 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 self.index.clear();
342 }
343
344 #[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 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;