1use std::collections::HashMap;
7
8#[derive(Debug, Clone)]
14pub struct Tag {
15 pub name: String,
17 pub embedding: Vec<f32>,
19 pub doc_frequency: u64,
21}
22
23impl Tag {
24 pub fn new(name: String, embedding: Vec<f32>) -> Self {
26 Self {
27 name,
28 embedding,
29 doc_frequency: 0,
30 }
31 }
32
33 pub fn similarity(&self, vec: &[f32]) -> f32 {
37 cosine_similarity(&self.embedding, vec)
38 }
39}
40
41#[derive(Debug, Clone)]
47pub struct TagAssignment {
48 pub document_id: u64,
50 pub tag_name: String,
52 pub score: f32,
54}
55
56impl TagAssignment {
57 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#[derive(Debug, Clone)]
73pub struct ExtractionConfig {
74 pub min_similarity: f32,
76 pub max_tags_per_doc: usize,
78 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#[derive(Debug, Clone, Default)]
98pub struct ExtractorStats {
99 pub total_documents: u64,
101 pub total_tags_assigned: u64,
103}
104
105impl ExtractorStats {
106 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
118pub struct SemanticTagExtractor {
125 pub tags: HashMap<String, Tag>,
127 pub config: ExtractionConfig,
129 pub stats: ExtractorStats,
131 pub total_docs: u64,
133}
134
135impl SemanticTagExtractor {
136 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 pub fn register_tag(&mut self, name: String, embedding: Vec<f32>) {
150 self.tags.insert(name.clone(), Tag::new(name, embedding));
151 }
152
153 pub fn extract_tags(&mut self, document_id: u64, doc_embedding: &[f32]) -> Vec<TagAssignment> {
162 self.total_docs += 1;
164 self.stats.total_documents += 1;
165
166 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 candidates.sort_by(|a, b| {
190 b.score
191 .partial_cmp(&a.score)
192 .unwrap_or(std::cmp::Ordering::Equal)
193 });
194
195 candidates.truncate(self.config.max_tags_per_doc);
197
198 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 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 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
226fn 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#[cfg(test)]
251mod tests {
252 use super::*;
253
254 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 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 #[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 #[test]
314 fn test_idf_weight_formula_new_tag() {
315 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 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 #[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 extractor
367 .tags
368 .get_mut("music")
369 .expect("tag must exist")
370 .doc_frequency = 42;
371 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 #[test]
385 fn test_extract_tags_above_threshold_returned() {
386 let mut extractor = default_extractor(); extractor.register_tag("rust".into(), unit_vec(4, 0));
388 extractor.register_tag("python".into(), unit_vec(4, 1));
389
390 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(); extractor.register_tag("unrelated".into(), unit_vec(4, 3));
402
403 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 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 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 #[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 extractor.register_tag("rare".into(), diagonal_vec(4));
479 extractor.register_tag("common".into(), diagonal_vec(4));
480
481 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 assert!(
527 (score - 1.0_f32).abs() < 1e-5,
528 "score without IDF should equal raw cosine similarity"
529 );
530 }
531
532 #[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); 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 #[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 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 #[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 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 #[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 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}