Skip to main content

ipfrs_semantic/
intent_classifier.rs

1//! Semantic Intent Classifier
2//!
3//! Classifies query intent by comparing query embeddings against registered
4//! intent prototype embeddings, enabling intent-aware search routing
5//! (informational vs navigational vs transactional vs exploratory vs custom).
6//!
7//! ## Usage
8//!
9//! ```rust
10//! use ipfrs_semantic::intent_classifier::{
11//!     SemanticIntentClassifier, IntentKind, IntentPrototype, ClassifierConfig,
12//! };
13//!
14//! let config = ClassifierConfig::default();
15//! let mut classifier = SemanticIntentClassifier::new(config);
16//!
17//! classifier.register_prototype(IntentPrototype {
18//!     intent: IntentKind::Informational,
19//!     embedding: vec![1.0, 0.0, 0.0],
20//!     weight: 1.0,
21//!     example_count: 10,
22//! });
23//!
24//! let result = classifier.classify(&[0.9, 0.1, 0.0]);
25//! assert!(result.is_some());
26//! ```
27
28use std::collections::HashMap;
29
30// ---------------------------------------------------------------------------
31// IntentKind
32// ---------------------------------------------------------------------------
33
34/// The kind of intent a query expresses.
35#[derive(Clone, Debug, PartialEq, Eq, Hash)]
36pub enum IntentKind {
37    /// User wants information or facts.
38    Informational,
39    /// User wants to navigate to a specific resource.
40    Navigational,
41    /// User wants to perform an action.
42    Transactional,
43    /// User wants to browse or discover content.
44    Exploratory,
45    /// User-defined custom intent with an arbitrary label.
46    Custom { label: String },
47}
48
49// ---------------------------------------------------------------------------
50// IntentPrototype
51// ---------------------------------------------------------------------------
52
53/// A prototype embedding representing a canonical example of a given intent.
54#[derive(Clone, Debug)]
55pub struct IntentPrototype {
56    /// The intent this prototype represents.
57    pub intent: IntentKind,
58    /// The embedding vector for this prototype.
59    pub embedding: Vec<f32>,
60    /// Importance weight applied to this prototype's similarity score.
61    /// Defaults to `1.0`.
62    pub weight: f32,
63    /// Number of examples this prototype was derived from.
64    pub example_count: u32,
65}
66
67// ---------------------------------------------------------------------------
68// IntentClassification
69// ---------------------------------------------------------------------------
70
71/// The result of classifying a query embedding against registered prototypes.
72#[derive(Clone, Debug)]
73pub struct IntentClassification {
74    /// The best-matching intent.
75    pub intent: IntentKind,
76    /// Confidence score in `[0.0, 1.0]`; the highest weighted cosine similarity
77    /// to any prototype of the winning intent.
78    pub confidence: f32,
79    /// The second-best intent, provided only when the gap between the best and
80    /// second-best weighted similarity is ≥ 0.1.
81    pub runner_up: Option<IntentKind>,
82}
83
84// ---------------------------------------------------------------------------
85// ClassifierConfig
86// ---------------------------------------------------------------------------
87
88/// Configuration for [`SemanticIntentClassifier`].
89#[derive(Clone, Debug)]
90pub struct ClassifierConfig {
91    /// Minimum confidence required to return a classification.
92    ///
93    /// Queries whose best weighted cosine similarity falls below this threshold
94    /// are returned as `None`. Defaults to `0.3`.
95    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// ---------------------------------------------------------------------------
107// ClassifierStats
108// ---------------------------------------------------------------------------
109
110/// Accumulated statistics for a [`SemanticIntentClassifier`].
111#[derive(Clone, Debug, Default)]
112pub struct ClassifierStats {
113    /// Total number of classification calls made.
114    pub total_queries: u64,
115    /// Number of classifications that returned `Some` (above min_confidence).
116    pub classified: u64,
117    /// Number of classifications that returned `None` (below min_confidence).
118    pub unclassified: u64,
119    /// Per-intent classification counts, keyed by the intent label string.
120    pub by_intent: HashMap<String, u64>,
121}
122
123// ---------------------------------------------------------------------------
124// Private helpers
125// ---------------------------------------------------------------------------
126
127/// Returns a canonical string label for an [`IntentKind`].
128fn 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
138/// Computes cosine similarity between two vectors.
139///
140/// Returns `0.0` when either vector is empty, the dimensions differ, or either
141/// vector has zero norm.
142fn 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// ---------------------------------------------------------------------------
156// SemanticIntentClassifier
157// ---------------------------------------------------------------------------
158
159/// Classifies query embeddings into intent categories using registered
160/// prototype embeddings and weighted cosine similarity.
161#[derive(Debug)]
162pub struct SemanticIntentClassifier {
163    /// Registered intent prototypes.
164    prototypes: Vec<IntentPrototype>,
165    /// Classifier configuration.
166    config: ClassifierConfig,
167    /// Accumulated statistics.
168    stats: ClassifierStats,
169}
170
171impl SemanticIntentClassifier {
172    /// Creates a new classifier with the given configuration.
173    pub fn new(config: ClassifierConfig) -> Self {
174        Self {
175            prototypes: Vec::new(),
176            config,
177            stats: ClassifierStats::default(),
178        }
179    }
180
181    /// Registers a new intent prototype.
182    ///
183    /// Multiple prototypes may be registered for the same [`IntentKind`].
184    /// When classifying, the maximum weighted similarity across all prototypes
185    /// of a given intent is used.
186    pub fn register_prototype(&mut self, prototype: IntentPrototype) {
187        self.prototypes.push(prototype);
188    }
189
190    /// Classifies `query_embedding` against all registered prototypes.
191    ///
192    /// Returns `None` when:
193    /// - No prototypes are registered.
194    /// - The best weighted cosine similarity is below `config.min_confidence`.
195    ///
196    /// Otherwise returns an [`IntentClassification`] with the winning intent,
197    /// its confidence score, and an optional runner-up.
198    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        // Compute the maximum weighted similarity for each intent kind.
207        // We use a Vec of (IntentKind, f32) rather than a HashMap so that
208        // IntentKind need not implement Ord or be hashable by value directly
209        // (HashMap<IntentKind, f32> is fine since IntentKind: Eq + Hash).
210        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        // Sort by weighted similarity descending.
225        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        // Determine runner-up: second intent whose gap from best is >= 0.1.
242        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        // Update stats.
251        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    /// Removes **all** prototypes whose intent matches `intent`.
263    pub fn remove_prototype(&mut self, intent: &IntentKind) {
264        self.prototypes.retain(|p| &p.intent != intent);
265    }
266
267    /// Returns a reference to the accumulated statistics.
268    pub fn stats(&self) -> &ClassifierStats {
269        &self.stats
270    }
271
272    /// Returns the number of registered prototypes.
273    pub fn prototype_count(&self) -> usize {
274        self.prototypes.len()
275    }
276}
277
278// ---------------------------------------------------------------------------
279// Tests
280// ---------------------------------------------------------------------------
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    // Helper: build a unit vector along a single axis.
287    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    // -----------------------------------------------------------------------
307    // 1. register_prototype adds entry
308    // -----------------------------------------------------------------------
309
310    #[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    // -----------------------------------------------------------------------
321    // 2. classify returns None for empty prototypes
322    // -----------------------------------------------------------------------
323
324    #[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    // -----------------------------------------------------------------------
331    // 3. classify returns correct intent
332    // -----------------------------------------------------------------------
333
334    #[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        // Query is almost identical to axis-0 (Informational)
340        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        // Normalize manually: [1/sqrt(2), 1/sqrt(2), 0]
366        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    // -----------------------------------------------------------------------
373    // 4. min_confidence threshold
374    // -----------------------------------------------------------------------
375
376    #[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        // Prototype on axis 0, query on axis 1 → similarity = 0.0
383        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        // Cosine sim between [1,0,0] and [0.9,0.1,0] is ~0.994
395        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    // -----------------------------------------------------------------------
412    // 5. runner_up when gap >= 0.1
413    // -----------------------------------------------------------------------
414
415    #[test]
416    fn test_runner_up_present_when_gap_sufficient() {
417        let mut c = make_classifier();
418        // Informational: exact match to axis 0
419        c.register_prototype(proto(IntentKind::Informational, unit(4, 0)));
420        // Navigational: points partially toward axis 1 only
421        c.register_prototype(proto(IntentKind::Navigational, unit(4, 1)));
422
423        // Query close to axis 0 — large gap between cosine sims
424        let result = c.classify(&[1.0, 0.0, 0.0, 0.0]).expect("should classify");
425        assert_eq!(result.intent, IntentKind::Informational);
426        // Gap = 1.0 - 0.0 = 1.0 >= 0.1 → runner_up should be set
427        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    // -----------------------------------------------------------------------
437    // 6. runner_up None when gap < 0.1
438    // -----------------------------------------------------------------------
439
440    #[test]
441    fn test_runner_up_absent_when_gap_small() {
442        let mut c = make_classifier();
443        // Two prototypes with very similar similarity to the query.
444        // Both at 45 degrees from each other in 2D.
445        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        // Query = [1, 0] → cos_sim to a = 1/sqrt(2) ≈ 0.707, to b = 1/sqrt(2) ≈ 0.707
450        // Gap ≈ 0.0 < 0.1 → no runner-up
451        let result = c.classify(&[1.0, 0.0]).expect("should classify");
452        assert!(result.runner_up.is_none());
453    }
454
455    // -----------------------------------------------------------------------
456    // 7. Multiple prototypes per intent: max sim used
457    // -----------------------------------------------------------------------
458
459    #[test]
460    fn test_multiple_prototypes_per_intent_uses_max() {
461        let mut c = make_classifier();
462        // Two prototypes for Informational: one bad, one perfect
463        c.register_prototype(proto(IntentKind::Informational, unit(3, 1))); // bad
464        c.register_prototype(proto(IntentKind::Informational, unit(3, 0))); // perfect
465        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        // Query = axis 0 → sim with first = 1.0, sim with second = 0.0 → max = 1.0
483        let result = c.classify(&unit(3, 0)).expect("should classify");
484        assert!((result.confidence - 1.0).abs() < 1e-5);
485    }
486
487    // -----------------------------------------------------------------------
488    // 8. Prototype weight affects selection
489    // -----------------------------------------------------------------------
490
491    #[test]
492    fn test_prototype_weight_boosts_score() {
493        let mut c = make_classifier();
494        // Informational: low weight, high raw similarity
495        c.register_prototype(IntentPrototype {
496            intent: IntentKind::Informational,
497            embedding: unit(3, 0),
498            weight: 0.5,
499            example_count: 10,
500        });
501        // Navigational: high weight, moderate raw similarity (axis 1)
502        // Query is axis 0, but navigational weight is very high.
503        // Weighted scores: Informational = 1.0 * 0.5 = 0.5
504        //                  Navigational  = 0.0 * 100 = 0.0
505        // Informational still wins because raw sim is 0 for Navigational.
506        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        // Query at 45 degrees between axis 0 and axis 1.
514        // Without weight: both have sim = 1/sqrt(2) ≈ 0.707 → tie
515        // With Navigational weight = 2.0: Navigational weighted = 2 * 0.707 = 1.414
516        //                                 Informational weighted = 1 * 0.707 = 0.707
517        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    // -----------------------------------------------------------------------
535    // 9. Custom intent classification
536    // -----------------------------------------------------------------------
537
538    #[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    // -----------------------------------------------------------------------
594    // 10. remove_prototype removes all matching
595    // -----------------------------------------------------------------------
596
597    #[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        // Ensure the remaining prototype is Navigational
607        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); // nothing to remove
616        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    // -----------------------------------------------------------------------
636    // 11. Stats: classified / unclassified / by_intent
637    // -----------------------------------------------------------------------
638
639    #[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)); // Informational
664        let _ = c.classify(&unit(3, 0)); // Informational
665        let _ = c.classify(&unit(3, 1)); // Navigational
666        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        // Query is perpendicular → sim = 0.0 < 0.99
679        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    // -----------------------------------------------------------------------
695    // 12. cosine_similarity edge cases
696    // -----------------------------------------------------------------------
697
698    #[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    // -----------------------------------------------------------------------
736    // Additional edge-case / integration tests
737    // -----------------------------------------------------------------------
738
739    #[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()); // only one intent registered
745    }
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]; // norm = 5
751        c.register_prototype(IntentPrototype {
752            intent: IntentKind::Informational,
753            embedding: embedding.clone(),
754            weight: 1.0,
755            example_count: 1,
756        });
757        // Query same direction, different magnitude
758        let query = vec![6.0f32, 8.0]; // norm = 10, same direction
759        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        }; // very low threshold
794        let mut c = SemanticIntentClassifier::new(config);
795        // Prototype on axis 0, query is zero vector → sim = 0.0 < 0.01
796        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        // Even with weight, negative raw sim stays negative and below threshold.
804        let mut c = make_classifier();
805        c.register_prototype(IntentPrototype {
806            intent: IntentKind::Transactional,
807            embedding: unit(2, 0),
808            weight: 10.0, // high weight
809            example_count: 1,
810        });
811        // Query is opposite direction → raw sim = -1.0, weighted = -10.0 < 0.3
812        let result = c.classify(&[-1.0, 0.0]);
813        assert!(result.is_none());
814    }
815}