jacquardSnapshot

← snapshot

6383 bytes
//! Shingling and lane hashing: text goes in, minima come out.

use crate::minhash::{MinHashSketch, SKETCH_LANES};

/// Words per shingle.
///
/// Three-word shingles are long enough that shared function words alone do
/// not register as similarity, and short enough that reworded-but-related
/// texts still overlap.
const SHINGLE_WORDS: usize = 3;

/// Streaming builder: feed text, take a sketch.
///
/// This type consumes content and its output is a [`MinHashSketch`]; the
/// content itself is dropped as it streams through. Determinism is total —
/// the same text always yields the same sketch, with no ambient randomness —
/// because rendezvous only works if two parties sketching the same thing get
/// comparable results.
#[derive(Debug, Clone)]
pub struct SketchBuilder {
    minima: [u64; SKETCH_LANES],
    window: [u64; SHINGLE_WORDS],
    words_seen: usize,
}

impl Default for SketchBuilder {
    fn default() -> Self {
        Self::new()
    }
}

impl SketchBuilder {
    /// Starts an empty builder.
    #[must_use]
    pub const fn new() -> Self {
        Self {
            minima: [u64::MAX; SKETCH_LANES],
            window: [0; SHINGLE_WORDS],
            words_seen: 0,
        }
    }

    /// Feeds a piece of text.
    ///
    /// Tokenisation is deliberately crude and deliberately stable: lowercase,
    /// split on anything that is not alphanumeric. Changing it changes every
    /// published sketch in the world, so it is part of the wire format.
    pub fn feed_text(&mut self, text: &str) {
        let mut word = String::new();
        for c in text.chars() {
            if c.is_alphanumeric() {
                word.extend(c.to_lowercase());
            } else if !word.is_empty() {
                self.feed_word(&word);
                word.clear();
            }
        }
        if !word.is_empty() {
            self.feed_word(&word);
        }
    }

    fn feed_word(&mut self, word: &str) {
        // One BLAKE3 hash per word; the shingle hash mixes the window.
        let h = blake3::hash(word.as_bytes());
        let mut first8 = [0u8; 8];
        first8.copy_from_slice(&h.as_bytes()[..8]);
        let word_hash = u64::from_le_bytes(first8);

        self.window.rotate_left(1);
        self.window[SHINGLE_WORDS - 1] = word_hash;
        self.words_seen += 1;

        if self.words_seen >= SHINGLE_WORDS {
            self.absorb_shingle();
        }
    }

    fn absorb_shingle(&mut self) {
        // Combine the window into one shingle hash, order-sensitively.
        let mut shingle = 0xcbf2_9ce4_8422_2325u64; // FNV offset basis
        for &w in &self.window {
            shingle ^= w;
            shingle = shingle.wrapping_mul(0x0000_0100_0000_01b3); // FNV prime
        }

        // Each lane applies an independent mix. splitmix64 over
        // (shingle ^ lane seed) is deterministic, cheap, and well-scrambled.
        for (lane, min) in self.minima.iter_mut().enumerate() {
            let seeded = shingle ^ (0x9e37_79b9_7f4a_7c15u64.wrapping_mul(lane as u64 + 1));
            let mixed = splitmix64(seeded);
            if mixed < *min {
                *min = mixed;
            }
        }
    }

    /// Finalises into a sketch.
    ///
    /// An input shorter than one shingle yields the empty sketch (all lanes
    /// at `u64::MAX`), which scores [`Similarity::IDENTICAL`] against another
    /// empty sketch and near-zero against anything real — two empty texts
    /// *are* identical.
    ///
    /// [`Similarity::IDENTICAL`]: crate::minhash::Similarity::IDENTICAL
    #[must_use]
    pub const fn finish(self) -> MinHashSketch {
        MinHashSketch(self.minima)
    }
}

/// The splitmix64 finaliser: a bijective scramble of the input.
const fn splitmix64(mut z: u64) -> u64 {
    z = z.wrapping_add(0x9e37_79b9_7f4a_7c15);
    z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
    z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
    z ^ (z >> 31)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::minhash::Similarity;

    fn sketch(text: &str) -> MinHashSketch {
        let mut b = SketchBuilder::new();
        b.feed_text(text);
        b.finish()
    }

    #[test]
    fn identical_text_is_identical() {
        let a = sketch("retry with exponential backoff fails closed on auth errors");
        let b = sketch("retry with exponential backoff fails closed on auth errors");
        assert_eq!(a.similarity(&b), Similarity::IDENTICAL);
    }

    #[test]
    fn tokenisation_ignores_case_and_punctuation() {
        let a = sketch("Retry, with exponential BACKOFF!");
        let b = sketch("retry with exponential backoff");
        assert_eq!(a.similarity(&b), Similarity::IDENTICAL);
    }

    #[test]
    fn unrelated_text_scores_near_zero() {
        let a = sketch(
            "the authentication retry policy treats every 401 and 403 as terminal and fails \
             closed rather than retrying with stale credentials against the token endpoint",
        );
        let b = sketch(
            "grapefruit marmalade requires slicing the peel thinly simmering twice and \
             resting overnight before the sugar goes in at a rolling boil",
        );
        assert!(
            a.similarity(&b).permille() < 100,
            "unrelated texts scored {}",
            a.similarity(&b)
        );
    }

    #[test]
    fn overlapping_text_lands_in_a_band() {
        // Two independently-worded statements of the same policy, sharing
        // phrases but not sentences. Deterministic input, so the band is
        // stable; assert a band, not a point.
        let a = sketch(
            "auth retry fails closed on 401 and 403 responses. the client never retries an \
             unauthorized request with cached credentials. exponential backoff applies to \
             transient network errors only, never to authentication failures.",
        );
        let b = sketch(
            "on 401 and 403 responses the retry layer fails closed. exponential backoff \
             applies to transient network errors only. cached credentials are never reused \
             for an unauthorized request.",
        );
        let s = a.similarity(&b).permille();
        assert!(
            (100..900).contains(&s),
            "related-but-reworded texts scored {s} permille, outside the expected band"
        );
    }
}