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}