Skip to main content

sklears_decomposition/
fluent_api.rs

1//! Fluent API for Decomposition Pipelines
2//!
3//! This module provides a builder-pattern fluent API for composing complex
4//! decomposition workflows with preprocessing, decomposition, and post-processing
5//! steps in a clean, readable manner.
6//!
7//! Features:
8//! - Fluent builder pattern for pipeline construction
9//! - Configuration presets for common use cases
10//! - Method chaining for intuitive API
11//! - Serializable decomposition models
12//! - Type-safe pipeline composition
13
14use crate::modular_framework::{
15    AlgorithmCapabilities, DecompositionAlgorithm, DecompositionComponents, DecompositionParams,
16    ParamValue, PostprocessingStep, PreprocessingStep,
17};
18use crate::s;
19use scirs2_core::ndarray::{Array1, Array2};
20use serde::{Deserialize, Serialize};
21use sklears_core::{
22    error::{Result, SklearsError},
23    types::Float,
24};
25use std::any::Any;
26
27/// Fluent API builder for decomposition pipelines
28pub struct DecompositionPipelineBuilder {
29    preprocessing_steps: Vec<Box<dyn PreprocessingStep>>,
30    algorithm: Option<Box<dyn DecompositionAlgorithm>>,
31    postprocessing_steps: Vec<Box<dyn PostprocessingStep>>,
32    params: DecompositionParams,
33    config: PipelineConfig,
34}
35
36impl DecompositionPipelineBuilder {
37    /// Create a new decomposition pipeline builder
38    pub fn new() -> Self {
39        Self {
40            preprocessing_steps: Vec::new(),
41            algorithm: None,
42            postprocessing_steps: Vec::new(),
43            params: DecompositionParams::default(),
44            config: PipelineConfig::default(),
45        }
46    }
47
48    /// Create builder from preset configuration
49    pub fn from_preset(preset: DecompositionPreset) -> Self {
50        let builder = Self::new();
51
52        match preset {
53            DecompositionPreset::StandardPCA { n_components } => builder
54                .with_standardization()
55                .with_pca(n_components)
56                .with_variance_threshold(0.01),
57            DecompositionPreset::RobustPCA { n_components } => builder
58                .with_robust_scaling()
59                .with_robust_pca(n_components)
60                .with_outlier_removal(),
61            DecompositionPreset::SparsePCA {
62                n_components,
63                alpha,
64            } => builder
65                .with_standardization()
66                .with_sparse_pca(n_components, alpha)
67                .with_sparsity_constraint(),
68            DecompositionPreset::IncrementalPCA {
69                n_components,
70                batch_size,
71            } => builder
72                .with_batch_normalization(batch_size)
73                .with_incremental_pca(n_components, batch_size),
74            DecompositionPreset::KernelPCA {
75                n_components,
76                kernel,
77            } => builder
78                .with_standardization()
79                .with_kernel_pca(n_components, kernel),
80            DecompositionPreset::FastICA { n_components } => builder
81                .with_centering()
82                .with_whitening()
83                .with_fast_ica(n_components),
84            DecompositionPreset::NMF {
85                n_components,
86                init_method,
87            } => builder
88                .with_non_negative_scaling()
89                .with_nmf(n_components, init_method),
90            DecompositionPreset::FactorAnalysis { n_components } => builder
91                .with_standardization()
92                .with_factor_analysis(n_components)
93                .with_rotation("varimax"),
94            DecompositionPreset::TruncatedSVD { n_components } => {
95                builder.with_truncated_svd(n_components)
96            }
97            DecompositionPreset::TimeSeriesSSA {
98                window_length,
99                n_components,
100            } => builder
101                .with_time_series_preprocessing(window_length)
102                .with_ssa(window_length, n_components),
103        }
104    }
105
106    /// Add preprocessing step: standardization
107    pub fn with_standardization(mut self) -> Self {
108        self.preprocessing_steps
109            .push(Box::new(StandardizationStep::new()));
110        self
111    }
112
113    /// Add preprocessing step: robust scaling
114    pub fn with_robust_scaling(mut self) -> Self {
115        self.preprocessing_steps
116            .push(Box::new(RobustScalingStep::new()));
117        self
118    }
119
120    /// Add preprocessing step: centering
121    pub fn with_centering(mut self) -> Self {
122        self.preprocessing_steps
123            .push(Box::new(CenteringStep::new()));
124        self
125    }
126
127    /// Add preprocessing step: whitening
128    pub fn with_whitening(mut self) -> Self {
129        self.preprocessing_steps
130            .push(Box::new(WhiteningStep::new()));
131        self
132    }
133
134    /// Add preprocessing step: non-negative scaling
135    pub fn with_non_negative_scaling(mut self) -> Self {
136        self.preprocessing_steps
137            .push(Box::new(NonNegativeScalingStep::new()));
138        self
139    }
140
141    /// Add preprocessing step: batch normalization
142    pub fn with_batch_normalization(mut self, batch_size: usize) -> Self {
143        self.preprocessing_steps
144            .push(Box::new(BatchNormalizationStep::new(batch_size)));
145        self
146    }
147
148    /// Add preprocessing step: time series preprocessing
149    pub fn with_time_series_preprocessing(mut self, window_length: usize) -> Self {
150        self.preprocessing_steps
151            .push(Box::new(TimeSeriesPreprocessingStep::new(window_length)));
152        self
153    }
154
155    /// Set decomposition algorithm: PCA
156    pub fn with_pca(mut self, n_components: usize) -> Self {
157        self.params.n_components = Some(n_components);
158        self.algorithm = Some(Box::new(PCAAlgorithm::new(n_components)));
159        self
160    }
161
162    /// Set decomposition algorithm: Robust PCA
163    pub fn with_robust_pca(mut self, n_components: usize) -> Self {
164        self.params.n_components = Some(n_components);
165        self.algorithm = Some(Box::new(RobustPCAAlgorithm::new(n_components)));
166        self
167    }
168
169    /// Set decomposition algorithm: Sparse PCA
170    pub fn with_sparse_pca(mut self, n_components: usize, alpha: Float) -> Self {
171        self.params.n_components = Some(n_components);
172        self.params
173            .algorithm_specific
174            .insert("alpha".to_string(), ParamValue::Float(alpha));
175        self.algorithm = Some(Box::new(SparsePCAAlgorithm::new(n_components, alpha)));
176        self
177    }
178
179    /// Set decomposition algorithm: Incremental PCA
180    pub fn with_incremental_pca(mut self, n_components: usize, batch_size: usize) -> Self {
181        self.params.n_components = Some(n_components);
182        self.params.algorithm_specific.insert(
183            "batch_size".to_string(),
184            ParamValue::Integer(batch_size as i64),
185        );
186        self.algorithm = Some(Box::new(IncrementalPCAAlgorithm::new(
187            n_components,
188            batch_size,
189        )));
190        self
191    }
192
193    /// Set decomposition algorithm: Kernel PCA
194    pub fn with_kernel_pca(mut self, n_components: usize, kernel: String) -> Self {
195        self.params.n_components = Some(n_components);
196        self.params
197            .algorithm_specific
198            .insert("kernel".to_string(), ParamValue::String(kernel.clone()));
199        self.algorithm = Some(Box::new(KernelPCAAlgorithm::new(n_components, kernel)));
200        self
201    }
202
203    /// Set decomposition algorithm: FastICA
204    pub fn with_fast_ica(mut self, n_components: usize) -> Self {
205        self.params.n_components = Some(n_components);
206        self.algorithm = Some(Box::new(FastICAAlgorithm::new(n_components)));
207        self
208    }
209
210    /// Set decomposition algorithm: NMF
211    pub fn with_nmf(mut self, n_components: usize, init_method: String) -> Self {
212        self.params.n_components = Some(n_components);
213        self.params.algorithm_specific.insert(
214            "init_method".to_string(),
215            ParamValue::String(init_method.clone()),
216        );
217        self.algorithm = Some(Box::new(NMFAlgorithm::new(n_components, init_method)));
218        self
219    }
220
221    /// Set decomposition algorithm: Factor Analysis
222    pub fn with_factor_analysis(mut self, n_components: usize) -> Self {
223        self.params.n_components = Some(n_components);
224        self.algorithm = Some(Box::new(FactorAnalysisAlgorithm::new(n_components)));
225        self
226    }
227
228    /// Set decomposition algorithm: Truncated SVD
229    pub fn with_truncated_svd(mut self, n_components: usize) -> Self {
230        self.params.n_components = Some(n_components);
231        self.algorithm = Some(Box::new(TruncatedSVDAlgorithm::new(n_components)));
232        self
233    }
234
235    /// Set decomposition algorithm: SSA (Singular Spectrum Analysis)
236    pub fn with_ssa(mut self, window_length: usize, n_components: usize) -> Self {
237        self.params.n_components = Some(n_components);
238        self.params.algorithm_specific.insert(
239            "window_length".to_string(),
240            ParamValue::Integer(window_length as i64),
241        );
242        self.algorithm = Some(Box::new(SSAAlgorithm::new(window_length, n_components)));
243        self
244    }
245
246    /// Add post-processing step: variance threshold
247    pub fn with_variance_threshold(mut self, threshold: Float) -> Self {
248        self.postprocessing_steps
249            .push(Box::new(VarianceThresholdStep::new(threshold)));
250        self
251    }
252
253    /// Add post-processing step: outlier removal
254    pub fn with_outlier_removal(mut self) -> Self {
255        self.postprocessing_steps
256            .push(Box::new(OutlierRemovalStep::new()));
257        self
258    }
259
260    /// Add post-processing step: sparsity constraint
261    pub fn with_sparsity_constraint(mut self) -> Self {
262        self.postprocessing_steps
263            .push(Box::new(SparsityConstraintStep::new()));
264        self
265    }
266
267    /// Add post-processing step: rotation (e.g., varimax)
268    pub fn with_rotation(mut self, method: &str) -> Self {
269        self.postprocessing_steps
270            .push(Box::new(RotationStep::new(method.to_string())));
271        self
272    }
273
274    /// Set number of components
275    pub fn n_components(mut self, n: usize) -> Self {
276        self.params.n_components = Some(n);
277        self
278    }
279
280    /// Set tolerance
281    pub fn tolerance(mut self, tol: Float) -> Self {
282        self.params.tolerance = Some(tol);
283        self
284    }
285
286    /// Set maximum iterations
287    pub fn max_iterations(mut self, max_iter: usize) -> Self {
288        self.params.max_iterations = Some(max_iter);
289        self
290    }
291
292    /// Set random seed for reproducibility
293    pub fn random_seed(mut self, seed: u64) -> Self {
294        self.params.random_seed = Some(seed);
295        self
296    }
297
298    /// Enable verbose output
299    pub fn verbose(mut self, verbose: bool) -> Self {
300        self.config.verbose = verbose;
301        self
302    }
303
304    /// Enable automatic parameter tuning
305    pub fn auto_tune(mut self, enabled: bool) -> Self {
306        self.config.auto_tune = enabled;
307        self
308    }
309
310    /// Set validation split ratio
311    pub fn validation_split(mut self, ratio: Float) -> Self {
312        self.config.validation_split = Some(ratio);
313        self
314    }
315
316    /// Build the decomposition pipeline
317    pub fn build(self) -> Result<DecompositionPipeline> {
318        if self.algorithm.is_none() {
319            return Err(SklearsError::InvalidInput(
320                "No decomposition algorithm specified".to_string(),
321            ));
322        }
323
324        Ok(DecompositionPipeline {
325            preprocessing_steps: self.preprocessing_steps,
326            algorithm: self.algorithm.expect("operation should succeed"),
327            postprocessing_steps: self.postprocessing_steps,
328            params: self.params,
329            config: self.config,
330            fitted: false,
331            cached_components: None,
332        })
333    }
334}
335
336impl Default for DecompositionPipelineBuilder {
337    fn default() -> Self {
338        Self::new()
339    }
340}
341
342/// Configuration presets for common decomposition use cases
343#[derive(Debug, Clone, Serialize, Deserialize)]
344pub enum DecompositionPreset {
345    /// Standard PCA with centering and scaling
346    StandardPCA { n_components: usize },
347
348    /// Robust PCA resistant to outliers
349    RobustPCA { n_components: usize },
350
351    /// Sparse PCA for interpretable components
352    SparsePCA { n_components: usize, alpha: Float },
353
354    /// Incremental PCA for large datasets
355    IncrementalPCA {
356        n_components: usize,
357        batch_size: usize,
358    },
359
360    /// Kernel PCA for non-linear dimensionality reduction
361    KernelPCA { n_components: usize, kernel: String },
362
363    /// FastICA for independent component analysis
364    FastICA { n_components: usize },
365
366    /// NMF for non-negative matrix factorization
367    NMF {
368        n_components: usize,
369        init_method: String,
370    },
371
372    /// Factor Analysis with rotation
373    FactorAnalysis { n_components: usize },
374
375    /// Truncated SVD for sparse matrices
376    TruncatedSVD { n_components: usize },
377
378    /// Singular Spectrum Analysis for time series
379    TimeSeriesSSA {
380        window_length: usize,
381        n_components: usize,
382    },
383}
384
385/// Pipeline configuration
386#[derive(Debug, Clone, Default, Serialize, Deserialize)]
387pub struct PipelineConfig {
388    pub verbose: bool,
389    pub auto_tune: bool,
390    pub validation_split: Option<Float>,
391    pub early_stopping: bool,
392    pub save_intermediate: bool,
393}
394
395/// Decomposition pipeline that chains preprocessing, decomposition, and post-processing
396pub struct DecompositionPipeline {
397    preprocessing_steps: Vec<Box<dyn PreprocessingStep>>,
398    algorithm: Box<dyn DecompositionAlgorithm>,
399    postprocessing_steps: Vec<Box<dyn PostprocessingStep>>,
400    params: DecompositionParams,
401    config: PipelineConfig,
402    fitted: bool,
403    /// Post-processed decomposition components cached after `fit()`.
404    cached_components: Option<DecompositionComponents>,
405}
406
407impl DecompositionPipeline {
408    /// Fit the pipeline to data
409    pub fn fit(&mut self, data: &Array2<Float>) -> Result<&mut Self> {
410        if self.config.verbose {
411            println!("Starting decomposition pipeline...");
412        }
413
414        // Apply preprocessing
415        let mut preprocessed_data = data.clone();
416        for (idx, step) in self.preprocessing_steps.iter_mut().enumerate() {
417            if self.config.verbose {
418                println!("  Applying preprocessing step {}: {}", idx + 1, step.name());
419            }
420            preprocessed_data = step.process(&preprocessed_data)?;
421        }
422
423        // Fit decomposition algorithm
424        if self.config.verbose {
425            println!("  Fitting algorithm: {}", self.algorithm.name());
426        }
427        self.algorithm.fit(&preprocessed_data, &self.params)?;
428
429        // Apply postprocessing steps to the raw decomposition components
430        if !self.postprocessing_steps.is_empty() {
431            if self.config.verbose {
432                println!(
433                    "  Applying {} postprocessing step(s)…",
434                    self.postprocessing_steps.len()
435                );
436            }
437            let raw_components = self.algorithm.get_components()?;
438            let mut components = raw_components;
439            for (idx, step) in self.postprocessing_steps.iter().enumerate() {
440                if self.config.verbose {
441                    println!("    Step {}: {}", idx + 1, step.name());
442                }
443                components = step.process(components)?;
444            }
445            self.cached_components = Some(components);
446        } else {
447            // Cache raw components so get_components() is always consistent
448            let raw_components = self.algorithm.get_components()?;
449            self.cached_components = Some(raw_components);
450        }
451
452        self.fitted = true;
453
454        if self.config.verbose {
455            println!("Pipeline fitted successfully!");
456        }
457
458        Ok(self)
459    }
460
461    /// Transform data using the fitted pipeline
462    pub fn transform(&self, data: &Array2<Float>) -> Result<Array2<Float>> {
463        if !self.fitted {
464            return Err(SklearsError::InvalidInput(
465                "Pipeline must be fitted before transform".to_string(),
466            ));
467        }
468
469        // Apply preprocessing
470        let mut preprocessed_data = data.clone();
471        for _step in &self.preprocessing_steps {
472            // Note: This is a simplified implementation
473            // In a full implementation, steps would need interior mutability
474            // or we would clone the step for processing
475            preprocessed_data = preprocessed_data.clone();
476        }
477
478        // Transform using algorithm
479        let transformed_data = self.algorithm.transform(&preprocessed_data)?;
480
481        Ok(transformed_data)
482    }
483
484    /// Fit and transform in one step
485    pub fn fit_transform(&mut self, data: &Array2<Float>) -> Result<Array2<Float>> {
486        self.fit(data)?;
487        self.transform(data)
488    }
489
490    /// Inverse transform if supported
491    pub fn inverse_transform(&self, data: &Array2<Float>) -> Result<Array2<Float>> {
492        if !self.fitted {
493            return Err(SklearsError::InvalidInput(
494                "Pipeline must be fitted before inverse_transform".to_string(),
495            ));
496        }
497
498        // Inverse transform using algorithm
499        let mut result = self.algorithm.inverse_transform(data)?;
500
501        // Apply inverse preprocessing in reverse order
502        for step in self.preprocessing_steps.iter().rev() {
503            result = step.inverse_process(&result)?;
504        }
505
506        Ok(result)
507    }
508
509    /// Get decomposition components (post-processed if postprocessing steps are configured)
510    pub fn get_components(&self) -> Result<DecompositionComponents> {
511        if !self.fitted {
512            return Err(SklearsError::InvalidInput(
513                "Pipeline must be fitted before getting components".to_string(),
514            ));
515        }
516
517        // Return post-processed components if available, otherwise fall back to raw
518        if let Some(ref cached) = self.cached_components {
519            Ok(cached.clone())
520        } else {
521            self.algorithm.get_components()
522        }
523    }
524
525    /// Get explained variance ratio if available
526    pub fn explained_variance_ratio(&self) -> Result<Array1<Float>> {
527        let components = self.get_components()?;
528        components.explained_variance_ratio.ok_or_else(|| {
529            SklearsError::InvalidInput("No explained variance available".to_string())
530        })
531    }
532
533    /// Check if pipeline is fitted
534    pub fn is_fitted(&self) -> bool {
535        self.fitted
536    }
537
538    /// Serialize pipeline to JSON
539    pub fn to_json(&self) -> Result<String> {
540        let serializable_pipeline = SerializablePipeline {
541            algorithm_name: self.algorithm.name().to_string(),
542            params: self.params.clone(),
543            config: self.config.clone(),
544        };
545
546        // Simplified JSON serialization without serde_json dependency
547        Ok(format!(
548            "{{\"algorithm\":\"{}\"}}",
549            serializable_pipeline.algorithm_name
550        ))
551    }
552
553    /// Deserialize pipeline from JSON
554    pub fn from_json(_json: &str) -> Result<Self> {
555        // Implementation would reconstruct pipeline from JSON
556        Err(SklearsError::InvalidInput(
557            "Deserialization not yet implemented".to_string(),
558        ))
559    }
560}
561
562/// Serializable representation of pipeline
563#[derive(Debug, Clone, Serialize, Deserialize)]
564pub struct SerializablePipeline {
565    pub algorithm_name: String,
566    pub params: DecompositionParams,
567    pub config: PipelineConfig,
568}
569
570// Placeholder implementations for preprocessing steps
571// These would be fully implemented in the actual codebase
572
573#[derive(Clone)]
574struct StandardizationStep {
575    mean: Option<Array1<Float>>,
576    std: Option<Array1<Float>>,
577}
578
579impl StandardizationStep {
580    fn new() -> Self {
581        Self {
582            mean: None,
583            std: None,
584        }
585    }
586}
587
588impl PreprocessingStep for StandardizationStep {
589    fn name(&self) -> &str {
590        "Standardization"
591    }
592
593    fn process(&mut self, data: &Array2<Float>) -> Result<Array2<Float>> {
594        let mean = data
595            .mean_axis(scirs2_core::ndarray::Axis(0))
596            .ok_or_else(|| {
597                SklearsError::InvalidInput("Cannot compute mean of empty array".to_string())
598            })?;
599        let std = data
600            .mapv(|x| x.powi(2))
601            .mean_axis(scirs2_core::ndarray::Axis(0))
602            .ok_or_else(|| {
603                SklearsError::InvalidInput("Cannot compute std of empty array".to_string())
604            })?
605            .mapv(|x| x.sqrt());
606
607        self.mean = Some(mean.clone());
608        self.std = Some(std.clone());
609
610        let result = (data - &mean) / &std;
611        Ok(result)
612    }
613
614    fn apply_fitted(&self, data: &Array2<Float>) -> Result<Array2<Float>> {
615        let mean = self.mean.as_ref().ok_or_else(|| {
616            SklearsError::InvalidInput("Standardization step not fitted".to_string())
617        })?;
618        let std = self.std.as_ref().ok_or_else(|| {
619            SklearsError::InvalidInput("Standardization step not fitted".to_string())
620        })?;
621        Ok((data - mean) / std)
622    }
623
624    fn is_fitted(&self) -> bool {
625        self.mean.is_some()
626    }
627
628    fn clone_step(&self) -> Box<dyn PreprocessingStep> {
629        Box::new(self.clone())
630    }
631}
632
633#[derive(Clone)]
634struct RobustScalingStep;
635impl RobustScalingStep {
636    fn new() -> Self {
637        Self
638    }
639}
640impl PreprocessingStep for RobustScalingStep {
641    fn name(&self) -> &str {
642        "RobustScaling"
643    }
644    fn process(&mut self, data: &Array2<Float>) -> Result<Array2<Float>> {
645        Ok(data.clone())
646    }
647    fn apply_fitted(&self, data: &Array2<Float>) -> Result<Array2<Float>> {
648        Ok(data.clone())
649    }
650    fn is_fitted(&self) -> bool {
651        true
652    }
653    fn clone_step(&self) -> Box<dyn PreprocessingStep> {
654        Box::new(self.clone())
655    }
656}
657
658#[derive(Clone)]
659struct CenteringStep {
660    mean: Option<Array1<Float>>,
661}
662impl CenteringStep {
663    fn new() -> Self {
664        Self { mean: None }
665    }
666}
667impl PreprocessingStep for CenteringStep {
668    fn name(&self) -> &str {
669        "Centering"
670    }
671    fn process(&mut self, data: &Array2<Float>) -> Result<Array2<Float>> {
672        let mean = data
673            .mean_axis(scirs2_core::ndarray::Axis(0))
674            .ok_or_else(|| {
675                SklearsError::InvalidInput("Cannot compute mean of empty array".to_string())
676            })?;
677        self.mean = Some(mean.clone());
678        Ok(data - &mean)
679    }
680    fn apply_fitted(&self, data: &Array2<Float>) -> Result<Array2<Float>> {
681        let mean = self
682            .mean
683            .as_ref()
684            .ok_or_else(|| SklearsError::InvalidInput("Centering step not fitted".to_string()))?;
685        Ok(data - mean)
686    }
687    fn is_fitted(&self) -> bool {
688        self.mean.is_some()
689    }
690    fn clone_step(&self) -> Box<dyn PreprocessingStep> {
691        Box::new(self.clone())
692    }
693}
694
695#[derive(Clone)]
696struct WhiteningStep;
697impl WhiteningStep {
698    fn new() -> Self {
699        Self
700    }
701}
702impl PreprocessingStep for WhiteningStep {
703    fn name(&self) -> &str {
704        "Whitening"
705    }
706    fn process(&mut self, data: &Array2<Float>) -> Result<Array2<Float>> {
707        Ok(data.clone())
708    }
709    fn apply_fitted(&self, data: &Array2<Float>) -> Result<Array2<Float>> {
710        Ok(data.clone())
711    }
712    fn is_fitted(&self) -> bool {
713        true
714    }
715    fn clone_step(&self) -> Box<dyn PreprocessingStep> {
716        Box::new(self.clone())
717    }
718}
719
720#[derive(Clone)]
721struct NonNegativeScalingStep;
722impl NonNegativeScalingStep {
723    fn new() -> Self {
724        Self
725    }
726}
727impl PreprocessingStep for NonNegativeScalingStep {
728    fn name(&self) -> &str {
729        "NonNegativeScaling"
730    }
731    fn process(&mut self, data: &Array2<Float>) -> Result<Array2<Float>> {
732        Ok(data.clone())
733    }
734    fn apply_fitted(&self, data: &Array2<Float>) -> Result<Array2<Float>> {
735        Ok(data.clone())
736    }
737    fn is_fitted(&self) -> bool {
738        true
739    }
740    fn clone_step(&self) -> Box<dyn PreprocessingStep> {
741        Box::new(self.clone())
742    }
743}
744
745#[derive(Clone)]
746struct BatchNormalizationStep {
747    batch_size: usize,
748    /// Per-batch statistics computed during `process()`: (mean, variance) per feature
749    batch_stats: Option<(
750        scirs2_core::ndarray::Array1<Float>,
751        scirs2_core::ndarray::Array1<Float>,
752    )>,
753}
754impl BatchNormalizationStep {
755    fn new(batch_size: usize) -> Self {
756        Self {
757            batch_size,
758            batch_stats: None,
759        }
760    }
761}
762impl PreprocessingStep for BatchNormalizationStep {
763    fn name(&self) -> &str {
764        "BatchNormalization"
765    }
766    /// Normalize each mini-batch of `batch_size` rows independently using mean/variance
767    /// computed within that batch.  The per-feature running statistics (across all
768    /// batches) are stored so they can be reused by `apply_fitted()`.
769    fn process(&mut self, data: &Array2<Float>) -> Result<Array2<Float>> {
770        let (n_rows, n_cols) = data.dim();
771        if n_rows == 0 || n_cols == 0 {
772            return Ok(data.clone());
773        }
774
775        let batch_size = if self.batch_size == 0 {
776            1
777        } else {
778            self.batch_size
779        };
780        let mut output = data.clone();
781
782        // Accumulators for overall statistics (used in apply_fitted)
783        let mut running_mean = scirs2_core::ndarray::Array1::<Float>::zeros(n_cols);
784        let mut running_var = scirs2_core::ndarray::Array1::<Float>::zeros(n_cols);
785        let mut n_batches: usize = 0;
786
787        let mut start = 0;
788        while start < n_rows {
789            let end = (start + batch_size).min(n_rows);
790            let batch = data.slice(scirs2_core::ndarray::s![start..end, ..]);
791
792            let batch_mean = batch
793                .mean_axis(scirs2_core::ndarray::Axis(0))
794                .ok_or_else(|| {
795                    SklearsError::InvalidInput("Cannot compute batch mean".to_string())
796                })?;
797            // Var = E[X^2] - E[X]^2 (unbiased enough for normalization)
798            let e_x2 = batch
799                .mapv(|x: Float| x.powi(2))
800                .mean_axis(scirs2_core::ndarray::Axis(0))
801                .ok_or_else(|| {
802                    SklearsError::InvalidInput("Cannot compute batch variance".to_string())
803                })?;
804            let batch_var: scirs2_core::ndarray::Array1<Float> =
805                (&e_x2 - &batch_mean.mapv(|m| m.powi(2))).mapv(|v: Float| v.max(0.0));
806
807            let batch_std = batch_var.mapv(|v: Float| v.sqrt() + 1e-8);
808
809            let batch_owned = batch.to_owned();
810            let normalized = (batch_owned - &batch_mean) / &batch_std;
811            output
812                .slice_mut(scirs2_core::ndarray::s![start..end, ..])
813                .assign(&normalized);
814
815            running_mean += &batch_mean;
816            running_var += &batch_var;
817            n_batches += 1;
818
819            start = end;
820        }
821
822        if n_batches > 0 {
823            running_mean /= n_batches as Float;
824            running_var /= n_batches as Float;
825        }
826        self.batch_stats = Some((running_mean, running_var));
827
828        Ok(output)
829    }
830    fn apply_fitted(&self, data: &Array2<Float>) -> Result<Array2<Float>> {
831        let (mean, var) = self.batch_stats.as_ref().ok_or_else(|| {
832            SklearsError::InvalidInput("BatchNormalization step not fitted".to_string())
833        })?;
834        let std = var.mapv(|v| v.sqrt() + 1e-8);
835        Ok((data - mean) / &std)
836    }
837    fn is_fitted(&self) -> bool {
838        self.batch_stats.is_some()
839    }
840    fn clone_step(&self) -> Box<dyn PreprocessingStep> {
841        Box::new(self.clone())
842    }
843}
844
845#[derive(Clone)]
846struct TimeSeriesPreprocessingStep {
847    window_length: usize,
848    fitted: bool,
849}
850impl TimeSeriesPreprocessingStep {
851    fn new(window_length: usize) -> Self {
852        Self {
853            window_length,
854            fitted: false,
855        }
856    }
857
858    /// Apply sliding-window feature extraction to a single time series row.
859    ///
860    /// Each row of the input is treated as a univariate series.  The output has
861    /// `n_rows - window_length + 1` rows, each containing a window of `window_length`
862    /// consecutive values.  Multiple input columns are concatenated within each window.
863    fn extract_windows(&self, data: &Array2<Float>) -> Result<Array2<Float>> {
864        let (n_rows, n_cols) = data.dim();
865        if self.window_length == 0 {
866            return Err(SklearsError::InvalidInput(
867                "window_length must be greater than zero".to_string(),
868            ));
869        }
870        if n_rows < self.window_length {
871            return Err(SklearsError::InvalidInput(format!(
872                "Data has {} rows but window_length is {}; need at least window_length rows",
873                n_rows, self.window_length
874            )));
875        }
876        let n_windows = n_rows - self.window_length + 1;
877        let window_features = self.window_length * n_cols;
878        let mut result = scirs2_core::ndarray::Array2::<Float>::zeros((n_windows, window_features));
879
880        for w in 0..n_windows {
881            let window_slice = data.slice(scirs2_core::ndarray::s![w..w + self.window_length, ..]);
882            // Flatten the window (row-major)
883            for (wi, row) in window_slice.outer_iter().enumerate() {
884                for (ci, &val) in row.iter().enumerate() {
885                    result[[w, wi * n_cols + ci]] = val;
886                }
887            }
888        }
889        Ok(result)
890    }
891}
892impl PreprocessingStep for TimeSeriesPreprocessingStep {
893    fn name(&self) -> &str {
894        "TimeSeriesPreprocessing"
895    }
896    fn process(&mut self, data: &Array2<Float>) -> Result<Array2<Float>> {
897        let result = self.extract_windows(data)?;
898        self.fitted = true;
899        Ok(result)
900    }
901    fn apply_fitted(&self, data: &Array2<Float>) -> Result<Array2<Float>> {
902        if !self.fitted {
903            return Err(SklearsError::InvalidInput(
904                "TimeSeriesPreprocessing step not fitted".to_string(),
905            ));
906        }
907        self.extract_windows(data)
908    }
909    fn is_fitted(&self) -> bool {
910        self.fitted
911    }
912    fn clone_step(&self) -> Box<dyn PreprocessingStep> {
913        Box::new(self.clone())
914    }
915}
916
917// Placeholder implementations for algorithms
918
919struct PCAAlgorithm {
920    n_components: usize,
921    components: Option<Array2<Float>>,
922}
923impl PCAAlgorithm {
924    fn new(n_components: usize) -> Self {
925        Self {
926            n_components,
927            components: None,
928        }
929    }
930}
931impl DecompositionAlgorithm for PCAAlgorithm {
932    fn name(&self) -> &str {
933        "PCA"
934    }
935    fn description(&self) -> &str {
936        "Principal Component Analysis"
937    }
938    fn capabilities(&self) -> AlgorithmCapabilities {
939        AlgorithmCapabilities::default()
940    }
941    fn validate_params(&self, _params: &DecompositionParams) -> Result<()> {
942        Ok(())
943    }
944    fn fit(&mut self, data: &Array2<Float>, _params: &DecompositionParams) -> Result<()> {
945        self.components = Some(data.slice(s![.., ..self.n_components]).to_owned());
946        Ok(())
947    }
948    fn transform(&self, data: &Array2<Float>) -> Result<Array2<Float>> {
949        Ok(data.slice(s![.., ..self.n_components]).to_owned())
950    }
951    fn get_components(&self) -> Result<DecompositionComponents> {
952        Ok(DecompositionComponents::default())
953    }
954    fn is_fitted(&self) -> bool {
955        self.components.is_some()
956    }
957    fn clone_algorithm(&self) -> Box<dyn DecompositionAlgorithm> {
958        Box::new(Self {
959            n_components: self.n_components,
960            components: self.components.clone(),
961        })
962    }
963    fn as_any(&self) -> &dyn Any {
964        self
965    }
966}
967
968// Additional placeholder algorithm implementations...
969macro_rules! impl_placeholder_algorithm {
970    ($name:ident, $display_name:expr, $($field:ident: $field_type:ty),*) => {
971        #[allow(dead_code)]
972        struct $name {
973            n_components: usize,
974            $(
975                $field: $field_type,
976            )*
977            components: Option<Array2<Float>>,
978        }
979        impl $name {
980            fn new(n_components: usize $(, $field: $field_type)*) -> Self {
981                Self {
982                    n_components,
983                    $(
984                        $field,
985                    )*
986                    components: None,
987                }
988            }
989        }
990        impl DecompositionAlgorithm for $name {
991            fn name(&self) -> &str { $display_name }
992            fn description(&self) -> &str { $display_name }
993            fn capabilities(&self) -> AlgorithmCapabilities { AlgorithmCapabilities::default() }
994            fn validate_params(&self, _params: &DecompositionParams) -> Result<()> { Ok(()) }
995            fn fit(&mut self, data: &Array2<Float>, _params: &DecompositionParams) -> Result<()> {
996                self.components = Some(data.slice(s![.., ..self.n_components]).to_owned());
997                Ok(())
998            }
999            fn transform(&self, data: &Array2<Float>) -> Result<Array2<Float>> {
1000                Ok(data.slice(s![.., ..self.n_components]).to_owned())
1001            }
1002            fn get_components(&self) -> Result<DecompositionComponents> {
1003                Ok(DecompositionComponents::default())
1004            }
1005            fn is_fitted(&self) -> bool { self.components.is_some() }
1006            fn clone_algorithm(&self) -> Box<dyn DecompositionAlgorithm> {
1007                Box::new(Self {
1008                    n_components: self.n_components,
1009                    $(
1010                        $field: self.$field.clone(),
1011                    )*
1012                    components: self.components.clone(),
1013                })
1014            }
1015            fn as_any(&self) -> &dyn Any { self }
1016        }
1017    };
1018}
1019
1020impl_placeholder_algorithm!(RobustPCAAlgorithm, "RobustPCA",);
1021impl_placeholder_algorithm!(SparsePCAAlgorithm, "SparsePCA", alpha: Float);
1022impl_placeholder_algorithm!(IncrementalPCAAlgorithm, "IncrementalPCA", batch_size: usize);
1023impl_placeholder_algorithm!(KernelPCAAlgorithm, "KernelPCA", kernel: String);
1024impl_placeholder_algorithm!(FastICAAlgorithm, "FastICA",);
1025impl_placeholder_algorithm!(NMFAlgorithm, "NMF", init_method: String);
1026impl_placeholder_algorithm!(FactorAnalysisAlgorithm, "FactorAnalysis",);
1027impl_placeholder_algorithm!(TruncatedSVDAlgorithm, "TruncatedSVD",);
1028impl_placeholder_algorithm!(SSAAlgorithm, "SSA", window_length: usize);
1029
1030// Placeholder implementations for post-processing steps
1031
1032#[derive(Clone)]
1033struct VarianceThresholdStep {
1034    threshold: Float,
1035}
1036impl VarianceThresholdStep {
1037    fn new(threshold: Float) -> Self {
1038        Self { threshold }
1039    }
1040}
1041impl PostprocessingStep for VarianceThresholdStep {
1042    fn name(&self) -> &str {
1043        "VarianceThreshold"
1044    }
1045    /// Remove components whose explained-variance ratio is below `threshold`.
1046    ///
1047    /// When `explained_variance_ratio` is available each component column is zeroed
1048    /// (or removed) if its ratio falls below the threshold.  When only raw
1049    /// eigenvalues are available, components are filtered by eigenvalue magnitude.
1050    fn process(&self, mut components: DecompositionComponents) -> Result<DecompositionComponents> {
1051        if let Some(ref evr) = components.explained_variance_ratio.clone() {
1052            // Build a mask of components that pass the threshold
1053            let keep_mask: Vec<bool> = evr.iter().map(|&r| r >= self.threshold).collect();
1054            let n_keep = keep_mask.iter().filter(|&&k| k).count();
1055
1056            // Apply mask to component matrix columns
1057            if let Some(ref comps) = components.components.clone() {
1058                let n_rows = comps.nrows();
1059                let mut kept = scirs2_core::ndarray::Array2::<Float>::zeros((n_rows, n_keep));
1060                let mut col_out = 0;
1061                for (col_in, &keep) in keep_mask.iter().enumerate() {
1062                    if keep {
1063                        kept.column_mut(col_out).assign(&comps.column(col_in));
1064                        col_out += 1;
1065                    }
1066                }
1067                components.components = Some(kept);
1068            }
1069
1070            // Trim explained_variance_ratio to kept components
1071            let new_evr: Vec<Float> = evr
1072                .iter()
1073                .zip(keep_mask.iter())
1074                .filter_map(|(&r, &k)| if k { Some(r) } else { None })
1075                .collect();
1076            components.explained_variance_ratio =
1077                Some(scirs2_core::ndarray::Array1::from_vec(new_evr));
1078
1079            // Trim eigenvalues likewise
1080            if let Some(ref eigs) = components.eigenvalues.clone() {
1081                let new_eigs: Vec<Float> = eigs
1082                    .iter()
1083                    .zip(keep_mask.iter())
1084                    .filter_map(|(&e, &k)| if k { Some(e) } else { None })
1085                    .collect();
1086                components.eigenvalues = Some(scirs2_core::ndarray::Array1::from_vec(new_eigs));
1087            }
1088
1089            components.metadata.insert(
1090                "variance_threshold_applied".to_string(),
1091                format!("{}", self.threshold),
1092            );
1093        }
1094        // No explained_variance_ratio — nothing to filter
1095        Ok(components)
1096    }
1097    fn clone_step(&self) -> Box<dyn PostprocessingStep> {
1098        Box::new(self.clone())
1099    }
1100}
1101
1102#[derive(Clone)]
1103struct OutlierRemovalStep;
1104impl OutlierRemovalStep {
1105    fn new() -> Self {
1106        Self
1107    }
1108}
1109impl PostprocessingStep for OutlierRemovalStep {
1110    fn name(&self) -> &str {
1111        "OutlierRemoval"
1112    }
1113    fn process(&self, components: DecompositionComponents) -> Result<DecompositionComponents> {
1114        Ok(components)
1115    }
1116    fn clone_step(&self) -> Box<dyn PostprocessingStep> {
1117        Box::new(self.clone())
1118    }
1119}
1120
1121#[derive(Clone)]
1122struct SparsityConstraintStep;
1123impl SparsityConstraintStep {
1124    fn new() -> Self {
1125        Self
1126    }
1127}
1128impl PostprocessingStep for SparsityConstraintStep {
1129    fn name(&self) -> &str {
1130        "SparsityConstraint"
1131    }
1132    fn process(&self, components: DecompositionComponents) -> Result<DecompositionComponents> {
1133        Ok(components)
1134    }
1135    fn clone_step(&self) -> Box<dyn PostprocessingStep> {
1136        Box::new(self.clone())
1137    }
1138}
1139
1140#[derive(Clone)]
1141struct RotationStep {
1142    method: String,
1143}
1144impl RotationStep {
1145    fn new(method: String) -> Self {
1146        Self { method }
1147    }
1148
1149    /// Apply Varimax rotation to a loading matrix using the standard Kaiser (1958) algorithm.
1150    ///
1151    /// Varimax maximises the variance of squared loadings within each component,
1152    /// making components easier to interpret by pushing loadings towards 0 or ±1.
1153    fn apply_varimax(
1154        matrix: &scirs2_core::ndarray::Array2<Float>,
1155        max_iter: usize,
1156        tol: Float,
1157    ) -> Result<scirs2_core::ndarray::Array2<Float>> {
1158        let (p, k) = matrix.dim();
1159        if k < 2 {
1160            // Nothing to rotate with a single component
1161            return Ok(matrix.clone());
1162        }
1163
1164        // Start with the identity rotation matrix
1165        let mut rot = scirs2_core::ndarray::Array2::<Float>::eye(k);
1166        let mut lambda = matrix.clone();
1167
1168        for _iter in 0..max_iter {
1169            let mut max_change: Float = 0.0;
1170
1171            // Iterate over all pairs (i, j) with i < j
1172            for i in 0..k {
1173                for j in (i + 1)..k {
1174                    // Extract columns i and j from current rotated matrix
1175                    let col_i = lambda.column(i).to_owned();
1176                    let col_j = lambda.column(j).to_owned();
1177
1178                    // Compute rotation angle using the Varimax criterion
1179                    let u: Vec<Float> = col_i
1180                        .iter()
1181                        .zip(col_j.iter())
1182                        .map(|(&a, &b)| a.powi(2) - b.powi(2))
1183                        .collect();
1184                    let v: Vec<Float> = col_i
1185                        .iter()
1186                        .zip(col_j.iter())
1187                        .map(|(&a, &b)| 2.0 * a * b)
1188                        .collect();
1189
1190                    let u_sum: Float = u.iter().sum();
1191                    let v_sum: Float = v.iter().sum();
1192                    let u2_sum: Float = u.iter().map(|x| x.powi(2)).sum::<Float>()
1193                        - u.iter().map(|x| x.powi(2)).sum::<Float>()
1194                            * u.iter().sum::<Float>().powi(2)
1195                            / p as Float;
1196                    let v2_sum: Float = v.iter().map(|x| x.powi(2)).sum::<Float>();
1197                    let uv_sum: Float = u.iter().zip(v.iter()).map(|(a, b)| a * b).sum::<Float>();
1198
1199                    // Numerator and denominator for the rotation angle
1200                    let num = 4.0 * (uv_sum - u_sum * v_sum / p as Float);
1201                    let den = u2_sum - v2_sum;
1202
1203                    let theta = if den.abs() < Float::EPSILON {
1204                        0.0
1205                    } else {
1206                        Float::atan2(num, den) / 4.0
1207                    };
1208
1209                    if theta.abs() < tol {
1210                        continue;
1211                    }
1212                    max_change = max_change.max(theta.abs());
1213
1214                    let cos_t = theta.cos();
1215                    let sin_t = theta.sin();
1216
1217                    // Update lambda: rotate columns i and j
1218                    let new_i: Vec<Float> = col_i
1219                        .iter()
1220                        .zip(col_j.iter())
1221                        .map(|(&a, &b)| cos_t * a + sin_t * b)
1222                        .collect();
1223                    let new_j: Vec<Float> = col_i
1224                        .iter()
1225                        .zip(col_j.iter())
1226                        .map(|(&a, &b)| -sin_t * a + cos_t * b)
1227                        .collect();
1228
1229                    for row in 0..p {
1230                        lambda[[row, i]] = new_i[row];
1231                        lambda[[row, j]] = new_j[row];
1232                    }
1233
1234                    // Update rotation matrix
1235                    let old_rot_i = rot.column(i).to_owned();
1236                    let old_rot_j = rot.column(j).to_owned();
1237                    for row in 0..k {
1238                        rot[[row, i]] = cos_t * old_rot_i[row] + sin_t * old_rot_j[row];
1239                        rot[[row, j]] = -sin_t * old_rot_i[row] + cos_t * old_rot_j[row];
1240                    }
1241                }
1242            }
1243
1244            if max_change < tol {
1245                break;
1246            }
1247        }
1248
1249        Ok(lambda)
1250    }
1251
1252    /// Apply Quartimax rotation (maximises variance of squared loadings per variable).
1253    ///
1254    /// Quartimax is simpler than Varimax and tends to produce one dominant general factor.
1255    fn apply_quartimax(
1256        matrix: &scirs2_core::ndarray::Array2<Float>,
1257        max_iter: usize,
1258        tol: Float,
1259    ) -> Result<scirs2_core::ndarray::Array2<Float>> {
1260        let (p, k) = matrix.dim();
1261        if k < 2 {
1262            return Ok(matrix.clone());
1263        }
1264
1265        let mut lambda = matrix.clone();
1266
1267        for _iter in 0..max_iter {
1268            let mut max_change: Float = 0.0;
1269
1270            for i in 0..k {
1271                for j in (i + 1)..k {
1272                    let col_i = lambda.column(i).to_owned();
1273                    let col_j = lambda.column(j).to_owned();
1274
1275                    // Quartimax criterion (no normalisation by p)
1276                    let u_sum: Float = col_i
1277                        .iter()
1278                        .zip(col_j.iter())
1279                        .map(|(&a, &b)| a.powi(2) - b.powi(2))
1280                        .sum();
1281                    let v_sum: Float = col_i
1282                        .iter()
1283                        .zip(col_j.iter())
1284                        .map(|(&a, &b)| 2.0 * a * b)
1285                        .sum();
1286                    let uv_sum: Float = col_i
1287                        .iter()
1288                        .zip(col_j.iter())
1289                        .map(|(&a, &b)| (a.powi(2) - b.powi(2)) * 2.0 * a * b)
1290                        .sum();
1291                    let u2_v2: Float = col_i
1292                        .iter()
1293                        .zip(col_j.iter())
1294                        .map(|(&a, &b)| {
1295                            (a.powi(2) - b.powi(2)).powi(2) - 4.0 * a.powi(2) * b.powi(2)
1296                        })
1297                        .sum();
1298
1299                    let theta = if (u2_v2.abs() + uv_sum.abs()) < Float::EPSILON {
1300                        0.0
1301                    } else {
1302                        Float::atan2(u_sum * v_sum - uv_sum * (p as Float), u2_v2) / 4.0
1303                    };
1304
1305                    let _ = (u_sum, v_sum); // suppress unused
1306
1307                    if theta.abs() < tol {
1308                        continue;
1309                    }
1310                    max_change = max_change.max(theta.abs());
1311
1312                    let cos_t = theta.cos();
1313                    let sin_t = theta.sin();
1314
1315                    let new_i: Vec<Float> = col_i
1316                        .iter()
1317                        .zip(col_j.iter())
1318                        .map(|(&a, &b)| cos_t * a + sin_t * b)
1319                        .collect();
1320                    let new_j: Vec<Float> = col_i
1321                        .iter()
1322                        .zip(col_j.iter())
1323                        .map(|(&a, &b)| -sin_t * a + cos_t * b)
1324                        .collect();
1325
1326                    for row in 0..p {
1327                        lambda[[row, i]] = new_i[row];
1328                        lambda[[row, j]] = new_j[row];
1329                    }
1330                }
1331            }
1332
1333            if max_change < tol {
1334                break;
1335            }
1336        }
1337        Ok(lambda)
1338    }
1339}
1340impl PostprocessingStep for RotationStep {
1341    fn name(&self) -> &str {
1342        "Rotation"
1343    }
1344    /// Apply the configured rotation method to factor loadings or component matrix.
1345    fn process(&self, mut components: DecompositionComponents) -> Result<DecompositionComponents> {
1346        let max_iter = 1000;
1347        let tol = 1e-6;
1348
1349        let target = if let Some(ref loadings) = components.factor_loadings {
1350            Some(("factor_loadings", loadings.clone()))
1351        } else {
1352            components
1353                .components
1354                .as_ref()
1355                .map(|c| ("components", c.clone()))
1356        };
1357
1358        if let Some((field_name, matrix)) = target {
1359            let rotated = match self.method.to_lowercase().as_str() {
1360                "varimax" => Self::apply_varimax(&matrix, max_iter, tol)?,
1361                "quartimax" => Self::apply_quartimax(&matrix, max_iter, tol)?,
1362                // Promax: apply varimax first, then raise to power 3 (simplified)
1363                "promax" => {
1364                    let vm = Self::apply_varimax(&matrix, max_iter, tol)?;
1365                    vm.mapv(|x| {
1366                        let sign = if x < 0.0 { -1.0 } else { 1.0 };
1367                        sign * x.abs().powi(3)
1368                    })
1369                }
1370                other => {
1371                    return Err(SklearsError::InvalidInput(format!(
1372                        "Unknown rotation method '{}'; supported: varimax, quartimax, promax",
1373                        other
1374                    )))
1375                }
1376            };
1377
1378            match field_name {
1379                "factor_loadings" => components.factor_loadings = Some(rotated),
1380                _ => components.components = Some(rotated),
1381            }
1382
1383            components
1384                .metadata
1385                .insert("rotation_applied".to_string(), self.method.clone());
1386        }
1387
1388        Ok(components)
1389    }
1390    fn clone_step(&self) -> Box<dyn PostprocessingStep> {
1391        Box::new(self.clone())
1392    }
1393}
1394
1395#[cfg(test)]
1396mod tests {
1397    use super::*;
1398
1399    #[test]
1400    fn test_pipeline_builder() {
1401        let builder = DecompositionPipelineBuilder::new()
1402            .with_standardization()
1403            .with_pca(3)
1404            .n_components(3)
1405            .tolerance(1e-6);
1406
1407        assert!(builder.build().is_ok());
1408    }
1409
1410    #[test]
1411    fn test_preset_standard_pca() {
1412        let builder = DecompositionPipelineBuilder::from_preset(DecompositionPreset::StandardPCA {
1413            n_components: 5,
1414        });
1415
1416        let pipeline = builder.build();
1417        assert!(pipeline.is_ok());
1418    }
1419
1420    #[test]
1421    fn test_pipeline_fit_transform() {
1422        use scirs2_core::random::thread_rng;
1423        let mut rng = thread_rng();
1424
1425        let data = Array2::from_shape_fn((50, 10), |(i, j)| {
1426            (i + j) as Float + rng.gen_range(-0.1..0.1)
1427        });
1428
1429        let mut pipeline = DecompositionPipelineBuilder::new()
1430            .with_standardization()
1431            .with_pca(3)
1432            .build()
1433            .expect("operation should succeed");
1434
1435        let result = pipeline.fit_transform(&data);
1436        assert!(result.is_ok());
1437    }
1438
1439    #[test]
1440    fn test_fluent_api_chaining() {
1441        let pipeline = DecompositionPipelineBuilder::new()
1442            .with_standardization()
1443            .with_pca(5)
1444            .n_components(5)
1445            .tolerance(1e-6)
1446            .max_iterations(100)
1447            .random_seed(42)
1448            .verbose(false)
1449            .build();
1450
1451        assert!(pipeline.is_ok());
1452    }
1453
1454    #[test]
1455    fn test_serialization() {
1456        let mut pipeline = DecompositionPipelineBuilder::new()
1457            .with_pca(3)
1458            .build()
1459            .expect("operation should succeed");
1460
1461        use scirs2_core::random::thread_rng;
1462        let mut rng = thread_rng();
1463        let data = Array2::from_shape_fn((20, 5), |(i, j)| {
1464            (i + j) as Float + rng.gen_range(-0.1..0.1)
1465        });
1466
1467        pipeline.fit(&data).expect("model fitting should succeed");
1468
1469        let json = pipeline.to_json();
1470        assert!(json.is_ok());
1471    }
1472
1473    // ---- Tests for newly-implemented parameters ----
1474
1475    /// batch_size actually normalises differently from a full-dataset normalisation.
1476    #[test]
1477    fn test_batch_normalization_affects_output() {
1478        let data = Array2::from_shape_vec((8, 3), (0..24).map(|x| x as Float).collect())
1479            .expect("shape should match");
1480
1481        // batch_size = 2: normalise within 4 batches of 2
1482        let mut step_small = BatchNormalizationStep::new(2);
1483        let out_small = step_small
1484            .process(&data)
1485            .expect("batch norm should succeed");
1486
1487        // batch_size = 8 (full dataset at once): different statistics
1488        let mut step_full = BatchNormalizationStep::new(8);
1489        let out_full = step_full.process(&data).expect("batch norm should succeed");
1490
1491        // The two outputs should differ (different batch statistics)
1492        assert_ne!(
1493            out_small.as_slice().expect("contiguous"),
1494            out_full.as_slice().expect("contiguous"),
1495            "batch_size should change the normalisation output"
1496        );
1497
1498        // After fitting, apply_fitted should use stored stats
1499        let apply_result = step_small.apply_fitted(&data);
1500        assert!(
1501            apply_result.is_ok(),
1502            "apply_fitted should work after process()"
1503        );
1504    }
1505
1506    /// window_length changes the shape of the output (more cols, fewer rows).
1507    #[test]
1508    fn test_window_length_affects_output() {
1509        let data = Array2::from_shape_vec((10, 2), (0..20).map(|x| x as Float).collect())
1510            .expect("shape should match");
1511
1512        let window = 3_usize;
1513        let mut step = TimeSeriesPreprocessingStep::new(window);
1514        let out = step
1515            .process(&data)
1516            .expect("time-series preprocessing should succeed");
1517
1518        // Expected: n_rows - window + 1 windows, window * n_cols features
1519        assert_eq!(
1520            out.nrows(),
1521            data.nrows() - window + 1,
1522            "output should have n_rows - window_length + 1 rows"
1523        );
1524        assert_eq!(
1525            out.ncols(),
1526            window * data.ncols(),
1527            "each window should have window_length * n_cols features"
1528        );
1529
1530        // window=1 should produce same number of rows, just a reshape
1531        let mut step1 = TimeSeriesPreprocessingStep::new(1);
1532        let out1 = step1.process(&data).expect("window=1 should work");
1533        assert_eq!(out1.nrows(), data.nrows());
1534        assert_eq!(out1.ncols(), data.ncols());
1535    }
1536
1537    /// variance_threshold removes components below the threshold.
1538    #[test]
1539    fn test_variance_threshold_filters_components() {
1540        use crate::modular_framework::{DecompositionComponents, PostprocessingStep};
1541        use scirs2_core::ndarray::{Array1, Array2};
1542
1543        // Build components with known explained_variance_ratio
1544        let evr = Array1::from_vec(vec![0.5_f64, 0.02, 0.3]);
1545        let comps = Array2::from_shape_vec(
1546            (4, 3),
1547            vec![
1548                1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0,
1549            ],
1550        )
1551        .expect("shape should match");
1552
1553        let dc = DecompositionComponents {
1554            explained_variance_ratio: Some(evr.clone()),
1555            components: Some(comps),
1556            ..Default::default()
1557        };
1558
1559        // Threshold 0.1: keep components with evr >= 0.1, i.e. indices 0 and 2
1560        let step = VarianceThresholdStep::new(0.1);
1561        let result = step.process(dc).expect("variance threshold should succeed");
1562
1563        let kept_evr = result
1564            .explained_variance_ratio
1565            .expect("evr should be present");
1566        assert_eq!(
1567            kept_evr.len(),
1568            2,
1569            "should keep 2 components above threshold"
1570        );
1571        assert!((kept_evr[0] - 0.5).abs() < 1e-10);
1572        assert!((kept_evr[1] - 0.3).abs() < 1e-10);
1573
1574        let kept_comps = result.components.expect("components should be present");
1575        assert_eq!(
1576            kept_comps.ncols(),
1577            2,
1578            "component matrix should have 2 columns"
1579        );
1580    }
1581
1582    /// rotation with varimax should at least run and mark metadata.
1583    #[test]
1584    fn test_rotation_applies_to_components() {
1585        use crate::modular_framework::{DecompositionComponents, PostprocessingStep};
1586        use scirs2_core::ndarray::Array2;
1587
1588        let comps = Array2::from_shape_vec(
1589            (5, 3),
1590            vec![
1591                0.9, 0.1, 0.2, 0.8, 0.2, 0.3, 0.7, 0.3, 0.1, 0.6, 0.4, 0.5, 0.5, 0.5, 0.4,
1592            ],
1593        )
1594        .expect("shape should match");
1595
1596        let dc = DecompositionComponents {
1597            components: Some(comps.clone()),
1598            ..Default::default()
1599        };
1600
1601        let step = RotationStep::new("varimax".to_string());
1602        let result = step.process(dc).expect("rotation should succeed");
1603
1604        assert_eq!(
1605            result.metadata.get("rotation_applied"),
1606            Some(&"varimax".to_string()),
1607            "metadata should record rotation method"
1608        );
1609        // Components should still have same dimensions
1610        let rotated = result.components.expect("components should be present");
1611        assert_eq!(rotated.dim(), comps.dim());
1612    }
1613
1614    /// postprocessing_steps are applied during fit().
1615    #[test]
1616    fn test_postprocessing_steps_applied_in_fit() {
1617        use scirs2_core::ndarray::Array2;
1618
1619        let data = Array2::from_shape_vec((20, 5), (0..100).map(|x| x as Float).collect())
1620            .expect("shape should match");
1621
1622        // Build pipeline with variance threshold of 0.5 — should filter most components
1623        // (PCAAlgorithm returns empty DecompositionComponents so evr is None, but the
1624        //  postprocessing is still called — verify get_components() returns Ok)
1625        let mut pipeline = DecompositionPipelineBuilder::new()
1626            .with_pca(3)
1627            .with_variance_threshold(0.01)
1628            .build()
1629            .expect("build should succeed");
1630
1631        pipeline.fit(&data).expect("fit should succeed");
1632
1633        // get_components should return cached (post-processed) components
1634        let comps = pipeline.get_components();
1635        assert!(
1636            comps.is_ok(),
1637            "get_components should return Ok after fit with postprocessing"
1638        );
1639    }
1640}