//! `SemanticIntentRecognizer` — embedding-based semantic intent matching. //! //! Embeds utterances with [`crate::embedding`] (deterministic feature hashing) //! and runs an **exact in-memory cosine k-NN** over enrolled intent exemplars. //! On a match above the similarity threshold the exemplar's intent is returned, //! with slots extracted from the incoming utterance via an optional paired //! regex. Below threshold (or with an empty index) it delegates to the inner //! [`RegexIntentRecognizer`](crate::recognizer::RegexIntentRecognizer). //! //! For the small intent vocabularies HOMECORE deals with, an exact cosine scan //! is both faster and far more robust than an external ANN index — it has no //! storage backend, no cross-crate feature coupling, and is fully deterministic. //! Embeddings are L2-normalised, so cosine similarity is a plain dot product. //! //! Gated behind the default-on `semantic` feature. When disabled, a thin //! delegating wrapper keeps the public type available. use async_trait::async_trait; #[cfg(feature = "semantic")] use std::collections::HashMap; #[cfg(feature = "semantic")] use regex::Regex; use crate::intent::Intent; #[cfg(feature = "semantic")] use crate::intent::IntentName; use crate::recognizer::{IntentRecognizer, RecognizerError, RegexIntentRecognizer}; /// Default cosine-similarity threshold above which a semantic match is accepted. pub const DEFAULT_SIMILARITY_THRESHOLD: f32 = 0.75; /// One enrolled exemplar: a natural-language phrase mapped to an intent, with /// an optional regex to extract slots from the *incoming* utterance on a hit. #[cfg(feature = "semantic")] struct Exemplar { name: IntentName, language: String, /// Optional slot-extraction regex applied to the matched utterance. slot_regex: Option, /// L2-normalised embedding of the enrolled phrase, for cosine k-NN. vector: Vec, } /// Semantic recognizer backed by a real ruvector-core HNSW index. /// /// Enroll exemplar phrases with [`enroll`](Self::enroll); `recognize` embeds /// the utterance, runs k-NN search over the index, and accepts the nearest /// exemplar when its similarity clears the threshold. Below threshold (or when /// the index is empty) it delegates to the inner regex recognizer. #[cfg(feature = "semantic")] pub struct SemanticIntentRecognizer { fallback: RegexIntentRecognizer, index: std::sync::Arc>, threshold: f32, } #[cfg(feature = "semantic")] struct SemanticIndexInner { /// Enrolled exemplars in insertion order; the `Vec` index is the id. exemplars: Vec, } #[cfg(feature = "semantic")] impl SemanticIntentRecognizer { /// Build a semantic recognizer wrapping `fallback`, using the default /// similarity threshold. pub fn new(fallback: RegexIntentRecognizer) -> Self { Self::with_threshold(fallback, DEFAULT_SIMILARITY_THRESHOLD) } /// Build with an explicit similarity threshold in `[0, 1]`. pub fn with_threshold(fallback: RegexIntentRecognizer, threshold: f32) -> Self { Self { fallback, index: std::sync::Arc::new(tokio::sync::RwLock::new(SemanticIndexInner { exemplars: Vec::new(), })), threshold, } } /// Enroll an exemplar phrase for `name`/`language`. /// /// `slot_pattern`, if given, is a regex whose named capture groups are /// extracted from the *incoming* utterance when this exemplar wins, so /// semantic matches still produce slots (e.g. `entity_id`). pub async fn enroll( &self, name: impl Into, phrase: &str, language: impl Into, slot_pattern: Option<&str>, ) -> Result<(), RecognizerError> { let slot_regex = match slot_pattern { Some(p) => Some(Regex::new(p).map_err(|e| RecognizerError::BadPattern(e.to_string()))?), None => None, }; let vector = crate::embedding::embed(phrase); let mut inner = self.index.write().await; inner.exemplars.push(Exemplar { name: IntentName::new(name), language: language.into(), slot_regex, vector, }); Ok(()) } /// Embed `utterance` and return the best `(exemplar_id, similarity)` whose /// exemplar matches `language`, or `None` if the index is empty. async fn nearest(&self, utterance: &str, language: &str) -> Option<(usize, f32)> { let normalised = utterance.trim().to_lowercase(); let query = crate::embedding::embed(&normalised); // Exact in-memory cosine k-NN. Embeddings are L2-normalised, so cosine // similarity is a plain dot product (see `crate::embedding`). Returns the // best language-eligible exemplar, or `None` for an empty index. let inner = self.index.read().await; inner .exemplars .iter() .enumerate() .filter(|(_, e)| e.language == "*" || e.language == language) .map(|(id, e)| (id, crate::embedding::cosine_similarity(&query, &e.vector))) .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)) } /// Like [`recognize`](IntentRecognizer::recognize) but also returns the /// cosine similarity of the winning exemplar (or the best below-threshold /// candidate). Exposed so callers/tests can see the real match score. pub async fn recognize_scored( &self, utterance: &str, language: &str, ) -> Result<(Option, Option), RecognizerError> { if let Some((id, similarity)) = self.nearest(utterance, language).await { if similarity >= self.threshold { let inner = self.index.read().await; let exemplar = &inner.exemplars[id]; let mut slots: HashMap = HashMap::new(); if let Some(re) = &exemplar.slot_regex { if let Some(caps) = re.captures(&utterance.trim().to_lowercase()) { for cap_name in re.capture_names().flatten() { if let Some(m) = caps.name(cap_name) { slots.insert( cap_name.to_owned(), serde_json::Value::String(m.as_str().to_owned()), ); } } } } return Ok(( Some(Intent { name: exemplar.name.clone(), slots, language: language.to_owned(), }), Some(similarity), )); } // Below threshold — fall back to regex but still report the score. let regex_hit = self.fallback.recognize(utterance, language).await?; return Ok((regex_hit, Some(similarity))); } // Empty index — pure regex fallback. Ok((self.fallback.recognize(utterance, language).await?, None)) } } #[cfg(feature = "semantic")] #[async_trait] impl IntentRecognizer for SemanticIntentRecognizer { async fn recognize( &self, utterance: &str, language: &str, ) -> Result, RecognizerError> { let (intent, _score) = self.recognize_scored(utterance, language).await?; Ok(intent) } } /// Fallback definition when the `semantic` feature is disabled: a thin /// delegating wrapper, so downstream code compiles without ruvector-core. #[cfg(not(feature = "semantic"))] pub struct SemanticIntentRecognizer { fallback: RegexIntentRecognizer, } #[cfg(not(feature = "semantic"))] impl SemanticIntentRecognizer { pub fn new(fallback: RegexIntentRecognizer) -> Self { Self { fallback } } } #[cfg(not(feature = "semantic"))] #[async_trait] impl IntentRecognizer for SemanticIntentRecognizer { async fn recognize( &self, utterance: &str, language: &str, ) -> Result, RecognizerError> { // Without the `semantic` feature there is no embedding/HNSW facility; // delegate to regex (honest: no semantic capability compiled in). self.fallback.recognize(utterance, language).await } } #[cfg(test)] mod tests { use super::*; use crate::recognizer::RegexIntentRecognizer; async fn turn_on_recognizer() -> RegexIntentRecognizer { let r = RegexIntentRecognizer::new(); r.register( "HassTurnOn", r"turn on (?:the )?(?P[a-z_][a-z0-9_ ]*(?:\.[a-z_][a-z0-9_]*)?)", "*", ) .await .unwrap(); r } #[tokio::test] async fn semantic_recognizer_delegates_to_fallback() { // No exemplars enrolled → empty HNSW index → pure regex fallback. let semantic = SemanticIntentRecognizer::new(turn_on_recognizer().await); let result = semantic .recognize("turn on light.kitchen", "en") .await .unwrap(); assert!(result.is_some()); } // ── Real HNSW-backed semantic matching (default `semantic` feature) ─────── #[cfg(feature = "semantic")] async fn enrolled_semantic() -> SemanticIntentRecognizer { // Regex fallback is empty so any positive result comes from HNSW search. let semantic = SemanticIntentRecognizer::new(RegexIntentRecognizer::new()); semantic .enroll( "HassTurnOn", "turn on the light", "en", Some(r"(?:turn on|switch on) (?:the )?(?P[a-z_][a-z0-9_ ]*(?:\.[a-z_][a-z0-9_]*)?)"), ) .await .unwrap(); semantic .enroll("HassNevermind", "never mind cancel that", "en", None) .await .unwrap(); semantic .enroll("HassGetWeather", "what is the weather forecast", "en", None) .await .unwrap(); semantic } #[cfg(feature = "semantic")] #[tokio::test] async fn semantic_matches_enrolled_paraphrase_with_real_score() { // FAILS against the old delegate-only stub: regex fallback is empty, // so the only way to get a hit is real embedding + HNSW search. let semantic = enrolled_semantic().await; let (intent, score) = semantic .recognize_scored("turn on the kitchen light", "en") .await .unwrap(); let intent = intent.expect("paraphrase of an enrolled exemplar must match"); assert_eq!(intent.name.as_str(), "HassTurnOn"); let sim = score.expect("a semantic match must report a similarity"); assert!( sim >= DEFAULT_SIMILARITY_THRESHOLD, "match similarity {sim:.4} must clear threshold {DEFAULT_SIMILARITY_THRESHOLD}" ); // Slots extracted from the *incoming* utterance via the paired regex. assert_eq!(intent.entity_id(), Some("kitchen light")); } #[cfg(feature = "semantic")] #[tokio::test] async fn semantic_no_match_for_unknown_utterance_with_real_score() { let semantic = enrolled_semantic().await; let (intent, score) = semantic .recognize_scored("schedule a dentist appointment", "en") .await .unwrap(); assert!(intent.is_none(), "unrelated utterance must not match any intent"); let sim = score.expect("even a no-match reports the best similarity seen"); assert!( sim < DEFAULT_SIMILARITY_THRESHOLD, "no-match similarity {sim:.4} must be below threshold {DEFAULT_SIMILARITY_THRESHOLD}" ); } #[cfg(feature = "semantic")] #[tokio::test] async fn semantic_match_outscores_no_match() { let semantic = enrolled_semantic().await; let (_, hit_score) = semantic .recognize_scored("please turn on the lights", "en") .await .unwrap(); let (_, miss_score) = semantic .recognize_scored("order a pizza for dinner", "en") .await .unwrap(); let hit = hit_score.unwrap(); let miss = miss_score.unwrap(); assert!( hit > miss, "enrolled paraphrase ({hit:.4}) must score above unrelated ({miss:.4})" ); } #[cfg(feature = "semantic")] #[tokio::test] async fn semantic_falls_back_to_regex_below_threshold() { // Enroll a weak exemplar; arrange a regex fallback that DOES match so we // prove the fallback path runs when similarity is below threshold. let semantic = SemanticIntentRecognizer::new(turn_on_recognizer().await); semantic .enroll("HassGetWeather", "what is the weather forecast", "en", None) .await .unwrap(); // This utterance is unrelated to the weather exemplar (low similarity) // but matches the regex fallback's HassTurnOn pattern. let (intent, score) = semantic .recognize_scored("turn on light.kitchen", "en") .await .unwrap(); let intent = intent.expect("regex fallback must catch this"); assert_eq!(intent.name.as_str(), "HassTurnOn"); let sim = score.expect("semantic score still reported on fallback"); assert!(sim < DEFAULT_SIMILARITY_THRESHOLD, "expected low sim, got {sim:.4}"); } }