Skip to main content

ipfrs_semantic/
multilingual_embedding_aligner.rs

1//! Multilingual Embedding Aligner
2//!
3//! Maps embeddings from different language spaces to a shared cross-lingual space
4//! using Procrustes alignment, Linear Regression, CCA, or Identity passthrough.
5
6use std::collections::{HashMap, VecDeque};
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use thiserror::Error;
10
11// ─── Type aliases ─────────────────────────────────────────────────────────────
12
13/// 8-byte language identifier (e.g. b"en\0\0\0\0\0\0")
14pub type LangId = [u8; 8];
15
16/// Convenience alias for `MultilingualEmbeddingAligner`
17pub type MeaMultilingualEmbeddingAligner = MultilingualEmbeddingAligner;
18/// Convenience alias for `LanguageSpace`
19pub type MeaLanguageSpace = LanguageSpace;
20/// Convenience alias for `AlignmentMatrix`
21pub type MeaAlignmentMatrix = AlignmentMatrix;
22/// Convenience alias for `MeaAlignerConfig`
23pub type MeaAlignerCfg = MeaAlignerConfig;
24/// Convenience alias for `MeaAlignerStats`
25pub type MeaStats = MeaAlignerStats;
26
27// ─── Errors ───────────────────────────────────────────────────────────────────
28
29/// Errors produced by the multilingual embedding aligner.
30#[derive(Debug, Error)]
31pub enum MeaError {
32    #[error("language '{0}' not found")]
33    LanguageNotFound(String),
34
35    #[error("embedding id {0} not found in language '{1}'")]
36    EmbeddingNotFound(u64, String),
37
38    #[error("dimension mismatch: expected {expected}, got {actual}")]
39    DimensionMismatch { expected: usize, actual: usize },
40
41    #[error("not enough anchor pairs: need at least {min}, got {got}")]
42    NotEnoughAnchors { min: usize, got: usize },
43
44    #[error("alignment matrix not found for ({0}, {1})")]
45    AlignmentNotFound(String, String),
46
47    #[error("SVD power iteration did not converge")]
48    SvdNotConverged,
49
50    #[error("arithmetic error: {0}")]
51    Arithmetic(String),
52
53    #[error("empty embedding vector")]
54    EmptyEmbedding,
55}
56
57// ─── Enums ────────────────────────────────────────────────────────────────────
58
59/// Strategy used to compute the alignment matrix from anchor pairs.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
61pub enum MeaAlignmentMethod {
62    /// Procrustes orthogonal alignment via SVD approximation (power iteration)
63    #[default]
64    Procrustes,
65    /// Least-squares linear regression (W = (X^T X)^{-1} X^T Y)
66    LinearRegression,
67    /// Canonical Correlation Analysis (approximate)
68    Cca,
69    /// No-op: return the source vector unchanged (useful for same-language pairs)
70    IdentityPassthrough,
71}
72
73// ─── Core data structures ─────────────────────────────────────────────────────
74
75/// A named embedding space associated with one language.
76#[derive(Debug, Clone)]
77pub struct LanguageSpace {
78    pub id: LangId,
79    pub name: String,
80    pub dim: usize,
81    /// (embedding_id, vector)
82    pub embeddings: Vec<(u64, Vec<f64>)>,
83    pub centroid: Option<Vec<f64>>,
84}
85
86impl LanguageSpace {
87    fn new(id: LangId, name: String, dim: usize) -> Self {
88        Self {
89            id,
90            name,
91            dim,
92            embeddings: Vec::new(),
93            centroid: None,
94        }
95    }
96
97    fn lang_name(&self) -> String {
98        String::from_utf8_lossy(&self.id)
99            .trim_end_matches('\0')
100            .to_string()
101    }
102}
103
104/// Alignment transformation from one language space to another (row-major dim×dim).
105#[derive(Debug, Clone)]
106pub struct AlignmentMatrix {
107    pub src_lang: LangId,
108    pub tgt_lang: LangId,
109    /// Row-major dim×dim matrix
110    pub matrix: Vec<f64>,
111    /// Mean cosine similarity on the anchor pairs after alignment
112    pub quality: f64,
113}
114
115impl AlignmentMatrix {
116    /// Apply matrix-vector product (dim×dim matrix × dim vector → dim vector).
117    fn apply(&self, v: &[f64]) -> Result<Vec<f64>, MeaError> {
118        let dim = v.len();
119        let expected_len = dim * dim;
120        if self.matrix.len() != expected_len {
121            return Err(MeaError::DimensionMismatch {
122                expected: expected_len,
123                actual: self.matrix.len(),
124            });
125        }
126        let mut out = vec![0.0_f64; dim];
127        for (i, out_i) in out.iter_mut().enumerate() {
128            let mut acc = 0.0_f64;
129            let row_off = i * dim;
130            for (j, vj) in v.iter().enumerate() {
131                acc += self.matrix[row_off + j] * vj;
132            }
133            *out_i = acc;
134        }
135        Ok(out)
136    }
137}
138
139/// Timestamped record of a past alignment operation.
140#[derive(Debug, Clone)]
141pub struct AlignmentRecord {
142    pub ts: u64,
143    pub src_lang: LangId,
144    pub tgt_lang: LangId,
145    pub n_anchors: usize,
146    pub quality: f64,
147}
148
149/// Aggregate statistics about the aligner.
150#[derive(Debug, Clone, Default)]
151pub struct MeaAlignerStats {
152    pub n_languages: usize,
153    pub n_alignments: usize,
154    pub n_history_records: usize,
155    pub total_embeddings: usize,
156    pub avg_alignment_quality: f64,
157}
158
159/// Configuration for `MultilingualEmbeddingAligner`.
160#[derive(Debug, Clone)]
161pub struct MeaAlignerConfig {
162    pub dim: usize,
163    pub normalize_embeddings: bool,
164    pub alignment_method: MeaAlignmentMethod,
165    pub min_anchor_pairs: usize,
166}
167
168impl Default for MeaAlignerConfig {
169    fn default() -> Self {
170        Self {
171            dim: 128,
172            normalize_embeddings: true,
173            alignment_method: MeaAlignmentMethod::Procrustes,
174            min_anchor_pairs: 5,
175        }
176    }
177}
178
179// ─── PRNG helpers (pure Rust, no external deps) ───────────────────────────────
180
181#[inline]
182fn xorshift64(state: &mut u64) -> u64 {
183    let mut x = *state;
184    x ^= x << 13;
185    x ^= x >> 7;
186    x ^= x << 17;
187    *state = x;
188    x
189}
190
191// ─── Math helpers ─────────────────────────────────────────────────────────────
192
193#[inline]
194fn cosine_similarity(a: &[f64], b: &[f64]) -> f64 {
195    let dot: f64 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
196    let na: f64 = a.iter().map(|x| x * x).sum::<f64>().sqrt();
197    let nb: f64 = b.iter().map(|x| x * x).sum::<f64>().sqrt();
198    if na < 1e-12 || nb < 1e-12 {
199        0.0
200    } else {
201        (dot / (na * nb)).clamp(-1.0, 1.0)
202    }
203}
204
205/// L2-normalise a vector in-place. Returns false if the norm is zero.
206#[inline]
207fn normalize_vec(v: &mut [f64]) -> bool {
208    let norm: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
209    if norm < 1e-12 {
210        return false;
211    }
212    for x in v.iter_mut() {
213        *x /= norm;
214    }
215    true
216}
217
218fn now_secs() -> u64 {
219    SystemTime::now()
220        .duration_since(UNIX_EPOCH)
221        .map(|d| d.as_secs())
222        .unwrap_or(0)
223}
224
225fn lang_name_from_id(id: &LangId) -> String {
226    String::from_utf8_lossy(id)
227        .trim_end_matches('\0')
228        .to_string()
229}
230
231// ─── Linear algebra helpers ───────────────────────────────────────────────────
232
233/// Matrix multiply C = A * B where A is m×k, B is k×n (all row-major).
234fn matmul(a: &[f64], b: &[f64], m: usize, k: usize, n: usize) -> Vec<f64> {
235    let mut c = vec![0.0_f64; m * n];
236    for i in 0..m {
237        for l in 0..k {
238            let a_il = a[i * k + l];
239            for j in 0..n {
240                c[i * n + j] += a_il * b[l * n + j];
241            }
242        }
243    }
244    c
245}
246
247/// Transpose an m×n matrix (row-major) → n×m matrix (row-major).
248fn transpose(a: &[f64], m: usize, n: usize) -> Vec<f64> {
249    let mut t = vec![0.0_f64; n * m];
250    for i in 0..m {
251        for j in 0..n {
252            t[j * m + i] = a[i * n + j];
253        }
254    }
255    t
256}
257
258/// Build the identity matrix of size d×d (row-major).
259fn identity(d: usize) -> Vec<f64> {
260    let mut m = vec![0.0_f64; d * d];
261    for i in 0..d {
262        m[i * d + i] = 1.0;
263    }
264    m
265}
266
267/// SVD result: (U matrix, singular values S, Vt matrix)
268type SvdResult = Result<(Vec<f64>, Vec<f64>, Vec<f64>), MeaError>;
269
270/// Power-iteration SVD to find the top-k singular triplets of an m×n matrix A.
271/// Returns (U, S, V^T) each as flattened row-major matrices:
272///   U  : m × k
273///   S  : k (diagonal values)
274///   Vt : k × n
275fn svd_power_iteration(
276    a: &[f64],
277    m: usize,
278    n: usize,
279    k: usize,
280    max_iter: usize,
281    tol: f64,
282    rng: &mut u64,
283) -> SvdResult {
284    // Deflation-based approach: extract one singular triplet at a time.
285    let mut u_cols: Vec<Vec<f64>> = Vec::with_capacity(k);
286    let mut sigmas: Vec<f64> = Vec::with_capacity(k);
287    let mut v_cols: Vec<Vec<f64>> = Vec::with_capacity(k);
288
289    // Working copy of A that we deflate.
290    let mut a_work = a.to_vec();
291
292    for _ in 0..k {
293        // Random init for v (n-dim).
294        let mut v = (0..n)
295            .map(|_| {
296                let r = xorshift64(rng);
297                // Map to [-1, 1]
298                (r as i64 as f64) / (i64::MAX as f64)
299            })
300            .collect::<Vec<f64>>();
301        // Normalise.
302        let vn: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
303        if vn < 1e-12 {
304            return Err(MeaError::SvdNotConverged);
305        }
306        for x in v.iter_mut() {
307            *x /= vn;
308        }
309
310        let mut sigma = 0.0_f64;
311        let mut u = vec![0.0_f64; m];
312
313        for iter in 0..max_iter {
314            // u = A * v (m-dim)
315            for i in 0..m {
316                let mut acc = 0.0;
317                for j in 0..n {
318                    acc += a_work[i * n + j] * v[j];
319                }
320                u[i] = acc;
321            }
322            let new_sigma: f64 = u.iter().map(|x| x * x).sum::<f64>().sqrt();
323            if new_sigma < 1e-12 {
324                break;
325            }
326            for x in u.iter_mut() {
327                *x /= new_sigma;
328            }
329
330            // v_new = A^T * u (n-dim)
331            let mut v_new = vec![0.0_f64; n];
332            for j in 0..n {
333                let mut acc = 0.0;
334                for i in 0..m {
335                    acc += a_work[i * n + j] * u[i];
336                }
337                v_new[j] = acc;
338            }
339            let v_norm: f64 = v_new.iter().map(|x| x * x).sum::<f64>().sqrt();
340            if v_norm < 1e-12 {
341                break;
342            }
343            for x in v_new.iter_mut() {
344                *x /= v_norm;
345            }
346
347            // Check convergence.
348            let diff: f64 = v_new.iter().zip(v.iter()).map(|(a, b)| (a - b).abs()).sum();
349            v = v_new;
350            sigma = new_sigma;
351            if iter > 0 && diff < tol {
352                break;
353            }
354        }
355
356        if sigma < 1e-12 {
357            // Remaining rank is zero; fill with identity-like vectors.
358            let mut ui = vec![0.0_f64; m];
359            let mut vi = vec![0.0_f64; n];
360            let idx = u_cols.len().min(m - 1);
361            if idx < m {
362                ui[idx] = 1.0;
363            }
364            let idx = u_cols.len().min(n - 1);
365            if idx < n {
366                vi[idx] = 1.0;
367            }
368            u_cols.push(ui);
369            sigmas.push(0.0);
370            v_cols.push(vi);
371            continue;
372        }
373
374        // Deflate: A = A - sigma * u * v^T
375        for i in 0..m {
376            for j in 0..n {
377                a_work[i * n + j] -= sigma * u[i] * v[j];
378            }
379        }
380
381        u_cols.push(u);
382        sigmas.push(sigma);
383        v_cols.push(v);
384    }
385
386    // Pack results.
387    let mut u_mat = vec![0.0_f64; m * k];
388    let mut vt_mat = vec![0.0_f64; k * n];
389    for t in 0..k {
390        for i in 0..m {
391            u_mat[i * k + t] = u_cols[t][i];
392        }
393        for j in 0..n {
394            vt_mat[t * n + j] = v_cols[t][j];
395        }
396    }
397
398    Ok((u_mat, sigmas, vt_mat))
399}
400
401/// Solve the Procrustes problem: find orthogonal W that minimises ||X W - Y||_F.
402/// Uses SVD of X^T Y via power iteration.
403fn procrustes(
404    x: &[f64], // n_pairs × dim, row-major
405    y: &[f64], // n_pairs × dim, row-major
406    n: usize,
407    dim: usize,
408    rng: &mut u64,
409) -> Result<Vec<f64>, MeaError> {
410    // M = X^T * Y   (dim × dim)
411    let xt = transpose(x, n, dim); // dim × n
412    let m = matmul(&xt, y, dim, n, dim); // dim × dim
413
414    // SVD of M: U, S, Vt  (all dim × dim here since k = dim)
415    let k = dim.min(n); // can't extract more singular triplets than min(dim, n)
416    let (u, _s, vt) = svd_power_iteration(&m, dim, dim, k, 200, 1e-7, rng)?;
417
418    // Pad U and Vt to full dim if k < dim.
419    let u_full = if k == dim {
420        u
421    } else {
422        let mut uf = identity(dim);
423        for i in 0..dim {
424            for t in 0..k {
425                uf[i * dim + t] = u[i * k + t];
426            }
427        }
428        uf
429    };
430    let vt_full = if k == dim {
431        vt
432    } else {
433        let mut vtf = identity(dim);
434        for t in 0..k {
435            for j in 0..dim {
436                vtf[t * dim + j] = vt[t * k + j];
437            }
438        }
439        vtf
440    };
441
442    // W = V * U^T = (Vt)^T * U^T
443    let v_full = transpose(&vt_full, dim, dim); // dim × dim
444    let ut_full = transpose(&u_full, dim, dim); // dim × dim
445    let w = matmul(&v_full, &ut_full, dim, dim, dim); // dim × dim
446    Ok(w)
447}
448
449/// Least-squares linear regression: W = (X^T X)^{-1} X^T Y.
450/// Uses a simple Gauss-Jordan inversion for the Gram matrix.
451fn linear_regression(x: &[f64], y: &[f64], n: usize, dim: usize) -> Result<Vec<f64>, MeaError> {
452    let xt = transpose(x, n, dim); // dim × n
453    let xtx = matmul(&xt, x, dim, n, dim); // dim × dim
454    let xty = matmul(&xt, y, dim, n, dim); // dim × dim
455
456    // Invert xtx via Gauss-Jordan.
457    let inv_xtx = gauss_jordan_invert(&xtx, dim)?;
458    let w = matmul(&inv_xtx, &xty, dim, dim, dim);
459    Ok(w)
460}
461
462/// Gauss-Jordan matrix inversion for a d×d matrix.
463fn gauss_jordan_invert(a: &[f64], d: usize) -> Result<Vec<f64>, MeaError> {
464    // Augmented matrix [A | I]
465    let mut aug = vec![0.0_f64; d * 2 * d];
466    for i in 0..d {
467        for j in 0..d {
468            aug[i * 2 * d + j] = a[i * d + j];
469        }
470        aug[i * 2 * d + d + i] = 1.0;
471    }
472
473    for col in 0..d {
474        // Find pivot.
475        let mut max_row = col;
476        let mut max_val = aug[col * 2 * d + col].abs();
477        for row in (col + 1)..d {
478            let v = aug[row * 2 * d + col].abs();
479            if v > max_val {
480                max_val = v;
481                max_row = row;
482            }
483        }
484        if max_val < 1e-14 {
485            return Err(MeaError::Arithmetic(
486                "singular matrix in Gauss-Jordan".to_string(),
487            ));
488        }
489        // Swap rows.
490        if max_row != col {
491            for j in 0..2 * d {
492                aug.swap(col * 2 * d + j, max_row * 2 * d + j);
493            }
494        }
495        let pivot = aug[col * 2 * d + col];
496        for j in 0..2 * d {
497            aug[col * 2 * d + j] /= pivot;
498        }
499        for row in 0..d {
500            if row == col {
501                continue;
502            }
503            let factor = aug[row * 2 * d + col];
504            for j in 0..2 * d {
505                let v = aug[col * 2 * d + j];
506                aug[row * 2 * d + j] -= factor * v;
507            }
508        }
509    }
510
511    let mut inv = vec![0.0_f64; d * d];
512    for i in 0..d {
513        for j in 0..d {
514            inv[i * d + j] = aug[i * 2 * d + d + j];
515        }
516    }
517    Ok(inv)
518}
519
520/// Approximate CCA alignment: whiten both sides then Procrustes.
521fn cca_alignment(
522    x: &[f64],
523    y: &[f64],
524    n: usize,
525    dim: usize,
526    rng: &mut u64,
527) -> Result<Vec<f64>, MeaError> {
528    // Whiten X.
529    let wx = whiten(x, n, dim)?;
530    // Whiten Y.
531    let wy = whiten(y, n, dim)?;
532    // Procrustes on whitened pair.
533    procrustes(&wx, &wy, n, dim, rng)
534}
535
536/// Whiten a matrix X (n × dim) so that X^T X ≈ I.
537fn whiten(x: &[f64], n: usize, dim: usize) -> Result<Vec<f64>, MeaError> {
538    let xt = transpose(x, n, dim);
539    let cov = matmul(&xt, x, dim, n, dim);
540    // Compute the symmetric square-root inverse via Gauss-Jordan on the Gram.
541    // For approximate whitening we do: W = diag(1/sqrt(diag(cov))).
542    let mut w_diag = vec![0.0_f64; dim];
543    for i in 0..dim {
544        let v = cov[i * dim + i];
545        w_diag[i] = if v > 1e-14 { 1.0 / v.sqrt() } else { 1.0 };
546    }
547    // X_white[row][col] = X[row][col] * w_diag[col]
548    let mut xw = vec![0.0_f64; n * dim];
549    for i in 0..n {
550        for j in 0..dim {
551            xw[i * dim + j] = x[i * dim + j] * w_diag[j];
552        }
553    }
554    Ok(xw)
555}
556
557// ─── Main struct ──────────────────────────────────────────────────────────────
558
559/// Multilingual embedding aligner: maps embeddings from different language
560/// spaces to a shared cross-lingual embedding space.
561pub struct MultilingualEmbeddingAligner {
562    pub config: MeaAlignerConfig,
563    pub language_spaces: HashMap<LangId, LanguageSpace>,
564    pub alignment_matrices: HashMap<(LangId, LangId), AlignmentMatrix>,
565    pub alignment_history: VecDeque<AlignmentRecord>,
566    rng: u64,
567}
568
569impl MultilingualEmbeddingAligner {
570    const HISTORY_CAP: usize = 500;
571
572    /// Create a new aligner with the given configuration.
573    pub fn new(config: MeaAlignerConfig) -> Self {
574        let seed = SystemTime::now()
575            .duration_since(UNIX_EPOCH)
576            .map(|d| d.subsec_nanos() as u64 + d.as_secs() * 1_000_000_000)
577            .unwrap_or(0x123456789abcdef0);
578        Self {
579            config,
580            language_spaces: HashMap::new(),
581            alignment_matrices: HashMap::new(),
582            alignment_history: VecDeque::with_capacity(Self::HISTORY_CAP),
583            rng: if seed == 0 { 0xdeadbeefcafe_u64 } else { seed },
584        }
585    }
586
587    /// Create an aligner with default config.
588    pub fn with_defaults() -> Self {
589        Self::new(MeaAlignerConfig::default())
590    }
591
592    // ── Language management ───────────────────────────────────────────────────
593
594    /// Register a new language space.
595    pub fn add_language(&mut self, id: LangId, name: String, dim: usize) {
596        self.language_spaces
597            .entry(id)
598            .or_insert_with(|| LanguageSpace::new(id, name, dim));
599    }
600
601    /// Remove a language space and all alignment matrices involving it.
602    pub fn remove_language(&mut self, id: LangId) {
603        self.language_spaces.remove(&id);
604        self.alignment_matrices
605            .retain(|(src, tgt), _| src != &id && tgt != &id);
606    }
607
608    // ── Embedding management ──────────────────────────────────────────────────
609
610    /// Add an embedding to a language space.
611    pub fn add_embedding(
612        &mut self,
613        lang_id: LangId,
614        emb_id: u64,
615        mut vector: Vec<f64>,
616    ) -> Result<(), MeaError> {
617        let space = self
618            .language_spaces
619            .get_mut(&lang_id)
620            .ok_or_else(|| MeaError::LanguageNotFound(lang_name_from_id(&lang_id)))?;
621        if vector.is_empty() {
622            return Err(MeaError::EmptyEmbedding);
623        }
624        if vector.len() != space.dim {
625            return Err(MeaError::DimensionMismatch {
626                expected: space.dim,
627                actual: vector.len(),
628            });
629        }
630        if self.config.normalize_embeddings {
631            normalize_vec(&mut vector);
632        }
633        // Upsert.
634        if let Some(entry) = space.embeddings.iter_mut().find(|(id, _)| *id == emb_id) {
635            entry.1 = vector;
636        } else {
637            space.embeddings.push((emb_id, vector));
638        }
639        // Invalidate centroid.
640        space.centroid = None;
641        Ok(())
642    }
643
644    /// Remove an embedding from a language space.
645    pub fn remove_embedding(&mut self, lang_id: LangId, emb_id: u64) -> Result<(), MeaError> {
646        let space = self
647            .language_spaces
648            .get_mut(&lang_id)
649            .ok_or_else(|| MeaError::LanguageNotFound(lang_name_from_id(&lang_id)))?;
650        let before = space.embeddings.len();
651        space.embeddings.retain(|(id, _)| *id != emb_id);
652        if space.embeddings.len() == before {
653            return Err(MeaError::EmbeddingNotFound(emb_id, space.lang_name()));
654        }
655        space.centroid = None;
656        Ok(())
657    }
658
659    // ── Alignment ─────────────────────────────────────────────────────────────
660
661    /// Compute and store an alignment matrix from `src` → `tgt`.
662    ///
663    /// `anchors` is a list of `(src_emb_id, tgt_emb_id)` pairs that are
664    /// known to be translation equivalents.
665    pub fn compute_alignment(
666        &mut self,
667        src: LangId,
668        tgt: LangId,
669        anchors: &[(u64, u64)],
670    ) -> Result<(), MeaError> {
671        let min = self.config.min_anchor_pairs;
672        if anchors.len() < min {
673            return Err(MeaError::NotEnoughAnchors {
674                min,
675                got: anchors.len(),
676            });
677        }
678
679        let dim = self.config.dim;
680
681        // Collect anchor vectors.
682        let src_vecs: Vec<Vec<f64>> = {
683            let space = self
684                .language_spaces
685                .get(&src)
686                .ok_or_else(|| MeaError::LanguageNotFound(lang_name_from_id(&src)))?;
687            anchors
688                .iter()
689                .map(|(sid, _)| {
690                    space
691                        .embeddings
692                        .iter()
693                        .find(|(id, _)| id == sid)
694                        .map(|(_, v)| v.clone())
695                        .ok_or_else(|| MeaError::EmbeddingNotFound(*sid, space.lang_name()))
696                })
697                .collect::<Result<Vec<_>, _>>()?
698        };
699
700        let tgt_vecs: Vec<Vec<f64>> = {
701            let space = self
702                .language_spaces
703                .get(&tgt)
704                .ok_or_else(|| MeaError::LanguageNotFound(lang_name_from_id(&tgt)))?;
705            anchors
706                .iter()
707                .map(|(_, tid)| {
708                    space
709                        .embeddings
710                        .iter()
711                        .find(|(id, _)| id == tid)
712                        .map(|(_, v)| v.clone())
713                        .ok_or_else(|| MeaError::EmbeddingNotFound(*tid, space.lang_name()))
714                })
715                .collect::<Result<Vec<_>, _>>()?
716        };
717
718        // Validate dimensions.
719        for v in src_vecs.iter().chain(tgt_vecs.iter()) {
720            if v.len() != dim {
721                return Err(MeaError::DimensionMismatch {
722                    expected: dim,
723                    actual: v.len(),
724                });
725            }
726        }
727
728        let n = anchors.len();
729
730        // Flatten to row-major matrices.
731        let x: Vec<f64> = src_vecs.iter().flat_map(|v| v.iter().copied()).collect();
732        let y: Vec<f64> = tgt_vecs.iter().flat_map(|v| v.iter().copied()).collect();
733
734        let matrix = match self.config.alignment_method {
735            MeaAlignmentMethod::IdentityPassthrough => identity(dim),
736            MeaAlignmentMethod::Procrustes => procrustes(&x, &y, n, dim, &mut self.rng)?,
737            MeaAlignmentMethod::LinearRegression => linear_regression(&x, &y, n, dim)?,
738            MeaAlignmentMethod::Cca => cca_alignment(&x, &y, n, dim, &mut self.rng)?,
739        };
740
741        // Measure alignment quality: mean cosine(W*x_i, y_i).
742        let quality = self.measure_quality(&matrix, &src_vecs, &tgt_vecs);
743
744        let record = AlignmentRecord {
745            ts: now_secs(),
746            src_lang: src,
747            tgt_lang: tgt,
748            n_anchors: n,
749            quality,
750        };
751
752        self.alignment_matrices.insert(
753            (src, tgt),
754            AlignmentMatrix {
755                src_lang: src,
756                tgt_lang: tgt,
757                matrix,
758                quality,
759            },
760        );
761
762        if self.alignment_history.len() >= Self::HISTORY_CAP {
763            self.alignment_history.pop_front();
764        }
765        self.alignment_history.push_back(record);
766
767        Ok(())
768    }
769
770    fn measure_quality(&self, matrix: &[f64], src_vecs: &[Vec<f64>], tgt_vecs: &[Vec<f64>]) -> f64 {
771        let n = src_vecs.len();
772        if n == 0 {
773            return 0.0;
774        }
775        let am = AlignmentMatrix {
776            src_lang: [0; 8],
777            tgt_lang: [0; 8],
778            matrix: matrix.to_vec(),
779            quality: 0.0,
780        };
781        let total: f64 = src_vecs
782            .iter()
783            .zip(tgt_vecs.iter())
784            .map(|(sv, tv)| {
785                am.apply(sv)
786                    .map(|aligned| cosine_similarity(&aligned, tv))
787                    .unwrap_or(0.0)
788            })
789            .sum();
790        total / n as f64
791    }
792
793    /// Apply the stored alignment matrix to transform `vector` from `src` space to `tgt` space.
794    pub fn align_embedding(
795        &self,
796        src: LangId,
797        tgt: LangId,
798        vector: &[f64],
799    ) -> Result<Vec<f64>, MeaError> {
800        if vector.is_empty() {
801            return Err(MeaError::EmptyEmbedding);
802        }
803        if vector.len() != self.config.dim {
804            return Err(MeaError::DimensionMismatch {
805                expected: self.config.dim,
806                actual: vector.len(),
807            });
808        }
809        let am = self.alignment_matrices.get(&(src, tgt)).ok_or_else(|| {
810            MeaError::AlignmentNotFound(lang_name_from_id(&src), lang_name_from_id(&tgt))
811        })?;
812        let mut out = am.apply(vector)?;
813        if self.config.normalize_embeddings {
814            normalize_vec(&mut out);
815        }
816        Ok(out)
817    }
818
819    // ── Cross-lingual search ──────────────────────────────────────────────────
820
821    /// Search in `target_lang`'s embedding space using a query from `query_lang`.
822    ///
823    /// Returns top-k `(emb_id, cosine_score)` pairs, sorted by descending score.
824    pub fn cross_lingual_search(
825        &self,
826        query_lang: LangId,
827        query: &[f64],
828        target_lang: LangId,
829        top_k: usize,
830    ) -> Result<Vec<(u64, f64)>, MeaError> {
831        if query.is_empty() {
832            return Err(MeaError::EmptyEmbedding);
833        }
834
835        // Validate that the target language exists before attempting alignment.
836        if !self.language_spaces.contains_key(&target_lang) {
837            return Err(MeaError::LanguageNotFound(lang_name_from_id(&target_lang)));
838        }
839
840        let aligned_query = if query_lang == target_lang {
841            query.to_vec()
842        } else {
843            self.align_embedding(query_lang, target_lang, query)?
844        };
845
846        let tgt_space = self
847            .language_spaces
848            .get(&target_lang)
849            .ok_or_else(|| MeaError::LanguageNotFound(lang_name_from_id(&target_lang)))?;
850
851        let mut scores: Vec<(u64, f64)> = tgt_space
852            .embeddings
853            .iter()
854            .map(|(id, v)| (*id, cosine_similarity(&aligned_query, v)))
855            .collect();
856
857        scores.sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
858        scores.truncate(top_k);
859        Ok(scores)
860    }
861
862    // ── Quality & stats ───────────────────────────────────────────────────────
863
864    /// Return the stored alignment quality for a (src, tgt) pair.
865    pub fn alignment_quality(&self, src: LangId, tgt: LangId) -> Option<f64> {
866        self.alignment_matrices
867            .get(&(src, tgt))
868            .map(|am| am.quality)
869    }
870
871    /// Compute and cache the centroid of a language space.
872    pub fn compute_centroid(&mut self, lang_id: LangId) -> Result<Vec<f64>, MeaError> {
873        let space = self
874            .language_spaces
875            .get_mut(&lang_id)
876            .ok_or_else(|| MeaError::LanguageNotFound(lang_name_from_id(&lang_id)))?;
877
878        if space.embeddings.is_empty() {
879            return Err(MeaError::Arithmetic(
880                "no embeddings to compute centroid from".to_string(),
881            ));
882        }
883
884        let dim = space.dim;
885        let mut c = vec![0.0_f64; dim];
886        for (_, v) in &space.embeddings {
887            for (ci, vi) in c.iter_mut().zip(v.iter()) {
888                *ci += vi;
889            }
890        }
891        let n = space.embeddings.len() as f64;
892        for ci in c.iter_mut() {
893            *ci /= n;
894        }
895        space.centroid = Some(c.clone());
896        Ok(c)
897    }
898
899    /// Aggregate statistics about the aligner state.
900    pub fn aligner_stats(&self) -> MeaAlignerStats {
901        let n_alignments = self.alignment_matrices.len();
902        let avg_quality = if n_alignments == 0 {
903            0.0
904        } else {
905            let sum: f64 = self.alignment_matrices.values().map(|am| am.quality).sum();
906            sum / n_alignments as f64
907        };
908        MeaAlignerStats {
909            n_languages: self.language_spaces.len(),
910            n_alignments,
911            n_history_records: self.alignment_history.len(),
912            total_embeddings: self
913                .language_spaces
914                .values()
915                .map(|s| s.embeddings.len())
916                .sum(),
917            avg_alignment_quality: avg_quality,
918        }
919    }
920
921    // ── Helpers ───────────────────────────────────────────────────────────────
922
923    /// Number of embeddings in a language space.
924    pub fn embedding_count(&self, lang_id: LangId) -> Option<usize> {
925        self.language_spaces
926            .get(&lang_id)
927            .map(|s| s.embeddings.len())
928    }
929
930    /// Retrieve a stored alignment matrix (immutable borrow).
931    pub fn get_alignment_matrix(&self, src: LangId, tgt: LangId) -> Option<&AlignmentMatrix> {
932        self.alignment_matrices.get(&(src, tgt))
933    }
934
935    /// List all registered language IDs.
936    pub fn language_ids(&self) -> Vec<LangId> {
937        self.language_spaces.keys().copied().collect()
938    }
939
940    /// Check whether an alignment exists for the given pair.
941    pub fn has_alignment(&self, src: LangId, tgt: LangId) -> bool {
942        self.alignment_matrices.contains_key(&(src, tgt))
943    }
944
945    /// Retrieve the centroid of a language space without recomputing.
946    pub fn centroid(&self, lang_id: LangId) -> Option<&Vec<f64>> {
947        self.language_spaces.get(&lang_id)?.centroid.as_ref()
948    }
949
950    /// Recent alignment history entries (newest first).
951    pub fn history(&self, limit: usize) -> Vec<&AlignmentRecord> {
952        self.alignment_history.iter().rev().take(limit).collect()
953    }
954}
955
956// ─── Tests ────────────────────────────────────────────────────────────────────
957
958#[cfg(test)]
959mod tests {
960    use super::*;
961
962    fn make_lang_id(s: &str) -> LangId {
963        let mut id = [0u8; 8];
964        for (i, b) in s.as_bytes().iter().take(8).enumerate() {
965            id[i] = *b;
966        }
967        id
968    }
969
970    fn make_aligner(dim: usize, method: MeaAlignmentMethod) -> MultilingualEmbeddingAligner {
971        MultilingualEmbeddingAligner::new(MeaAlignerConfig {
972            dim,
973            normalize_embeddings: false,
974            alignment_method: method,
975            min_anchor_pairs: 3,
976        })
977    }
978
979    fn add_lang(aligner: &mut MultilingualEmbeddingAligner, name: &str, dim: usize) -> LangId {
980        let id = make_lang_id(name);
981        aligner.add_language(id, name.to_string(), dim);
982        id
983    }
984
985    fn add_emb(aligner: &mut MultilingualEmbeddingAligner, lang: LangId, emb_id: u64, v: Vec<f64>) {
986        aligner
987            .add_embedding(lang, emb_id, v)
988            .expect("add_embedding failed");
989    }
990
991    // ── 1: basic construction ─────────────────────────────────────────────────
992
993    #[test]
994    fn test_new_aligner_default() {
995        let a = MultilingualEmbeddingAligner::with_defaults();
996        assert!(a.language_spaces.is_empty());
997        assert!(a.alignment_matrices.is_empty());
998        assert!(a.alignment_history.is_empty());
999    }
1000
1001    #[test]
1002    fn test_new_aligner_custom_config() {
1003        let cfg = MeaAlignerConfig {
1004            dim: 32,
1005            normalize_embeddings: true,
1006            alignment_method: MeaAlignmentMethod::LinearRegression,
1007            min_anchor_pairs: 10,
1008        };
1009        let a = MultilingualEmbeddingAligner::new(cfg.clone());
1010        assert_eq!(a.config.dim, 32);
1011        assert_eq!(a.config.min_anchor_pairs, 10);
1012        assert!(a.config.normalize_embeddings);
1013    }
1014
1015    // ── 2: language management ────────────────────────────────────────────────
1016
1017    #[test]
1018    fn test_add_language() {
1019        let mut a = make_aligner(4, MeaAlignmentMethod::IdentityPassthrough);
1020        let en = make_lang_id("en");
1021        a.add_language(en, "English".to_string(), 4);
1022        assert_eq!(a.language_spaces.len(), 1);
1023        assert_eq!(a.language_spaces[&en].name, "English");
1024    }
1025
1026    #[test]
1027    fn test_add_language_idempotent() {
1028        let mut a = make_aligner(4, MeaAlignmentMethod::IdentityPassthrough);
1029        let en = make_lang_id("en");
1030        a.add_language(en, "English".to_string(), 4);
1031        a.add_language(en, "English2".to_string(), 4);
1032        // Second add should be a no-op (entry already exists).
1033        assert_eq!(a.language_spaces[&en].name, "English");
1034    }
1035
1036    #[test]
1037    fn test_remove_language() {
1038        let mut a = make_aligner(4, MeaAlignmentMethod::IdentityPassthrough);
1039        let en = add_lang(&mut a, "en", 4);
1040        a.remove_language(en);
1041        assert!(a.language_spaces.is_empty());
1042    }
1043
1044    #[test]
1045    fn test_remove_language_cleans_alignments() {
1046        let mut a = make_aligner(4, MeaAlignmentMethod::IdentityPassthrough);
1047        let en = add_lang(&mut a, "en", 4);
1048        let fr = add_lang(&mut a, "fr", 4);
1049        // Manually insert a dummy alignment.
1050        a.alignment_matrices.insert(
1051            (en, fr),
1052            AlignmentMatrix {
1053                src_lang: en,
1054                tgt_lang: fr,
1055                matrix: identity(4),
1056                quality: 1.0,
1057            },
1058        );
1059        a.remove_language(en);
1060        assert!(!a.alignment_matrices.contains_key(&(en, fr)));
1061    }
1062
1063    // ── 3: embedding management ───────────────────────────────────────────────
1064
1065    #[test]
1066    fn test_add_embedding_ok() {
1067        let mut a = make_aligner(4, MeaAlignmentMethod::IdentityPassthrough);
1068        let en = add_lang(&mut a, "en", 4);
1069        add_emb(&mut a, en, 1, vec![1.0, 0.0, 0.0, 0.0]);
1070        assert_eq!(a.embedding_count(en), Some(1));
1071    }
1072
1073    #[test]
1074    fn test_add_embedding_dim_mismatch() {
1075        let mut a = make_aligner(4, MeaAlignmentMethod::IdentityPassthrough);
1076        let en = add_lang(&mut a, "en", 4);
1077        let res = a.add_embedding(en, 1, vec![1.0, 0.0]);
1078        assert!(matches!(res, Err(MeaError::DimensionMismatch { .. })));
1079    }
1080
1081    #[test]
1082    fn test_add_embedding_unknown_lang() {
1083        let mut a = make_aligner(4, MeaAlignmentMethod::IdentityPassthrough);
1084        let unknown = make_lang_id("xx");
1085        let res = a.add_embedding(unknown, 1, vec![1.0, 0.0, 0.0, 0.0]);
1086        assert!(matches!(res, Err(MeaError::LanguageNotFound(_))));
1087    }
1088
1089    #[test]
1090    fn test_add_embedding_empty_vector() {
1091        let mut a = make_aligner(4, MeaAlignmentMethod::IdentityPassthrough);
1092        let en = add_lang(&mut a, "en", 4);
1093        let res = a.add_embedding(en, 1, vec![]);
1094        assert!(matches!(res, Err(MeaError::EmptyEmbedding)));
1095    }
1096
1097    #[test]
1098    fn test_add_embedding_upsert() {
1099        let mut a = make_aligner(4, MeaAlignmentMethod::IdentityPassthrough);
1100        let en = add_lang(&mut a, "en", 4);
1101        add_emb(&mut a, en, 1, vec![1.0, 0.0, 0.0, 0.0]);
1102        add_emb(&mut a, en, 1, vec![0.0, 1.0, 0.0, 0.0]);
1103        assert_eq!(a.embedding_count(en), Some(1));
1104        let v = &a.language_spaces[&en].embeddings[0].1;
1105        assert!((v[1] - 1.0).abs() < 1e-9);
1106    }
1107
1108    #[test]
1109    fn test_remove_embedding_ok() {
1110        let mut a = make_aligner(4, MeaAlignmentMethod::IdentityPassthrough);
1111        let en = add_lang(&mut a, "en", 4);
1112        add_emb(&mut a, en, 1, vec![1.0, 0.0, 0.0, 0.0]);
1113        a.remove_embedding(en, 1).expect("remove failed");
1114        assert_eq!(a.embedding_count(en), Some(0));
1115    }
1116
1117    #[test]
1118    fn test_remove_embedding_not_found() {
1119        let mut a = make_aligner(4, MeaAlignmentMethod::IdentityPassthrough);
1120        let en = add_lang(&mut a, "en", 4);
1121        let res = a.remove_embedding(en, 99);
1122        assert!(matches!(res, Err(MeaError::EmbeddingNotFound(99, _))));
1123    }
1124
1125    #[test]
1126    fn test_remove_embedding_unknown_lang() {
1127        let mut a = make_aligner(4, MeaAlignmentMethod::IdentityPassthrough);
1128        let unknown = make_lang_id("xx");
1129        let res = a.remove_embedding(unknown, 1);
1130        assert!(matches!(res, Err(MeaError::LanguageNotFound(_))));
1131    }
1132
1133    // ── 4: identity alignment ─────────────────────────────────────────────────
1134
1135    #[test]
1136    fn test_compute_alignment_identity() {
1137        let dim = 4;
1138        let mut a = make_aligner(dim, MeaAlignmentMethod::IdentityPassthrough);
1139        let en = add_lang(&mut a, "en", dim);
1140        let fr = add_lang(&mut a, "fr", dim);
1141        for i in 0..5u64 {
1142            let v: Vec<f64> = (0..dim)
1143                .map(|j| ((i * dim as u64 + j as u64) % 7) as f64)
1144                .collect();
1145            add_emb(&mut a, en, i, v.clone());
1146            add_emb(&mut a, fr, i, v);
1147        }
1148        let anchors: Vec<(u64, u64)> = (0..5).map(|i| (i, i)).collect();
1149        a.compute_alignment(en, fr, &anchors)
1150            .expect("alignment failed");
1151        assert!(a.has_alignment(en, fr));
1152    }
1153
1154    #[test]
1155    fn test_compute_alignment_too_few_anchors() {
1156        let mut a = make_aligner(4, MeaAlignmentMethod::IdentityPassthrough);
1157        let en = add_lang(&mut a, "en", 4);
1158        let fr = add_lang(&mut a, "fr", 4);
1159        let anchors: Vec<(u64, u64)> = vec![(0, 0), (1, 1)]; // only 2 < min 3
1160        let res = a.compute_alignment(en, fr, &anchors);
1161        assert!(matches!(res, Err(MeaError::NotEnoughAnchors { .. })));
1162    }
1163
1164    #[test]
1165    fn test_compute_alignment_unknown_src() {
1166        let mut a = make_aligner(4, MeaAlignmentMethod::IdentityPassthrough);
1167        let xx = make_lang_id("xx");
1168        let fr = add_lang(&mut a, "fr", 4);
1169        let anchors: Vec<(u64, u64)> = (0..5).map(|i| (i, i)).collect();
1170        let res = a.compute_alignment(xx, fr, &anchors);
1171        assert!(matches!(res, Err(MeaError::LanguageNotFound(_))));
1172    }
1173
1174    #[test]
1175    fn test_compute_alignment_unknown_tgt() {
1176        let mut a = make_aligner(4, MeaAlignmentMethod::IdentityPassthrough);
1177        let en = add_lang(&mut a, "en", 4);
1178        let xx = make_lang_id("xx");
1179        for i in 0..5u64 {
1180            add_emb(&mut a, en, i, vec![i as f64, 0.0, 0.0, 0.0]);
1181        }
1182        let anchors: Vec<(u64, u64)> = (0..5).map(|i| (i, i)).collect();
1183        let res = a.compute_alignment(en, xx, &anchors);
1184        assert!(matches!(res, Err(MeaError::LanguageNotFound(_))));
1185    }
1186
1187    #[test]
1188    fn test_alignment_quality_stored() {
1189        let dim = 4;
1190        let mut a = make_aligner(dim, MeaAlignmentMethod::IdentityPassthrough);
1191        let en = add_lang(&mut a, "en", dim);
1192        let fr = add_lang(&mut a, "fr", dim);
1193        for i in 0..5u64 {
1194            let v = vec![i as f64 + 1.0, 0.0, 0.0, 0.0];
1195            add_emb(&mut a, en, i, v.clone());
1196            add_emb(&mut a, fr, i, v);
1197        }
1198        let anchors: Vec<(u64, u64)> = (0..5).map(|i| (i, i)).collect();
1199        a.compute_alignment(en, fr, &anchors)
1200            .expect("test: compute_alignment failed");
1201        // Identity + identical vectors → quality ≈ 1.0
1202        let q = a
1203            .alignment_quality(en, fr)
1204            .expect("test: alignment_quality not found");
1205        assert!(q > 0.99, "quality={q}");
1206    }
1207
1208    #[test]
1209    fn test_alignment_quality_none_for_missing() {
1210        let a = make_aligner(4, MeaAlignmentMethod::IdentityPassthrough);
1211        let en = make_lang_id("en");
1212        let fr = make_lang_id("fr");
1213        assert!(a.alignment_quality(en, fr).is_none());
1214    }
1215
1216    // ── 5: align_embedding ────────────────────────────────────────────────────
1217
1218    #[test]
1219    fn test_align_embedding_identity() {
1220        let dim = 4;
1221        let mut a = make_aligner(dim, MeaAlignmentMethod::IdentityPassthrough);
1222        let en = add_lang(&mut a, "en", dim);
1223        let fr = add_lang(&mut a, "fr", dim);
1224        for i in 0..5u64 {
1225            let v = vec![i as f64 + 1.0, 0.0, 0.0, 0.0];
1226            add_emb(&mut a, en, i, v.clone());
1227            add_emb(&mut a, fr, i, v);
1228        }
1229        let anchors: Vec<(u64, u64)> = (0..5).map(|i| (i, i)).collect();
1230        a.compute_alignment(en, fr, &anchors)
1231            .expect("test: compute_alignment identity passthrough failed");
1232        let q = vec![2.0, 0.0, 0.0, 0.0];
1233        let aligned = a
1234            .align_embedding(en, fr, &q)
1235            .expect("test: align_embedding identity passthrough failed");
1236        // Identity: output ≈ input
1237        assert!((aligned[0] - 2.0).abs() < 1e-6, "aligned={aligned:?}");
1238    }
1239
1240    #[test]
1241    fn test_align_embedding_no_matrix() {
1242        let a = make_aligner(4, MeaAlignmentMethod::IdentityPassthrough);
1243        let en = make_lang_id("en");
1244        let fr = make_lang_id("fr");
1245        let res = a.align_embedding(en, fr, &[1.0, 0.0, 0.0, 0.0]);
1246        assert!(matches!(res, Err(MeaError::AlignmentNotFound(_, _))));
1247    }
1248
1249    #[test]
1250    fn test_align_embedding_dim_mismatch() {
1251        let dim = 4;
1252        let mut a = make_aligner(dim, MeaAlignmentMethod::IdentityPassthrough);
1253        let en = add_lang(&mut a, "en", dim);
1254        let fr = add_lang(&mut a, "fr", dim);
1255        for i in 0..5u64 {
1256            let v = vec![i as f64 + 1.0, 0.0, 0.0, 0.0];
1257            add_emb(&mut a, en, i, v.clone());
1258            add_emb(&mut a, fr, i, v);
1259        }
1260        let anchors: Vec<(u64, u64)> = (0..5).map(|i| (i, i)).collect();
1261        a.compute_alignment(en, fr, &anchors)
1262            .expect("test: compute_alignment for dim mismatch test failed");
1263        let res = a.align_embedding(en, fr, &[1.0, 0.0]); // wrong dim
1264        assert!(matches!(res, Err(MeaError::DimensionMismatch { .. })));
1265    }
1266
1267    #[test]
1268    fn test_align_embedding_empty() {
1269        let dim = 4;
1270        let mut a = make_aligner(dim, MeaAlignmentMethod::IdentityPassthrough);
1271        let en = add_lang(&mut a, "en", dim);
1272        let fr = add_lang(&mut a, "fr", dim);
1273        for i in 0..5u64 {
1274            add_emb(&mut a, en, i, vec![i as f64 + 1.0, 0.0, 0.0, 0.0]);
1275            add_emb(&mut a, fr, i, vec![i as f64 + 1.0, 0.0, 0.0, 0.0]);
1276        }
1277        let anchors: Vec<(u64, u64)> = (0..5).map(|i| (i, i)).collect();
1278        a.compute_alignment(en, fr, &anchors)
1279            .expect("test: compute_alignment for empty align test failed");
1280        let res = a.align_embedding(en, fr, &[]);
1281        assert!(matches!(res, Err(MeaError::EmptyEmbedding)));
1282    }
1283
1284    // ── 6: cross-lingual search ───────────────────────────────────────────────
1285
1286    #[test]
1287    fn test_cross_lingual_search_same_lang() {
1288        let dim = 4;
1289        let mut a = make_aligner(dim, MeaAlignmentMethod::IdentityPassthrough);
1290        let en = add_lang(&mut a, "en", dim);
1291        add_emb(&mut a, en, 1, vec![1.0, 0.0, 0.0, 0.0]);
1292        add_emb(&mut a, en, 2, vec![0.0, 1.0, 0.0, 0.0]);
1293        let res = a
1294            .cross_lingual_search(en, &[1.0, 0.0, 0.0, 0.0], en, 2)
1295            .expect("test: cross_lingual_search same lang failed");
1296        assert_eq!(res[0].0, 1);
1297    }
1298
1299    #[test]
1300    fn test_cross_lingual_search_top_k() {
1301        let dim = 4;
1302        let mut a = make_aligner(dim, MeaAlignmentMethod::IdentityPassthrough);
1303        let en = add_lang(&mut a, "en", dim);
1304        for i in 1..=10u64 {
1305            add_emb(&mut a, en, i, vec![i as f64, 0.0, 0.0, 0.0]);
1306        }
1307        let res = a
1308            .cross_lingual_search(en, &[10.0, 0.0, 0.0, 0.0], en, 3)
1309            .expect("test: cross_lingual_search top k failed");
1310        assert_eq!(res.len(), 3);
1311    }
1312
1313    #[test]
1314    fn test_cross_lingual_search_sorted_by_score() {
1315        let dim = 4;
1316        let mut a = make_aligner(dim, MeaAlignmentMethod::IdentityPassthrough);
1317        let en = add_lang(&mut a, "en", dim);
1318        add_emb(&mut a, en, 1, vec![1.0, 0.0, 0.0, 0.0]);
1319        add_emb(&mut a, en, 2, vec![0.9, 0.1, 0.0, 0.0]);
1320        add_emb(&mut a, en, 3, vec![0.0, 0.0, 1.0, 0.0]);
1321        let res = a
1322            .cross_lingual_search(en, &[1.0, 0.0, 0.0, 0.0], en, 3)
1323            .expect("test: cross_lingual_search sorted failed");
1324        // Scores must be non-increasing.
1325        for w in res.windows(2) {
1326            assert!(w[0].1 >= w[1].1);
1327        }
1328    }
1329
1330    #[test]
1331    fn test_cross_lingual_search_cross_lang() {
1332        let dim = 4;
1333        let mut a = make_aligner(dim, MeaAlignmentMethod::IdentityPassthrough);
1334        let en = add_lang(&mut a, "en", dim);
1335        let fr = add_lang(&mut a, "fr", dim);
1336        for i in 0..5u64 {
1337            let v = vec![i as f64 + 1.0, 0.0, 0.0, 0.0];
1338            add_emb(&mut a, en, i, v.clone());
1339            add_emb(&mut a, fr, i, v);
1340        }
1341        let anchors: Vec<(u64, u64)> = (0..5).map(|i| (i, i)).collect();
1342        a.compute_alignment(en, fr, &anchors)
1343            .expect("test: compute_alignment cross lang search failed");
1344        let res = a
1345            .cross_lingual_search(en, &[5.0, 0.0, 0.0, 0.0], fr, 1)
1346            .expect("test: cross_lingual_search cross lang failed");
1347        assert_eq!(res.len(), 1);
1348    }
1349
1350    #[test]
1351    fn test_cross_lingual_search_empty_query() {
1352        let dim = 4;
1353        let mut a = make_aligner(dim, MeaAlignmentMethod::IdentityPassthrough);
1354        let en = add_lang(&mut a, "en", dim);
1355        let res = a.cross_lingual_search(en, &[], en, 1);
1356        assert!(matches!(res, Err(MeaError::EmptyEmbedding)));
1357    }
1358
1359    #[test]
1360    fn test_cross_lingual_search_unknown_target() {
1361        let dim = 4;
1362        let mut a = make_aligner(dim, MeaAlignmentMethod::IdentityPassthrough);
1363        let en = add_lang(&mut a, "en", dim);
1364        let xx = make_lang_id("xx");
1365        add_emb(&mut a, en, 1, vec![1.0, 0.0, 0.0, 0.0]);
1366        // Same lang → skip alignment, so unknown tgt triggers LanguageNotFound.
1367        let res = a.cross_lingual_search(en, &[1.0, 0.0, 0.0, 0.0], xx, 1);
1368        assert!(matches!(res, Err(MeaError::LanguageNotFound(_))));
1369    }
1370
1371    // ── 7: centroid ───────────────────────────────────────────────────────────
1372
1373    #[test]
1374    fn test_compute_centroid_basic() {
1375        let dim = 4;
1376        let mut a = make_aligner(dim, MeaAlignmentMethod::IdentityPassthrough);
1377        let en = add_lang(&mut a, "en", dim);
1378        add_emb(&mut a, en, 1, vec![2.0, 0.0, 0.0, 0.0]);
1379        add_emb(&mut a, en, 2, vec![4.0, 0.0, 0.0, 0.0]);
1380        let c = a
1381            .compute_centroid(en)
1382            .expect("test: compute_centroid basic failed");
1383        assert!((c[0] - 3.0).abs() < 1e-9);
1384    }
1385
1386    #[test]
1387    fn test_compute_centroid_cached() {
1388        let dim = 4;
1389        let mut a = make_aligner(dim, MeaAlignmentMethod::IdentityPassthrough);
1390        let en = add_lang(&mut a, "en", dim);
1391        add_emb(&mut a, en, 1, vec![2.0, 0.0, 0.0, 0.0]);
1392        a.compute_centroid(en)
1393            .expect("test: compute_centroid cached failed");
1394        assert!(a.centroid(en).is_some());
1395    }
1396
1397    #[test]
1398    fn test_compute_centroid_empty() {
1399        let dim = 4;
1400        let mut a = make_aligner(dim, MeaAlignmentMethod::IdentityPassthrough);
1401        let en = add_lang(&mut a, "en", dim);
1402        let res = a.compute_centroid(en);
1403        assert!(matches!(res, Err(MeaError::Arithmetic(_))));
1404    }
1405
1406    #[test]
1407    fn test_compute_centroid_unknown_lang() {
1408        let mut a = make_aligner(4, MeaAlignmentMethod::IdentityPassthrough);
1409        let xx = make_lang_id("xx");
1410        let res = a.compute_centroid(xx);
1411        assert!(matches!(res, Err(MeaError::LanguageNotFound(_))));
1412    }
1413
1414    #[test]
1415    fn test_centroid_invalidated_after_add() {
1416        let dim = 4;
1417        let mut a = make_aligner(dim, MeaAlignmentMethod::IdentityPassthrough);
1418        let en = add_lang(&mut a, "en", dim);
1419        add_emb(&mut a, en, 1, vec![2.0, 0.0, 0.0, 0.0]);
1420        a.compute_centroid(en)
1421            .expect("test: compute_centroid for invalidation test failed");
1422        // Adding a new embedding invalidates the centroid.
1423        add_emb(&mut a, en, 2, vec![4.0, 0.0, 0.0, 0.0]);
1424        assert!(a.centroid(en).is_none());
1425    }
1426
1427    // ── 8: statistics ─────────────────────────────────────────────────────────
1428
1429    #[test]
1430    fn test_aligner_stats_empty() {
1431        let a = make_aligner(4, MeaAlignmentMethod::IdentityPassthrough);
1432        let stats = a.aligner_stats();
1433        assert_eq!(stats.n_languages, 0);
1434        assert_eq!(stats.n_alignments, 0);
1435        assert_eq!(stats.total_embeddings, 0);
1436    }
1437
1438    #[test]
1439    fn test_aligner_stats_after_ops() {
1440        let dim = 4;
1441        let mut a = make_aligner(dim, MeaAlignmentMethod::IdentityPassthrough);
1442        let en = add_lang(&mut a, "en", dim);
1443        let fr = add_lang(&mut a, "fr", dim);
1444        for i in 0..5u64 {
1445            let v = vec![i as f64 + 1.0, 0.0, 0.0, 0.0];
1446            add_emb(&mut a, en, i, v.clone());
1447            add_emb(&mut a, fr, i, v);
1448        }
1449        let anchors: Vec<(u64, u64)> = (0..5).map(|i| (i, i)).collect();
1450        a.compute_alignment(en, fr, &anchors)
1451            .expect("test: compute_alignment for stats test failed");
1452        let stats = a.aligner_stats();
1453        assert_eq!(stats.n_languages, 2);
1454        assert_eq!(stats.n_alignments, 1);
1455        assert_eq!(stats.total_embeddings, 10);
1456        assert_eq!(stats.n_history_records, 1);
1457    }
1458
1459    #[test]
1460    fn test_history_bounded() {
1461        let dim = 2;
1462        let mut a = MultilingualEmbeddingAligner::new(MeaAlignerConfig {
1463            dim,
1464            normalize_embeddings: false,
1465            alignment_method: MeaAlignmentMethod::IdentityPassthrough,
1466            min_anchor_pairs: 1,
1467        });
1468        let en = add_lang(&mut a, "en", dim);
1469        let fr = add_lang(&mut a, "fr", dim);
1470        for i in 0..510u64 {
1471            // Overwrite so IDs 0..5 remain valid.
1472            let _ = a.add_embedding(en, i % 5, vec![1.0, 0.0]);
1473            let _ = a.add_embedding(fr, i % 5, vec![1.0, 0.0]);
1474            let anchors = vec![(i % 5, i % 5)];
1475            let _ = a.compute_alignment(en, fr, &anchors);
1476        }
1477        assert!(a.alignment_history.len() <= MultilingualEmbeddingAligner::HISTORY_CAP);
1478    }
1479
1480    // ── 9: Procrustes alignment ───────────────────────────────────────────────
1481
1482    #[test]
1483    fn test_procrustes_identity_mapping() {
1484        let dim = 4;
1485        let mut a = make_aligner(dim, MeaAlignmentMethod::Procrustes);
1486        let en = add_lang(&mut a, "en", dim);
1487        let fr = add_lang(&mut a, "fr", dim);
1488        // Same embeddings → should learn near-identity
1489        let vecs: Vec<Vec<f64>> = vec![
1490            vec![1.0, 0.0, 0.0, 0.0],
1491            vec![0.0, 1.0, 0.0, 0.0],
1492            vec![0.0, 0.0, 1.0, 0.0],
1493            vec![0.0, 0.0, 0.0, 1.0],
1494            vec![1.0, 1.0, 0.0, 0.0],
1495        ];
1496        for (i, v) in vecs.iter().enumerate() {
1497            add_emb(&mut a, en, i as u64, v.clone());
1498            add_emb(&mut a, fr, i as u64, v.clone());
1499        }
1500        let anchors: Vec<(u64, u64)> = (0..5).map(|i| (i, i)).collect();
1501        a.compute_alignment(en, fr, &anchors)
1502            .expect("test: compute_alignment procrustes failed");
1503        let q = a
1504            .alignment_quality(en, fr)
1505            .expect("test: alignment_quality procrustes not found");
1506        assert!(q > 0.9, "Procrustes quality on identity={q}");
1507    }
1508
1509    #[test]
1510    fn test_procrustes_rotation() {
1511        let dim = 2;
1512        let mut a = make_aligner(dim, MeaAlignmentMethod::Procrustes);
1513        let en = add_lang(&mut a, "en", dim);
1514        let fr = add_lang(&mut a, "fr", dim);
1515        // 90° rotation: (x,y) → (-y, x)
1516        let src_vecs: Vec<Vec<f64>> = vec![
1517            vec![1.0, 0.0],
1518            vec![0.0, 1.0],
1519            vec![1.0, 1.0],
1520            vec![2.0, 0.0],
1521            vec![0.0, 2.0],
1522        ];
1523        let tgt_vecs: Vec<Vec<f64>> = src_vecs.iter().map(|v| vec![-v[1], v[0]]).collect();
1524        for (i, (sv, tv)) in src_vecs.iter().zip(tgt_vecs.iter()).enumerate() {
1525            add_emb(&mut a, en, i as u64, sv.clone());
1526            add_emb(&mut a, fr, i as u64, tv.clone());
1527        }
1528        let anchors: Vec<(u64, u64)> = (0..5).map(|i| (i, i)).collect();
1529        a.compute_alignment(en, fr, &anchors)
1530            .expect("test: compute_alignment procrustes rotation failed");
1531        let q = a
1532            .alignment_quality(en, fr)
1533            .expect("test: alignment_quality procrustes rotation not found");
1534        assert!(q > 0.9, "Procrustes rotation quality={q}");
1535    }
1536
1537    // ── 10: linear regression ─────────────────────────────────────────────────
1538
1539    #[test]
1540    fn test_linear_regression_identity() {
1541        let dim = 4;
1542        let mut a = make_aligner(dim, MeaAlignmentMethod::LinearRegression);
1543        let en = add_lang(&mut a, "en", dim);
1544        let fr = add_lang(&mut a, "fr", dim);
1545        let vecs: Vec<Vec<f64>> = vec![
1546            vec![1.0, 0.0, 0.0, 0.0],
1547            vec![0.0, 1.0, 0.0, 0.0],
1548            vec![0.0, 0.0, 1.0, 0.0],
1549            vec![0.0, 0.0, 0.0, 1.0],
1550            vec![0.5, 0.5, 0.0, 0.0],
1551        ];
1552        for (i, v) in vecs.iter().enumerate() {
1553            add_emb(&mut a, en, i as u64, v.clone());
1554            add_emb(&mut a, fr, i as u64, v.clone());
1555        }
1556        let anchors: Vec<(u64, u64)> = (0..5).map(|i| (i, i)).collect();
1557        a.compute_alignment(en, fr, &anchors)
1558            .expect("test: compute_alignment linreg identity failed");
1559        let q = a
1560            .alignment_quality(en, fr)
1561            .expect("test: alignment_quality linreg identity not found");
1562        assert!(q > 0.9, "LinReg quality={q}");
1563    }
1564
1565    #[test]
1566    fn test_linear_regression_scaling() {
1567        let dim = 2;
1568        let mut a = make_aligner(dim, MeaAlignmentMethod::LinearRegression);
1569        let en = add_lang(&mut a, "en", dim);
1570        let fr = add_lang(&mut a, "fr", dim);
1571        let src: Vec<Vec<f64>> = vec![
1572            vec![1.0, 0.0],
1573            vec![0.0, 1.0],
1574            vec![1.0, 1.0],
1575            vec![2.0, 0.0],
1576            vec![0.0, 2.0],
1577        ];
1578        // Scale x by 2, y by 3
1579        let tgt: Vec<Vec<f64>> = src.iter().map(|v| vec![v[0] * 2.0, v[1] * 3.0]).collect();
1580        for (i, (sv, tv)) in src.iter().zip(tgt.iter()).enumerate() {
1581            add_emb(&mut a, en, i as u64, sv.clone());
1582            add_emb(&mut a, fr, i as u64, tv.clone());
1583        }
1584        let anchors: Vec<(u64, u64)> = (0..5).map(|i| (i, i)).collect();
1585        a.compute_alignment(en, fr, &anchors)
1586            .expect("test: compute_alignment linreg scaling failed");
1587        // Test that alignment maps [1,0] → [2,0] approximately
1588        let out = a
1589            .align_embedding(en, fr, &[1.0, 0.0])
1590            .expect("test: align_embedding linreg scaling failed");
1591        assert!((out[0] - 2.0).abs() < 0.1, "out={out:?}");
1592    }
1593
1594    // ── 11: CCA ───────────────────────────────────────────────────────────────
1595
1596    #[test]
1597    fn test_cca_alignment() {
1598        let dim = 4;
1599        let mut a = make_aligner(dim, MeaAlignmentMethod::Cca);
1600        let en = add_lang(&mut a, "en", dim);
1601        let fr = add_lang(&mut a, "fr", dim);
1602        let vecs: Vec<Vec<f64>> = vec![
1603            vec![1.0, 0.0, 0.0, 0.0],
1604            vec![0.0, 1.0, 0.0, 0.0],
1605            vec![0.0, 0.0, 1.0, 0.0],
1606            vec![0.0, 0.0, 0.0, 1.0],
1607            vec![0.5, 0.5, 0.5, 0.5],
1608        ];
1609        for (i, v) in vecs.iter().enumerate() {
1610            add_emb(&mut a, en, i as u64, v.clone());
1611            add_emb(&mut a, fr, i as u64, v.clone());
1612        }
1613        let anchors: Vec<(u64, u64)> = (0..5).map(|i| (i, i)).collect();
1614        a.compute_alignment(en, fr, &anchors)
1615            .expect("test: compute_alignment CCA failed");
1616        assert!(a.has_alignment(en, fr));
1617    }
1618
1619    // ── 12: misc helpers ──────────────────────────────────────────────────────
1620
1621    #[test]
1622    fn test_language_ids() {
1623        let mut a = make_aligner(4, MeaAlignmentMethod::IdentityPassthrough);
1624        let en = add_lang(&mut a, "en", 4);
1625        let fr = add_lang(&mut a, "fr", 4);
1626        let ids = a.language_ids();
1627        assert!(ids.contains(&en));
1628        assert!(ids.contains(&fr));
1629    }
1630
1631    #[test]
1632    fn test_has_alignment_false() {
1633        let a = make_aligner(4, MeaAlignmentMethod::IdentityPassthrough);
1634        assert!(!a.has_alignment(make_lang_id("en"), make_lang_id("fr")));
1635    }
1636
1637    #[test]
1638    fn test_get_alignment_matrix() {
1639        let dim = 4;
1640        let mut a = make_aligner(dim, MeaAlignmentMethod::IdentityPassthrough);
1641        let en = add_lang(&mut a, "en", dim);
1642        let fr = add_lang(&mut a, "fr", dim);
1643        for i in 0..5u64 {
1644            let v = vec![i as f64 + 1.0, 0.0, 0.0, 0.0];
1645            add_emb(&mut a, en, i, v.clone());
1646            add_emb(&mut a, fr, i, v);
1647        }
1648        let anchors: Vec<(u64, u64)> = (0..5).map(|i| (i, i)).collect();
1649        a.compute_alignment(en, fr, &anchors)
1650            .expect("test: compute_alignment for get_alignment_matrix test failed");
1651        assert!(a.get_alignment_matrix(en, fr).is_some());
1652        assert!(a.get_alignment_matrix(fr, en).is_none());
1653    }
1654
1655    #[test]
1656    fn test_history_recent_first() {
1657        let dim = 2;
1658        let mut a = MultilingualEmbeddingAligner::new(MeaAlignerConfig {
1659            dim,
1660            normalize_embeddings: false,
1661            alignment_method: MeaAlignmentMethod::IdentityPassthrough,
1662            min_anchor_pairs: 1,
1663        });
1664        let en = add_lang(&mut a, "en", dim);
1665        let fr = add_lang(&mut a, "fr", dim);
1666        let de = add_lang(&mut a, "de", dim);
1667        for i in 0..3u64 {
1668            let _ = a.add_embedding(en, i, vec![1.0, 0.0]);
1669            let _ = a.add_embedding(fr, i, vec![1.0, 0.0]);
1670            let _ = a.add_embedding(de, i, vec![1.0, 0.0]);
1671        }
1672        a.compute_alignment(en, fr, &[(0, 0)])
1673            .expect("test: compute_alignment en->fr history failed");
1674        a.compute_alignment(en, de, &[(0, 0)])
1675            .expect("test: compute_alignment en->de history failed");
1676        let hist = a.history(2);
1677        // Most recent = de alignment
1678        assert_eq!(hist[0].tgt_lang, de);
1679    }
1680
1681    // ── 13: cosine helpers ────────────────────────────────────────────────────
1682
1683    #[test]
1684    fn test_cosine_similarity_identical() {
1685        let v = vec![1.0, 0.0, 0.0];
1686        assert!((cosine_similarity(&v, &v) - 1.0).abs() < 1e-9);
1687    }
1688
1689    #[test]
1690    fn test_cosine_similarity_orthogonal() {
1691        let a = vec![1.0, 0.0];
1692        let b = vec![0.0, 1.0];
1693        assert!(cosine_similarity(&a, &b).abs() < 1e-9);
1694    }
1695
1696    #[test]
1697    fn test_cosine_similarity_zero_vector() {
1698        let a = vec![0.0, 0.0];
1699        let b = vec![1.0, 0.0];
1700        assert_eq!(cosine_similarity(&a, &b), 0.0);
1701    }
1702
1703    #[test]
1704    fn test_cosine_similarity_opposite() {
1705        let a = vec![1.0, 0.0];
1706        let b = vec![-1.0, 0.0];
1707        assert!((cosine_similarity(&a, &b) + 1.0).abs() < 1e-9);
1708    }
1709
1710    // ── 14: xorshift ─────────────────────────────────────────────────────────
1711
1712    #[test]
1713    fn test_xorshift_not_zero() {
1714        let mut state = 0xdeadbeef_u64;
1715        let v = xorshift64(&mut state);
1716        assert_ne!(v, 0);
1717    }
1718
1719    #[test]
1720    fn test_xorshift_deterministic() {
1721        let mut s1 = 42u64;
1722        let mut s2 = 42u64;
1723        assert_eq!(xorshift64(&mut s1), xorshift64(&mut s2));
1724    }
1725
1726    #[test]
1727    fn test_xorshift_state_changes() {
1728        let mut state = 1u64;
1729        let v1 = xorshift64(&mut state);
1730        let v2 = xorshift64(&mut state);
1731        assert_ne!(v1, v2);
1732    }
1733
1734    // ── 15: normalize ─────────────────────────────────────────────────────────
1735
1736    #[test]
1737    fn test_normalize_vec_unit() {
1738        let mut v = vec![3.0, 4.0];
1739        normalize_vec(&mut v);
1740        let norm: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
1741        assert!((norm - 1.0).abs() < 1e-9);
1742    }
1743
1744    #[test]
1745    fn test_normalize_vec_zero_returns_false() {
1746        let mut v = vec![0.0, 0.0];
1747        assert!(!normalize_vec(&mut v));
1748    }
1749
1750    // ── 16: normalization flag ────────────────────────────────────────────────
1751
1752    #[test]
1753    fn test_normalize_flag_normalizes_on_insert() {
1754        let dim = 2;
1755        let mut a = MultilingualEmbeddingAligner::new(MeaAlignerConfig {
1756            dim,
1757            normalize_embeddings: true,
1758            alignment_method: MeaAlignmentMethod::IdentityPassthrough,
1759            min_anchor_pairs: 1,
1760        });
1761        let en = add_lang(&mut a, "en", dim);
1762        a.add_embedding(en, 1, vec![3.0, 4.0])
1763            .expect("test: add_embedding normalize flag failed");
1764        let v = &a.language_spaces[&en].embeddings[0].1;
1765        let norm: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
1766        assert!((norm - 1.0).abs() < 1e-9);
1767    }
1768
1769    // ── 17: matrix math ───────────────────────────────────────────────────────
1770
1771    #[test]
1772    fn test_matmul_identity() {
1773        let i = identity(3);
1774        let v = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0];
1775        let out = matmul(&i, &v, 3, 3, 3);
1776        for (a, b) in out.iter().zip(v.iter()) {
1777            assert!((a - b).abs() < 1e-9);
1778        }
1779    }
1780
1781    #[test]
1782    fn test_transpose_correctness() {
1783        let a = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]; // 2×3
1784        let t = transpose(&a, 2, 3); // should be 3×2
1785        assert_eq!(t, vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0]);
1786    }
1787
1788    #[test]
1789    fn test_gauss_jordan_2x2() {
1790        // [[2, 0], [0, 4]] → [[0.5, 0], [0, 0.25]]
1791        let a = vec![2.0, 0.0, 0.0, 4.0];
1792        let inv = gauss_jordan_invert(&a, 2).expect("test: gauss_jordan_invert 2x2 failed");
1793        assert!((inv[0] - 0.5).abs() < 1e-9);
1794        assert!((inv[3] - 0.25).abs() < 1e-9);
1795    }
1796
1797    #[test]
1798    fn test_gauss_jordan_singular() {
1799        let a = vec![1.0, 2.0, 2.0, 4.0]; // singular
1800        let res = gauss_jordan_invert(&a, 2);
1801        assert!(matches!(res, Err(MeaError::Arithmetic(_))));
1802    }
1803
1804    // ── 18: alignment_matrix apply ───────────────────────────────────────────
1805
1806    #[test]
1807    fn test_alignment_matrix_apply_identity() {
1808        let dim = 3;
1809        let am = AlignmentMatrix {
1810            src_lang: [0; 8],
1811            tgt_lang: [0; 8],
1812            matrix: identity(dim),
1813            quality: 1.0,
1814        };
1815        let v = vec![1.0, 2.0, 3.0];
1816        let out = am
1817            .apply(&v)
1818            .expect("test: AlignmentMatrix::apply on identity matrix failed");
1819        assert_eq!(out, v);
1820    }
1821
1822    #[test]
1823    fn test_alignment_matrix_apply_dim_mismatch() {
1824        let am = AlignmentMatrix {
1825            src_lang: [0; 8],
1826            tgt_lang: [0; 8],
1827            matrix: identity(3), // 3×3 = 9 elements
1828            quality: 1.0,
1829        };
1830        // Applying to a 4-element vector: matrix.len()=9 ≠ 4*4=16
1831        let res = am.apply(&[1.0, 2.0, 3.0, 4.0]);
1832        assert!(matches!(res, Err(MeaError::DimensionMismatch { .. })));
1833    }
1834
1835    // ── 19: multi-hop alignment chains ───────────────────────────────────────
1836
1837    #[test]
1838    fn test_multiple_language_pairs() {
1839        let dim = 4;
1840        let mut a = make_aligner(dim, MeaAlignmentMethod::IdentityPassthrough);
1841        let en = add_lang(&mut a, "en", dim);
1842        let fr = add_lang(&mut a, "fr", dim);
1843        let de = add_lang(&mut a, "de", dim);
1844        for i in 0..5u64 {
1845            let v = vec![i as f64 + 1.0, 0.0, 0.0, 0.0];
1846            add_emb(&mut a, en, i, v.clone());
1847            add_emb(&mut a, fr, i, v.clone());
1848            add_emb(&mut a, de, i, v);
1849        }
1850        let anch: Vec<(u64, u64)> = (0..5).map(|i| (i, i)).collect();
1851        a.compute_alignment(en, fr, &anch)
1852            .expect("test: compute_alignment en->fr for multiple pairs failed");
1853        a.compute_alignment(en, de, &anch)
1854            .expect("test: compute_alignment en->de for multiple pairs failed");
1855        assert_eq!(a.alignment_matrices.len(), 2);
1856    }
1857
1858    // ── 20: embedding_count helper ────────────────────────────────────────────
1859
1860    #[test]
1861    fn test_embedding_count_none_for_unknown_lang() {
1862        let a = make_aligner(4, MeaAlignmentMethod::IdentityPassthrough);
1863        assert!(a.embedding_count(make_lang_id("xx")).is_none());
1864    }
1865
1866    #[test]
1867    fn test_embedding_count_increments() {
1868        let dim = 4;
1869        let mut a = make_aligner(dim, MeaAlignmentMethod::IdentityPassthrough);
1870        let en = add_lang(&mut a, "en", dim);
1871        add_emb(&mut a, en, 1, vec![1.0, 0.0, 0.0, 0.0]);
1872        add_emb(&mut a, en, 2, vec![0.0, 1.0, 0.0, 0.0]);
1873        assert_eq!(a.embedding_count(en), Some(2));
1874    }
1875
1876    // ── 21: type alias availability ───────────────────────────────────────────
1877
1878    #[test]
1879    fn test_type_aliases() {
1880        let _: MeaMultilingualEmbeddingAligner = MultilingualEmbeddingAligner::with_defaults();
1881        let _id: LangId = make_lang_id("test");
1882    }
1883}