Skip to main content

pith_text/
lib.rs

1//! Text fingerprints: canonicalisation, word-3-shingles and a 128-word
2//! MinHash signature (design spec §4, tier-2 text lane).
3//!
4//! Part of the `pith` suite: every crate in the suite builds without a
5//! single registry package.
6//!
7//! # Pipeline
8//!
9//! 1. [`canonicalize`]: NFC (via `pith_unicode::nfc`) → lowercase →
10//!    strip trailing whitespace → exactly one `'\n'`.
11//! 2. Words = `split_whitespace` runs of the canonical text; combining
12//!    marks stay attached to their base letter (accents are kept —
13//!    stripping them would erase distinctions Vietnamese relies on).
14//! 3. Shingles = consecutive `k`-word windows, `k = min(3, n)` for an
15//!    `n`-word document; hashed with FNV-1a 64 over the words joined by
16//!    a single ASCII space.
17//! 4. [`signature`] = 128-word MinHash: permutation `i` draws `seed_i`
18//!    from `SplitMix64::new(SEED_STREAM)` and each signature word is
19//!    `min over shingles of sm64_mix(fnv1a64(shingle) ^ seed_i)`,
20//!    where `sm64_mix` is the splitmix64 output function applied as a
21//!    pure finalizer (spec §4: `splitmix64(FNV1a64(w) ^ seed_i)`).
22//!
23//! The crate is `no_std`: only `alloc` containers and `core` string
24//! operations are used, so the same code runs on embedded targets. The
25//! `std` feature (on by default) links `std` so the `cdylib` the
26//! language SDKs bind through carries a panic handler.
27
28#![cfg_attr(not(feature = "std"), no_std)]
29// `unsafe` is denied everywhere except `ffi`, the C ABI surface the
30// language SDKs bind through: raw pointers exist only at that boundary,
31// and every exported function is a documented `unsafe extern "C"` fn.
32#![deny(unsafe_code)]
33#![deny(missing_docs)]
34
35extern crate alloc;
36
37use alloc::string::String;
38use alloc::vec::Vec;
39
40use pith_digest::{SplitMix64, fnv1a64};
41
42pub mod ffi;
43// The Java SDK's native-method surface: `Java_hash_pith_text_*` exports
44// that forward to the C ABI above. Compiled out of the unit-test build
45// (the `#[no_mangle]` exports would collide with the test binary's
46// copies) and out of `--no-default-features` builds (the glue needs
47// `std` allocations); `tests/java_ffi.rs` covers the glue against a
48// synthetic JNI environment instead. Private module: the JVM links the
49// exports by symbol name, so nothing here needs to be publicly
50// nameable in Rust.
51#[cfg(all(not(test), feature = "std"))]
52mod ffi_jni;
53pub mod reference;
54
55/// Number of words per shingle (spec §4: "3 từ liên tiếp").
56const SHINGLE_WORDS: usize = 3;
57
58/// Length of a [`signature`] in `u64` words.
59pub const SIGNATURE_WORDS: usize = 128;
60
61/// Seed of the stream that produces the per-permutation `seed_i` values.
62/// A pinned constant: the signature is a function of the text alone.
63const SEED_STREAM: u64 = 0x5445_5854_4D48_3634; // "TEXTMH64", LE-pinned
64
65/// The splitmix64 output function as a pure finalizer over `x`
66/// (Steele, Lea & Flood 2014). Identical math to
67/// [`SplitMix64::next_u64`], but applied to a value rather than to
68/// generator state — this is the permutation mixer of spec §4.
69fn sm64_mix(mut z: u64) -> u64 {
70    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
71    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
72    z ^ (z >> 31)
73}
74
75/// Canonicalises `input` for fingerprinting.
76///
77/// Steps, in order: **NFC** via [`pith_unicode::nfc`] (so NFD and NFC
78/// spellings of the same text are indistinguishable downstream), then
79/// **lowercase** via `str::to_lowercase`, then **trailing whitespace is
80/// removed and exactly one `'\n'` is appended** — a single trailing
81/// newline is part of the canonical form, and any amount of trailing
82/// whitespace (spaces, tabs, `\r\n`, blank lines) is insignificant.
83///
84/// NFC runs *before* lowercasing because composition is defined on the
85/// original code points; lowercasing is applied to already-composed
86/// text so a composed letter and its lowercase form stay one unit.
87///
88/// [`canonicalize`] is idempotent: `canonicalize(canonicalize(x)) ==
89/// canonicalize(x)` for every input.
90#[must_use]
91pub fn canonicalize(input: &str) -> String {
92    let nfc = pith_unicode::nfc(input);
93    let lowered = nfc.to_lowercase();
94    let mut out = String::with_capacity(lowered.len() + 1);
95    out.push_str(lowered.trim_end());
96    out.push('\n');
97    out
98}
99
100/// FNV-1a 64 hash of one shingle: its words joined by a single ASCII
101/// space. Joining instead of hashing words separately keeps word
102/// boundaries significant (`"a bc"` ≠ `"ab c"`).
103fn shingle_hash(shingle: &[&str]) -> u64 {
104    fnv1a64(shingle.join(" ").as_bytes())
105}
106
107/// The 128 `seed_i` permutation seeds, drawn in order from
108/// `SplitMix64::new(SEED_STREAM)`.
109fn seeds() -> [u64; SIGNATURE_WORDS] {
110    let mut rng = SplitMix64::new(SEED_STREAM);
111    let mut out = [0u64; SIGNATURE_WORDS];
112    for slot in &mut out {
113        *slot = rng.next_u64();
114    }
115    out
116}
117
118/// MinHash over a slice of already-hashed set elements: word `i` is the
119/// minimum of `sm64_mix(element ^ seed_i)` over all elements. An empty
120/// slice yields `[u64::MAX; SIGNATURE_WORDS]` — the sentinel signature
121/// of the empty set.
122fn minhash(elements: &[u64]) -> [u64; SIGNATURE_WORDS] {
123    let seeds = seeds();
124    let mut out = [u64::MAX; SIGNATURE_WORDS];
125    for &e in elements {
126        for (slot, &seed) in out.iter_mut().zip(seeds.iter()) {
127            let h = sm64_mix(e ^ seed);
128            if h < *slot {
129                *slot = h;
130            }
131        }
132    }
133    out
134}
135
136/// The 128-word MinHash signature of `input`.
137///
138/// The input is [`canonicalize`]d, split into words, windowed into
139/// consecutive `k`-word shingles (`k = min(3, n)` — a document shorter
140/// than three words still contributes one shorter shingle rather than
141/// no signal), each shingle FNV-1a-hashed, and the shingle-hash set is
142/// MinHashed per spec §4: word `i` =
143/// `min over shingles of sm64_mix(fnv1a64(shingle) ^ seed_i)`.
144///
145/// The signature of the empty document is `[u64::MAX; 128]`.
146#[must_use]
147pub fn signature(input: &str) -> Vec<u64> {
148    let canonical = canonicalize(input);
149    let words: Vec<&str> = canonical.split_whitespace().collect();
150    let k = words.len().min(SHINGLE_WORDS);
151    let shingles: Vec<u64> = if k == 0 {
152        Vec::new()
153    } else {
154        words.windows(k).map(shingle_hash).collect()
155    };
156    minhash(&shingles).to_vec()
157}
158
159/// Estimates the Jaccard index of the two shingle sets that produced
160/// `a` and `b`: the fraction of equal signature words, returned as an
161/// `f64` in `[0.0, 1.0]`.
162///
163/// Only the common prefix is compared, so slices of unequal length are
164/// scored over `min(a.len(), b.len())` positions. Two empty slices
165/// score `1.0`; an empty slice against a non-empty one also compares
166/// zero positions and scores `1.0` — callers comparing signatures
167/// should always pass full [`SIGNATURE_WORDS`]-word signatures, where
168/// the estimate's standard error is about 0.04.
169#[must_use]
170pub fn jaccard_estimate(a: &[u64], b: &[u64]) -> f64 {
171    let n = a.len().min(b.len());
172    if n == 0 {
173        return 1.0;
174    }
175    let equal = a
176        .iter()
177        .zip(b.iter())
178        .take(n)
179        .filter(|(x, y)| x == y)
180        .count();
181    equal as f64 / n as f64
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use alloc::vec;
188
189    /// Splitmix64-drawn synthetic sets with a known overlap, then the
190    /// 128-word MinHash estimate must converge on the true Jaccard.
191    /// Tolerance per spec §6: `|err| <= 0.1` for 95% of pairs at n=128;
192    /// the observed outlier count is pinned so a broken permutation
193    /// mixer or a dropped seed turns this red instead of drifting.
194    #[test]
195    fn minhash_estimates_jaccard() {
196        let mut rng = SplitMix64::new(0xC0FF_EE11_2233_4455);
197        const PAIRS: usize = 200;
198        let mut out_of_tolerance = 0usize;
199        let mut err_sum = 0.0f64;
200        for _ in 0..PAIRS {
201            let sa = 30 + (rng.next_u64() % 90) as usize;
202            let sb = 30 + (rng.next_u64() % 90) as usize;
203            let overlap = (rng.next_u64() as usize) % (sa.min(sb) + 1);
204            let mut a = Vec::with_capacity(sa);
205            let mut b = Vec::with_capacity(sb);
206            for _ in 0..overlap {
207                let v = rng.next_u64();
208                a.push(v);
209                b.push(v);
210            }
211            while a.len() < sa {
212                a.push(rng.next_u64());
213            }
214            while b.len() < sb {
215                b.push(rng.next_u64());
216            }
217            let true_j = overlap as f64 / (sa + sb - overlap) as f64;
218            let est = jaccard_estimate(&minhash(&a), &minhash(&b));
219            let err = (est - true_j).abs();
220            err_sum += err;
221            if err > 0.1 {
222                out_of_tolerance += 1;
223            }
224        }
225        // Observed (deterministic): 1/200 pairs outside |err| <= 0.1 —
226        // far inside the 95% allowance of 10 — and mean |err| = 0.0283.
227        // Bounds are pinned just above the observation, so a broken
228        // permutation mixer, a broadcast seed or a dropped xor pushes
229        // the counts far past them instead of silently drifting.
230        assert!(
231            out_of_tolerance <= 4,
232            "{out_of_tolerance}/{PAIRS} pairs outside |err| <= 0.1 (95% bound allows 10)"
233        );
234        assert!(
235            err_sum / PAIRS as f64 <= 0.04,
236            "mean |err| {} exceeds the 0.04 bound",
237            err_sum / PAIRS as f64
238        );
239    }
240
241    /// The empty set MinHashes to the all-`u64::MAX` sentinel and every
242    /// non-empty set lands strictly below it on at least one word.
243    #[test]
244    fn minhash_empty_set_sentinel() {
245        assert_eq!(minhash(&[]), [u64::MAX; SIGNATURE_WORDS]);
246        let one = minhash(&[0]);
247        assert_ne!(one, [u64::MAX; SIGNATURE_WORDS]);
248        assert!(one.iter().any(|&w| w != u64::MAX));
249    }
250
251    /// Identical sets produce identical signatures; disjoint sets share
252    /// (almost) no words — 200 disjoint pairs must all estimate below
253    /// the 0.1 tolerance band around Jaccard 0.
254    #[test]
255    fn minhash_disjoint_sets_estimate_zero() {
256        let mut rng = SplitMix64::new(0xDEAD_BEEF_CAFE_F00D);
257        for _ in 0..50 {
258            let a: Vec<u64> = (0..40).map(|_| rng.next_u64()).collect();
259            let b: Vec<u64> = (0..40).map(|_| rng.next_u64()).collect();
260            assert!(jaccard_estimate(&minhash(&a), &minhash(&b)) <= 0.1);
261        }
262        let s: Vec<u64> = (0..40).map(|_| rng.next_u64()).collect();
263        assert_eq!(minhash(&s), minhash(&s));
264        assert_eq!(
265            vec![u64::MAX; SIGNATURE_WORDS].as_slice(),
266            minhash(&[]).as_slice()
267        );
268    }
269}