//! Deterministic text embedding for semantic intent matching. //! //! No ML model dependency: utterances are embedded with the classic //! **feature-hashing** (hashing-vectorizer) technique. Each n-gram feature is //! hashed into a fixed-width vector; a second sign-hash decides whether the //! feature adds or subtracts, which keeps the expected dot-product unbiased //! under collisions. The vector is L2-normalised so that cosine similarity is //! a clean `1 - distance`. //! //! Features used per utterance: //! - **word unigrams** — whole tokens after lowercasing/trimming punctuation. //! - **character trigrams** — sliding 3-grams over each padded token, which //! gives partial-overlap credit ("kitchen" ~ "kitchens") and robustness to //! small lexical variation. //! //! This is intentionally *lexical-semantic*: paraphrases that share tokens //! ("turn on the light" vs "turn on the kitchen light") land close together, //! while unrelated utterances ("play jazz music") land far apart. It is a real, //! reproducible similarity signal — not a hash that ignores meaning. //! //! The output dimension matches [`EMBEDDING_DIM`] and is consumed directly by //! the exact in-memory cosine k-NN in `crate::semantic_recognizer`. /// Dimensionality of the hashed embedding space. /// /// 256 buckets keeps collisions low for the small intent vocabularies HOMECORE /// deals with while staying cheap to index in HNSW. pub const EMBEDDING_DIM: usize = 256; // FNV-1a 64 constants — small, fast, well-distributed for feature hashing. const FNV_OFFSET_BASIS_64: u64 = 0xcbf2_9ce4_8422_2325; const FNV_PRIME_64: u64 = 0x0000_0100_0000_01b3; #[inline] fn fnv1a64(seed: u64, bytes: &[u8]) -> u64 { let mut hash = seed; for &b in bytes { hash ^= u64::from(b); hash = hash.wrapping_mul(FNV_PRIME_64); } hash } /// Accumulate one hashed feature into `acc` with signed weight. #[inline] fn add_feature(acc: &mut [f32], feature: &[u8], weight: f32) { let h = fnv1a64(FNV_OFFSET_BASIS_64, feature); let bucket = (h % EMBEDDING_DIM as u64) as usize; // Independent sign hash (different seed) → unbiased under collisions. let sign = if fnv1a64(0x100, feature) & 1 == 0 { 1.0 } else { -1.0 }; acc[bucket] += sign * weight; } /// Normalise text: lowercase, keep alphanumerics, split on everything else. fn tokenize(text: &str) -> Vec { text.to_lowercase() .split(|c: char| !c.is_alphanumeric()) .filter(|s| !s.is_empty()) .map(|s| s.to_owned()) .collect() } /// Embed an utterance into a deterministic, L2-normalised vector. /// /// Returns a zero vector only for input with no alphanumeric content. pub fn embed(text: &str) -> Vec { let mut acc = vec![0.0_f32; EMBEDDING_DIM]; let tokens = tokenize(text); for tok in &tokens { // Word unigram — weighted higher than sub-word features. add_feature(&mut acc, format!("w:{tok}").as_bytes(), 1.5); // Character trigrams over a padded token so prefixes/suffixes count. let padded: Vec = format!("^{tok}$").chars().collect(); if padded.len() >= 3 { for window in padded.windows(3) { let gram: String = window.iter().collect(); add_feature(&mut acc, format!("c:{gram}").as_bytes(), 1.0); } } } l2_normalise(&mut acc); acc } /// L2-normalise in place; no-op for the zero vector. fn l2_normalise(v: &mut [f32]) { let norm = v.iter().map(|x| x * x).sum::().sqrt(); if norm > 1e-12 { for x in v.iter_mut() { *x /= norm; } } } /// Cosine similarity of two equal-length vectors (dot product of unit vectors). /// /// Exposed for tests and for callers that want similarity without round-tripping /// through the HNSW index. pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 { debug_assert_eq!(a.len(), b.len()); a.iter().zip(b).map(|(x, y)| x * y).sum() } #[cfg(test)] mod tests { use super::*; #[test] fn embedding_has_correct_dim() { assert_eq!(embed("turn on the light").len(), EMBEDDING_DIM); } #[test] fn embedding_is_deterministic() { assert_eq!(embed("turn on the light"), embed("turn on the light")); } #[test] fn embedding_is_unit_norm() { let v = embed("turn on the kitchen light"); let norm_sq: f32 = v.iter().map(|x| x * x).sum(); assert!((norm_sq - 1.0).abs() < 1e-4, "norm^2 = {norm_sq}"); } #[test] fn empty_input_is_zero_vector() { let v = embed("!!! ???"); assert!(v.iter().all(|x| *x == 0.0)); } #[test] fn paraphrase_is_more_similar_than_unrelated() { let exemplar = embed("turn on the light"); let paraphrase = embed("turn on the kitchen light"); let unrelated = embed("play some jazz music"); let sim_para = cosine_similarity(&exemplar, ¶phrase); let sim_unrel = cosine_similarity(&exemplar, &unrelated); assert!( sim_para > sim_unrel, "paraphrase ({sim_para:.3}) must beat unrelated ({sim_unrel:.3})" ); // Real, non-trivial separation. assert!(sim_para > 0.5, "paraphrase similarity too low: {sim_para:.3}"); assert!(sim_unrel < 0.3, "unrelated similarity too high: {sim_unrel:.3}"); } #[test] fn identical_text_is_similarity_one() { let a = embed("lock the front door"); let b = embed("lock the front door"); let sim = cosine_similarity(&a, &b); assert!((sim - 1.0).abs() < 1e-4, "sim = {sim}"); } }