Skip to main content

ipfrs_semantic/
multilingual_index.rs

1// multilingual_index.rs — SemanticMultilingualIndex
2//
3// Indexes document embeddings organised by language, enabling cross-lingual
4// semantic search via language-specific and language-agnostic retrieval.
5
6use std::collections::HashMap;
7
8// ── Language ─────────────────────────────────────────────────────────────────
9
10/// ISO-639-1-aware language discriminant.
11#[derive(Clone, Debug, PartialEq, Eq, Hash)]
12pub enum Language {
13    English,
14    Japanese,
15    German,
16    French,
17    Spanish,
18    /// Any other language identified by its ISO 639-1 code.
19    Other {
20        code: String,
21    },
22}
23
24/// Return the canonical two-letter label used as map keys throughout this
25/// module.  Kept private; callers should rely on [`Language`] values instead.
26fn 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
37// ── Cosine similarity ─────────────────────────────────────────────────────────
38
39/// Cosine similarity in [−1, 1].  Returns 0.0 for empty slices, dimension
40/// mismatches, or zero-norm vectors.
41fn 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// ── MultilingualDoc ───────────────────────────────────────────────────────────
60
61/// A single document stored in the index.
62#[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// ── CrossLingualQuery ─────────────────────────────────────────────────────────
71
72/// A query that can target a subset of languages (or all, when the vec is
73/// empty).
74#[derive(Clone, Debug)]
75pub struct CrossLingualQuery {
76    pub query_embedding: Vec<f32>,
77    /// Languages to restrict retrieval to.  Empty → search all languages.
78    pub target_languages: Vec<Language>,
79    pub top_k: usize,
80}
81
82// ── MultilingualResult ────────────────────────────────────────────────────────
83
84/// A single result returned by [`SemanticMultilingualIndex::search`].
85#[derive(Clone, Debug)]
86pub struct MultilingualResult {
87    pub doc_id: u64,
88    pub language: Language,
89    pub similarity: f32,
90    /// 0-indexed rank in the result list (0 = most similar).
91    pub rank: usize,
92}
93
94// ── MultilingualIndexStats ────────────────────────────────────────────────────
95
96/// Aggregate statistics exposed by the index.
97#[derive(Clone, Debug, Default)]
98pub struct MultilingualIndexStats {
99    pub total_docs: usize,
100    /// Keyed by language label string (e.g. `"en"`, `"ja"`).
101    pub by_language: HashMap<String, usize>,
102    pub total_searches: u64,
103}
104
105// ── SemanticMultilingualIndex ─────────────────────────────────────────────────
106
107/// Embedding index organised by language that supports both monolingual and
108/// cross-lingual cosine-similarity search.
109#[derive(Debug)]
110pub struct SemanticMultilingualIndex {
111    /// Primary store: doc_id → document.
112    docs: HashMap<u64, MultilingualDoc>,
113    /// Inverted index: lang_label → sorted list of doc_ids.
114    language_index: HashMap<String, Vec<u64>>,
115    stats: MultilingualIndexStats,
116}
117
118impl SemanticMultilingualIndex {
119    /// Create an empty index.
120    pub fn new() -> Self {
121        Self {
122            docs: HashMap::new(),
123            language_index: HashMap::new(),
124            stats: MultilingualIndexStats::default(),
125        }
126    }
127
128    /// Insert a document.  If a document with the same `doc_id` already
129    /// exists it is replaced (old language-index entry is cleaned up first).
130    pub fn add_doc(&mut self, doc: MultilingualDoc) {
131        // If replacing, remove the stale language-index entry.
132        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            // Update by_language counter for old language.
138            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        // Update language index.
146        let ids = self.language_index.entry(label.clone()).or_default();
147        ids.push(doc.doc_id);
148        ids.sort_unstable();
149
150        // Update stats.
151        *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    /// Remove a document by `doc_id`.  Returns `true` if the document existed.
158    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        // Remove from language index.
165        if let Some(ids) = self.language_index.get_mut(&label) {
166            ids.retain(|&id| id != doc_id);
167        }
168
169        // Update stats.
170        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    /// Cross-lingual cosine-similarity search.
178    ///
179    /// * `target_languages` empty → search all documents.
180    /// * Results are sorted by similarity descending; ties broken by `doc_id`
181    ///   ascending.
182    /// * At most `top_k` results are returned, each with a 0-indexed `rank`.
183    pub fn search(&mut self, query: &CrossLingualQuery) -> Vec<MultilingualResult> {
184        self.stats.total_searches += 1;
185
186        // Collect candidate doc_ids.
187        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        // Score candidates.
203        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        // Sort: similarity descending, then doc_id ascending for ties.
213        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        // Truncate to top_k and assign ranks.
221        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    /// Return all `doc_id`s for the given language, sorted ascending.
239    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    /// Borrow the current statistics snapshot.
252    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// ── Tests ─────────────────────────────────────────────────────────────────────
264
265#[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    // ── add_doc ───────────────────────────────────────────────────────────────
297
298    #[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        // Replace doc 1 with a different language.
334        idx.add_doc(make_doc(1, Language::Spanish, vec![0.0, 1.0]));
335        // Must not appear under English any more.
336        assert!(!idx.docs_for_language(&Language::English).contains(&1));
337        // Must appear under Spanish.
338        assert!(idx.docs_for_language(&Language::Spanish).contains(&1));
339        // Total docs must still be 1.
340        assert_eq!(idx.stats().total_docs, 1);
341    }
342
343    // ── remove_doc ────────────────────────────────────────────────────────────
344
345    #[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    // ── search — all languages ────────────────────────────────────────────────
385
386    #[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    // ── search — language filter ──────────────────────────────────────────────
414
415    #[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    // ── search — top_k truncation ─────────────────────────────────────────────
449
450    #[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    // ── search — rank assignment ──────────────────────────────────────────────
478
479    #[test]
480    fn test_search_rank_assigned_correctly() {
481        let mut idx = SemanticMultilingualIndex::new();
482        // doc 1 is more similar to query [1,0] than doc 2
483        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        // Most similar doc should have rank 0.
493        assert_eq!(results[0].rank, 0);
494        assert_eq!(results[1].rank, 1);
495        // Most similar to [1,0] is doc 1.
496        assert_eq!(results[0].doc_id, 1);
497    }
498
499    // ── search — cosine similarity ordering ──────────────────────────────────
500
501    #[test]
502    fn test_search_cosine_similarity_ordering() {
503        let mut idx = SemanticMultilingualIndex::new();
504        // Similarities to [1, 0, 0]:
505        //  doc 1: [1, 0, 0] → 1.0
506        //  doc 2: [0.7, 0.7, 0] → cos ≈ 0.707
507        //  doc 3: [0, 1, 0] → 0.0
508        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    // ── search — doc_id tie-breaking ─────────────────────────────────────────
525
526    #[test]
527    fn test_search_doc_id_tie_breaking() {
528        let mut idx = SemanticMultilingualIndex::new();
529        // All docs have equal cosine similarity to [1,0].
530        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        // Should be sorted ascending by doc_id.
541        assert_eq!(ids, vec![10, 20, 30]);
542    }
543
544    // ── docs_for_language ─────────────────────────────────────────────────────
545
546    #[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    // ── by_language stats ─────────────────────────────────────────────────────
563
564    #[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    // ── Other language code handling ──────────────────────────────────────────
585
586    #[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    // ── stats.total_searches ──────────────────────────────────────────────────
617
618    #[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    // ── metadata round-trip ───────────────────────────────────────────────────
632
633    #[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    // ── language field on result ──────────────────────────────────────────────
655
656    #[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    // ── zero-norm vector returns 0.0 similarity ───────────────────────────────
670
671    #[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    // ── dimension mismatch returns 0.0 ────────────────────────────────────────
685
686    #[test]
687    fn test_dimension_mismatch_gives_zero_similarity() {
688        let mut idx = SemanticMultilingualIndex::new();
689        // 3-D doc, 2-D query.
690        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}