1use std::fmt;
21
22#[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#[derive(Debug, Clone, PartialEq)]
42pub enum FinetunerError {
43 DimensionMismatch { expected: usize, got: usize },
45 EmptyInput,
47 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#[derive(Debug, Clone)]
71pub struct TrainingPair {
72 pub anchor: Vec<f64>,
74 pub positive: Vec<f64>,
76 pub negative: Vec<f64>,
78}
79
80impl TrainingPair {
81 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#[derive(Debug, Clone, Copy)]
99pub struct TripletLoss {
100 pub margin: f64,
102}
103
104impl TripletLoss {
105 pub fn new(margin: f64) -> Self {
107 Self { margin }
108 }
109
110 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#[derive(Debug, Clone)]
126pub struct ProjectionLayer {
127 pub weights: Vec<Vec<f64>>,
129 pub bias: Vec<f64>,
131}
132
133impl ProjectionLayer {
134 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 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 pub fn input_dim(&self) -> usize {
156 self.weights.first().map(|r| r.len()).unwrap_or(0)
157 }
158
159 pub fn output_dim(&self) -> usize {
161 self.weights.len()
162 }
163}
164
165#[derive(Debug, Clone)]
171pub struct FinetunerConfig {
172 pub input_dim: usize,
174 pub output_dim: usize,
176 pub learning_rate: f64,
178 pub margin: f64,
180 pub max_epochs: usize,
182 pub batch_size: usize,
184 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 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#[derive(Debug, Clone)]
219pub struct TrainingStats {
220 pub epoch: usize,
222 pub avg_loss: f64,
224 pub positive_pairs_closer: usize,
226 pub negative_pairs_farther: usize,
229 pub learning_rate: f64,
231}
232
233#[derive(Debug, Clone)]
258pub struct EmbeddingFinetuner {
259 pub config: FinetunerConfig,
261 pub layer: ProjectionLayer,
263 pub training_history: Vec<TrainingStats>,
265 pub total_pairs_seen: u64,
267 pub rng_state: u64,
269}
270
271impl EmbeddingFinetuner {
272 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 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 pub fn l2_distance_sq(a: &[f64], b: &[f64]) -> f64 {
328 l2_distance_sq(a, b)
329 }
330
331 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 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; for (in_idx, w) in row.iter_mut().enumerate() {
365 *w -= lr * grad_out * raw_anchor[in_idx];
366 }
367 }
368 }
369
370 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 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 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 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 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, 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 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 let mut order: Vec<usize> = (0..pairs.len()).collect();
461
462 for epoch in 0..max_epochs {
463 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 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 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 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 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 pub fn training_history(&self) -> &[TrainingStats] {
569 &self.training_history
570 }
571}
572
573pub 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
585pub 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#[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 #[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 #[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 #[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 #[test]
687 fn test_triplet_loss_zero_when_margin_satisfied() {
688 let loss = TripletLoss::new(1.0);
689 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 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 let a = vec![0.0];
713 let p = vec![1.0];
714 let _n = [2.0]; 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 #[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); }
733 }
734
735 #[test]
736 fn test_projection_layer_relu_clips_negative() {
737 let mut layer = ProjectionLayer::new(2, 2);
738 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 #[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 #[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 assert_ne!(ft.rng_state, 0);
807 }
808
809 #[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 #[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 #[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 #[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 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 #[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 #[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 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 #[test]
1028 fn test_training_reduces_loss_on_trivial_problem() {
1029 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], )
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 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}