1use std::collections::HashMap;
7
8#[derive(Clone, Debug, PartialEq, Eq, Hash)]
12pub enum Language {
13 English,
14 Japanese,
15 German,
16 French,
17 Spanish,
18 Other {
20 code: String,
21 },
22}
23
24fn lang_label(lang: &Language) -> String {
27 match lang {
28 Language::English => "en".to_owned(),
29 Language::Japanese => "ja".to_owned(),
30 Language::German => "de".to_owned(),
31 Language::French => "fr".to_owned(),
32 Language::Spanish => "es".to_owned(),
33 Language::Other { code } => code.clone(),
34 }
35}
36
37fn cosine_sim(a: &[f32], b: &[f32]) -> f32 {
42 if a.is_empty() || a.len() != b.len() {
43 return 0.0;
44 }
45 let mut dot = 0.0_f32;
46 let mut mag_a = 0.0_f32;
47 let mut mag_b = 0.0_f32;
48 for (x, y) in a.iter().zip(b.iter()) {
49 dot += x * y;
50 mag_a += x * x;
51 mag_b += y * y;
52 }
53 if mag_a == 0.0 || mag_b == 0.0 {
54 return 0.0;
55 }
56 dot / (mag_a.sqrt() * mag_b.sqrt())
57}
58
59#[derive(Clone, Debug)]
63pub struct MultilingualDoc {
64 pub doc_id: u64,
65 pub language: Language,
66 pub embedding: Vec<f32>,
67 pub metadata: HashMap<String, String>,
68}
69
70#[derive(Clone, Debug)]
75pub struct CrossLingualQuery {
76 pub query_embedding: Vec<f32>,
77 pub target_languages: Vec<Language>,
79 pub top_k: usize,
80}
81
82#[derive(Clone, Debug)]
86pub struct MultilingualResult {
87 pub doc_id: u64,
88 pub language: Language,
89 pub similarity: f32,
90 pub rank: usize,
92}
93
94#[derive(Clone, Debug, Default)]
98pub struct MultilingualIndexStats {
99 pub total_docs: usize,
100 pub by_language: HashMap<String, usize>,
102 pub total_searches: u64,
103}
104
105#[derive(Debug)]
110pub struct SemanticMultilingualIndex {
111 docs: HashMap<u64, MultilingualDoc>,
113 language_index: HashMap<String, Vec<u64>>,
115 stats: MultilingualIndexStats,
116}
117
118impl SemanticMultilingualIndex {
119 pub fn new() -> Self {
121 Self {
122 docs: HashMap::new(),
123 language_index: HashMap::new(),
124 stats: MultilingualIndexStats::default(),
125 }
126 }
127
128 pub fn add_doc(&mut self, doc: MultilingualDoc) {
131 if let Some(old) = self.docs.get(&doc.doc_id) {
133 let old_label = lang_label(&old.language);
134 if let Some(ids) = self.language_index.get_mut(&old_label) {
135 ids.retain(|&id| id != doc.doc_id);
136 }
137 let cnt = self.stats.by_language.entry(old_label).or_insert(0);
139 *cnt = cnt.saturating_sub(1);
140 self.stats.total_docs = self.stats.total_docs.saturating_sub(1);
141 }
142
143 let label = lang_label(&doc.language);
144
145 let ids = self.language_index.entry(label.clone()).or_default();
147 ids.push(doc.doc_id);
148 ids.sort_unstable();
149
150 *self.stats.by_language.entry(label).or_insert(0) += 1;
152 self.stats.total_docs += 1;
153
154 self.docs.insert(doc.doc_id, doc);
155 }
156
157 pub fn remove_doc(&mut self, doc_id: u64) -> bool {
159 let Some(doc) = self.docs.remove(&doc_id) else {
160 return false;
161 };
162 let label = lang_label(&doc.language);
163
164 if let Some(ids) = self.language_index.get_mut(&label) {
166 ids.retain(|&id| id != doc_id);
167 }
168
169 let cnt = self.stats.by_language.entry(label).or_insert(0);
171 *cnt = cnt.saturating_sub(1);
172 self.stats.total_docs = self.stats.total_docs.saturating_sub(1);
173
174 true
175 }
176
177 pub fn search(&mut self, query: &CrossLingualQuery) -> Vec<MultilingualResult> {
184 self.stats.total_searches += 1;
185
186 let candidate_ids: Vec<u64> = if query.target_languages.is_empty() {
188 self.docs.keys().copied().collect()
189 } else {
190 let mut ids = Vec::new();
191 for lang in &query.target_languages {
192 let label = lang_label(lang);
193 if let Some(lang_ids) = self.language_index.get(&label) {
194 ids.extend_from_slice(lang_ids);
195 }
196 }
197 ids.sort_unstable();
198 ids.dedup();
199 ids
200 };
201
202 let mut scored: Vec<(u64, f32)> = candidate_ids
204 .into_iter()
205 .filter_map(|id| {
206 let doc = self.docs.get(&id)?;
207 let sim = cosine_sim(&query.query_embedding, &doc.embedding);
208 Some((id, sim))
209 })
210 .collect();
211
212 scored.sort_unstable_by(|(id_a, sim_a), (id_b, sim_b)| {
214 sim_b
215 .partial_cmp(sim_a)
216 .unwrap_or(std::cmp::Ordering::Equal)
217 .then_with(|| id_a.cmp(id_b))
218 });
219
220 scored.truncate(query.top_k);
222
223 scored
224 .into_iter()
225 .enumerate()
226 .filter_map(|(rank, (doc_id, similarity))| {
227 let doc = self.docs.get(&doc_id)?;
228 Some(MultilingualResult {
229 doc_id,
230 language: doc.language.clone(),
231 similarity,
232 rank,
233 })
234 })
235 .collect()
236 }
237
238 pub fn docs_for_language(&self, language: &Language) -> Vec<u64> {
240 let label = lang_label(language);
241 match self.language_index.get(&label) {
242 Some(ids) => {
243 let mut sorted = ids.clone();
244 sorted.sort_unstable();
245 sorted
246 }
247 None => Vec::new(),
248 }
249 }
250
251 pub fn stats(&self) -> &MultilingualIndexStats {
253 &self.stats
254 }
255}
256
257impl Default for SemanticMultilingualIndex {
258 fn default() -> Self {
259 Self::new()
260 }
261}
262
263#[cfg(test)]
266mod tests {
267 use super::*;
268
269 fn make_doc(doc_id: u64, language: Language, embedding: Vec<f32>) -> MultilingualDoc {
270 MultilingualDoc {
271 doc_id,
272 language,
273 embedding,
274 metadata: HashMap::new(),
275 }
276 }
277
278 fn make_doc_meta(
279 doc_id: u64,
280 language: Language,
281 embedding: Vec<f32>,
282 meta: &[(&str, &str)],
283 ) -> MultilingualDoc {
284 let metadata = meta
285 .iter()
286 .map(|(k, v)| (k.to_string(), v.to_string()))
287 .collect();
288 MultilingualDoc {
289 doc_id,
290 language,
291 embedding,
292 metadata,
293 }
294 }
295
296 #[test]
299 fn test_add_doc_inserts_into_docs_map() {
300 let mut idx = SemanticMultilingualIndex::new();
301 idx.add_doc(make_doc(1, Language::English, vec![1.0, 0.0]));
302 assert!(idx.docs.contains_key(&1));
303 }
304
305 #[test]
306 fn test_add_doc_inserts_into_language_index() {
307 let mut idx = SemanticMultilingualIndex::new();
308 idx.add_doc(make_doc(42, Language::Japanese, vec![0.0, 1.0]));
309 let ids = idx.docs_for_language(&Language::Japanese);
310 assert_eq!(ids, vec![42]);
311 }
312
313 #[test]
314 fn test_add_doc_updates_total_docs_stat() {
315 let mut idx = SemanticMultilingualIndex::new();
316 idx.add_doc(make_doc(1, Language::German, vec![1.0]));
317 idx.add_doc(make_doc(2, Language::German, vec![0.0]));
318 assert_eq!(idx.stats().total_docs, 2);
319 }
320
321 #[test]
322 fn test_add_doc_updates_by_language_stat() {
323 let mut idx = SemanticMultilingualIndex::new();
324 idx.add_doc(make_doc(1, Language::French, vec![1.0]));
325 idx.add_doc(make_doc(2, Language::French, vec![0.0]));
326 assert_eq!(idx.stats().by_language.get("fr").copied().unwrap_or(0), 2);
327 }
328
329 #[test]
330 fn test_add_doc_replace_updates_language_index() {
331 let mut idx = SemanticMultilingualIndex::new();
332 idx.add_doc(make_doc(1, Language::English, vec![1.0, 0.0]));
333 idx.add_doc(make_doc(1, Language::Spanish, vec![0.0, 1.0]));
335 assert!(!idx.docs_for_language(&Language::English).contains(&1));
337 assert!(idx.docs_for_language(&Language::Spanish).contains(&1));
339 assert_eq!(idx.stats().total_docs, 1);
341 }
342
343 #[test]
346 fn test_remove_doc_returns_true_when_exists() {
347 let mut idx = SemanticMultilingualIndex::new();
348 idx.add_doc(make_doc(10, Language::English, vec![1.0]));
349 assert!(idx.remove_doc(10));
350 }
351
352 #[test]
353 fn test_remove_doc_returns_false_when_missing() {
354 let mut idx = SemanticMultilingualIndex::new();
355 assert!(!idx.remove_doc(999));
356 }
357
358 #[test]
359 fn test_remove_doc_clears_docs_map_entry() {
360 let mut idx = SemanticMultilingualIndex::new();
361 idx.add_doc(make_doc(5, Language::English, vec![1.0]));
362 idx.remove_doc(5);
363 assert!(!idx.docs.contains_key(&5));
364 }
365
366 #[test]
367 fn test_remove_doc_clears_language_index_entry() {
368 let mut idx = SemanticMultilingualIndex::new();
369 idx.add_doc(make_doc(7, Language::Japanese, vec![1.0]));
370 idx.remove_doc(7);
371 assert!(idx.docs_for_language(&Language::Japanese).is_empty());
372 }
373
374 #[test]
375 fn test_remove_doc_updates_stats() {
376 let mut idx = SemanticMultilingualIndex::new();
377 idx.add_doc(make_doc(1, Language::German, vec![1.0]));
378 idx.add_doc(make_doc(2, Language::German, vec![0.5]));
379 idx.remove_doc(1);
380 assert_eq!(idx.stats().total_docs, 1);
381 assert_eq!(idx.stats().by_language.get("de").copied().unwrap_or(0), 1);
382 }
383
384 #[test]
387 fn test_search_all_languages_when_target_empty() {
388 let mut idx = SemanticMultilingualIndex::new();
389 idx.add_doc(make_doc(1, Language::English, vec![1.0, 0.0]));
390 idx.add_doc(make_doc(2, Language::Japanese, vec![0.0, 1.0]));
391 let q = CrossLingualQuery {
392 query_embedding: vec![1.0, 0.0],
393 target_languages: vec![],
394 top_k: 10,
395 };
396 let results = idx.search(&q);
397 let ids: Vec<u64> = results.iter().map(|r| r.doc_id).collect();
398 assert!(ids.contains(&1));
399 assert!(ids.contains(&2));
400 }
401
402 #[test]
403 fn test_search_empty_index_returns_empty() {
404 let mut idx = SemanticMultilingualIndex::new();
405 let q = CrossLingualQuery {
406 query_embedding: vec![1.0, 0.0],
407 target_languages: vec![],
408 top_k: 5,
409 };
410 assert!(idx.search(&q).is_empty());
411 }
412
413 #[test]
416 fn test_search_filtered_by_target_languages() {
417 let mut idx = SemanticMultilingualIndex::new();
418 idx.add_doc(make_doc(1, Language::English, vec![1.0, 0.0]));
419 idx.add_doc(make_doc(2, Language::French, vec![1.0, 0.0]));
420 let q = CrossLingualQuery {
421 query_embedding: vec![1.0, 0.0],
422 target_languages: vec![Language::English],
423 top_k: 10,
424 };
425 let results = idx.search(&q);
426 assert_eq!(results.len(), 1);
427 assert_eq!(results[0].doc_id, 1);
428 }
429
430 #[test]
431 fn test_search_multiple_target_languages() {
432 let mut idx = SemanticMultilingualIndex::new();
433 idx.add_doc(make_doc(1, Language::English, vec![1.0, 0.0]));
434 idx.add_doc(make_doc(2, Language::German, vec![1.0, 0.0]));
435 idx.add_doc(make_doc(3, Language::Spanish, vec![1.0, 0.0]));
436 let q = CrossLingualQuery {
437 query_embedding: vec![1.0, 0.0],
438 target_languages: vec![Language::English, Language::German],
439 top_k: 10,
440 };
441 let results = idx.search(&q);
442 let ids: Vec<u64> = results.iter().map(|r| r.doc_id).collect();
443 assert!(ids.contains(&1));
444 assert!(ids.contains(&2));
445 assert!(!ids.contains(&3));
446 }
447
448 #[test]
451 fn test_search_top_k_truncation() {
452 let mut idx = SemanticMultilingualIndex::new();
453 for id in 0..10_u64 {
454 idx.add_doc(make_doc(id, Language::English, vec![1.0, 0.0]));
455 }
456 let q = CrossLingualQuery {
457 query_embedding: vec![1.0, 0.0],
458 target_languages: vec![],
459 top_k: 3,
460 };
461 let results = idx.search(&q);
462 assert_eq!(results.len(), 3);
463 }
464
465 #[test]
466 fn test_search_top_k_zero_returns_empty() {
467 let mut idx = SemanticMultilingualIndex::new();
468 idx.add_doc(make_doc(1, Language::English, vec![1.0]));
469 let q = CrossLingualQuery {
470 query_embedding: vec![1.0],
471 target_languages: vec![],
472 top_k: 0,
473 };
474 assert!(idx.search(&q).is_empty());
475 }
476
477 #[test]
480 fn test_search_rank_assigned_correctly() {
481 let mut idx = SemanticMultilingualIndex::new();
482 idx.add_doc(make_doc(1, Language::English, vec![1.0, 0.0]));
484 idx.add_doc(make_doc(2, Language::English, vec![0.0, 1.0]));
485 let q = CrossLingualQuery {
486 query_embedding: vec![1.0, 0.0],
487 target_languages: vec![],
488 top_k: 5,
489 };
490 let results = idx.search(&q);
491 assert_eq!(results.len(), 2);
492 assert_eq!(results[0].rank, 0);
494 assert_eq!(results[1].rank, 1);
495 assert_eq!(results[0].doc_id, 1);
497 }
498
499 #[test]
502 fn test_search_cosine_similarity_ordering() {
503 let mut idx = SemanticMultilingualIndex::new();
504 idx.add_doc(make_doc(1, Language::English, vec![1.0, 0.0, 0.0]));
509 idx.add_doc(make_doc(2, Language::English, vec![0.707, 0.707, 0.0]));
510 idx.add_doc(make_doc(3, Language::English, vec![0.0, 1.0, 0.0]));
511 let q = CrossLingualQuery {
512 query_embedding: vec![1.0, 0.0, 0.0],
513 target_languages: vec![],
514 top_k: 3,
515 };
516 let results = idx.search(&q);
517 assert_eq!(results[0].doc_id, 1);
518 assert_eq!(results[1].doc_id, 2);
519 assert_eq!(results[2].doc_id, 3);
520 assert!(results[0].similarity > results[1].similarity);
521 assert!(results[1].similarity > results[2].similarity);
522 }
523
524 #[test]
527 fn test_search_doc_id_tie_breaking() {
528 let mut idx = SemanticMultilingualIndex::new();
529 idx.add_doc(make_doc(30, Language::English, vec![1.0, 0.0]));
531 idx.add_doc(make_doc(10, Language::English, vec![1.0, 0.0]));
532 idx.add_doc(make_doc(20, Language::English, vec![1.0, 0.0]));
533 let q = CrossLingualQuery {
534 query_embedding: vec![1.0, 0.0],
535 target_languages: vec![],
536 top_k: 3,
537 };
538 let results = idx.search(&q);
539 let ids: Vec<u64> = results.iter().map(|r| r.doc_id).collect();
540 assert_eq!(ids, vec![10, 20, 30]);
542 }
543
544 #[test]
547 fn test_docs_for_language_sorted_ascending() {
548 let mut idx = SemanticMultilingualIndex::new();
549 idx.add_doc(make_doc(50, Language::Spanish, vec![1.0]));
550 idx.add_doc(make_doc(10, Language::Spanish, vec![0.5]));
551 idx.add_doc(make_doc(30, Language::Spanish, vec![0.8]));
552 let ids = idx.docs_for_language(&Language::Spanish);
553 assert_eq!(ids, vec![10, 30, 50]);
554 }
555
556 #[test]
557 fn test_docs_for_language_empty_when_none() {
558 let idx = SemanticMultilingualIndex::new();
559 assert!(idx.docs_for_language(&Language::French).is_empty());
560 }
561
562 #[test]
565 fn test_by_language_stats_updated_on_add() {
566 let mut idx = SemanticMultilingualIndex::new();
567 idx.add_doc(make_doc(1, Language::English, vec![1.0]));
568 idx.add_doc(make_doc(2, Language::English, vec![0.0]));
569 idx.add_doc(make_doc(3, Language::German, vec![1.0]));
570 let stats = idx.stats();
571 assert_eq!(stats.by_language.get("en").copied().unwrap_or(0), 2);
572 assert_eq!(stats.by_language.get("de").copied().unwrap_or(0), 1);
573 }
574
575 #[test]
576 fn test_by_language_stats_updated_on_remove() {
577 let mut idx = SemanticMultilingualIndex::new();
578 idx.add_doc(make_doc(1, Language::English, vec![1.0]));
579 idx.add_doc(make_doc(2, Language::English, vec![0.0]));
580 idx.remove_doc(1);
581 assert_eq!(idx.stats().by_language.get("en").copied().unwrap_or(0), 1);
582 }
583
584 #[test]
587 fn test_other_language_code_handling() {
588 let lang = Language::Other {
589 code: "zh".to_owned(),
590 };
591 let mut idx = SemanticMultilingualIndex::new();
592 idx.add_doc(make_doc(1, lang.clone(), vec![1.0, 0.0]));
593 let ids = idx.docs_for_language(&lang);
594 assert_eq!(ids, vec![1]);
595 assert_eq!(idx.stats().by_language.get("zh").copied().unwrap_or(0), 1);
596 }
597
598 #[test]
599 fn test_other_language_code_in_search_filter() {
600 let lang = Language::Other {
601 code: "ko".to_owned(),
602 };
603 let mut idx = SemanticMultilingualIndex::new();
604 idx.add_doc(make_doc(1, lang.clone(), vec![1.0, 0.0]));
605 idx.add_doc(make_doc(2, Language::English, vec![1.0, 0.0]));
606 let q = CrossLingualQuery {
607 query_embedding: vec![1.0, 0.0],
608 target_languages: vec![lang],
609 top_k: 10,
610 };
611 let results = idx.search(&q);
612 assert_eq!(results.len(), 1);
613 assert_eq!(results[0].doc_id, 1);
614 }
615
616 #[test]
619 fn test_total_searches_increments() {
620 let mut idx = SemanticMultilingualIndex::new();
621 let q = CrossLingualQuery {
622 query_embedding: vec![1.0],
623 target_languages: vec![],
624 top_k: 5,
625 };
626 idx.search(&q);
627 idx.search(&q);
628 assert_eq!(idx.stats().total_searches, 2);
629 }
630
631 #[test]
634 fn test_metadata_preserved() {
635 let mut idx = SemanticMultilingualIndex::new();
636 let doc = make_doc_meta(
637 99,
638 Language::English,
639 vec![1.0],
640 &[("author", "alice"), ("year", "2024")],
641 );
642 idx.add_doc(doc);
643 let stored = idx.docs.get(&99).expect("doc must exist");
644 assert_eq!(
645 stored.metadata.get("author").map(String::as_str),
646 Some("alice")
647 );
648 assert_eq!(
649 stored.metadata.get("year").map(String::as_str),
650 Some("2024")
651 );
652 }
653
654 #[test]
657 fn test_result_language_matches_doc() {
658 let mut idx = SemanticMultilingualIndex::new();
659 idx.add_doc(make_doc(1, Language::Japanese, vec![1.0, 0.0]));
660 let q = CrossLingualQuery {
661 query_embedding: vec![1.0, 0.0],
662 target_languages: vec![],
663 top_k: 5,
664 };
665 let results = idx.search(&q);
666 assert_eq!(results[0].language, Language::Japanese);
667 }
668
669 #[test]
672 fn test_zero_norm_embedding_gives_zero_similarity() {
673 let mut idx = SemanticMultilingualIndex::new();
674 idx.add_doc(make_doc(1, Language::English, vec![0.0, 0.0]));
675 let q = CrossLingualQuery {
676 query_embedding: vec![1.0, 0.0],
677 target_languages: vec![],
678 top_k: 5,
679 };
680 let results = idx.search(&q);
681 assert_eq!(results[0].similarity, 0.0);
682 }
683
684 #[test]
687 fn test_dimension_mismatch_gives_zero_similarity() {
688 let mut idx = SemanticMultilingualIndex::new();
689 idx.add_doc(make_doc(1, Language::English, vec![1.0, 0.0, 0.0]));
691 let q = CrossLingualQuery {
692 query_embedding: vec![1.0, 0.0],
693 target_languages: vec![],
694 top_k: 5,
695 };
696 let results = idx.search(&q);
697 assert_eq!(results[0].similarity, 0.0);
698 }
699}