use polyc_eventlog::nav::distinct_terms_of;
const TERM_HASH_BITS: u32 = 24;
const TERM_HASH_MASK: u32 = (1 << TERM_HASH_BITS) - 1;
const TERM_DOMAIN: &[u8] = b"polychrome.search.term.v1";
const KEY_ID_DOMAIN: &[u8] = b"polychrome.search.key-id.v1";
#[derive(Clone)]
pub(crate) struct TermKey([u8; 32]);
impl TermKey {
pub(crate) const fn new(secret: [u8; 32]) -> Self {
Self(secret)
}
pub(crate) fn key_id(&self) -> String {
let mut hasher = blake3::Hasher::new_keyed(&self.0);
hasher.update(KEY_ID_DOMAIN);
hasher
.finalize()
.as_bytes()
.iter()
.take(16)
.fold(String::new(), |mut out, byte| {
use std::fmt::Write as _;
let _ = write!(out, "{byte:02x}");
out
})
}
pub(crate) fn hash_term(&self, term: &str) -> u32 {
let mut hasher = blake3::Hasher::new_keyed(&self.0);
hasher.update(TERM_DOMAIN);
hasher.update(&[0x00]);
hasher.update(term.as_bytes());
let digest = hasher.finalize();
let bytes = digest.as_bytes();
u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) & TERM_HASH_MASK
}
pub(crate) fn hash_text(&self, text: &str) -> Vec<u32> {
let mut hashes: Vec<u32> = distinct_terms_of(text)
.iter()
.filter(|term| term.is_ascii())
.map(|term| self.hash_term(term))
.collect();
hashes.sort_unstable();
hashes.dedup();
hashes
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
fn key() -> TermKey {
TermKey::new([7u8; 32])
}
#[test]
fn hashes_are_stable_for_the_same_key_and_term() {
assert_eq!(key().hash_term("timeout"), key().hash_term("timeout"));
}
#[test]
fn hashes_fit_the_declared_width() {
for term in ["a", "timeout", "polychrome", "0123456789"] {
assert!(
key().hash_term(term) <= TERM_HASH_MASK,
"{term} hashed outside the {TERM_HASH_BITS}-bit space"
);
}
}
#[test]
fn a_different_key_yields_a_different_hash() {
let other = TermKey::new([9u8; 32]);
assert_ne!(key().hash_term("timeout"), other.hash_term("timeout"));
}
#[test]
fn text_hashes_are_sorted_deduplicated_and_case_insensitive() {
let hashes = key().hash_text("Timeout timeout TIMEOUT decide");
assert_eq!(hashes.len(), 2, "one hash per distinct term: {hashes:?}");
assert!(hashes.windows(2).all(|w| w[0] < w[1]), "sorted and deduped");
assert_eq!(hashes, key().hash_text("decide timeout"));
}
#[test]
fn tokenization_matches_the_live_lexical_core() {
assert_eq!(
key().hash_text("where-did/we decide?"),
key().hash_text("we did where decide")
);
}
#[test]
fn non_ascii_terms_are_skipped_at_index_time() {
let hashes = key().hash_text("timeout \u{4f60}\u{597d} decide");
assert_eq!(
hashes,
key().hash_text("timeout decide"),
"a non-ASCII term must not occupy a slot it can never match from"
);
}
#[test]
fn key_id_is_stable_for_the_same_key() {
assert_eq!(key().key_id(), key().key_id());
}
#[test]
fn a_different_key_yields_a_different_key_id() {
assert_ne!(key().key_id(), TermKey::new([9u8; 32]).key_id());
}
#[test]
fn the_key_id_is_not_derivable_as_a_term_hash() {
let id = key().key_id();
assert!(!id.is_empty());
assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn empty_and_punctuation_only_text_yields_no_hashes() {
assert!(key().hash_text("").is_empty());
assert!(key().hash_text(" --- ??? ").is_empty());
}
}