//! The `MinHash` sketch itself.
use core::fmt;
use serde::{Deserialize, Serialize};
/// Number of lanes (independent min-hash functions) in a sketch.
///
/// 64 lanes bounds the standard error of the Jaccard estimate at
/// `1/sqrt(64) = 12.5%`. Enough to separate "same topic" from "unrelated";
/// deliberately not enough to fingerprint fine structure.
pub const SKETCH_LANES: usize = 64;
/// Estimated Jaccard similarity in `[0, 1]`, stored as permille.
///
/// A `u16` rather than a float so similarity is `Eq`/`Ord` and thresholds
/// never hit float-comparison traps.
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default,
)]
#[serde(transparent)]
pub struct Similarity(u16);
impl Similarity {
/// No lanes agree.
pub const NONE: Self = Self(0);
/// Every lane agrees.
pub const IDENTICAL: Self = Self(1000);
/// Constructs a similarity from permille, saturating at 1000.
#[must_use]
pub const fn from_permille(v: u16) -> Self {
if v > 1000 { Self(1000) } else { Self(v) }
}
/// The value in permille (0..=1000).
#[must_use]
pub const fn permille(self) -> u16 {
self.0
}
}
impl fmt::Display for Similarity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}.{:01}%", self.0 / 10, self.0 % 10)
}
}
/// A `MinHash` sketch: 64 `u64` minima over the input's shingles.
///
/// There is no accessor that returns, reconstructs, or iterates input
/// content, because none can exist: the minima are one-way images of the
/// shingles that produced them. The only observations a holder can make are
/// equality and [`similarity`](Self::similarity).
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct MinHashSketch(pub(crate) [u64; SKETCH_LANES]);
// Serde impls are written by hand because derive stops at 32-element arrays.
// The wire form is a fixed-length sequence of 64 u64 lane minima.
impl Serialize for MinHashSketch {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeTuple as _;
let mut t = s.serialize_tuple(SKETCH_LANES)?;
for lane in &self.0 {
t.serialize_element(lane)?;
}
t.end()
}
}
impl<'de> Deserialize<'de> for MinHashSketch {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct V;
impl<'de> serde::de::Visitor<'de> for V {
type Value = MinHashSketch;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "a sequence of {SKETCH_LANES} u64 lane minima")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(
self,
mut seq: A,
) -> Result<MinHashSketch, A::Error> {
let mut lanes = [0u64; SKETCH_LANES];
for (i, lane) in lanes.iter_mut().enumerate() {
*lane = seq
.next_element()?
.ok_or_else(|| serde::de::Error::invalid_length(i, &self))?;
}
Ok(MinHashSketch(lanes))
}
}
d.deserialize_tuple(SKETCH_LANES, V)
}
}
impl MinHashSketch {
/// Estimated Jaccard similarity: the fraction of lanes that agree.
#[must_use]
pub fn similarity(&self, other: &Self) -> Similarity {
let equal = self
.0
.iter()
.zip(other.0.iter())
.filter(|(a, b)| a == b)
.count();
// equal <= 64, so the arithmetic stays far inside u16.
let permille = (equal * 1000) / SKETCH_LANES;
Similarity::from_permille(u16::try_from(permille).unwrap_or(1000))
}
/// Number of lanes. Exposed so narration can say what a sketch is.
#[must_use]
pub const fn lanes(&self) -> usize {
SKETCH_LANES
}
}
impl fmt::Debug for MinHashSketch {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// Deliberately abbreviated: even the debug form shows the shape, not
// the 64 minima, so a stray log line stays boring.
write!(f, "MinHashSketch({SKETCH_LANES} lanes)")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identical_sketches_are_identical() {
let s = MinHashSketch([7; SKETCH_LANES]);
assert_eq!(s.similarity(&s), Similarity::IDENTICAL);
}
#[test]
fn disjoint_sketches_score_zero() {
let a = MinHashSketch(core::array::from_fn(|i| i as u64));
let b = MinHashSketch(core::array::from_fn(|i| (i + 1000) as u64));
assert_eq!(a.similarity(&b), Similarity::NONE);
}
#[test]
fn similarity_saturates() {
assert_eq!(Similarity::from_permille(2000), Similarity::IDENTICAL);
}
}