1use std::collections::HashMap;
29
30#[derive(Clone, Debug, PartialEq, Eq, Hash)]
36pub enum IntentKind {
37 Informational,
39 Navigational,
41 Transactional,
43 Exploratory,
45 Custom { label: String },
47}
48
49#[derive(Clone, Debug)]
55pub struct IntentPrototype {
56 pub intent: IntentKind,
58 pub embedding: Vec<f32>,
60 pub weight: f32,
63 pub example_count: u32,
65}
66
67#[derive(Clone, Debug)]
73pub struct IntentClassification {
74 pub intent: IntentKind,
76 pub confidence: f32,
79 pub runner_up: Option<IntentKind>,
82}
83
84#[derive(Clone, Debug)]
90pub struct ClassifierConfig {
91 pub min_confidence: f32,
96}
97
98impl Default for ClassifierConfig {
99 fn default() -> Self {
100 Self {
101 min_confidence: 0.3,
102 }
103 }
104}
105
106#[derive(Clone, Debug, Default)]
112pub struct ClassifierStats {
113 pub total_queries: u64,
115 pub classified: u64,
117 pub unclassified: u64,
119 pub by_intent: HashMap<String, u64>,
121}
122
123fn intent_label(intent: &IntentKind) -> String {
129 match intent {
130 IntentKind::Informational => "informational".to_owned(),
131 IntentKind::Navigational => "navigational".to_owned(),
132 IntentKind::Transactional => "transactional".to_owned(),
133 IntentKind::Exploratory => "exploratory".to_owned(),
134 IntentKind::Custom { label } => label.clone(),
135 }
136}
137
138fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
143 if a.is_empty() || a.len() != b.len() {
144 return 0.0;
145 }
146 let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
147 let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
148 let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
149 if norm_a < 1e-9 || norm_b < 1e-9 {
150 return 0.0;
151 }
152 (dot / (norm_a * norm_b)).clamp(-1.0, 1.0)
153}
154
155#[derive(Debug)]
162pub struct SemanticIntentClassifier {
163 prototypes: Vec<IntentPrototype>,
165 config: ClassifierConfig,
167 stats: ClassifierStats,
169}
170
171impl SemanticIntentClassifier {
172 pub fn new(config: ClassifierConfig) -> Self {
174 Self {
175 prototypes: Vec::new(),
176 config,
177 stats: ClassifierStats::default(),
178 }
179 }
180
181 pub fn register_prototype(&mut self, prototype: IntentPrototype) {
187 self.prototypes.push(prototype);
188 }
189
190 pub fn classify(&mut self, query_embedding: &[f32]) -> Option<IntentClassification> {
199 self.stats.total_queries += 1;
200
201 if self.prototypes.is_empty() {
202 self.stats.unclassified += 1;
203 return None;
204 }
205
206 let mut intent_scores: HashMap<String, (IntentKind, f32)> = HashMap::new();
211
212 for proto in &self.prototypes {
213 let sim = cosine_similarity(query_embedding, &proto.embedding);
214 let weighted = sim * proto.weight;
215 let label = intent_label(&proto.intent);
216 let entry = intent_scores
217 .entry(label)
218 .or_insert_with(|| (proto.intent.clone(), f32::NEG_INFINITY));
219 if weighted > entry.1 {
220 entry.1 = weighted;
221 }
222 }
223
224 let mut ranked: Vec<(IntentKind, f32)> = intent_scores.into_values().collect();
226 ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
227
228 let (best_intent, best_score) = match ranked.first() {
229 Some(pair) => pair,
230 None => {
231 self.stats.unclassified += 1;
232 return None;
233 }
234 };
235
236 if *best_score < self.config.min_confidence {
237 self.stats.unclassified += 1;
238 return None;
239 }
240
241 let runner_up = ranked.get(1).and_then(|(intent, score)| {
243 if best_score - score >= 0.1 {
244 Some(intent.clone())
245 } else {
246 None
247 }
248 });
249
250 self.stats.classified += 1;
252 let label = intent_label(best_intent);
253 *self.stats.by_intent.entry(label).or_insert(0) += 1;
254
255 Some(IntentClassification {
256 intent: best_intent.clone(),
257 confidence: *best_score,
258 runner_up,
259 })
260 }
261
262 pub fn remove_prototype(&mut self, intent: &IntentKind) {
264 self.prototypes.retain(|p| &p.intent != intent);
265 }
266
267 pub fn stats(&self) -> &ClassifierStats {
269 &self.stats
270 }
271
272 pub fn prototype_count(&self) -> usize {
274 self.prototypes.len()
275 }
276}
277
278#[cfg(test)]
283mod tests {
284 use super::*;
285
286 fn unit(dim: usize, axis: usize) -> Vec<f32> {
288 let mut v = vec![0.0f32; dim];
289 v[axis] = 1.0;
290 v
291 }
292
293 fn make_classifier() -> SemanticIntentClassifier {
294 SemanticIntentClassifier::new(ClassifierConfig::default())
295 }
296
297 fn proto(intent: IntentKind, embedding: Vec<f32>) -> IntentPrototype {
298 IntentPrototype {
299 intent,
300 embedding,
301 weight: 1.0,
302 example_count: 5,
303 }
304 }
305
306 #[test]
311 fn test_register_prototype_increments_count() {
312 let mut c = make_classifier();
313 assert_eq!(c.prototype_count(), 0);
314 c.register_prototype(proto(IntentKind::Informational, unit(3, 0)));
315 assert_eq!(c.prototype_count(), 1);
316 c.register_prototype(proto(IntentKind::Navigational, unit(3, 1)));
317 assert_eq!(c.prototype_count(), 2);
318 }
319
320 #[test]
325 fn test_classify_empty_prototypes_returns_none() {
326 let mut c = make_classifier();
327 assert!(c.classify(&[0.5, 0.5, 0.0]).is_none());
328 }
329
330 #[test]
335 fn test_classify_returns_correct_intent() {
336 let mut c = make_classifier();
337 c.register_prototype(proto(IntentKind::Informational, unit(3, 0)));
338 c.register_prototype(proto(IntentKind::Navigational, unit(3, 1)));
339 let result = c.classify(&[0.99, 0.01, 0.0]).expect("should classify");
341 assert_eq!(result.intent, IntentKind::Informational);
342 }
343
344 #[test]
345 fn test_classify_navigational_intent() {
346 let mut c = make_classifier();
347 c.register_prototype(proto(IntentKind::Informational, unit(3, 0)));
348 c.register_prototype(proto(IntentKind::Navigational, unit(3, 1)));
349 let result = c.classify(&[0.01, 0.99, 0.0]).expect("should classify");
350 assert_eq!(result.intent, IntentKind::Navigational);
351 }
352
353 #[test]
354 fn test_classify_transactional_intent() {
355 let mut c = make_classifier();
356 c.register_prototype(proto(IntentKind::Transactional, unit(3, 2)));
357 let result = c.classify(&[0.0, 0.0, 1.0]).expect("should classify");
358 assert_eq!(result.intent, IntentKind::Transactional);
359 }
360
361 #[test]
362 fn test_classify_exploratory_intent() {
363 let mut c = make_classifier();
364 c.register_prototype(proto(IntentKind::Exploratory, vec![1.0, 1.0, 0.0]));
365 let result = c
367 .classify(&[1.0_f32 / 2.0_f32.sqrt(), 1.0_f32 / 2.0_f32.sqrt(), 0.0])
368 .expect("should classify");
369 assert_eq!(result.intent, IntentKind::Exploratory);
370 }
371
372 #[test]
377 fn test_min_confidence_threshold_rejects_low_similarity() {
378 let config = ClassifierConfig {
379 min_confidence: 0.9,
380 };
381 let mut c = SemanticIntentClassifier::new(config);
382 c.register_prototype(proto(IntentKind::Informational, unit(3, 0)));
384 assert!(c.classify(&unit(3, 1)).is_none());
385 }
386
387 #[test]
388 fn test_min_confidence_threshold_accepts_high_similarity() {
389 let config = ClassifierConfig {
390 min_confidence: 0.5,
391 };
392 let mut c = SemanticIntentClassifier::new(config);
393 c.register_prototype(proto(IntentKind::Informational, unit(3, 0)));
394 let result = c.classify(&[0.9, 0.1, 0.0]);
396 assert!(result.is_some());
397 assert!(
398 result
399 .expect("test: classify should return Some above min_confidence threshold")
400 .confidence
401 >= 0.5
402 );
403 }
404
405 #[test]
406 fn test_default_min_confidence_is_0_3() {
407 let config = ClassifierConfig::default();
408 assert!((config.min_confidence - 0.3).abs() < f32::EPSILON);
409 }
410
411 #[test]
416 fn test_runner_up_present_when_gap_sufficient() {
417 let mut c = make_classifier();
418 c.register_prototype(proto(IntentKind::Informational, unit(4, 0)));
420 c.register_prototype(proto(IntentKind::Navigational, unit(4, 1)));
422
423 let result = c.classify(&[1.0, 0.0, 0.0, 0.0]).expect("should classify");
425 assert_eq!(result.intent, IntentKind::Informational);
426 assert!(result.runner_up.is_some());
428 assert_eq!(
429 result
430 .runner_up
431 .expect("test: runner_up should be set when gap >= 0.1"),
432 IntentKind::Navigational
433 );
434 }
435
436 #[test]
441 fn test_runner_up_absent_when_gap_small() {
442 let mut c = make_classifier();
443 let a = vec![1.0f32 / 2.0f32.sqrt(), 1.0 / 2.0f32.sqrt()];
446 let b = vec![1.0f32 / 2.0f32.sqrt(), -1.0 / 2.0f32.sqrt()];
447 c.register_prototype(proto(IntentKind::Informational, a));
448 c.register_prototype(proto(IntentKind::Navigational, b));
449 let result = c.classify(&[1.0, 0.0]).expect("should classify");
452 assert!(result.runner_up.is_none());
453 }
454
455 #[test]
460 fn test_multiple_prototypes_per_intent_uses_max() {
461 let mut c = make_classifier();
462 c.register_prototype(proto(IntentKind::Informational, unit(3, 1))); c.register_prototype(proto(IntentKind::Informational, unit(3, 0))); c.register_prototype(proto(IntentKind::Navigational, unit(3, 2)));
466
467 let result = c.classify(&unit(3, 0)).expect("should classify");
468 assert_eq!(result.intent, IntentKind::Informational);
469 assert!((result.confidence - 1.0).abs() < 1e-5);
470 }
471
472 #[test]
473 fn test_multiple_prototypes_confidence_reflects_best() {
474 let mut c = make_classifier();
475 c.register_prototype(proto(IntentKind::Informational, unit(3, 0)));
476 c.register_prototype(IntentPrototype {
477 intent: IntentKind::Informational,
478 embedding: unit(3, 1),
479 weight: 1.0,
480 example_count: 1,
481 });
482 let result = c.classify(&unit(3, 0)).expect("should classify");
484 assert!((result.confidence - 1.0).abs() < 1e-5);
485 }
486
487 #[test]
492 fn test_prototype_weight_boosts_score() {
493 let mut c = make_classifier();
494 c.register_prototype(IntentPrototype {
496 intent: IntentKind::Informational,
497 embedding: unit(3, 0),
498 weight: 0.5,
499 example_count: 10,
500 });
501 let result = c.classify(&unit(3, 0)).expect("should classify");
507 assert_eq!(result.intent, IntentKind::Informational);
508 }
509
510 #[test]
511 fn test_prototype_weight_shifts_winner() {
512 let mut c = make_classifier();
513 c.register_prototype(IntentPrototype {
518 intent: IntentKind::Informational,
519 embedding: unit(2, 0),
520 weight: 1.0,
521 example_count: 5,
522 });
523 c.register_prototype(IntentPrototype {
524 intent: IntentKind::Navigational,
525 embedding: unit(2, 1),
526 weight: 2.0,
527 example_count: 5,
528 });
529 let query = vec![1.0_f32 / 2.0_f32.sqrt(), 1.0_f32 / 2.0_f32.sqrt()];
530 let result = c.classify(&query).expect("should classify");
531 assert_eq!(result.intent, IntentKind::Navigational);
532 }
533
534 #[test]
539 fn test_custom_intent_classification() {
540 let mut c = make_classifier();
541 c.register_prototype(proto(
542 IntentKind::Custom {
543 label: "shopping".to_owned(),
544 },
545 unit(3, 0),
546 ));
547 let result = c.classify(&unit(3, 0)).expect("should classify");
548 assert_eq!(
549 result.intent,
550 IntentKind::Custom {
551 label: "shopping".to_owned()
552 }
553 );
554 }
555
556 #[test]
557 fn test_custom_intent_label_in_stats() {
558 let mut c = make_classifier();
559 c.register_prototype(proto(
560 IntentKind::Custom {
561 label: "support".to_owned(),
562 },
563 unit(3, 2),
564 ));
565 let _ = c.classify(&unit(3, 2));
566 assert_eq!(c.stats().by_intent.get("support").copied().unwrap_or(0), 1);
567 }
568
569 #[test]
570 fn test_multiple_custom_intents() {
571 let mut c = make_classifier();
572 c.register_prototype(proto(
573 IntentKind::Custom {
574 label: "alpha".to_owned(),
575 },
576 unit(3, 0),
577 ));
578 c.register_prototype(proto(
579 IntentKind::Custom {
580 label: "beta".to_owned(),
581 },
582 unit(3, 1),
583 ));
584 let result = c.classify(&unit(3, 0)).expect("should classify");
585 assert_eq!(
586 result.intent,
587 IntentKind::Custom {
588 label: "alpha".to_owned()
589 }
590 );
591 }
592
593 #[test]
598 fn test_remove_prototype_removes_all_matching() {
599 let mut c = make_classifier();
600 c.register_prototype(proto(IntentKind::Informational, unit(3, 0)));
601 c.register_prototype(proto(IntentKind::Informational, unit(3, 1)));
602 c.register_prototype(proto(IntentKind::Navigational, unit(3, 2)));
603 assert_eq!(c.prototype_count(), 3);
604 c.remove_prototype(&IntentKind::Informational);
605 assert_eq!(c.prototype_count(), 1);
606 let result = c.classify(&unit(3, 2)).expect("should classify");
608 assert_eq!(result.intent, IntentKind::Navigational);
609 }
610
611 #[test]
612 fn test_remove_prototype_no_effect_on_other_intents() {
613 let mut c = make_classifier();
614 c.register_prototype(proto(IntentKind::Navigational, unit(3, 1)));
615 c.remove_prototype(&IntentKind::Informational); assert_eq!(c.prototype_count(), 1);
617 }
618
619 #[test]
620 fn test_remove_custom_prototype() {
621 let mut c = make_classifier();
622 c.register_prototype(proto(
623 IntentKind::Custom {
624 label: "buy".to_owned(),
625 },
626 unit(3, 0),
627 ));
628 c.register_prototype(proto(IntentKind::Informational, unit(3, 1)));
629 c.remove_prototype(&IntentKind::Custom {
630 label: "buy".to_owned(),
631 });
632 assert_eq!(c.prototype_count(), 1);
633 }
634
635 #[test]
640 fn test_stats_empty_prototypes_increments_unclassified() {
641 let mut c = make_classifier();
642 let _ = c.classify(&[0.1, 0.2]);
643 assert_eq!(c.stats().total_queries, 1);
644 assert_eq!(c.stats().unclassified, 1);
645 assert_eq!(c.stats().classified, 0);
646 }
647
648 #[test]
649 fn test_stats_successful_classification_increments_classified() {
650 let mut c = make_classifier();
651 c.register_prototype(proto(IntentKind::Informational, unit(2, 0)));
652 let _ = c.classify(&unit(2, 0));
653 assert_eq!(c.stats().total_queries, 1);
654 assert_eq!(c.stats().classified, 1);
655 assert_eq!(c.stats().unclassified, 0);
656 }
657
658 #[test]
659 fn test_stats_by_intent_counts_correctly() {
660 let mut c = make_classifier();
661 c.register_prototype(proto(IntentKind::Informational, unit(3, 0)));
662 c.register_prototype(proto(IntentKind::Navigational, unit(3, 1)));
663 let _ = c.classify(&unit(3, 0)); let _ = c.classify(&unit(3, 0)); let _ = c.classify(&unit(3, 1)); let by = &c.stats().by_intent;
667 assert_eq!(by.get("informational").copied().unwrap_or(0), 2);
668 assert_eq!(by.get("navigational").copied().unwrap_or(0), 1);
669 }
670
671 #[test]
672 fn test_stats_below_threshold_increments_unclassified() {
673 let config = ClassifierConfig {
674 min_confidence: 0.99,
675 };
676 let mut c = SemanticIntentClassifier::new(config);
677 c.register_prototype(proto(IntentKind::Informational, unit(3, 0)));
678 let _ = c.classify(&unit(3, 1));
680 assert_eq!(c.stats().unclassified, 1);
681 assert_eq!(c.stats().classified, 0);
682 }
683
684 #[test]
685 fn test_stats_total_queries_accumulates() {
686 let mut c = make_classifier();
687 c.register_prototype(proto(IntentKind::Informational, unit(2, 0)));
688 for _ in 0..5 {
689 let _ = c.classify(&unit(2, 0));
690 }
691 assert_eq!(c.stats().total_queries, 5);
692 }
693
694 #[test]
699 fn test_cosine_similarity_empty_returns_zero() {
700 let sim = cosine_similarity(&[], &[]);
701 assert_eq!(sim, 0.0);
702 }
703
704 #[test]
705 fn test_cosine_similarity_dimension_mismatch_returns_zero() {
706 let sim = cosine_similarity(&[1.0, 0.0], &[1.0, 0.0, 0.0]);
707 assert_eq!(sim, 0.0);
708 }
709
710 #[test]
711 fn test_cosine_similarity_zero_vector_returns_zero() {
712 let sim = cosine_similarity(&[0.0, 0.0, 0.0], &[1.0, 2.0, 3.0]);
713 assert_eq!(sim, 0.0);
714 }
715
716 #[test]
717 fn test_cosine_similarity_identical_vectors_returns_one() {
718 let v = vec![0.3, 0.4, 0.5];
719 let sim = cosine_similarity(&v, &v);
720 assert!((sim - 1.0).abs() < 1e-5);
721 }
722
723 #[test]
724 fn test_cosine_similarity_orthogonal_returns_zero() {
725 let sim = cosine_similarity(&[1.0, 0.0], &[0.0, 1.0]);
726 assert!(sim.abs() < 1e-6);
727 }
728
729 #[test]
730 fn test_cosine_similarity_opposite_returns_minus_one() {
731 let sim = cosine_similarity(&[1.0, 0.0], &[-1.0, 0.0]);
732 assert!((sim + 1.0).abs() < 1e-5);
733 }
734
735 #[test]
740 fn test_classify_single_prototype_no_runner_up() {
741 let mut c = make_classifier();
742 c.register_prototype(proto(IntentKind::Informational, unit(3, 0)));
743 let result = c.classify(&unit(3, 0)).expect("should classify");
744 assert!(result.runner_up.is_none()); }
746
747 #[test]
748 fn test_classify_confidence_equals_weighted_cosine() {
749 let mut c = make_classifier();
750 let embedding = vec![3.0f32, 4.0]; c.register_prototype(IntentPrototype {
752 intent: IntentKind::Informational,
753 embedding: embedding.clone(),
754 weight: 1.0,
755 example_count: 1,
756 });
757 let query = vec![6.0f32, 8.0]; let result = c.classify(&query).expect("should classify");
760 assert!((result.confidence - 1.0).abs() < 1e-5);
761 }
762
763 #[test]
764 fn test_intent_label_helper_all_variants() {
765 assert_eq!(intent_label(&IntentKind::Informational), "informational");
766 assert_eq!(intent_label(&IntentKind::Navigational), "navigational");
767 assert_eq!(intent_label(&IntentKind::Transactional), "transactional");
768 assert_eq!(intent_label(&IntentKind::Exploratory), "exploratory");
769 assert_eq!(
770 intent_label(&IntentKind::Custom {
771 label: "foo".to_owned()
772 }),
773 "foo"
774 );
775 }
776
777 #[test]
778 fn test_prototype_count_after_removal_is_correct() {
779 let mut c = make_classifier();
780 for _ in 0..5 {
781 c.register_prototype(proto(IntentKind::Transactional, unit(3, 2)));
782 }
783 c.register_prototype(proto(IntentKind::Informational, unit(3, 0)));
784 assert_eq!(c.prototype_count(), 6);
785 c.remove_prototype(&IntentKind::Transactional);
786 assert_eq!(c.prototype_count(), 1);
787 }
788
789 #[test]
790 fn test_classify_returns_none_when_all_sims_zero() {
791 let config = ClassifierConfig {
792 min_confidence: 0.01,
793 }; let mut c = SemanticIntentClassifier::new(config);
795 c.register_prototype(proto(IntentKind::Informational, unit(3, 0)));
797 let result = c.classify(&[0.0, 0.0, 0.0]);
798 assert!(result.is_none());
799 }
800
801 #[test]
802 fn test_weighted_negative_similarity_treated_as_negative() {
803 let mut c = make_classifier();
805 c.register_prototype(IntentPrototype {
806 intent: IntentKind::Transactional,
807 embedding: unit(2, 0),
808 weight: 10.0, example_count: 1,
810 });
811 let result = c.classify(&[-1.0, 0.0]);
813 assert!(result.is_none());
814 }
815}