//! 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"
);
}
}