Skip to main content

dedup/
shingle.rs

1//! Shingling (k-gram generation) for text documents.
2//!
3//! Converts documents into sets of overlapping subsequences (shingles/k-grams)
4//! which are then hashed for MinHash computation.
5
6use crate::fast_hash::hash_bytes;
7
8/// Iterator over shingles (k-grams) of a byte sequence.
9///
10/// Produces overlapping windows of size `k` from the input data.
11#[derive(Debug, Clone)]
12pub struct ShingleIterator<'a> {
13    /// Input data.
14    data: &'a [u8],
15    /// Shingle size (window length).
16    k: usize,
17    /// Current position.
18    pos: usize,
19}
20
21impl<'a> ShingleIterator<'a> {
22    /// Create a new shingle iterator.
23    ///
24    /// # Panics
25    ///
26    /// Returns an empty iterator if `k` is 0.
27    pub fn new(data: &'a [u8], k: usize) -> Self {
28        Self { data, k: k.max(1), pos: 0 }
29    }
30
31    /// Create a new shingle iterator from a string.
32    ///
33    /// Shingles are computed over UTF-8 bytes.
34    pub fn from_str(s: &'a str, k: usize) -> Self {
35        Self::new(s.as_bytes(), k)
36    }
37
38    /// Get the number of shingles this iterator will produce.
39    #[must_use]
40    pub fn count_shingles(&self) -> usize {
41        if self.data.len() < self.k {
42            return 0;
43        }
44        self.data.len().saturating_sub(self.k).saturating_add(1)
45    }
46
47    /// Check if the document has any shingles.
48    #[must_use]
49    pub fn is_empty(&self) -> bool {
50        self.count_shingles() == 0
51    }
52}
53
54impl<'a> Iterator for ShingleIterator<'a> {
55    type Item = &'a [u8];
56
57    fn next(&mut self) -> Option<Self::Item> {
58        let end = self.pos.checked_add(self.k)?;
59        if end > self.data.len() {
60            return None;
61        }
62
63        let shingle = &self.data[self.pos..end];
64        self.pos += 1;
65        Some(shingle)
66    }
67
68    fn size_hint(&self) -> (usize, Option<usize>) {
69        let remaining = self.count_shingles().saturating_sub(self.pos);
70        (remaining, Some(remaining))
71    }
72}
73
74impl ExactSizeIterator for ShingleIterator<'_> {}
75
76/// Iterator that yields hashed shingles directly.
77///
78/// More efficient than materializing all shingles first.
79#[derive(Debug, Clone)]
80pub struct HashedShingleIterator<'a> {
81    inner: ShingleIterator<'a>,
82}
83
84impl<'a> HashedShingleIterator<'a> {
85    /// Create a new hashed shingle iterator.
86    pub fn new(data: &'a [u8], k: usize) -> Self {
87        Self {
88            inner: ShingleIterator::new(data, k),
89        }
90    }
91
92    /// Create from a string.
93    #[allow(dead_code)]
94    pub fn from_str(s: &'a str, k: usize) -> Self {
95        Self::new(s.as_bytes(), k)
96    }
97}
98
99impl Iterator for HashedShingleIterator<'_> {
100    type Item = u64;
101
102    fn next(&mut self) -> Option<Self::Item> {
103        self.inner.next().map(hash_bytes)
104    }
105
106    fn size_hint(&self) -> (usize, Option<usize>) {
107        self.inner.size_hint()
108    }
109}
110
111impl ExactSizeIterator for HashedShingleIterator<'_> {}
112
113/// Compute all shingles for a document.
114#[must_use]
115#[allow(dead_code)]
116pub fn get_shingles(data: &[u8], k: usize) -> Vec<&[u8]> {
117    ShingleIterator::new(data, k).collect()
118}
119
120/// Compute all hashed shingles for a document.
121#[must_use]
122#[allow(dead_code)]
123pub fn get_hashed_shingles(data: &[u8], k: usize) -> Vec<u64> {
124    HashedShingleIterator::new(data, k).collect()
125}
126
127/// Normalize text by lowercasing and removing excess whitespace.
128///
129/// This is useful for text deduplication where case differences
130/// shouldn't affect similarity.
131#[must_use]
132#[allow(dead_code)]
133pub fn normalize_text(text: &str) -> String {
134    let mut result = String::with_capacity(text.len());
135    let mut prev_was_space = true; // Skip leading whitespace
136
137    for c in text.chars() {
138        if c.is_whitespace() {
139            if !prev_was_space {
140                result.push(' ');
141                prev_was_space = true;
142            }
143        } else {
144            // Use lowercase for case-insensitive comparison
145            for lc in c.to_lowercase() {
146                result.push(lc);
147            }
148            prev_was_space = false;
149        }
150    }
151
152    // Trim trailing whitespace
153    if result.ends_with(' ') {
154        result.pop();
155    }
156
157    result
158}
159
160/// Create word-level shingles (n-grams of words).
161///
162/// Useful for document deduplication where word order matters
163/// more than character-level variations.
164#[allow(dead_code)]
165pub struct WordShingleIterator<'a> {
166    /// Words in the document.
167    words: Vec<&'a str>,
168    /// Shingle size in words.
169    k: usize,
170    /// Current position.
171    pos: usize,
172}
173
174impl<'a> WordShingleIterator<'a> {
175    /// Create a new word shingle iterator.
176    ///
177    /// # Panics
178    ///
179    /// Clamps `k` to at least 1.
180    #[allow(dead_code)]
181    pub fn new(text: &'a str, k: usize) -> Self {
182        let k = k.max(1);
183        let words: Vec<&str> = text.split_whitespace().collect();
184        Self { words, k, pos: 0 }
185    }
186
187    /// Get the number of word shingles.
188    #[must_use]
189    #[allow(dead_code)]
190    pub fn count_shingles(&self) -> usize {
191        if self.words.len() < self.k {
192            return 0;
193        }
194        self.words.len().saturating_sub(self.k).saturating_add(1)
195    }
196}
197
198impl<'a> Iterator for WordShingleIterator<'a> {
199    type Item = Vec<&'a str>;
200
201    fn next(&mut self) -> Option<Self::Item> {
202        let end = self.pos.checked_add(self.k)?;
203        if end > self.words.len() {
204            return None;
205        }
206
207        let shingle: Vec<&str> = self.words[self.pos..end].to_vec();
208        self.pos += 1;
209        Some(shingle)
210    }
211
212    fn size_hint(&self) -> (usize, Option<usize>) {
213        let remaining = self.count_shingles().saturating_sub(self.pos);
214        (remaining, Some(remaining))
215    }
216}
217
218// size_hint returns an exact (remaining, Some(remaining)) bound, so the default
219// ExactSizeIterator::len() (the lower bound) is exact. Matches ShingleIterator
220// and HashedShingleIterator above (ONE-PLACE consistency).
221impl ExactSizeIterator for WordShingleIterator<'_> {}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn shingle_iterator_basic() {
229        let data = b"hello";
230        let shingles: Vec<_> = ShingleIterator::new(data, 2).collect();
231        
232        assert_eq!(shingles.len(), 4);
233        assert_eq!(shingles[0], b"he");
234        assert_eq!(shingles[1], b"el");
235        assert_eq!(shingles[2], b"ll");
236        assert_eq!(shingles[3], b"lo");
237    }
238
239    #[test]
240    fn shingle_iterator_single_byte() {
241        let data = b"abc";
242        let shingles: Vec<_> = ShingleIterator::new(data, 1).collect();
243        
244        assert_eq!(shingles.len(), 3);
245        assert_eq!(shingles[0], b"a");
246        assert_eq!(shingles[1], b"b");
247        assert_eq!(shingles[2], b"c");
248    }
249
250    #[test]
251    fn shingle_iterator_short_input() {
252        let data = b"hi";
253        let shingles: Vec<_> = ShingleIterator::new(data, 5).collect();
254        assert!(shingles.is_empty());
255    }
256
257    #[test]
258    fn shingle_count_correct() {
259        let iter = ShingleIterator::new(b"hello world", 3);
260        assert_eq!(iter.count_shingles(), 9); // "hel", "ell", "llo", "lo ", "o w", " wo", "wor", "orl", "rld"
261    }
262
263    #[test]
264    fn exact_size_iterator() {
265        let iter = ShingleIterator::new(b"hello", 2);
266        let (low, high) = iter.size_hint();
267        assert_eq!(low, 4);
268        assert_eq!(high, Some(4));
269    }
270
271    #[test]
272    fn hashed_shingle_iterator() {
273        let data = b"test";
274        let hashes: Vec<_> = HashedShingleIterator::new(data, 2).collect();
275        
276        assert_eq!(hashes.len(), 3);
277        // Hashes should be deterministic
278        let hashes2: Vec<_> = HashedShingleIterator::new(data, 2).collect();
279        assert_eq!(hashes, hashes2);
280    }
281
282    #[test]
283    fn from_str_works() {
284        let s = "hello";
285        let shingles: Vec<_> = ShingleIterator::from_str(s, 2).collect();
286        assert_eq!(shingles[0], b"he");
287    }
288
289    #[test]
290    fn get_shingles_helper() {
291        let shingles = get_shingles(b"abcd", 2);
292        assert_eq!(shingles.len(), 3);
293    }
294
295    #[test]
296    fn normalize_text_basic() {
297        assert_eq!(normalize_text("Hello World"), "hello world");
298        assert_eq!(normalize_text("  multiple   spaces  "), "multiple spaces");
299    }
300
301    #[test]
302    fn normalize_text_unicode() {
303        assert_eq!(normalize_text("CAFÉ"), "café");
304        assert_eq!(normalize_text("Hello 世界"), "hello 世界");
305    }
306
307    #[test]
308    fn word_shingle_iterator() {
309        let text = "the quick brown fox";
310        let shingles: Vec<_> = WordShingleIterator::new(text, 2).collect();
311        
312        assert_eq!(shingles.len(), 3);
313        assert_eq!(shingles[0], vec!["the", "quick"]);
314        assert_eq!(shingles[1], vec!["quick", "brown"]);
315        assert_eq!(shingles[2], vec!["brown", "fox"]);
316    }
317
318    #[test]
319    fn word_shingle_empty() {
320        let shingles: Vec<_> = WordShingleIterator::new("", 2).collect();
321        assert!(shingles.is_empty());
322    }
323
324    #[test]
325    fn shingle_iterator_is_empty() {
326        let iter = ShingleIterator::new(b"hi", 5);
327        assert!(iter.is_empty());
328        
329        let iter = ShingleIterator::new(b"hello", 2);
330        assert!(!iter.is_empty());
331    }
332
333    #[test]
334    fn large_k_works() {
335        let data = vec![b'a'; 1000];
336        let shingles: Vec<_> = ShingleIterator::new(&data, 100).collect();
337        assert_eq!(shingles.len(), 901);
338    }
339}