use crate::frontend::Token;
use super::normalize::{NormAtom, NormToken};
const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) struct ContentDigest([u8; 16]);
impl ContentDigest {
#[cfg(test)]
pub(crate) const fn from_bytes(bytes: [u8; 16]) -> Self {
Self(bytes)
}
}
#[derive(Debug, Clone, Copy)]
struct Fnv(u64);
impl Fnv {
const fn new() -> Self {
Self(FNV_OFFSET)
}
const fn byte(mut self, b: u8) -> Self {
self.0 ^= b as u64;
self.0 = self.0.wrapping_mul(FNV_PRIME);
self
}
fn bytes(mut self, bytes: &[u8]) -> Self {
for &b in bytes {
self = self.byte(b);
}
self
}
const fn finish(self) -> u64 {
self.0
}
}
#[must_use]
pub fn raw_token_hash(token: &Token) -> u64 {
Fnv::new()
.byte(token.kind.tag())
.bytes(token.text.as_bytes())
.finish()
}
#[must_use]
pub fn norm_token_hash(token: &NormToken<'_>) -> u64 {
let h = Fnv::new().byte(token.tag);
match token.atom {
NormAtom::Renamed(n) => h.byte(1).bytes(&n.to_le_bytes()),
NormAtom::Text(text) => h.byte(2).bytes(text.as_bytes()),
NormAtom::Literal(class) => h.byte(3).byte(class),
}
.finish()
}
#[must_use]
pub fn raw_sequence_hash(tokens: &[Token]) -> u64 {
tokens
.iter()
.fold(Fnv::new(), |h, t| h.bytes(&raw_token_hash(t).to_le_bytes()))
.finish()
}
#[must_use]
pub fn norm_sequence_hash(tokens: &[NormToken<'_>]) -> u64 {
tokens
.iter()
.fold(Fnv::new(), |h, t| {
h.bytes(&norm_token_hash(t).to_le_bytes())
})
.finish()
}
#[must_use]
pub(crate) fn raw_sequence_digest(tokens: &[Token]) -> ContentDigest {
let mut hasher = sequence_digest_hasher("codehelion/group/raw/v1", tokens.len());
for token in tokens {
hasher.update(&[token.kind.tag()]);
write_bytes(&mut hasher, token.text.as_bytes());
}
finish_digest(&hasher)
}
#[must_use]
pub(crate) fn norm_sequence_digest(tokens: &[NormToken<'_>]) -> ContentDigest {
let mut hasher = sequence_digest_hasher("codehelion/group/normalized/v1", tokens.len());
for token in tokens {
hasher.update(&[token.tag]);
match token.atom {
NormAtom::Renamed(value) => {
hasher.update(&[1]);
hasher.update(&value.to_le_bytes());
}
NormAtom::Text(text) => {
hasher.update(&[2]);
write_bytes(&mut hasher, text.as_bytes());
}
NormAtom::Literal(class) => {
hasher.update(&[3, class]);
}
}
}
finish_digest(&hasher)
}
fn sequence_digest_hasher(domain: &str, token_count: usize) -> blake3::Hasher {
let mut hasher = blake3::Hasher::new();
write_bytes(&mut hasher, domain.as_bytes());
hasher.update(&u64::try_from(token_count).unwrap_or(u64::MAX).to_le_bytes());
hasher
}
fn write_bytes(hasher: &mut blake3::Hasher, bytes: &[u8]) {
hasher.update(&u64::try_from(bytes.len()).unwrap_or(u64::MAX).to_le_bytes());
hasher.update(bytes);
}
fn finish_digest(hasher: &blake3::Hasher) -> ContentDigest {
let mut bytes = [0; 16];
bytes.copy_from_slice(&hasher.finalize().as_bytes()[..16]);
ContentDigest(bytes)
}
#[must_use]
pub fn kgram_hashes(units: &[u64], k: usize) -> Vec<u64> {
const B: u64 = FNV_PRIME;
if k == 0 || units.len() < k {
return Vec::new();
}
let pow = B.wrapping_pow(u32::try_from(k - 1).unwrap_or(u32::MAX));
let mut out = Vec::with_capacity(units.len() - k + 1);
let mut h: u64 = 0;
for &u in &units[..k] {
h = h.wrapping_mul(B).wrapping_add(u);
}
out.push(h);
for i in k..units.len() {
h = h
.wrapping_sub(units[i - k].wrapping_mul(pow))
.wrapping_mul(B)
.wrapping_add(units[i]);
out.push(h);
}
out
}
#[must_use]
pub fn winnow(hashes: &[u64], w: usize) -> Vec<(u64, usize)> {
use std::collections::VecDeque;
if hashes.is_empty() || w == 0 {
return Vec::new();
}
if hashes.len() < w {
let mut best = 0usize;
for (i, &h) in hashes.iter().enumerate() {
if h <= hashes[best] {
best = i;
}
}
return vec![(hashes[best], best)];
}
let mut candidates = VecDeque::with_capacity(w);
let mut picks = Vec::with_capacity(hashes.len().div_ceil(w));
for (index, &hash) in hashes.iter().enumerate() {
while candidates
.back()
.is_some_and(|&previous| hashes[previous] >= hash)
{
candidates.pop_back();
}
candidates.push_back(index);
if index + 1 < w {
continue;
}
let start = index + 1 - w;
while candidates.front().is_some_and(|&previous| previous < start) {
candidates.pop_front();
}
let best = *candidates.front().unwrap_or(&index);
if picks.last().is_none_or(|&(_, previous)| previous != best) {
picks.push((hashes[best], best));
}
}
picks
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn kgram_count_and_rolling_consistency() {
let units: Vec<u64> = (0..40u64).map(|i| i.wrapping_mul(0x9e37_79b9)).collect();
let k = 5;
let hashes = kgram_hashes(&units, k);
assert_eq!(hashes.len(), units.len() - k + 1);
for (i, &h) in hashes.iter().enumerate() {
let direct = units[i..i + k]
.iter()
.fold(0u64, |acc, &u| acc.wrapping_mul(FNV_PRIME).wrapping_add(u));
assert_eq!(h, direct, "gram {i}");
}
}
#[test]
fn kgram_short_input_is_empty() {
assert!(kgram_hashes(&[1, 2, 3], 4).is_empty());
assert!(kgram_hashes(&[], 1).is_empty());
}
#[test]
fn winnow_covers_every_window() {
let hashes: Vec<u64> = (0..100u64).map(|i| i.wrapping_mul(0x517c_c1b7)).collect();
let w = 4;
let picks = winnow(&hashes, w);
let picked: std::collections::BTreeSet<usize> = picks.iter().map(|&(_, i)| i).collect();
for start in 0..=(hashes.len() - w) {
assert!(
(start..start + w).any(|i| picked.contains(&i)),
"window at {start} has no pick"
);
}
}
#[test]
fn winnow_short_input_selects_global_min() {
let hashes = [50u64, 10, 30];
let picks = winnow(&hashes, 8);
assert_eq!(picks, vec![(10, 1)]);
}
#[test]
fn winnow_is_deterministic() {
let hashes: Vec<u64> = (0..64u64).map(|i| i ^ (i << 3)).collect();
assert_eq!(winnow(&hashes, 4), winnow(&hashes, 4));
}
#[test]
fn winnow_matches_window_rescanning_for_ties_and_every_window_size() {
fn reference(hashes: &[u64], w: usize) -> Vec<(u64, usize)> {
use std::collections::BTreeSet;
if hashes.is_empty() || w == 0 {
return Vec::new();
}
let mut picks = BTreeSet::new();
for start in 0..hashes.len().saturating_sub(w).saturating_add(1) {
let end = (start + w).min(hashes.len());
let best = (start..end).min_by_key(|&index| (hashes[index], usize::MAX - index));
if let Some(best) = best {
picks.insert((best, hashes[best]));
}
}
picks
.into_iter()
.map(|(index, hash)| (hash, index))
.collect()
}
let hashes = [9, 4, 4, 7, 2, 2, 2, 5, 1, 1, 8, 3];
for w in 0..=hashes.len() + 2 {
assert_eq!(winnow(&hashes, w), reference(&hashes, w), "window {w}");
}
}
#[test]
fn sequence_hash_distinguishes_order_and_content() {
use crate::engine::normalize::{NormAtom, NormToken};
let a = [
NormToken {
tag: 1,
atom: NormAtom::Renamed(0),
},
NormToken {
tag: 4,
atom: NormAtom::Text("+"),
},
];
let b = [
NormToken {
tag: 4,
atom: NormAtom::Text("+"),
},
NormToken {
tag: 1,
atom: NormAtom::Renamed(0),
},
];
assert_ne!(norm_sequence_hash(&a), norm_sequence_hash(&b));
assert_eq!(norm_sequence_hash(&a), norm_sequence_hash(&a));
}
}