Skip to main content

ipfrs_semantic/
tag_extractor.rs

1//! Semantic Tag Extractor
2//!
3//! Extracts semantic tags from embedding vectors using similarity-based tag assignment
4//! and TF-IDF-like scoring for production-quality tag management.
5
6use std::collections::HashMap;
7
8// ---------------------------------------------------------------------------
9// Tag
10// ---------------------------------------------------------------------------
11
12/// A registered semantic tag with its representative embedding and usage statistics.
13#[derive(Debug, Clone)]
14pub struct Tag {
15    /// Human-readable name of this tag.
16    pub name: String,
17    /// Representative embedding vector for this tag.
18    pub embedding: Vec<f32>,
19    /// Number of documents that have been assigned this tag.
20    pub doc_frequency: u64,
21}
22
23impl Tag {
24    /// Create a new [`Tag`] with zero doc-frequency.
25    pub fn new(name: String, embedding: Vec<f32>) -> Self {
26        Self {
27            name,
28            embedding,
29            doc_frequency: 0,
30        }
31    }
32
33    /// Cosine similarity between this tag's embedding and `vec`.
34    ///
35    /// Returns `0.0` if either vector has a zero norm.
36    pub fn similarity(&self, vec: &[f32]) -> f32 {
37        cosine_similarity(&self.embedding, vec)
38    }
39}
40
41// ---------------------------------------------------------------------------
42// TagAssignment
43// ---------------------------------------------------------------------------
44
45/// The result of assigning a tag to a specific document.
46#[derive(Debug, Clone)]
47pub struct TagAssignment {
48    /// Identifier of the document that received this tag.
49    pub document_id: u64,
50    /// Name of the assigned tag.
51    pub tag_name: String,
52    /// Combined score: `similarity * idf_weight` (or just `similarity` when IDF is disabled).
53    pub score: f32,
54}
55
56impl TagAssignment {
57    /// Smoothed IDF weight.
58    ///
59    /// Formula: `ln((total_docs + 1) / (doc_freq + 1)) + 1`
60    pub fn idf_weight(doc_freq: u64, total_docs: u64) -> f32 {
61        let numerator = total_docs as f32 + 1.0;
62        let denominator = doc_freq as f32 + 1.0;
63        (numerator / denominator).ln() + 1.0
64    }
65}
66
67// ---------------------------------------------------------------------------
68// ExtractionConfig
69// ---------------------------------------------------------------------------
70
71/// Configuration knobs for [`SemanticTagExtractor`].
72#[derive(Debug, Clone)]
73pub struct ExtractionConfig {
74    /// Tags with cosine similarity below this threshold are discarded (default `0.5`).
75    pub min_similarity: f32,
76    /// Maximum number of tags assigned per document (default `10`).
77    pub max_tags_per_doc: usize,
78    /// When `true` (default), scores are weighted by the smoothed IDF.
79    pub use_idf_weighting: bool,
80}
81
82impl Default for ExtractionConfig {
83    fn default() -> Self {
84        Self {
85            min_similarity: 0.5,
86            max_tags_per_doc: 10,
87            use_idf_weighting: true,
88        }
89    }
90}
91
92// ---------------------------------------------------------------------------
93// ExtractorStats
94// ---------------------------------------------------------------------------
95
96/// Running statistics collected by [`SemanticTagExtractor`].
97#[derive(Debug, Clone, Default)]
98pub struct ExtractorStats {
99    /// Total number of documents processed.
100    pub total_documents: u64,
101    /// Total number of tag assignments made across all documents.
102    pub total_tags_assigned: u64,
103}
104
105impl ExtractorStats {
106    /// Average number of tags assigned per document.
107    ///
108    /// Returns `0.0` when no documents have been processed.
109    pub fn avg_tags_per_doc(&self) -> f64 {
110        if self.total_documents == 0 {
111            0.0
112        } else {
113            self.total_tags_assigned as f64 / self.total_documents as f64
114        }
115    }
116}
117
118// ---------------------------------------------------------------------------
119// SemanticTagExtractor
120// ---------------------------------------------------------------------------
121
122/// Assigns semantic tags to documents by comparing their embedding vectors against
123/// a registry of tag embeddings, optionally weighted by an IDF-like score.
124pub struct SemanticTagExtractor {
125    /// Registered tags, keyed by tag name.
126    pub tags: HashMap<String, Tag>,
127    /// Extraction configuration.
128    pub config: ExtractionConfig,
129    /// Running statistics.
130    pub stats: ExtractorStats,
131    /// Total number of documents seen; used for IDF computation.
132    pub total_docs: u64,
133}
134
135impl SemanticTagExtractor {
136    /// Create a new extractor with the given configuration.
137    pub fn new(config: ExtractionConfig) -> Self {
138        Self {
139            tags: HashMap::new(),
140            config,
141            stats: ExtractorStats::default(),
142            total_docs: 0,
143        }
144    }
145
146    /// Register a tag with its representative embedding.
147    ///
148    /// If a tag with the same name already exists it is replaced (doc_frequency reset to 0).
149    pub fn register_tag(&mut self, name: String, embedding: Vec<f32>) {
150        self.tags.insert(name.clone(), Tag::new(name, embedding));
151    }
152
153    /// Extract and score tags for a document given its embedding vector.
154    ///
155    /// The method:
156    /// 1. Computes cosine similarity between `doc_embedding` and every registered tag.
157    /// 2. Discards tags below `config.min_similarity`.
158    /// 3. Optionally multiplies similarity by a smoothed IDF weight.
159    /// 4. Sorts descending by score and truncates to `config.max_tags_per_doc`.
160    /// 5. Increments `doc_frequency` for selected tags and updates statistics.
161    pub fn extract_tags(&mut self, document_id: u64, doc_embedding: &[f32]) -> Vec<TagAssignment> {
162        // Increment total_docs first so that IDF reflects the document being processed.
163        self.total_docs += 1;
164        self.stats.total_documents += 1;
165
166        // Collect candidates that meet the similarity threshold.
167        let mut candidates: Vec<TagAssignment> = self
168            .tags
169            .values()
170            .filter_map(|tag| {
171                let sim = tag.similarity(doc_embedding);
172                if sim < self.config.min_similarity {
173                    return None;
174                }
175                let score = if self.config.use_idf_weighting {
176                    sim * TagAssignment::idf_weight(tag.doc_frequency, self.total_docs)
177                } else {
178                    sim
179                };
180                Some(TagAssignment {
181                    document_id,
182                    tag_name: tag.name.clone(),
183                    score,
184                })
185            })
186            .collect();
187
188        // Sort descending by score (higher is better).
189        candidates.sort_by(|a, b| {
190            b.score
191                .partial_cmp(&a.score)
192                .unwrap_or(std::cmp::Ordering::Equal)
193        });
194
195        // Enforce the per-document cap.
196        candidates.truncate(self.config.max_tags_per_doc);
197
198        // Update per-tag doc frequencies and global stats.
199        for assignment in &candidates {
200            if let Some(tag) = self.tags.get_mut(&assignment.tag_name) {
201                tag.doc_frequency += 1;
202            }
203        }
204        self.stats.total_tags_assigned += candidates.len() as u64;
205
206        candidates
207    }
208
209    /// Return the top-k tags sorted by doc_frequency (descending).
210    pub fn top_tags(&self, k: usize) -> Vec<&Tag> {
211        let mut sorted: Vec<&Tag> = self.tags.values().collect();
212        sorted.sort_by_key(|b| std::cmp::Reverse(b.doc_frequency));
213        sorted.truncate(k);
214        sorted
215    }
216
217    /// Convenience wrapper that returns only tag names from `extract_tags`.
218    pub fn tags_for_document(&mut self, document_id: u64, doc_embedding: &[f32]) -> Vec<String> {
219        self.extract_tags(document_id, doc_embedding)
220            .into_iter()
221            .map(|a| a.tag_name)
222            .collect()
223    }
224}
225
226// ---------------------------------------------------------------------------
227// Internal helpers
228// ---------------------------------------------------------------------------
229
230/// Compute cosine similarity between two slices.
231///
232/// Returns `0.0` if either vector has a zero norm or the slices have mismatched lengths.
233fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
234    if a.len() != b.len() || a.is_empty() {
235        return 0.0;
236    }
237    let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
238    let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
239    let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
240    if norm_a == 0.0 || norm_b == 0.0 {
241        return 0.0;
242    }
243    dot / (norm_a * norm_b)
244}
245
246// ---------------------------------------------------------------------------
247// Tests
248// ---------------------------------------------------------------------------
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    // Helper: build a simple unit-vector tag embedding pointing in a given direction.
255    fn unit_vec(dim: usize, hot_index: usize) -> Vec<f32> {
256        let mut v = vec![0.0_f32; dim];
257        v[hot_index] = 1.0;
258        v
259    }
260
261    // Helper: build a normalised diagonal embedding (all components equal).
262    fn diagonal_vec(dim: usize) -> Vec<f32> {
263        let val = 1.0_f32 / (dim as f32).sqrt();
264        vec![val; dim]
265    }
266
267    fn default_extractor() -> SemanticTagExtractor {
268        SemanticTagExtractor::new(ExtractionConfig::default())
269    }
270
271    // ------------------------------------------------------------------
272    // Tag::similarity
273    // ------------------------------------------------------------------
274
275    #[test]
276    fn test_tag_similarity_identical() {
277        let tag = Tag::new("rust".into(), vec![1.0, 0.0, 0.0]);
278        let result = tag.similarity(&[1.0, 0.0, 0.0]);
279        assert!(
280            (result - 1.0).abs() < 1e-6,
281            "identical vectors should yield similarity 1.0"
282        );
283    }
284
285    #[test]
286    fn test_tag_similarity_orthogonal() {
287        let tag = Tag::new("rust".into(), vec![1.0, 0.0, 0.0]);
288        let result = tag.similarity(&[0.0, 1.0, 0.0]);
289        assert!(
290            (result - 0.0).abs() < 1e-6,
291            "orthogonal vectors should yield similarity 0.0"
292        );
293    }
294
295    #[test]
296    fn test_tag_similarity_zero_embedding_returns_zero() {
297        let tag = Tag::new("empty".into(), vec![0.0, 0.0, 0.0]);
298        let result = tag.similarity(&[1.0, 0.0, 0.0]);
299        assert_eq!(result, 0.0, "zero-norm tag embedding should yield 0.0");
300    }
301
302    #[test]
303    fn test_tag_similarity_zero_query_returns_zero() {
304        let tag = Tag::new("rust".into(), vec![1.0, 0.0, 0.0]);
305        let result = tag.similarity(&[0.0, 0.0, 0.0]);
306        assert_eq!(result, 0.0, "zero-norm query should yield 0.0");
307    }
308
309    // ------------------------------------------------------------------
310    // TagAssignment::idf_weight
311    // ------------------------------------------------------------------
312
313    #[test]
314    fn test_idf_weight_formula_new_tag() {
315        // doc_freq=0, total_docs=100 → ln(101/1) + 1 ≈ 5.615
316        let w = TagAssignment::idf_weight(0, 100);
317        let expected = (101.0_f32 / 1.0_f32).ln() + 1.0;
318        assert!(
319            (w - expected).abs() < 1e-5,
320            "idf_weight mismatch for new tag"
321        );
322    }
323
324    #[test]
325    fn test_idf_weight_formula_high_freq() {
326        // doc_freq=50, total_docs=100 → ln(101/51) + 1 ≈ 1.683
327        let w = TagAssignment::idf_weight(50, 100);
328        let expected = (101.0_f32 / 51.0_f32).ln() + 1.0;
329        assert!(
330            (w - expected).abs() < 1e-5,
331            "idf_weight mismatch for high-freq tag"
332        );
333    }
334
335    #[test]
336    fn test_idf_weight_decreases_with_doc_frequency() {
337        let total = 1000_u64;
338        let w_rare = TagAssignment::idf_weight(1, total);
339        let w_common = TagAssignment::idf_weight(500, total);
340        assert!(w_rare > w_common, "rare tag should have higher IDF weight");
341    }
342
343    // ------------------------------------------------------------------
344    // register_tag
345    // ------------------------------------------------------------------
346
347    #[test]
348    fn test_register_tag_adds_entry() {
349        let mut extractor = default_extractor();
350        extractor.register_tag("science".into(), vec![0.1, 0.2, 0.3]);
351        assert!(extractor.tags.contains_key("science"));
352    }
353
354    #[test]
355    fn test_register_tag_initial_doc_frequency_is_zero() {
356        let mut extractor = default_extractor();
357        extractor.register_tag("tech".into(), vec![1.0, 0.0]);
358        assert_eq!(extractor.tags["tech"].doc_frequency, 0);
359    }
360
361    #[test]
362    fn test_register_tag_overwrites_existing() {
363        let mut extractor = default_extractor();
364        extractor.register_tag("music".into(), vec![1.0, 0.0]);
365        // Manually bump doc_frequency to simulate prior usage.
366        extractor
367            .tags
368            .get_mut("music")
369            .expect("tag must exist")
370            .doc_frequency = 42;
371        // Re-register should reset.
372        extractor.register_tag("music".into(), vec![0.0, 1.0]);
373        assert_eq!(
374            extractor.tags["music"].doc_frequency, 0,
375            "re-registration should reset doc_frequency"
376        );
377        assert_eq!(extractor.tags["music"].embedding, vec![0.0, 1.0]);
378    }
379
380    // ------------------------------------------------------------------
381    // extract_tags — basic
382    // ------------------------------------------------------------------
383
384    #[test]
385    fn test_extract_tags_above_threshold_returned() {
386        let mut extractor = default_extractor(); // min_similarity = 0.5
387        extractor.register_tag("rust".into(), unit_vec(4, 0));
388        extractor.register_tag("python".into(), unit_vec(4, 1));
389
390        // Query almost identical to "rust" tag.
391        let doc_emb = unit_vec(4, 0);
392        let assignments = extractor.extract_tags(1, &doc_emb);
393
394        let names: Vec<&str> = assignments.iter().map(|a| a.tag_name.as_str()).collect();
395        assert!(names.contains(&"rust"), "rust should be assigned");
396    }
397
398    #[test]
399    fn test_extract_tags_below_threshold_not_returned() {
400        let mut extractor = default_extractor(); // min_similarity = 0.5
401        extractor.register_tag("unrelated".into(), unit_vec(4, 3));
402
403        // Query orthogonal to the tag.
404        let doc_emb = unit_vec(4, 0);
405        let assignments = extractor.extract_tags(1, &doc_emb);
406        assert!(
407            assignments.is_empty(),
408            "tag below threshold must not be returned"
409        );
410    }
411
412    #[test]
413    fn test_extract_tags_max_tags_per_doc_enforced() {
414        let config = ExtractionConfig {
415            min_similarity: 0.0,
416            max_tags_per_doc: 3,
417            use_idf_weighting: false,
418        };
419        let mut extractor = SemanticTagExtractor::new(config);
420
421        // Register 6 tags, all identical to the query so all similarity = 1.0.
422        for i in 0..6_usize {
423            extractor.register_tag(format!("tag_{i}"), diagonal_vec(4));
424        }
425
426        let doc_emb = diagonal_vec(4);
427        let assignments = extractor.extract_tags(1, &doc_emb);
428        assert_eq!(
429            assignments.len(),
430            3,
431            "at most max_tags_per_doc tags should be returned"
432        );
433    }
434
435    #[test]
436    fn test_extract_tags_sorted_by_score_descending() {
437        let config = ExtractionConfig {
438            min_similarity: 0.0,
439            max_tags_per_doc: 10,
440            use_idf_weighting: false,
441        };
442        let mut extractor = SemanticTagExtractor::new(config);
443
444        // "exact" tag is identical to the query; "partial" is at 45°.
445        extractor.register_tag("exact".into(), unit_vec(2, 0));
446        let partial_emb = vec![1.0_f32 / 2.0_f32.sqrt(), 1.0_f32 / 2.0_f32.sqrt()];
447        extractor.register_tag("partial".into(), partial_emb);
448
449        let doc_emb = unit_vec(2, 0);
450        let assignments = extractor.extract_tags(1, &doc_emb);
451
452        assert!(
453            !assignments.is_empty(),
454            "should have at least one assignment"
455        );
456        for window in assignments.windows(2) {
457            assert!(
458                window[0].score >= window[1].score,
459                "assignments must be sorted descending by score"
460            );
461        }
462    }
463
464    // ------------------------------------------------------------------
465    // IDF weighting
466    // ------------------------------------------------------------------
467
468    #[test]
469    fn test_idf_weighting_reduces_high_freq_tag_score() {
470        let config = ExtractionConfig {
471            min_similarity: 0.0,
472            max_tags_per_doc: 10,
473            use_idf_weighting: true,
474        };
475        let mut extractor = SemanticTagExtractor::new(config);
476
477        // Both tags have the same embedding as the query.
478        extractor.register_tag("rare".into(), diagonal_vec(4));
479        extractor.register_tag("common".into(), diagonal_vec(4));
480
481        // Artificially inflate "common" tag's doc_frequency.
482        extractor
483            .tags
484            .get_mut("common")
485            .expect("tag exists")
486            .doc_frequency = 999;
487
488        let doc_emb = diagonal_vec(4);
489        let assignments = extractor.extract_tags(1, &doc_emb);
490
491        let rare_score = assignments
492            .iter()
493            .find(|a| a.tag_name == "rare")
494            .map(|a| a.score)
495            .expect("rare must be assigned");
496        let common_score = assignments
497            .iter()
498            .find(|a| a.tag_name == "common")
499            .map(|a| a.score)
500            .expect("common must be assigned");
501
502        assert!(
503            rare_score > common_score,
504            "rare tag should score higher than common tag (IDF weighting)"
505        );
506    }
507
508    #[test]
509    fn test_idf_weighting_disabled_uses_raw_similarity() {
510        let config = ExtractionConfig {
511            min_similarity: 0.0,
512            max_tags_per_doc: 10,
513            use_idf_weighting: false,
514        };
515        let mut extractor = SemanticTagExtractor::new(config);
516        extractor.register_tag("tag_a".into(), unit_vec(3, 0));
517
518        let doc_emb = unit_vec(3, 0);
519        let assignments = extractor.extract_tags(1, &doc_emb);
520        let score = assignments
521            .first()
522            .map(|a| a.score)
523            .expect("should have assignment");
524
525        // Without IDF, score == cosine similarity ≈ 1.0.
526        assert!(
527            (score - 1.0_f32).abs() < 1e-5,
528            "score without IDF should equal raw cosine similarity"
529        );
530    }
531
532    // ------------------------------------------------------------------
533    // doc_frequency increments
534    // ------------------------------------------------------------------
535
536    #[test]
537    fn test_doc_frequency_increments_on_assignment() {
538        let mut extractor = default_extractor();
539        extractor.register_tag("rust".into(), unit_vec(3, 0));
540
541        let doc_emb = unit_vec(3, 0);
542        extractor.extract_tags(1, &doc_emb);
543        extractor.extract_tags(2, &doc_emb);
544
545        assert_eq!(
546            extractor.tags["rust"].doc_frequency, 2,
547            "doc_frequency should increment for each assignment"
548        );
549    }
550
551    #[test]
552    fn test_doc_frequency_not_incremented_below_threshold() {
553        let mut extractor = default_extractor();
554        extractor.register_tag("unrelated".into(), unit_vec(3, 2));
555
556        let doc_emb = unit_vec(3, 0); // orthogonal → not assigned
557        extractor.extract_tags(1, &doc_emb);
558
559        assert_eq!(
560            extractor.tags["unrelated"].doc_frequency, 0,
561            "doc_frequency must not increment when tag is below threshold"
562        );
563    }
564
565    // ------------------------------------------------------------------
566    // top_tags
567    // ------------------------------------------------------------------
568
569    #[test]
570    fn test_top_tags_sorted_by_doc_frequency_descending() {
571        let mut extractor = default_extractor();
572        extractor.register_tag("a".into(), diagonal_vec(2));
573        extractor.register_tag("b".into(), diagonal_vec(2));
574        extractor.register_tag("c".into(), diagonal_vec(2));
575
576        // Manually set doc_frequency to create a known ordering.
577        extractor.tags.get_mut("a").expect("a exists").doc_frequency = 5;
578        extractor.tags.get_mut("b").expect("b exists").doc_frequency = 20;
579        extractor.tags.get_mut("c").expect("c exists").doc_frequency = 10;
580
581        let top = extractor.top_tags(2);
582        assert_eq!(top.len(), 2);
583        assert_eq!(
584            top[0].name, "b",
585            "highest doc_frequency tag should be first"
586        );
587        assert_eq!(top[1].name, "c", "second highest should be second");
588    }
589
590    #[test]
591    fn test_top_tags_k_larger_than_registry_returns_all() {
592        let mut extractor = default_extractor();
593        extractor.register_tag("x".into(), unit_vec(2, 0));
594        extractor.register_tag("y".into(), unit_vec(2, 1));
595
596        let top = extractor.top_tags(100);
597        assert_eq!(
598            top.len(),
599            2,
600            "should return all tags when k exceeds registry size"
601        );
602    }
603
604    // ------------------------------------------------------------------
605    // stats
606    // ------------------------------------------------------------------
607
608    #[test]
609    fn test_stats_total_documents_increments() {
610        let mut extractor = default_extractor();
611        extractor.register_tag("t".into(), diagonal_vec(3));
612        let doc_emb = diagonal_vec(3);
613
614        extractor.extract_tags(1, &doc_emb);
615        extractor.extract_tags(2, &doc_emb);
616        extractor.extract_tags(3, &doc_emb);
617
618        assert_eq!(extractor.stats.total_documents, 3);
619    }
620
621    #[test]
622    fn test_stats_avg_tags_per_doc_correct() {
623        let config = ExtractionConfig {
624            min_similarity: 0.0,
625            max_tags_per_doc: 10,
626            use_idf_weighting: false,
627        };
628        let mut extractor = SemanticTagExtractor::new(config);
629        extractor.register_tag("a".into(), unit_vec(3, 0));
630        extractor.register_tag("b".into(), unit_vec(3, 0));
631
632        // Both tags identical to query → 2 assignments per doc.
633        let doc_emb = unit_vec(3, 0);
634        extractor.extract_tags(1, &doc_emb);
635        extractor.extract_tags(2, &doc_emb);
636
637        let avg = extractor.stats.avg_tags_per_doc();
638        assert!(
639            (avg - 2.0).abs() < 1e-9,
640            "avg_tags_per_doc should be 2.0, got {avg}"
641        );
642    }
643
644    #[test]
645    fn test_stats_avg_tags_per_doc_zero_when_no_docs() {
646        let extractor = default_extractor();
647        assert_eq!(extractor.stats.avg_tags_per_doc(), 0.0);
648    }
649
650    // ------------------------------------------------------------------
651    // Edge cases
652    // ------------------------------------------------------------------
653
654    #[test]
655    fn test_empty_doc_embedding_returns_no_tags() {
656        let mut extractor = default_extractor();
657        extractor.register_tag("rust".into(), vec![1.0, 0.0]);
658
659        // Empty slice → cosine_similarity returns 0.0 < min_similarity.
660        let assignments = extractor.extract_tags(1, &[]);
661        assert!(
662            assignments.is_empty(),
663            "empty embedding should produce no assignments"
664        );
665    }
666
667    #[test]
668    fn test_no_tags_registered_returns_empty() {
669        let mut extractor = default_extractor();
670        let doc_emb = diagonal_vec(4);
671        let assignments = extractor.extract_tags(1, &doc_emb);
672        assert!(
673            assignments.is_empty(),
674            "no registered tags → no assignments"
675        );
676    }
677
678    #[test]
679    fn test_tags_for_document_returns_names() {
680        let config = ExtractionConfig {
681            min_similarity: 0.0,
682            max_tags_per_doc: 10,
683            use_idf_weighting: false,
684        };
685        let mut extractor = SemanticTagExtractor::new(config);
686        extractor.register_tag("alpha".into(), unit_vec(3, 0));
687        extractor.register_tag("beta".into(), unit_vec(3, 0));
688
689        let doc_emb = unit_vec(3, 0);
690        let names = extractor.tags_for_document(1, &doc_emb);
691        assert!(names.contains(&"alpha".to_string()));
692        assert!(names.contains(&"beta".to_string()));
693    }
694}