Skip to main content

ipfrs_semantic/
embedding_finetuner.rs

1//! # Embedding Fine-Tuner
2//!
3//! A contrastive learning fine-tuner that adapts embedding spaces using
4//! positive/negative pair training with triplet loss and margin-based updates.
5//!
6//! ## Overview
7//!
8//! `EmbeddingFinetuner` implements a linear projection layer trained via
9//! triplet-loss contrastive learning. Given anchor/positive/negative triplets,
10//! it learns a projection W such that projected positives are closer to their
11//! anchors than projected negatives, by at least a configurable margin.
12//!
13//! ## Algorithm
14//!
15//! - **Triplet Loss**: `max(0, ||W·a - W·p||² - ||W·a - W·n||² + margin)`
16//! - **Gradient**: simplified first-order update (gradient of L w.r.t. W)
17//! - **Regularisation**: L2 weight decay applied after every mini-batch
18//! - **Initialisation**: Xavier uniform via xorshift64 PRNG (no external crates)
19
20use std::fmt;
21
22// ---------------------------------------------------------------------------
23// PRNG helper (xorshift64 – no rand crate)
24// ---------------------------------------------------------------------------
25
26#[inline]
27fn xorshift64(state: &mut u64) -> u64 {
28    let mut x = *state;
29    x ^= x << 13;
30    x ^= x >> 7;
31    x ^= x << 17;
32    *state = x;
33    x
34}
35
36// ---------------------------------------------------------------------------
37// Error type
38// ---------------------------------------------------------------------------
39
40/// Errors produced by [`EmbeddingFinetuner`].
41#[derive(Debug, Clone, PartialEq)]
42pub enum FinetunerError {
43    /// Input dimensionality did not match the layer's expected dimension.
44    DimensionMismatch { expected: usize, got: usize },
45    /// An empty slice was passed where at least one element is required.
46    EmptyInput,
47    /// The model has not been trained yet (no training history).
48    NotTrained,
49}
50
51impl fmt::Display for FinetunerError {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        match self {
54            Self::DimensionMismatch { expected, got } => {
55                write!(f, "dimension mismatch: expected {expected}, got {got}")
56            }
57            Self::EmptyInput => write!(f, "input must not be empty"),
58            Self::NotTrained => write!(f, "model has not been trained"),
59        }
60    }
61}
62
63impl std::error::Error for FinetunerError {}
64
65// ---------------------------------------------------------------------------
66// Core data structures
67// ---------------------------------------------------------------------------
68
69/// An anchor / positive / negative triplet used for contrastive training.
70#[derive(Debug, Clone)]
71pub struct TrainingPair {
72    /// The reference embedding.
73    pub anchor: Vec<f64>,
74    /// An embedding semantically close to `anchor`.
75    pub positive: Vec<f64>,
76    /// An embedding semantically far from `anchor`.
77    pub negative: Vec<f64>,
78}
79
80impl TrainingPair {
81    /// Construct a new [`TrainingPair`].
82    pub fn new(anchor: Vec<f64>, positive: Vec<f64>, negative: Vec<f64>) -> Self {
83        Self {
84            anchor,
85            positive,
86            negative,
87        }
88    }
89}
90
91// ---------------------------------------------------------------------------
92// Triplet loss
93// ---------------------------------------------------------------------------
94
95/// Margin-based triplet loss.
96///
97/// `loss = max(0, ||a-p||² - ||a-n||² + margin)`
98#[derive(Debug, Clone, Copy)]
99pub struct TripletLoss {
100    /// Minimum desired distance gap between positive and negative pairs.
101    pub margin: f64,
102}
103
104impl TripletLoss {
105    /// Create a new [`TripletLoss`] with the given margin.
106    pub fn new(margin: f64) -> Self {
107        Self { margin }
108    }
109
110    /// Compute the triplet loss for a single triplet.
111    pub fn compute(&self, anchor: &[f64], pos: &[f64], neg: &[f64]) -> f64 {
112        let d_pos = l2_distance_sq(anchor, pos);
113        let d_neg = l2_distance_sq(anchor, neg);
114        (d_pos - d_neg + self.margin).max(0.0)
115    }
116}
117
118// ---------------------------------------------------------------------------
119// Projection layer
120// ---------------------------------------------------------------------------
121
122/// A linear projection followed by a ReLU activation.
123///
124/// Dimensions: `weights[output_dim][input_dim]`, `bias[output_dim]`.
125#[derive(Debug, Clone)]
126pub struct ProjectionLayer {
127    /// Weight matrix stored in row-major order: `weights[out][in]`.
128    pub weights: Vec<Vec<f64>>,
129    /// Bias vector of length `output_dim`.
130    pub bias: Vec<f64>,
131}
132
133impl ProjectionLayer {
134    /// Create a zero-initialised projection layer.
135    pub fn new(input_dim: usize, output_dim: usize) -> Self {
136        Self {
137            weights: vec![vec![0.0_f64; input_dim]; output_dim],
138            bias: vec![0.0_f64; output_dim],
139        }
140    }
141
142    /// Forward pass: `output[i] = relu(Σ_j weights[i][j] * input[j] + bias[i])`.
143    pub fn forward(&self, input: &[f64]) -> Vec<f64> {
144        self.weights
145            .iter()
146            .zip(self.bias.iter())
147            .map(|(row, b)| {
148                let dot: f64 = row.iter().zip(input.iter()).map(|(w, x)| w * x).sum();
149                (dot + b).max(0.0)
150            })
151            .collect()
152    }
153
154    /// Return input dimensionality.
155    pub fn input_dim(&self) -> usize {
156        self.weights.first().map(|r| r.len()).unwrap_or(0)
157    }
158
159    /// Return output dimensionality.
160    pub fn output_dim(&self) -> usize {
161        self.weights.len()
162    }
163}
164
165// ---------------------------------------------------------------------------
166// Configuration
167// ---------------------------------------------------------------------------
168
169/// Hyper-parameters for [`EmbeddingFinetuner`].
170#[derive(Debug, Clone)]
171pub struct FinetunerConfig {
172    /// Dimensionality of the raw input embeddings.
173    pub input_dim: usize,
174    /// Dimensionality of the projected output.
175    pub output_dim: usize,
176    /// Gradient descent step size.
177    pub learning_rate: f64,
178    /// Triplet loss margin.
179    pub margin: f64,
180    /// Maximum number of training epochs.
181    pub max_epochs: usize,
182    /// Mini-batch size.
183    pub batch_size: usize,
184    /// L2 regularisation coefficient.
185    pub l2_reg: f64,
186}
187
188impl Default for FinetunerConfig {
189    fn default() -> Self {
190        Self {
191            input_dim: 128,
192            output_dim: 64,
193            learning_rate: 0.01,
194            margin: 1.0,
195            max_epochs: 10,
196            batch_size: 32,
197            l2_reg: 1e-4,
198        }
199    }
200}
201
202impl FinetunerConfig {
203    /// Create a configuration with the given input / output dimensions and defaults.
204    pub fn new(input_dim: usize, output_dim: usize) -> Self {
205        Self {
206            input_dim,
207            output_dim,
208            ..Default::default()
209        }
210    }
211}
212
213// ---------------------------------------------------------------------------
214// Training statistics
215// ---------------------------------------------------------------------------
216
217/// Per-epoch training statistics.
218#[derive(Debug, Clone)]
219pub struct TrainingStats {
220    /// Epoch index (0-based).
221    pub epoch: usize,
222    /// Mean triplet loss across all mini-batches in this epoch.
223    pub avg_loss: f64,
224    /// Number of pairs where `d(a,p) < d(a,n)` after projection.
225    pub positive_pairs_closer: usize,
226    /// Number of pairs where `d(a,n) > d(a,p)` (same as `positive_pairs_closer`
227    /// but surfaced explicitly for clarity).
228    pub negative_pairs_farther: usize,
229    /// Learning rate at this epoch (may vary if a schedule is added later).
230    pub learning_rate: f64,
231}
232
233// ---------------------------------------------------------------------------
234// Fine-tuner
235// ---------------------------------------------------------------------------
236
237/// Contrastive embedding fine-tuner using triplet loss.
238///
239/// # Example
240///
241/// ```rust
242/// use ipfrs_semantic::embedding_finetuner::{
243///     EmbeddingFinetuner, FinetunerConfig, TrainingPair,
244/// };
245///
246/// let config = FinetunerConfig::new(4, 4);
247/// let mut finetuner = EmbeddingFinetuner::new(config);
248///
249/// let pairs = vec![
250///     TrainingPair::new(vec![1.0, 0.0, 0.0, 0.0],
251///                       vec![0.9, 0.1, 0.0, 0.0],
252///                       vec![0.0, 0.0, 1.0, 0.0]),
253/// ];
254/// let history = finetuner.train(&pairs);
255/// assert!(!history.is_empty());
256/// ```
257#[derive(Debug, Clone)]
258pub struct EmbeddingFinetuner {
259    /// Hyper-parameters.
260    pub config: FinetunerConfig,
261    /// Linear projection layer.
262    pub layer: ProjectionLayer,
263    /// Epoch-level statistics accumulated across all `train` calls.
264    pub training_history: Vec<TrainingStats>,
265    /// Running count of training pairs consumed.
266    pub total_pairs_seen: u64,
267    /// Internal xorshift64 PRNG state.
268    pub rng_state: u64,
269}
270
271impl EmbeddingFinetuner {
272    /// Construct a new fine-tuner, initialising weights with Xavier uniform.
273    pub fn new(config: FinetunerConfig) -> Self {
274        let mut rng_state: u64 = 0xFEED_FACE_1234_5678_u64;
275        let input_dim = config.input_dim;
276        let output_dim = config.output_dim;
277        let scale = 2.0_f64 / (input_dim as f64).sqrt();
278
279        let weights: Vec<Vec<f64>> = (0..output_dim)
280            .map(|_| {
281                (0..input_dim)
282                    .map(|_| {
283                        let u = xorshift64(&mut rng_state) as f64 / u64::MAX as f64;
284                        (u - 0.5) * scale
285                    })
286                    .collect()
287            })
288            .collect();
289
290        let layer = ProjectionLayer {
291            weights,
292            bias: vec![0.0_f64; output_dim],
293        };
294
295        Self {
296            config,
297            layer,
298            training_history: Vec::new(),
299            total_pairs_seen: 0,
300            rng_state,
301        }
302    }
303
304    // -----------------------------------------------------------------------
305    // Core utilities
306    // -----------------------------------------------------------------------
307
308    /// Project a single embedding through the linear layer.
309    pub fn project(&self, embedding: &[f64]) -> Result<Vec<f64>, FinetunerError> {
310        if embedding.is_empty() {
311            return Err(FinetunerError::EmptyInput);
312        }
313        let expected = self.layer.input_dim();
314        if embedding.len() != expected {
315            return Err(FinetunerError::DimensionMismatch {
316                expected,
317                got: embedding.len(),
318            });
319        }
320        Ok(self.layer.forward(embedding))
321    }
322
323    /// Squared L2 distance between two equal-length slices.
324    ///
325    /// Panics in debug if lengths differ; silently stops at the shorter length
326    /// in release (caller must ensure equal lengths).
327    pub fn l2_distance_sq(a: &[f64], b: &[f64]) -> f64 {
328        l2_distance_sq(a, b)
329    }
330
331    // -----------------------------------------------------------------------
332    // Gradient helpers (private)
333    // -----------------------------------------------------------------------
334
335    /// Simplified gradient update for one triplet.
336    ///
337    /// When the loss is > 0 (the margin is violated), we nudge:
338    ///
339    /// * `W` toward making `W·a` closer to `W·p`: gradient direction
340    ///   proportional to `(a_projected - p_projected)` outer-producted with input.
341    /// * `W` away from making `W·a` close to `W·n`: gradient direction
342    ///   proportional to `-(a_projected - n_projected)` outer-producted with input.
343    ///
344    /// This is the first-order approximation of the true triplet-loss gradient
345    /// when ignoring the ReLU in the projection (treating it as linear post-hoc).
346    fn apply_triplet_gradient(
347        weights: &mut [Vec<f64>],
348        projected_anchor: &[f64],
349        projected_pos: &[f64],
350        projected_neg: &[f64],
351        raw_anchor: &[f64],
352        lr: f64,
353    ) {
354        // diff_ap[i] = 2 * (proj_a[i] - proj_p[i])  (gradient of ||a-p||^2 w.r.t. proj_a)
355        // diff_an[i] = 2 * (proj_a[i] - proj_n[i])  (gradient of ||a-n||^2 w.r.t. proj_a)
356        //
357        // Loss = ||a-p||^2 - ||a-n||^2 + margin
358        // dLoss/dW[out][in] ≈ diff_ap[out] * raw_anchor[in]
359        //                    - diff_an[out] * raw_anchor[in]
360        for (out_idx, row) in weights.iter_mut().enumerate() {
361            let diff_ap = 2.0 * (projected_anchor[out_idx] - projected_pos[out_idx]);
362            let diff_an = 2.0 * (projected_anchor[out_idx] - projected_neg[out_idx]);
363            let grad_out = diff_ap - diff_an; // net gradient
364            for (in_idx, w) in row.iter_mut().enumerate() {
365                *w -= lr * grad_out * raw_anchor[in_idx];
366            }
367        }
368    }
369
370    // -----------------------------------------------------------------------
371    // Training
372    // -----------------------------------------------------------------------
373
374    /// Run a single mini-batch training step and return statistics.
375    ///
376    /// For each pair in `pairs`:
377    /// 1. Project anchor, positive, negative.
378    /// 2. Compute triplet loss.
379    /// 3. If loss > 0, compute simplified gradient and update weights.
380    /// 4. Apply L2 regularisation: `w ← w - l2_reg * w`.
381    pub fn train_step(&mut self, pairs: &[TrainingPair]) -> TrainingStats {
382        let mut total_loss = 0.0_f64;
383        let mut positive_pairs_closer: usize = 0;
384        let mut negative_pairs_farther: usize = 0;
385        let triplet_loss = TripletLoss::new(self.config.margin);
386
387        for pair in pairs {
388            // Project all three embeddings (ignore dimension errors: skip bad pairs)
389            let proj_a = match self.project(&pair.anchor) {
390                Ok(v) => v,
391                Err(_) => continue,
392            };
393            let proj_p = match self.project(&pair.positive) {
394                Ok(v) => v,
395                Err(_) => continue,
396            };
397            let proj_n = match self.project(&pair.negative) {
398                Ok(v) => v,
399                Err(_) => continue,
400            };
401
402            let loss = triplet_loss.compute(&proj_a, &proj_p, &proj_n);
403            total_loss += loss;
404
405            // Track correctness
406            let d_pos = l2_distance_sq(&proj_a, &proj_p);
407            let d_neg = l2_distance_sq(&proj_a, &proj_n);
408            if d_pos < d_neg {
409                positive_pairs_closer += 1;
410                negative_pairs_farther += 1;
411            }
412
413            // Gradient update only when margin is violated
414            if loss > 0.0 {
415                Self::apply_triplet_gradient(
416                    &mut self.layer.weights,
417                    &proj_a,
418                    &proj_p,
419                    &proj_n,
420                    &pair.anchor,
421                    self.config.learning_rate,
422                );
423            }
424        }
425
426        // L2 regularisation
427        let l2 = self.config.l2_reg;
428        for row in &mut self.layer.weights {
429            for w in row.iter_mut() {
430                *w -= l2 * *w;
431            }
432        }
433
434        let n = pairs.len().max(1);
435        TrainingStats {
436            epoch: 0, // caller sets epoch
437            avg_loss: total_loss / n as f64,
438            positive_pairs_closer,
439            negative_pairs_farther,
440            learning_rate: self.config.learning_rate,
441        }
442    }
443
444    /// Full training loop over `max_epochs`, processing `batch_size` pairs per step.
445    ///
446    /// Pairs are shuffled at the start of each epoch using Fisher-Yates with
447    /// the internal xorshift64 PRNG.
448    ///
449    /// Returns the per-epoch [`TrainingStats`] (also appended to `self.training_history`).
450    pub fn train(&mut self, pairs: &[TrainingPair]) -> Vec<TrainingStats> {
451        if pairs.is_empty() {
452            return Vec::new();
453        }
454
455        let max_epochs = self.config.max_epochs;
456        let batch_size = self.config.batch_size.max(1);
457        let mut epoch_stats: Vec<TrainingStats> = Vec::with_capacity(max_epochs);
458
459        // Working copy that we shuffle in-place each epoch
460        let mut order: Vec<usize> = (0..pairs.len()).collect();
461
462        for epoch in 0..max_epochs {
463            // Fisher-Yates shuffle
464            for i in (1..order.len()).rev() {
465                let j = (xorshift64(&mut self.rng_state) as usize) % (i + 1);
466                order.swap(i, j);
467            }
468
469            let mut epoch_total_loss = 0.0_f64;
470            let mut epoch_pos_closer: usize = 0;
471            let mut epoch_neg_farther: usize = 0;
472            let mut num_batches: usize = 0;
473
474            // Process mini-batches
475            for chunk in order.chunks(batch_size) {
476                let batch: Vec<TrainingPair> = chunk.iter().map(|&i| pairs[i].clone()).collect();
477                let mut stats = self.train_step(&batch);
478                stats.epoch = epoch;
479
480                epoch_total_loss += stats.avg_loss * batch.len() as f64;
481                epoch_pos_closer += stats.positive_pairs_closer;
482                epoch_neg_farther += stats.negative_pairs_farther;
483                num_batches += 1;
484
485                self.total_pairs_seen += batch.len() as u64;
486            }
487
488            let avg_loss = if num_batches > 0 {
489                epoch_total_loss / pairs.len() as f64
490            } else {
491                0.0
492            };
493
494            let stat = TrainingStats {
495                epoch,
496                avg_loss,
497                positive_pairs_closer: epoch_pos_closer,
498                negative_pairs_farther: epoch_neg_farther,
499                learning_rate: self.config.learning_rate,
500            };
501            self.training_history.push(stat.clone());
502            epoch_stats.push(stat);
503        }
504
505        epoch_stats
506    }
507
508    // -----------------------------------------------------------------------
509    // Inference
510    // -----------------------------------------------------------------------
511
512    /// Project a batch of embeddings.
513    pub fn encode_batch(&self, embeddings: &[Vec<f64>]) -> Result<Vec<Vec<f64>>, FinetunerError> {
514        if embeddings.is_empty() {
515            return Err(FinetunerError::EmptyInput);
516        }
517        embeddings.iter().map(|e| self.project(e)).collect()
518    }
519
520    /// Cosine similarity between the projected versions of `a` and `b`.
521    ///
522    /// Falls back to the cosine of the raw vectors if projection fails.
523    pub fn similarity(&self, a: &[f64], b: &[f64]) -> f64 {
524        let (va, vb) = match (self.project(a), self.project(b)) {
525            (Ok(x), Ok(y)) => (x, y),
526            _ => (a.to_vec(), b.to_vec()),
527        };
528        cosine_similarity(&va, &vb)
529    }
530
531    /// Evaluate a set of triplets, returning `(avg_loss, fraction_correct)`.
532    ///
533    /// A triplet is "correct" when `d(proj_a, proj_p) < d(proj_a, proj_n)`.
534    pub fn evaluate_pairs(&self, pairs: &[TrainingPair]) -> (f64, f64) {
535        if pairs.is_empty() {
536            return (0.0, 0.0);
537        }
538        let triplet_loss = TripletLoss::new(self.config.margin);
539        let mut total_loss = 0.0_f64;
540        let mut correct: usize = 0;
541
542        for pair in pairs {
543            let (proj_a, proj_p, proj_n) = match (
544                self.project(&pair.anchor),
545                self.project(&pair.positive),
546                self.project(&pair.negative),
547            ) {
548                (Ok(a), Ok(p), Ok(n)) => (a, p, n),
549                _ => continue,
550            };
551
552            let loss = triplet_loss.compute(&proj_a, &proj_p, &proj_n);
553            total_loss += loss;
554
555            let d_pos = l2_distance_sq(&proj_a, &proj_p);
556            let d_neg = l2_distance_sq(&proj_a, &proj_n);
557            if d_pos < d_neg {
558                correct += 1;
559            }
560        }
561
562        let avg_loss = total_loss / pairs.len() as f64;
563        let fraction_correct = correct as f64 / pairs.len() as f64;
564        (avg_loss, fraction_correct)
565    }
566
567    /// Read-only view of accumulated per-epoch statistics.
568    pub fn training_history(&self) -> &[TrainingStats] {
569        &self.training_history
570    }
571}
572
573// ---------------------------------------------------------------------------
574// Free functions
575// ---------------------------------------------------------------------------
576
577/// Squared Euclidean distance: `Σ (a_i - b_i)²`.
578///
579/// Iterates to the length of the shorter slice; call-sites must ensure equal
580/// lengths for a mathematically meaningful result.
581pub fn l2_distance_sq(a: &[f64], b: &[f64]) -> f64 {
582    a.iter().zip(b.iter()).map(|(x, y)| (x - y).powi(2)).sum()
583}
584
585/// Cosine similarity in `[-1, 1]`.  Returns `0.0` if either vector is zero.
586pub fn cosine_similarity(a: &[f64], b: &[f64]) -> f64 {
587    let dot: f64 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
588    let na: f64 = a.iter().map(|x| x * x).sum::<f64>().sqrt();
589    let nb: f64 = b.iter().map(|x| x * x).sum::<f64>().sqrt();
590    if na < f64::EPSILON || nb < f64::EPSILON {
591        0.0
592    } else {
593        dot / (na * nb)
594    }
595}
596
597// ---------------------------------------------------------------------------
598// Tests
599// ---------------------------------------------------------------------------
600
601#[cfg(test)]
602mod tests {
603    use crate::embedding_finetuner::{
604        cosine_similarity, l2_distance_sq, xorshift64, EmbeddingFinetuner, FinetunerConfig,
605        FinetunerError, ProjectionLayer, TrainingPair, TripletLoss,
606    };
607
608    // -- xorshift64 -----------------------------------------------------------
609
610    #[test]
611    fn test_xorshift64_not_zero() {
612        let mut state: u64 = 0xDEAD_BEEF_0000_0001;
613        let v = xorshift64(&mut state);
614        assert_ne!(v, 0);
615        assert_ne!(state, 0xDEAD_BEEF_0000_0001);
616    }
617
618    #[test]
619    fn test_xorshift64_deterministic() {
620        let mut s1: u64 = 42;
621        let mut s2: u64 = 42;
622        assert_eq!(xorshift64(&mut s1), xorshift64(&mut s2));
623    }
624
625    #[test]
626    fn test_xorshift64_produces_different_values() {
627        let mut state: u64 = 1;
628        let a = xorshift64(&mut state);
629        let b = xorshift64(&mut state);
630        assert_ne!(a, b);
631    }
632
633    // -- l2_distance_sq -------------------------------------------------------
634
635    #[test]
636    fn test_l2_distance_sq_zero() {
637        let v = vec![1.0, 2.0, 3.0];
638        assert!((l2_distance_sq(&v, &v) - 0.0).abs() < 1e-12);
639    }
640
641    #[test]
642    fn test_l2_distance_sq_known_value() {
643        let a = vec![0.0, 0.0];
644        let b = vec![3.0, 4.0];
645        assert!((l2_distance_sq(&a, &b) - 25.0).abs() < 1e-12);
646    }
647
648    #[test]
649    fn test_l2_distance_sq_symmetry() {
650        let a = vec![1.0, -2.0, 3.0];
651        let b = vec![4.0, 5.0, -6.0];
652        assert!((l2_distance_sq(&a, &b) - l2_distance_sq(&b, &a)).abs() < 1e-12);
653    }
654
655    // -- cosine_similarity ----------------------------------------------------
656
657    #[test]
658    fn test_cosine_similarity_identical() {
659        let v = vec![1.0, 2.0, 3.0];
660        assert!((cosine_similarity(&v, &v) - 1.0).abs() < 1e-10);
661    }
662
663    #[test]
664    fn test_cosine_similarity_orthogonal() {
665        let a = vec![1.0, 0.0];
666        let b = vec![0.0, 1.0];
667        assert!((cosine_similarity(&a, &b)).abs() < 1e-12);
668    }
669
670    #[test]
671    fn test_cosine_similarity_opposite() {
672        let a = vec![1.0, 0.0];
673        let b = vec![-1.0, 0.0];
674        assert!((cosine_similarity(&a, &b) + 1.0).abs() < 1e-12);
675    }
676
677    #[test]
678    fn test_cosine_similarity_zero_vector() {
679        let a = vec![0.0, 0.0];
680        let b = vec![1.0, 2.0];
681        assert_eq!(cosine_similarity(&a, &b), 0.0);
682    }
683
684    // -- TripletLoss ----------------------------------------------------------
685
686    #[test]
687    fn test_triplet_loss_zero_when_margin_satisfied() {
688        let loss = TripletLoss::new(1.0);
689        // anchor == positive, negative is far → d_pos = 0, d_neg = 9  → loss < 0 → clamped 0
690        let a = vec![0.0, 0.0];
691        let p = vec![0.0, 0.0];
692        let n = vec![3.0, 0.0];
693        assert!((loss.compute(&a, &p, &n) - 0.0).abs() < 1e-12);
694    }
695
696    #[test]
697    fn test_triplet_loss_positive_when_violated() {
698        let loss = TripletLoss::new(1.0);
699        // d_pos = 25, d_neg = 1, margin = 1 → loss = 25 - 1 + 1 = 25
700        let a = vec![0.0, 0.0];
701        let p = vec![3.0, 4.0];
702        let n = vec![1.0, 0.0];
703        let v = loss.compute(&a, &p, &n);
704        assert!((v - 25.0).abs() < 1e-9);
705    }
706
707    #[test]
708    fn test_triplet_loss_margin_boundary() {
709        let margin = 2.0;
710        let loss = TripletLoss::new(margin);
711        // d_pos = 1, d_neg = 2, margin = 2 → loss = 1 - 2 + 2 = 1
712        let a = vec![0.0];
713        let p = vec![1.0];
714        let _n = [2.0]; // wait: d_neg from a = 4
715                        // let's be precise: d_pos = 1, d_neg from a=[0] to n=[sqrt(2)] = 2
716        let n2 = vec![(2.0_f64).sqrt()];
717        let v = loss.compute(&a, &p, &n2);
718        let expected = (1.0_f64 - 2.0 + margin).max(0.0);
719        assert!((v - expected).abs() < 1e-9);
720    }
721
722    // -- ProjectionLayer ------------------------------------------------------
723
724    #[test]
725    fn test_projection_layer_zero_output_for_zero_input() {
726        let layer = ProjectionLayer::new(4, 4);
727        let input = vec![0.0_f64; 4];
728        let out = layer.forward(&input);
729        assert_eq!(out.len(), 4);
730        for v in &out {
731            assert!(*v >= 0.0); // relu(0) = 0
732        }
733    }
734
735    #[test]
736    fn test_projection_layer_relu_clips_negative() {
737        let mut layer = ProjectionLayer::new(2, 2);
738        // Manually set weights to produce negative pre-activation
739        layer.weights[0] = vec![-1.0, -1.0];
740        layer.weights[1] = vec![1.0, 1.0];
741        layer.bias = vec![0.0, 0.0];
742        let input = vec![1.0, 1.0];
743        let out = layer.forward(&input);
744        assert!((out[0] - 0.0).abs() < 1e-12, "relu should clip to 0");
745        assert!((out[1] - 2.0).abs() < 1e-12);
746    }
747
748    #[test]
749    fn test_projection_layer_dimensions() {
750        let layer = ProjectionLayer::new(8, 4);
751        assert_eq!(layer.input_dim(), 8);
752        assert_eq!(layer.output_dim(), 4);
753    }
754
755    #[test]
756    fn test_projection_layer_bias_added() {
757        let mut layer = ProjectionLayer::new(1, 1);
758        layer.weights[0] = vec![0.0];
759        layer.bias[0] = 5.0;
760        let out = layer.forward(&[1.0]);
761        assert!((out[0] - 5.0).abs() < 1e-12);
762    }
763
764    // -- FinetunerConfig ------------------------------------------------------
765
766    #[test]
767    fn test_finetuner_config_default() {
768        let cfg = FinetunerConfig::default();
769        assert!((cfg.learning_rate - 0.01).abs() < 1e-15);
770        assert!((cfg.margin - 1.0).abs() < 1e-15);
771        assert_eq!(cfg.max_epochs, 10);
772        assert_eq!(cfg.batch_size, 32);
773        assert!((cfg.l2_reg - 1e-4).abs() < 1e-20);
774    }
775
776    #[test]
777    fn test_finetuner_config_new() {
778        let cfg = FinetunerConfig::new(16, 8);
779        assert_eq!(cfg.input_dim, 16);
780        assert_eq!(cfg.output_dim, 8);
781    }
782
783    // -- EmbeddingFinetuner construction --------------------------------------
784
785    #[test]
786    fn test_finetuner_new_weight_dimensions() {
787        let cfg = FinetunerConfig::new(8, 4);
788        let ft = EmbeddingFinetuner::new(cfg);
789        assert_eq!(ft.layer.output_dim(), 4);
790        assert_eq!(ft.layer.input_dim(), 8);
791    }
792
793    #[test]
794    fn test_finetuner_new_weights_nonzero() {
795        let cfg = FinetunerConfig::new(8, 4);
796        let ft = EmbeddingFinetuner::new(cfg);
797        let any_nonzero = ft.layer.weights.iter().flatten().any(|&w| w.abs() > 1e-15);
798        assert!(any_nonzero, "Xavier init should produce non-zero weights");
799    }
800
801    #[test]
802    fn test_finetuner_new_rng_seed() {
803        let cfg = FinetunerConfig::new(4, 4);
804        let ft = EmbeddingFinetuner::new(cfg);
805        // After construction the rng_state has advanced (it was used for init)
806        assert_ne!(ft.rng_state, 0);
807    }
808
809    // -- project --------------------------------------------------------------
810
811    #[test]
812    fn test_project_correct_output_dim() {
813        let cfg = FinetunerConfig::new(6, 3);
814        let ft = EmbeddingFinetuner::new(cfg);
815        let v = ft.project(&[1.0; 6]).expect("project should succeed");
816        assert_eq!(v.len(), 3);
817    }
818
819    #[test]
820    fn test_project_dimension_mismatch_error() {
821        let cfg = FinetunerConfig::new(4, 2);
822        let ft = EmbeddingFinetuner::new(cfg);
823        let err = ft.project(&[1.0; 5]).unwrap_err();
824        assert!(matches!(
825            err,
826            FinetunerError::DimensionMismatch {
827                expected: 4,
828                got: 5
829            }
830        ));
831    }
832
833    #[test]
834    fn test_project_empty_error() {
835        let cfg = FinetunerConfig::new(4, 2);
836        let ft = EmbeddingFinetuner::new(cfg);
837        let err = ft.project(&[]).unwrap_err();
838        assert_eq!(err, FinetunerError::EmptyInput);
839    }
840
841    // -- encode_batch ---------------------------------------------------------
842
843    #[test]
844    fn test_encode_batch_basic() {
845        let cfg = FinetunerConfig::new(4, 2);
846        let ft = EmbeddingFinetuner::new(cfg);
847        let batch = vec![vec![1.0; 4], vec![0.5; 4]];
848        let out = ft
849            .encode_batch(&batch)
850            .expect("encode_batch should succeed");
851        assert_eq!(out.len(), 2);
852        assert_eq!(out[0].len(), 2);
853    }
854
855    #[test]
856    fn test_encode_batch_empty_error() {
857        let cfg = FinetunerConfig::new(4, 2);
858        let ft = EmbeddingFinetuner::new(cfg);
859        let err = ft.encode_batch(&[]).unwrap_err();
860        assert_eq!(err, FinetunerError::EmptyInput);
861    }
862
863    // -- train_step -----------------------------------------------------------
864
865    #[test]
866    fn test_train_step_returns_stats() {
867        let cfg = FinetunerConfig {
868            input_dim: 4,
869            output_dim: 4,
870            ..FinetunerConfig::default()
871        };
872        let mut ft = EmbeddingFinetuner::new(cfg);
873        let pairs = vec![TrainingPair::new(
874            vec![1.0, 0.0, 0.0, 0.0],
875            vec![0.9, 0.1, 0.0, 0.0],
876            vec![0.0, 0.0, 1.0, 0.0],
877        )];
878        let stats = ft.train_step(&pairs);
879        assert!(stats.avg_loss >= 0.0);
880    }
881
882    #[test]
883    fn test_train_step_empty_pairs() {
884        let cfg = FinetunerConfig::new(4, 4);
885        let mut ft = EmbeddingFinetuner::new(cfg);
886        let stats = ft.train_step(&[]);
887        assert!((stats.avg_loss - 0.0).abs() < 1e-12);
888    }
889
890    // -- train ----------------------------------------------------------------
891
892    #[test]
893    fn test_train_returns_epoch_stats() {
894        let cfg = FinetunerConfig {
895            input_dim: 4,
896            output_dim: 4,
897            max_epochs: 3,
898            ..FinetunerConfig::default()
899        };
900        let mut ft = EmbeddingFinetuner::new(cfg);
901        let pairs = vec![
902            TrainingPair::new(
903                vec![1.0, 0.0, 0.0, 0.0],
904                vec![0.9, 0.1, 0.0, 0.0],
905                vec![0.0, 0.0, 1.0, 0.0],
906            ),
907            TrainingPair::new(
908                vec![0.0, 1.0, 0.0, 0.0],
909                vec![0.1, 0.9, 0.0, 0.0],
910                vec![0.0, 0.0, 0.0, 1.0],
911            ),
912        ];
913        let history = ft.train(&pairs);
914        assert_eq!(history.len(), 3);
915    }
916
917    #[test]
918    fn test_train_history_appended() {
919        let cfg = FinetunerConfig {
920            input_dim: 4,
921            output_dim: 4,
922            max_epochs: 2,
923            ..FinetunerConfig::default()
924        };
925        let mut ft = EmbeddingFinetuner::new(cfg);
926        let pairs = vec![TrainingPair::new(
927            vec![1.0, 0.0, 0.0, 0.0],
928            vec![0.9, 0.1, 0.0, 0.0],
929            vec![0.0, 0.0, 1.0, 0.0],
930        )];
931        ft.train(&pairs);
932        assert_eq!(ft.training_history().len(), 2);
933    }
934
935    #[test]
936    fn test_train_total_pairs_seen_incremented() {
937        let cfg = FinetunerConfig {
938            input_dim: 4,
939            output_dim: 4,
940            max_epochs: 2,
941            batch_size: 10,
942            ..FinetunerConfig::default()
943        };
944        let mut ft = EmbeddingFinetuner::new(cfg);
945        let pairs: Vec<TrainingPair> = (0..5)
946            .map(|_| {
947                TrainingPair::new(
948                    vec![1.0, 0.0, 0.0, 0.0],
949                    vec![0.9, 0.1, 0.0, 0.0],
950                    vec![0.0, 0.0, 1.0, 0.0],
951                )
952            })
953            .collect();
954        ft.train(&pairs);
955        // 5 pairs × 2 epochs = 10
956        assert_eq!(ft.total_pairs_seen, 10);
957    }
958
959    #[test]
960    fn test_train_empty_pairs_returns_empty() {
961        let cfg = FinetunerConfig::new(4, 4);
962        let mut ft = EmbeddingFinetuner::new(cfg);
963        let history = ft.train(&[]);
964        assert!(history.is_empty());
965    }
966
967    // -- evaluate_pairs -------------------------------------------------------
968
969    #[test]
970    fn test_evaluate_pairs_fraction_in_range() {
971        let cfg = FinetunerConfig {
972            input_dim: 4,
973            output_dim: 4,
974            max_epochs: 5,
975            ..FinetunerConfig::default()
976        };
977        let mut ft = EmbeddingFinetuner::new(cfg);
978        let pairs: Vec<TrainingPair> = (0..10)
979            .map(|i| {
980                let f = i as f64 / 10.0;
981                TrainingPair::new(
982                    vec![1.0, 0.0, 0.0, 0.0],
983                    vec![1.0 - f, f, 0.0, 0.0],
984                    vec![0.0, 0.0, 1.0, 0.0],
985                )
986            })
987            .collect();
988        ft.train(&pairs);
989        let (avg_loss, frac) = ft.evaluate_pairs(&pairs);
990        assert!(avg_loss >= 0.0);
991        assert!((0.0..=1.0).contains(&frac));
992    }
993
994    #[test]
995    fn test_evaluate_pairs_empty() {
996        let cfg = FinetunerConfig::new(4, 4);
997        let ft = EmbeddingFinetuner::new(cfg);
998        let (loss, frac) = ft.evaluate_pairs(&[]);
999        assert_eq!(loss, 0.0);
1000        assert_eq!(frac, 0.0);
1001    }
1002
1003    // -- similarity -----------------------------------------------------------
1004
1005    #[test]
1006    fn test_similarity_same_vector() {
1007        let cfg = FinetunerConfig::new(4, 4);
1008        let ft = EmbeddingFinetuner::new(cfg);
1009        let v = vec![1.0, 2.0, 3.0, 4.0];
1010        let s = ft.similarity(&v, &v);
1011        // Should be close to 1.0 (identical projected vectors)
1012        assert!(s > 0.9 || (s - 1.0).abs() < 1e-6);
1013    }
1014
1015    #[test]
1016    fn test_similarity_range() {
1017        let cfg = FinetunerConfig::new(4, 4);
1018        let ft = EmbeddingFinetuner::new(cfg);
1019        let a = vec![1.0, 0.0, 0.0, 0.0];
1020        let b = vec![0.0, 1.0, 0.0, 0.0];
1021        let s = ft.similarity(&a, &b);
1022        assert!((-1.0..=1.0).contains(&s));
1023    }
1024
1025    // -- training convergence (smoke test) ------------------------------------
1026
1027    #[test]
1028    fn test_training_reduces_loss_on_trivial_problem() {
1029        // Very clean triplets: positives are very close, negatives are far.
1030        let cfg = FinetunerConfig {
1031            input_dim: 4,
1032            output_dim: 4,
1033            max_epochs: 20,
1034            batch_size: 4,
1035            learning_rate: 0.05,
1036            margin: 0.5,
1037            l2_reg: 1e-5,
1038        };
1039        let mut ft = EmbeddingFinetuner::new(cfg);
1040        let pairs: Vec<TrainingPair> = (0..20)
1041            .map(|i| {
1042                let sign = if i % 2 == 0 { 1.0_f64 } else { -1.0_f64 };
1043                TrainingPair::new(
1044                    vec![sign, 0.0, 0.0, 0.0],
1045                    vec![sign * 0.99, 0.01, 0.0, 0.0],
1046                    vec![0.0, 0.0, sign, 0.0], // orthogonal, far in projected space
1047                )
1048            })
1049            .collect();
1050        let history = ft.train(&pairs);
1051        let first_loss = history.first().map(|s| s.avg_loss).unwrap_or(0.0);
1052        let last_loss = history.last().map(|s| s.avg_loss).unwrap_or(0.0);
1053        // Loss should not increase (may stay 0 or decrease)
1054        assert!(
1055            last_loss <= first_loss + 1e-6,
1056            "Expected loss to not increase: first={first_loss:.6}, last={last_loss:.6}"
1057        );
1058    }
1059}