Skip to main content

ipfrs_semantic/
dimension_reducer.rs

1//! # Semantic Dimension Reducer
2//!
3//! Dimensionality reduction for embeddings using random projection, PCA, or truncation.
4//!
5//! Provides [`SemanticDimensionReducer`] which can reduce high-dimensional embeddings
6//! to lower dimensions while preserving semantic structure (Johnson-Lindenstrauss property
7//! for random projection).
8
9/// Reduction method to use for dimensionality reduction.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ReductionMethod {
12    /// Gaussian random matrix projection (Johnson-Lindenstrauss)
13    RandomProjection,
14    /// Simplified PCA via power iteration
15    PCA,
16    /// Simply take the first N dimensions
17    Truncation,
18}
19
20/// Configuration for the dimension reducer.
21#[derive(Debug, Clone)]
22pub struct ReducerConfig {
23    /// Dimensionality of input embeddings
24    pub input_dim: usize,
25    /// Target dimensionality after reduction
26    pub output_dim: usize,
27    /// Method to use for reduction
28    pub method: ReductionMethod,
29    /// Seed for reproducible random projection
30    pub seed: u64,
31}
32
33/// Result metadata from a reduction operation.
34#[derive(Debug, Clone)]
35pub struct ReductionResult {
36    /// Original embedding dimensionality
37    pub original_dim: usize,
38    /// Reduced embedding dimensionality
39    pub reduced_dim: usize,
40    /// Reconstruction error (MSE), if computed
41    pub reconstruction_error: Option<f64>,
42}
43
44/// Statistics about the reducer state.
45#[derive(Debug, Clone)]
46pub struct ReducerStats {
47    /// Input dimensionality
48    pub input_dim: usize,
49    /// Output dimensionality
50    pub output_dim: usize,
51    /// Reduction method in use
52    pub method: ReductionMethod,
53    /// Whether the reducer has been fitted
54    pub fitted: bool,
55    /// Number of reductions performed
56    pub reductions_performed: u64,
57}
58
59/// Semantic dimension reducer supporting random projection, PCA, and truncation.
60pub struct SemanticDimensionReducer {
61    config: ReducerConfig,
62    /// Projection matrix: output_dim x input_dim
63    projection_matrix: Option<Vec<Vec<f64>>>,
64    fitted: bool,
65    reductions_performed: u64,
66}
67
68/// FNV-1a based PRNG for deterministic random number generation from a seed.
69struct FnvPrng {
70    state: u64,
71}
72
73impl FnvPrng {
74    fn new(seed: u64) -> Self {
75        Self {
76            state: seed ^ 0xcbf29ce484222325,
77        }
78    }
79
80    /// Generate next u64 using FNV-1a mixing.
81    fn next_u64(&mut self) -> u64 {
82        // FNV-1a round
83        self.state ^= self.state.wrapping_shr(13);
84        self.state = self.state.wrapping_mul(0x100000001b3);
85        self.state ^= self.state.wrapping_shr(7);
86        self.state = self.state.wrapping_mul(0x100000001b3);
87        self.state ^= self.state.wrapping_shr(17);
88        self.state
89    }
90
91    /// Generate a pseudo-Gaussian value using Box-Muller approximation.
92    /// Returns a value approximately distributed as N(0, 1).
93    fn next_gaussian(&mut self) -> f64 {
94        // Use inverse transform approximation: average of 12 uniform values - 6
95        // (central limit theorem approximation)
96        let mut sum = 0.0f64;
97        for _ in 0..12 {
98            let u = (self.next_u64() as f64) / (u64::MAX as f64);
99            sum += u;
100        }
101        sum - 6.0
102    }
103}
104
105impl SemanticDimensionReducer {
106    /// Create a new dimension reducer with the given configuration.
107    pub fn new(config: ReducerConfig) -> Self {
108        Self {
109            config,
110            projection_matrix: None,
111            fitted: false,
112            reductions_performed: 0,
113        }
114    }
115
116    /// Fit the reducer to the given embeddings.
117    ///
118    /// - `RandomProjection`: generates a Gaussian random matrix from the seed
119    /// - `PCA`: computes top eigenvectors via power iteration
120    /// - `Truncation`: no-op (always ready)
121    pub fn fit(&mut self, embeddings: &[Vec<f64>]) -> Result<(), String> {
122        if self.config.output_dim > self.config.input_dim {
123            return Err(format!(
124                "output_dim ({}) must be <= input_dim ({})",
125                self.config.output_dim, self.config.input_dim
126            ));
127        }
128
129        // Validate embeddings dimensions
130        for (i, emb) in embeddings.iter().enumerate() {
131            if emb.len() != self.config.input_dim {
132                return Err(format!(
133                    "embedding at index {} has dimension {} but expected {}",
134                    i,
135                    emb.len(),
136                    self.config.input_dim
137                ));
138            }
139        }
140
141        match self.config.method {
142            ReductionMethod::RandomProjection => {
143                self.fit_random_projection()?;
144            }
145            ReductionMethod::PCA => {
146                self.fit_pca(embeddings)?;
147            }
148            ReductionMethod::Truncation => {
149                // Truncation needs no fitting — just take first output_dim dimensions
150            }
151        }
152
153        self.fitted = true;
154        Ok(())
155    }
156
157    /// Generate the Gaussian random projection matrix from the configured seed.
158    fn fit_random_projection(&mut self) -> Result<(), String> {
159        let input_dim = self.config.input_dim;
160        let output_dim = self.config.output_dim;
161        let mut prng = FnvPrng::new(self.config.seed);
162
163        // Generate output_dim x input_dim matrix with Gaussian entries
164        let mut matrix = Vec::with_capacity(output_dim);
165        for _ in 0..output_dim {
166            let mut row = Vec::with_capacity(input_dim);
167            for _ in 0..input_dim {
168                row.push(prng.next_gaussian());
169            }
170            matrix.push(row);
171        }
172
173        // Normalize columns for better numerical properties
174        self.normalize_columns(&mut matrix);
175
176        self.projection_matrix = Some(matrix);
177        Ok(())
178    }
179
180    /// Normalize columns of the matrix to unit length.
181    fn normalize_columns(&self, matrix: &mut [Vec<f64>]) {
182        if matrix.is_empty() {
183            return;
184        }
185        let input_dim = matrix[0].len();
186        let output_dim = matrix.len();
187
188        for col in 0..input_dim {
189            let mut norm_sq = 0.0f64;
190            for row in matrix.iter().take(output_dim) {
191                norm_sq += row[col] * row[col];
192            }
193            let norm = norm_sq.sqrt();
194            if norm > 1e-15 {
195                for row in matrix.iter_mut().take(output_dim) {
196                    row[col] /= norm;
197                }
198            }
199        }
200    }
201
202    /// Fit PCA using power iteration to find top eigenvectors of the covariance matrix.
203    fn fit_pca(&mut self, embeddings: &[Vec<f64>]) -> Result<(), String> {
204        if embeddings.is_empty() {
205            return Err("cannot fit PCA with zero embeddings".to_string());
206        }
207
208        let n = embeddings.len();
209        let d = self.config.input_dim;
210        let k = self.config.output_dim;
211
212        // Compute mean
213        let mut mean = vec![0.0f64; d];
214        for emb in embeddings {
215            for (j, val) in emb.iter().enumerate() {
216                mean[j] += val;
217            }
218        }
219        let n_f64 = n as f64;
220        for m in &mut mean {
221            *m /= n_f64;
222        }
223
224        // Center the data
225        let centered: Vec<Vec<f64>> = embeddings
226            .iter()
227            .map(|emb| emb.iter().zip(mean.iter()).map(|(v, m)| v - m).collect())
228            .collect();
229
230        // Power iteration to find top-k eigenvectors of X^T X / n
231        let mut prng = FnvPrng::new(self.config.seed);
232        let mut components: Vec<Vec<f64>> = Vec::with_capacity(k);
233        let max_iterations = 100;
234
235        for comp_idx in 0..k {
236            // Initialize random vector
237            let mut v: Vec<f64> = (0..d).map(|_| prng.next_gaussian()).collect();
238            let mut v_norm = vec_norm(&v);
239            if v_norm > 1e-15 {
240                for val in &mut v {
241                    *val /= v_norm;
242                }
243            }
244
245            for _iter in 0..max_iterations {
246                // Compute X^T * (X * v) / n  (covariance times v)
247                // First: proj_i = centered[i] . v
248                let projections: Vec<f64> = centered.iter().map(|row| dot(row, &v)).collect();
249
250                // Then: new_v = sum_i (proj_i * centered[i]) / n
251                let mut new_v = vec![0.0f64; d];
252                for (i, proj) in projections.iter().enumerate() {
253                    for (j, val) in centered[i].iter().enumerate() {
254                        new_v[j] += proj * val;
255                    }
256                }
257                for val in &mut new_v {
258                    *val /= n_f64;
259                }
260
261                // Deflate: remove components from previously found eigenvectors
262                for prev in &components {
263                    let proj = dot(&new_v, prev);
264                    for (j, val) in new_v.iter_mut().enumerate() {
265                        *val -= proj * prev[j];
266                    }
267                }
268
269                // Normalize
270                v_norm = vec_norm(&new_v);
271                if v_norm < 1e-15 {
272                    // Degenerate case — use random direction
273                    for val in new_v.iter_mut() {
274                        *val = prng.next_gaussian();
275                    }
276                    v_norm = vec_norm(&new_v);
277                    if v_norm > 1e-15 {
278                        for val in &mut new_v {
279                            *val /= v_norm;
280                        }
281                    }
282                } else {
283                    for val in &mut new_v {
284                        *val /= v_norm;
285                    }
286                }
287
288                // Check convergence
289                let diff: f64 = v
290                    .iter()
291                    .zip(new_v.iter())
292                    .map(|(a, b)| (a - b).powi(2))
293                    .sum();
294                v = new_v;
295                if diff < 1e-10 {
296                    break;
297                }
298            }
299
300            components.push(v);
301            let _ = comp_idx; // suppress unused warning
302        }
303
304        // Build projection matrix: k x d (each row is an eigenvector)
305        self.projection_matrix = Some(components);
306        Ok(())
307    }
308
309    /// Transform a single embedding to lower dimensionality.
310    pub fn transform(&mut self, embedding: &[f64]) -> Result<Vec<f64>, String> {
311        if !self.fitted {
312            return Err("reducer has not been fitted yet".to_string());
313        }
314
315        if embedding.len() != self.config.input_dim {
316            return Err(format!(
317                "input dimension mismatch: expected {}, got {}",
318                self.config.input_dim,
319                embedding.len()
320            ));
321        }
322
323        let result = match self.config.method {
324            ReductionMethod::Truncation => embedding[..self.config.output_dim].to_vec(),
325            ReductionMethod::RandomProjection | ReductionMethod::PCA => {
326                let matrix = self
327                    .projection_matrix
328                    .as_ref()
329                    .ok_or_else(|| "projection matrix not initialized".to_string())?;
330                let mut out = Vec::with_capacity(self.config.output_dim);
331                for row in matrix {
332                    out.push(dot(row, embedding));
333                }
334                out
335            }
336        };
337
338        self.reductions_performed += 1;
339        Ok(result)
340    }
341
342    /// Fit the reducer and transform all embeddings in one step.
343    pub fn fit_transform(&mut self, embeddings: &[Vec<f64>]) -> Result<Vec<Vec<f64>>, String> {
344        self.fit(embeddings)?;
345        let mut results = Vec::with_capacity(embeddings.len());
346        for emb in embeddings {
347            results.push(self.transform(emb)?);
348        }
349        Ok(results)
350    }
351
352    /// Compute the reconstruction error (MSE) between the original and back-projected embedding.
353    ///
354    /// For `RandomProjection`, uses pseudo-inverse (transpose for column-normalized matrices).
355    /// For `PCA`, uses transpose of components.
356    /// For `Truncation`, pads with zeros.
357    pub fn reconstruction_error(&self, original: &[f64], reduced: &[f64]) -> f64 {
358        let reconstructed = match self.config.method {
359            ReductionMethod::Truncation => {
360                let mut r = reduced.to_vec();
361                r.resize(self.config.input_dim, 0.0);
362                r
363            }
364            ReductionMethod::RandomProjection | ReductionMethod::PCA => {
365                // Back-project using transpose of projection matrix
366                if let Some(matrix) = &self.projection_matrix {
367                    let mut r = vec![0.0f64; self.config.input_dim];
368                    for (i, row) in matrix.iter().enumerate() {
369                        if i < reduced.len() {
370                            for (j, &val) in row.iter().enumerate() {
371                                r[j] += reduced[i] * val;
372                            }
373                        }
374                    }
375                    r
376                } else {
377                    vec![0.0f64; self.config.input_dim]
378                }
379            }
380        };
381
382        // MSE
383        let n = original.len().min(reconstructed.len());
384        if n == 0 {
385            return 0.0;
386        }
387        let mse: f64 = original
388            .iter()
389            .take(n)
390            .zip(reconstructed.iter().take(n))
391            .map(|(a, b)| (a - b).powi(2))
392            .sum::<f64>()
393            / n as f64;
394        mse
395    }
396
397    /// Whether the reducer has been fitted.
398    pub fn is_fitted(&self) -> bool {
399        self.fitted
400    }
401
402    /// Reset the reducer, clearing the projection matrix and fitted state.
403    pub fn reset(&mut self) {
404        self.projection_matrix = None;
405        self.fitted = false;
406        self.reductions_performed = 0;
407    }
408
409    /// Get statistics about the reducer.
410    pub fn stats(&self) -> ReducerStats {
411        ReducerStats {
412            input_dim: self.config.input_dim,
413            output_dim: self.config.output_dim,
414            method: self.config.method,
415            fitted: self.fitted,
416            reductions_performed: self.reductions_performed,
417        }
418    }
419}
420
421/// Compute dot product of two slices.
422fn dot(a: &[f64], b: &[f64]) -> f64 {
423    a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
424}
425
426/// Compute the L2 norm of a vector.
427fn vec_norm(v: &[f64]) -> f64 {
428    v.iter().map(|x| x * x).sum::<f64>().sqrt()
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    fn make_config(
436        input_dim: usize,
437        output_dim: usize,
438        method: ReductionMethod,
439        seed: u64,
440    ) -> ReducerConfig {
441        ReducerConfig {
442            input_dim,
443            output_dim,
444            method,
445            seed,
446        }
447    }
448
449    // --- Random Projection Tests ---
450
451    #[test]
452    fn test_random_projection_reduces_dim() {
453        let config = make_config(100, 10, ReductionMethod::RandomProjection, 42);
454        let mut reducer = SemanticDimensionReducer::new(config);
455        let embeddings = vec![vec![1.0; 100]; 5];
456        reducer.fit(&embeddings).expect("fit should succeed");
457        let result = reducer
458            .transform(&embeddings[0])
459            .expect("transform should succeed");
460        assert_eq!(result.len(), 10);
461    }
462
463    #[test]
464    fn test_random_projection_deterministic_same_seed() {
465        let config1 = make_config(50, 10, ReductionMethod::RandomProjection, 123);
466        let config2 = make_config(50, 10, ReductionMethod::RandomProjection, 123);
467        let mut r1 = SemanticDimensionReducer::new(config1);
468        let mut r2 = SemanticDimensionReducer::new(config2);
469        let embeddings = vec![vec![0.5; 50]; 3];
470        r1.fit(&embeddings).expect("fit should succeed");
471        r2.fit(&embeddings).expect("fit should succeed");
472        let t1 = r1.transform(&embeddings[0]).expect("transform");
473        let t2 = r2.transform(&embeddings[0]).expect("transform");
474        assert_eq!(t1, t2);
475    }
476
477    #[test]
478    fn test_random_projection_different_seeds_differ() {
479        let config1 = make_config(50, 10, ReductionMethod::RandomProjection, 100);
480        let config2 = make_config(50, 10, ReductionMethod::RandomProjection, 200);
481        let mut r1 = SemanticDimensionReducer::new(config1);
482        let mut r2 = SemanticDimensionReducer::new(config2);
483        let embeddings = vec![vec![0.5; 50]; 3];
484        r1.fit(&embeddings).expect("fit");
485        r2.fit(&embeddings).expect("fit");
486        let t1 = r1.transform(&embeddings[0]).expect("transform");
487        let t2 = r2.transform(&embeddings[0]).expect("transform");
488        assert_ne!(t1, t2);
489    }
490
491    #[test]
492    fn test_random_projection_reconstruction_error() {
493        let config = make_config(20, 15, ReductionMethod::RandomProjection, 42);
494        let mut reducer = SemanticDimensionReducer::new(config);
495        let embedding = (0..20).map(|i| i as f64 * 0.1).collect::<Vec<_>>();
496        let embeddings = vec![embedding.clone()];
497        reducer.fit(&embeddings).expect("fit");
498        let reduced = reducer.transform(&embedding).expect("transform");
499        let error = reducer.reconstruction_error(&embedding, &reduced);
500        // Error should be finite and non-negative
501        assert!(error >= 0.0);
502        assert!(error.is_finite());
503    }
504
505    // --- Truncation Tests ---
506
507    #[test]
508    fn test_truncation_takes_first_n() {
509        let config = make_config(10, 5, ReductionMethod::Truncation, 0);
510        let mut reducer = SemanticDimensionReducer::new(config);
511        let embedding: Vec<f64> = (0..10).map(|i| i as f64).collect();
512        let embeddings = vec![embedding.clone()];
513        reducer.fit(&embeddings).expect("fit");
514        let result = reducer.transform(&embedding).expect("transform");
515        assert_eq!(result, vec![0.0, 1.0, 2.0, 3.0, 4.0]);
516    }
517
518    #[test]
519    fn test_truncation_reconstruction_error() {
520        let config = make_config(10, 5, ReductionMethod::Truncation, 0);
521        let mut reducer = SemanticDimensionReducer::new(config);
522        let embedding: Vec<f64> = (0..10).map(|i| i as f64).collect();
523        let embeddings = vec![embedding.clone()];
524        reducer.fit(&embeddings).expect("fit");
525        let reduced = reducer.transform(&embedding).expect("transform");
526        let error = reducer.reconstruction_error(&embedding, &reduced);
527        // Error from truncation: last 5 dims are lost (5,6,7,8,9)
528        // MSE = (25+36+49+64+81) / 10 = 255/10 = 25.5
529        assert!((error - 25.5).abs() < 1e-10);
530    }
531
532    // --- PCA Tests ---
533
534    #[test]
535    fn test_pca_reduces_dim() {
536        let config = make_config(10, 3, ReductionMethod::PCA, 42);
537        let mut reducer = SemanticDimensionReducer::new(config);
538        // Create data with clear variance structure
539        let mut embeddings = Vec::new();
540        for i in 0..20 {
541            let mut emb = vec![0.0; 10];
542            emb[0] = i as f64 * 10.0; // high variance dimension
543            emb[1] = i as f64 * 5.0; // medium variance
544            emb[2] = i as f64 * 1.0; // lower variance
545            for (j, val) in emb.iter_mut().enumerate().skip(3) {
546                *val = 0.01 * (i as f64 + j as f64);
547            }
548            embeddings.push(emb);
549        }
550        reducer.fit(&embeddings).expect("fit");
551        let result = reducer.transform(&embeddings[0]).expect("transform");
552        assert_eq!(result.len(), 3);
553    }
554
555    #[test]
556    fn test_pca_preserves_variance_direction() {
557        let config = make_config(5, 1, ReductionMethod::PCA, 42);
558        let mut reducer = SemanticDimensionReducer::new(config);
559        // All variance along first dimension
560        let embeddings: Vec<Vec<f64>> = (0..50)
561            .map(|i| {
562                let mut v = vec![0.0; 5];
563                v[0] = i as f64;
564                v
565            })
566            .collect();
567        reducer.fit(&embeddings).expect("fit");
568
569        // Two points differing only in the high-variance dim should be well separated
570        let t1 = reducer.transform(&embeddings[0]).expect("transform");
571        let t2 = reducer.transform(&embeddings[49]).expect("transform");
572        let separation = (t1[0] - t2[0]).abs();
573        assert!(
574            separation > 1.0,
575            "PCA should preserve main variance direction, got separation {separation}"
576        );
577    }
578
579    #[test]
580    fn test_pca_empty_embeddings_error() {
581        let config = make_config(10, 3, ReductionMethod::PCA, 42);
582        let mut reducer = SemanticDimensionReducer::new(config);
583        let result = reducer.fit(&[]);
584        assert!(result.is_err());
585    }
586
587    #[test]
588    fn test_pca_reconstruction_error() {
589        let config = make_config(10, 5, ReductionMethod::PCA, 42);
590        let mut reducer = SemanticDimensionReducer::new(config);
591        let embeddings: Vec<Vec<f64>> = (0..30)
592            .map(|i| (0..10).map(|j| (i * 10 + j) as f64 * 0.01).collect())
593            .collect();
594        reducer.fit(&embeddings).expect("fit");
595        let reduced = reducer.transform(&embeddings[0]).expect("transform");
596        let error = reducer.reconstruction_error(&embeddings[0], &reduced);
597        assert!(error >= 0.0);
598        assert!(error.is_finite());
599    }
600
601    // --- Error Cases ---
602
603    #[test]
604    fn test_transform_error_if_not_fitted() {
605        let config = make_config(10, 5, ReductionMethod::RandomProjection, 42);
606        let mut reducer = SemanticDimensionReducer::new(config);
607        let result = reducer.transform(&[1.0; 10]);
608        assert!(result.is_err());
609        assert!(result
610            .expect_err("should error")
611            .contains("not been fitted"));
612    }
613
614    #[test]
615    fn test_transform_error_wrong_input_dim() {
616        let config = make_config(10, 5, ReductionMethod::RandomProjection, 42);
617        let mut reducer = SemanticDimensionReducer::new(config);
618        let embeddings = vec![vec![1.0; 10]; 3];
619        reducer.fit(&embeddings).expect("fit");
620        let result = reducer.transform(&[1.0; 7]);
621        assert!(result.is_err());
622        assert!(result
623            .expect_err("should error")
624            .contains("dimension mismatch"));
625    }
626
627    #[test]
628    fn test_fit_error_output_dim_greater_than_input_dim() {
629        let config = make_config(5, 10, ReductionMethod::RandomProjection, 42);
630        let mut reducer = SemanticDimensionReducer::new(config);
631        let result = reducer.fit(&[vec![1.0; 5]]);
632        assert!(result.is_err());
633    }
634
635    #[test]
636    fn test_fit_error_wrong_embedding_dim() {
637        let config = make_config(10, 5, ReductionMethod::RandomProjection, 42);
638        let mut reducer = SemanticDimensionReducer::new(config);
639        let result = reducer.fit(&[vec![1.0; 7]]);
640        assert!(result.is_err());
641        assert!(result.expect_err("should error").contains("dimension"));
642    }
643
644    // --- fit_transform ---
645
646    #[test]
647    fn test_fit_transform_random_projection() {
648        let config = make_config(20, 5, ReductionMethod::RandomProjection, 42);
649        let mut reducer = SemanticDimensionReducer::new(config);
650        let embeddings: Vec<Vec<f64>> = (0..10)
651            .map(|i| (0..20).map(|j| (i * 20 + j) as f64 * 0.01).collect())
652            .collect();
653        let results = reducer.fit_transform(&embeddings).expect("fit_transform");
654        assert_eq!(results.len(), 10);
655        for r in &results {
656            assert_eq!(r.len(), 5);
657        }
658        assert!(reducer.is_fitted());
659    }
660
661    #[test]
662    fn test_fit_transform_truncation() {
663        let config = make_config(8, 3, ReductionMethod::Truncation, 0);
664        let mut reducer = SemanticDimensionReducer::new(config);
665        let embeddings = vec![
666            vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0],
667            vec![10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0],
668        ];
669        let results = reducer.fit_transform(&embeddings).expect("fit_transform");
670        assert_eq!(results[0], vec![1.0, 2.0, 3.0]);
671        assert_eq!(results[1], vec![10.0, 20.0, 30.0]);
672    }
673
674    #[test]
675    fn test_fit_transform_pca() {
676        let config = make_config(6, 2, ReductionMethod::PCA, 99);
677        let mut reducer = SemanticDimensionReducer::new(config);
678        let embeddings: Vec<Vec<f64>> = (0..20)
679            .map(|i| {
680                let mut v = vec![0.0; 6];
681                v[0] = i as f64 * 3.0;
682                v[1] = i as f64 * 2.0;
683                v[2] = i as f64 * 0.1;
684                v
685            })
686            .collect();
687        let results = reducer.fit_transform(&embeddings).expect("fit_transform");
688        assert_eq!(results.len(), 20);
689        for r in &results {
690            assert_eq!(r.len(), 2);
691        }
692    }
693
694    // --- State management ---
695
696    #[test]
697    fn test_is_fitted_initially_false() {
698        let config = make_config(10, 5, ReductionMethod::RandomProjection, 42);
699        let reducer = SemanticDimensionReducer::new(config);
700        assert!(!reducer.is_fitted());
701    }
702
703    #[test]
704    fn test_reset_clears_state() {
705        let config = make_config(10, 5, ReductionMethod::RandomProjection, 42);
706        let mut reducer = SemanticDimensionReducer::new(config);
707        let embeddings = vec![vec![1.0; 10]; 5];
708        reducer.fit(&embeddings).expect("fit");
709        let _ = reducer.transform(&embeddings[0]);
710        assert!(reducer.is_fitted());
711        assert!(reducer.stats().reductions_performed > 0);
712
713        reducer.reset();
714
715        assert!(!reducer.is_fitted());
716        assert_eq!(reducer.stats().reductions_performed, 0);
717        assert!(reducer.transform(&embeddings[0]).is_err());
718    }
719
720    #[test]
721    fn test_stats_accuracy() {
722        let config = make_config(20, 8, ReductionMethod::RandomProjection, 55);
723        let mut reducer = SemanticDimensionReducer::new(config);
724        let stats = reducer.stats();
725        assert_eq!(stats.input_dim, 20);
726        assert_eq!(stats.output_dim, 8);
727        assert_eq!(stats.method, ReductionMethod::RandomProjection);
728        assert!(!stats.fitted);
729        assert_eq!(stats.reductions_performed, 0);
730
731        let embeddings = vec![vec![1.0; 20]; 3];
732        reducer.fit(&embeddings).expect("fit");
733        let _ = reducer.transform(&embeddings[0]);
734        let _ = reducer.transform(&embeddings[1]);
735
736        let stats = reducer.stats();
737        assert!(stats.fitted);
738        assert_eq!(stats.reductions_performed, 2);
739    }
740
741    #[test]
742    fn test_stats_method_truncation() {
743        let config = make_config(10, 5, ReductionMethod::Truncation, 0);
744        let reducer = SemanticDimensionReducer::new(config);
745        assert_eq!(reducer.stats().method, ReductionMethod::Truncation);
746    }
747
748    #[test]
749    fn test_stats_method_pca() {
750        let config = make_config(10, 5, ReductionMethod::PCA, 0);
751        let reducer = SemanticDimensionReducer::new(config);
752        assert_eq!(reducer.stats().method, ReductionMethod::PCA);
753    }
754
755    // --- Edge cases ---
756
757    #[test]
758    fn test_input_dim_equals_output_dim() {
759        let config = make_config(5, 5, ReductionMethod::RandomProjection, 42);
760        let mut reducer = SemanticDimensionReducer::new(config);
761        let embedding = vec![1.0, 2.0, 3.0, 4.0, 5.0];
762        let embeddings = vec![embedding.clone()];
763        reducer.fit(&embeddings).expect("fit");
764        let result = reducer.transform(&embedding).expect("transform");
765        assert_eq!(result.len(), 5);
766    }
767
768    #[test]
769    fn test_input_dim_equals_output_dim_truncation() {
770        let config = make_config(5, 5, ReductionMethod::Truncation, 0);
771        let mut reducer = SemanticDimensionReducer::new(config);
772        let embedding = vec![1.0, 2.0, 3.0, 4.0, 5.0];
773        let embeddings = vec![embedding.clone()];
774        reducer.fit(&embeddings).expect("fit");
775        let result = reducer.transform(&embedding).expect("transform");
776        assert_eq!(result, vec![1.0, 2.0, 3.0, 4.0, 5.0]);
777    }
778
779    #[test]
780    fn test_single_embedding() {
781        let config = make_config(10, 3, ReductionMethod::RandomProjection, 42);
782        let mut reducer = SemanticDimensionReducer::new(config);
783        let embeddings = vec![vec![1.0; 10]];
784        reducer.fit(&embeddings).expect("fit");
785        let result = reducer.transform(&embeddings[0]).expect("transform");
786        assert_eq!(result.len(), 3);
787    }
788
789    #[test]
790    fn test_single_embedding_pca() {
791        // PCA with single embedding should still work (degenerate but no crash)
792        let config = make_config(10, 3, ReductionMethod::PCA, 42);
793        let mut reducer = SemanticDimensionReducer::new(config);
794        let embeddings = vec![vec![1.0; 10]];
795        reducer.fit(&embeddings).expect("fit");
796        let result = reducer.transform(&embeddings[0]).expect("transform");
797        assert_eq!(result.len(), 3);
798    }
799
800    #[test]
801    fn test_reduce_to_one_dimension() {
802        let config = make_config(50, 1, ReductionMethod::RandomProjection, 42);
803        let mut reducer = SemanticDimensionReducer::new(config);
804        let embeddings = vec![vec![0.5; 50]; 5];
805        reducer.fit(&embeddings).expect("fit");
806        let result = reducer.transform(&embeddings[0]).expect("transform");
807        assert_eq!(result.len(), 1);
808    }
809
810    #[test]
811    fn test_large_reduction_ratio() {
812        let config = make_config(1000, 2, ReductionMethod::RandomProjection, 42);
813        let mut reducer = SemanticDimensionReducer::new(config);
814        let embeddings = vec![vec![0.1; 1000]; 3];
815        reducer.fit(&embeddings).expect("fit");
816        let result = reducer.transform(&embeddings[0]).expect("transform");
817        assert_eq!(result.len(), 2);
818    }
819
820    #[test]
821    fn test_reductions_counter_increments() {
822        let config = make_config(10, 5, ReductionMethod::Truncation, 0);
823        let mut reducer = SemanticDimensionReducer::new(config);
824        let embeddings = vec![vec![1.0; 10]; 3];
825        reducer.fit(&embeddings).expect("fit");
826        assert_eq!(reducer.stats().reductions_performed, 0);
827        let _ = reducer.transform(&embeddings[0]);
828        assert_eq!(reducer.stats().reductions_performed, 1);
829        let _ = reducer.transform(&embeddings[1]);
830        let _ = reducer.transform(&embeddings[2]);
831        assert_eq!(reducer.stats().reductions_performed, 3);
832    }
833
834    #[test]
835    fn test_reduction_result_struct() {
836        let result = ReductionResult {
837            original_dim: 100,
838            reduced_dim: 10,
839            reconstruction_error: Some(0.05),
840        };
841        assert_eq!(result.original_dim, 100);
842        assert_eq!(result.reduced_dim, 10);
843        assert!((result.reconstruction_error.expect("should have error") - 0.05).abs() < 1e-10);
844    }
845
846    #[test]
847    fn test_reduction_result_no_error() {
848        let result = ReductionResult {
849            original_dim: 100,
850            reduced_dim: 10,
851            reconstruction_error: None,
852        };
853        assert!(result.reconstruction_error.is_none());
854    }
855
856    #[test]
857    fn test_reconstruction_error_zero_for_identity_truncation() {
858        // When output_dim == input_dim for truncation, reconstruction error should be ~0
859        let config = make_config(5, 5, ReductionMethod::Truncation, 0);
860        let mut reducer = SemanticDimensionReducer::new(config);
861        let embedding = vec![1.0, 2.0, 3.0, 4.0, 5.0];
862        reducer.fit(std::slice::from_ref(&embedding)).expect("fit");
863        let reduced = reducer.transform(&embedding).expect("transform");
864        let error = reducer.reconstruction_error(&embedding, &reduced);
865        assert!(
866            error < 1e-10,
867            "identity truncation should have ~0 error, got {error}"
868        );
869    }
870
871    #[test]
872    fn test_fit_transform_counts_reductions() {
873        let config = make_config(10, 3, ReductionMethod::RandomProjection, 42);
874        let mut reducer = SemanticDimensionReducer::new(config);
875        let embeddings = vec![vec![1.0; 10]; 7];
876        let _ = reducer.fit_transform(&embeddings).expect("fit_transform");
877        assert_eq!(reducer.stats().reductions_performed, 7);
878    }
879}