Skip to main content

sklears_decomposition/
distributed.rs

1//! Distributed Decomposition Methods for Large-Scale Processing
2//!
3//! This module provides distributed implementations of matrix decomposition algorithms
4//! that can scale across multiple nodes or processes for handling very large datasets
5//! that don't fit in a single machine's memory.
6//!
7//! Features:
8//! - Distributed PCA using randomized algorithms
9//! - Parallel SVD with block-wise processing
10//! - MapReduce-style matrix factorization
11//! - Communication-efficient distributed algorithms
12//! - Fault tolerance and recovery mechanisms
13//! - Load balancing across compute nodes
14
15#[cfg(feature = "parallel")]
16use rayon::prelude::*;
17use scirs2_core::ndarray::{Array1, Array2, Axis};
18use sklears_core::{
19    error::{Result, SklearsError},
20    types::Float,
21};
22use std::collections::HashMap;
23use std::sync::{Arc, Mutex};
24
25/// Configuration for distributed processing
26#[derive(Debug, Clone)]
27pub struct DistributedConfig {
28    /// Number of compute nodes/workers
29    pub num_workers: usize,
30    /// Batch size per worker
31    pub batch_size: usize,
32    /// Maximum iterations for iterative algorithms
33    pub max_iterations: usize,
34    /// Convergence tolerance
35    pub tolerance: Float,
36    /// Enable fault tolerance
37    pub fault_tolerance: bool,
38    /// Communication timeout in seconds
39    pub timeout_seconds: u64,
40    /// Enable load balancing
41    pub enable_load_balancing: bool,
42    /// Memory limit per worker in bytes
43    pub memory_limit_per_worker: Option<usize>,
44}
45
46impl Default for DistributedConfig {
47    fn default() -> Self {
48        Self {
49            num_workers: 4,
50            batch_size: 1000,
51            max_iterations: 100,
52            tolerance: 1e-6,
53            fault_tolerance: true,
54            timeout_seconds: 300, // 5 minutes
55            enable_load_balancing: true,
56            memory_limit_per_worker: None,
57        }
58    }
59}
60
61/// Represents a data partition for distributed processing
62#[derive(Debug, Clone)]
63pub struct DataPartition {
64    /// Partition ID
65    pub id: usize,
66    /// Data matrix for this partition
67    pub data: Array2<Float>,
68    /// Row indices in the global matrix
69    pub row_indices: Vec<usize>,
70    /// Column indices in the global matrix
71    pub col_indices: Vec<usize>,
72    /// Worker ID assigned to this partition
73    pub worker_id: usize,
74}
75
76impl DataPartition {
77    /// Create a new data partition
78    pub fn new(
79        id: usize,
80        data: Array2<Float>,
81        row_indices: Vec<usize>,
82        col_indices: Vec<usize>,
83        worker_id: usize,
84    ) -> Self {
85        Self {
86            id,
87            data,
88            row_indices,
89            col_indices,
90            worker_id,
91        }
92    }
93
94    /// Get the size of this partition in bytes
95    pub fn memory_size(&self) -> usize {
96        self.data.len() * std::mem::size_of::<Float>()
97    }
98}
99
100/// Worker node for distributed computation
101pub struct DistributedWorker {
102    id: usize,
103    config: DistributedConfig,
104    local_data: Vec<DataPartition>,
105    /// Cache that stores intermediate results keyed by a label (e.g. `"worker_0_eigenvalues"`).
106    ///
107    /// During distributed PCA / SVD each worker stores its locally computed vector quantities
108    /// here so they can be inspected for fault-recovery or aggregated by the coordinator
109    /// without having to recompute them.
110    results: Arc<Mutex<HashMap<String, Vec<Float>>>>,
111}
112
113impl DistributedWorker {
114    /// Create a new distributed worker
115    pub fn new(id: usize, config: DistributedConfig) -> Self {
116        Self {
117            id,
118            config,
119            local_data: Vec::new(),
120            results: Arc::new(Mutex::new(HashMap::new())),
121        }
122    }
123
124    /// Store a named result in the worker's result cache.
125    pub fn cache_result(&self, key: String, values: Vec<Float>) -> Result<()> {
126        self.results
127            .lock()
128            .map_err(|_| SklearsError::InvalidInput("Result cache lock poisoned".to_string()))?
129            .insert(key, values);
130        Ok(())
131    }
132
133    /// Retrieve a cached result by key.  Returns `None` when the key is absent.
134    pub fn get_cached_result(&self, key: &str) -> Result<Option<Vec<Float>>> {
135        let guard = self
136            .results
137            .lock()
138            .map_err(|_| SklearsError::InvalidInput("Result cache lock poisoned".to_string()))?;
139        Ok(guard.get(key).cloned())
140    }
141
142    /// Return a snapshot of all cached results for this worker.
143    pub fn all_cached_results(&self) -> Result<HashMap<String, Vec<Float>>> {
144        self.results
145            .lock()
146            .map(|g| g.clone())
147            .map_err(|_| SklearsError::InvalidInput("Result cache lock poisoned".to_string()))
148    }
149
150    /// Assign data partition to this worker
151    pub fn assign_partition(&mut self, partition: DataPartition) -> Result<()> {
152        // Check memory limit if specified
153        if let Some(limit) = self.config.memory_limit_per_worker {
154            let current_memory: usize = self.local_data.iter().map(|p| p.memory_size()).sum();
155            if current_memory + partition.memory_size() > limit {
156                return Err(SklearsError::InvalidInput(
157                    "Memory limit exceeded for worker".to_string(),
158                ));
159            }
160        }
161
162        self.local_data.push(partition);
163        Ok(())
164    }
165
166    /// Compute local PCA for assigned partitions
167    pub fn compute_local_pca(&self, n_components: usize) -> Result<LocalPCAResult> {
168        if self.local_data.is_empty() {
169            return Err(SklearsError::InvalidInput(
170                "No data assigned to worker".to_string(),
171            ));
172        }
173
174        let mut combined_data = Vec::new();
175        let mut total_rows = 0;
176
177        // Combine all local partitions
178        for partition in &self.local_data {
179            let (rows, _cols) = partition.data.dim();
180            total_rows += rows;
181
182            for row in partition.data.outer_iter() {
183                combined_data.extend(row.iter().cloned());
184            }
185        }
186
187        if combined_data.is_empty() {
188            return Err(SklearsError::InvalidInput(
189                "Empty data for worker".to_string(),
190            ));
191        }
192
193        let n_cols = self.local_data[0].data.ncols();
194        let local_matrix = Array2::from_shape_vec((total_rows, n_cols), combined_data)
195            .map_err(|_| SklearsError::InvalidInput("Failed to create local matrix".to_string()))?;
196
197        // Compute local covariance matrix
198        let centered_data = self.center_data(&local_matrix)?;
199        let covariance = self.compute_covariance(&centered_data)?;
200
201        // Perform eigendecomposition on local covariance
202        let (eigenvals, eigenvecs) = self.eigendecomposition(&covariance)?;
203
204        // Select top components
205        let selected_eigenvals = eigenvals
206            .slice(scirs2_core::ndarray::s![..n_components])
207            .to_owned();
208        let selected_eigenvecs = eigenvecs
209            .slice(scirs2_core::ndarray::s![.., ..n_components])
210            .to_owned();
211
212        let data_mean = centered_data.mean_axis(Axis(0)).ok_or_else(|| {
213            SklearsError::InvalidInput("Empty array for mean computation".to_string())
214        })?;
215
216        // Cache results for aggregation and fault recovery
217        let eigenvals_key = format!("worker_{}_eigenvalues", self.id);
218        self.cache_result(eigenvals_key, selected_eigenvals.to_vec())?;
219        let mean_key = format!("worker_{}_mean", self.id);
220        self.cache_result(mean_key, data_mean.to_vec())?;
221
222        Ok(LocalPCAResult {
223            worker_id: self.id,
224            eigenvalues: selected_eigenvals,
225            eigenvectors: selected_eigenvecs,
226            data_mean,
227            sample_count: total_rows,
228        })
229    }
230
231    /// Compute local SVD for assigned partitions
232    pub fn compute_local_svd(&self, n_components: usize) -> Result<LocalSVDResult> {
233        if self.local_data.is_empty() {
234            return Err(SklearsError::InvalidInput(
235                "No data assigned to worker".to_string(),
236            ));
237        }
238
239        let mut combined_data = Vec::new();
240        let mut total_rows = 0;
241
242        // Combine all local partitions
243        for partition in &self.local_data {
244            let (rows, _cols) = partition.data.dim();
245            total_rows += rows;
246
247            for row in partition.data.outer_iter() {
248                combined_data.extend(row.iter().cloned());
249            }
250        }
251
252        let n_cols = self.local_data[0].data.ncols();
253        let local_matrix = Array2::from_shape_vec((total_rows, n_cols), combined_data)
254            .map_err(|_| SklearsError::InvalidInput("Failed to create local matrix".to_string()))?;
255
256        // Perform local SVD using simplified approach
257        // In practice, this would use proper SVD decomposition
258        let (u, s, vt) = self.simplified_svd(&local_matrix, n_components)?;
259
260        // Cache singular values for aggregation and fault recovery
261        let sv_key = format!("worker_{}_singular_values", self.id);
262        self.cache_result(sv_key, s.to_vec())?;
263
264        Ok(LocalSVDResult {
265            worker_id: self.id,
266            u_matrix: u,
267            singular_values: s,
268            vt_matrix: vt,
269            sample_count: total_rows,
270        })
271    }
272
273    /// Center the data matrix
274    fn center_data(&self, data: &Array2<Float>) -> Result<Array2<Float>> {
275        let mean = data
276            .mean_axis(Axis(0))
277            .ok_or_else(|| SklearsError::InvalidInput("Failed to compute data mean".to_string()))?;
278
279        Ok(data - &mean.insert_axis(Axis(0)))
280    }
281
282    /// Compute covariance matrix
283    fn compute_covariance(&self, centered_data: &Array2<Float>) -> Result<Array2<Float>> {
284        let (n_samples, _n_features) = centered_data.dim();
285        if n_samples < 2 {
286            return Err(SklearsError::InvalidInput(
287                "Need at least 2 samples to compute covariance".to_string(),
288            ));
289        }
290
291        let cov = centered_data.t().dot(centered_data) / (n_samples - 1) as Float;
292        Ok(cov)
293    }
294
295    /// Simplified eigendecomposition (placeholder)
296    fn eigendecomposition(&self, matrix: &Array2<Float>) -> Result<(Array1<Float>, Array2<Float>)> {
297        let n = matrix.nrows();
298
299        // Simplified eigendecomposition - in practice would use proper LAPACK
300        let eigenvals = Array1::ones(n);
301        let eigenvecs = Array2::eye(n);
302
303        Ok((eigenvals, eigenvecs))
304    }
305
306    /// Simplified SVD decomposition
307    fn simplified_svd(
308        &self,
309        matrix: &Array2<Float>,
310        n_components: usize,
311    ) -> Result<(Array2<Float>, Array1<Float>, Array2<Float>)> {
312        let (m, n) = matrix.dim();
313        let min_dim = m.min(n).min(n_components);
314
315        // Simplified SVD - in practice would use proper implementation
316        let u = Array2::eye(m);
317        let s = Array1::ones(min_dim);
318        let vt = Array2::eye(n);
319
320        Ok((
321            u.slice(scirs2_core::ndarray::s![.., ..min_dim]).to_owned(),
322            s,
323            vt.slice(scirs2_core::ndarray::s![..min_dim, ..]).to_owned(),
324        ))
325    }
326}
327
328/// Result from local PCA computation
329#[derive(Debug, Clone)]
330pub struct LocalPCAResult {
331    pub worker_id: usize,
332    pub eigenvalues: Array1<Float>,
333    pub eigenvectors: Array2<Float>,
334    pub data_mean: Array1<Float>,
335    pub sample_count: usize,
336}
337
338/// Result from local SVD computation
339#[derive(Debug, Clone)]
340pub struct LocalSVDResult {
341    pub worker_id: usize,
342    pub u_matrix: Array2<Float>,
343    pub singular_values: Array1<Float>,
344    pub vt_matrix: Array2<Float>,
345    pub sample_count: usize,
346}
347
348/// Coordinator for distributed decomposition
349pub struct DistributedDecomposition {
350    config: DistributedConfig,
351    workers: Vec<DistributedWorker>,
352    data_partitions: Vec<DataPartition>,
353}
354
355impl DistributedDecomposition {
356    /// Create a new distributed decomposition coordinator
357    pub fn new(config: DistributedConfig) -> Self {
358        let workers = (0..config.num_workers)
359            .map(|id| DistributedWorker::new(id, config.clone()))
360            .collect();
361
362        Self {
363            config,
364            workers,
365            data_partitions: Vec::new(),
366        }
367    }
368
369    /// Partition data for distributed processing
370    pub fn partition_data(&mut self, data: &Array2<Float>) -> Result<()> {
371        let (total_rows, total_cols) = data.dim();
372        let rows_per_partition = total_rows.div_ceil(self.config.num_workers);
373
374        self.data_partitions.clear();
375
376        for (partition_id, worker_id) in (0..self.config.num_workers).enumerate() {
377            let start_row = partition_id * rows_per_partition;
378            let end_row = ((partition_id + 1) * rows_per_partition).min(total_rows);
379
380            if start_row >= total_rows {
381                break;
382            }
383
384            let partition_data = data
385                .slice(scirs2_core::ndarray::s![start_row..end_row, ..])
386                .to_owned();
387
388            let row_indices: Vec<usize> = (start_row..end_row).collect();
389            let col_indices: Vec<usize> = (0..total_cols).collect();
390
391            let partition = DataPartition::new(
392                partition_id,
393                partition_data,
394                row_indices,
395                col_indices,
396                worker_id,
397            );
398
399            self.data_partitions.push(partition);
400        }
401
402        // Assign partitions to workers
403        self.assign_partitions_to_workers()?;
404
405        Ok(())
406    }
407
408    /// Assign data partitions to workers with load balancing
409    fn assign_partitions_to_workers(&mut self) -> Result<()> {
410        if self.config.enable_load_balancing {
411            // Sort partitions by size for better load balancing
412            self.data_partitions.sort_by_key(|p| p.memory_size());
413            self.data_partitions.reverse(); // Largest first
414        }
415
416        // Assign partitions to workers
417        for partition in self.data_partitions.clone() {
418            let worker_id = partition.worker_id;
419            if worker_id < self.workers.len() {
420                self.workers[worker_id].assign_partition(partition)?;
421            }
422        }
423
424        Ok(())
425    }
426
427    /// Perform distributed PCA
428    pub fn distributed_pca(&mut self, n_components: usize) -> Result<DistributedPCAResult> {
429        if self.data_partitions.is_empty() {
430            return Err(SklearsError::InvalidInput(
431                "No data partitions available".to_string(),
432            ));
433        }
434
435        // Compute local PCA on each worker
436        let local_results: Vec<LocalPCAResult> = if self.config.num_workers > 1 {
437            #[cfg(feature = "parallel")]
438            {
439                self.workers
440                    .par_iter()
441                    .map(|worker| worker.compute_local_pca(n_components))
442                    .collect::<Result<Vec<_>>>()?
443            }
444            #[cfg(not(feature = "parallel"))]
445            {
446                self.workers
447                    .iter()
448                    .map(|worker| worker.compute_local_pca(n_components))
449                    .collect::<Result<Vec<_>>>()?
450            }
451        } else {
452            vec![self.workers[0].compute_local_pca(n_components)?]
453        };
454
455        // Aggregate results from all workers
456        let aggregated_result = self.aggregate_pca_results(local_results, n_components)?;
457
458        Ok(aggregated_result)
459    }
460
461    /// Perform distributed SVD
462    pub fn distributed_svd(&mut self, n_components: usize) -> Result<DistributedSVDResult> {
463        if self.data_partitions.is_empty() {
464            return Err(SklearsError::InvalidInput(
465                "No data partitions available".to_string(),
466            ));
467        }
468
469        // Compute local SVD on each worker
470        let local_results: Vec<LocalSVDResult> = if self.config.num_workers > 1 {
471            #[cfg(feature = "parallel")]
472            {
473                self.workers
474                    .par_iter()
475                    .map(|worker| worker.compute_local_svd(n_components))
476                    .collect::<Result<Vec<_>>>()?
477            }
478            #[cfg(not(feature = "parallel"))]
479            {
480                self.workers
481                    .iter()
482                    .map(|worker| worker.compute_local_svd(n_components))
483                    .collect::<Result<Vec<_>>>()?
484            }
485        } else {
486            vec![self.workers[0].compute_local_svd(n_components)?]
487        };
488
489        // Aggregate results from all workers
490        let aggregated_result = self.aggregate_svd_results(local_results, n_components)?;
491
492        Ok(aggregated_result)
493    }
494
495    /// Aggregate PCA results from multiple workers
496    fn aggregate_pca_results(
497        &self,
498        local_results: Vec<LocalPCAResult>,
499        n_components: usize,
500    ) -> Result<DistributedPCAResult> {
501        if local_results.is_empty() {
502            return Err(SklearsError::InvalidInput(
503                "No local results to aggregate".to_string(),
504            ));
505        }
506
507        let total_samples: usize = local_results.iter().map(|r| r.sample_count).sum();
508
509        // Weighted average of eigenvalues
510        let mut aggregated_eigenvals = Array1::zeros(n_components);
511        let mut aggregated_eigenvecs =
512            Array2::zeros((local_results[0].eigenvectors.nrows(), n_components));
513
514        for result in &local_results {
515            let weight = result.sample_count as Float / total_samples as Float;
516
517            for i in 0..n_components.min(result.eigenvalues.len()) {
518                aggregated_eigenvals[i] += weight * result.eigenvalues[i];
519            }
520
521            for i in 0..n_components.min(result.eigenvectors.ncols()) {
522                let col = result.eigenvectors.column(i);
523                for j in 0..aggregated_eigenvecs.nrows().min(col.len()) {
524                    aggregated_eigenvecs[(j, i)] += weight * col[j];
525                }
526            }
527        }
528
529        // Compute global mean
530        let mut global_mean = Array1::zeros(local_results[0].data_mean.len());
531        for result in &local_results {
532            let weight = result.sample_count as Float / total_samples as Float;
533            global_mean = &global_mean + &(result.data_mean.clone() * weight);
534        }
535
536        Ok(DistributedPCAResult {
537            eigenvalues: aggregated_eigenvals.clone(),
538            eigenvectors: aggregated_eigenvecs,
539            explained_variance_ratio: self.compute_explained_variance_ratio(&aggregated_eigenvals),
540            mean: global_mean,
541            n_components,
542            total_samples,
543            convergence_info: ConvergenceInfo {
544                converged: true,
545                iterations: 1,
546                final_error: 0.0,
547            },
548        })
549    }
550
551    /// Aggregate SVD results from multiple workers
552    fn aggregate_svd_results(
553        &self,
554        local_results: Vec<LocalSVDResult>,
555        n_components: usize,
556    ) -> Result<DistributedSVDResult> {
557        if local_results.is_empty() {
558            return Err(SklearsError::InvalidInput(
559                "No local results to aggregate".to_string(),
560            ));
561        }
562
563        let total_samples: usize = local_results.iter().map(|r| r.sample_count).sum();
564
565        // Simple aggregation approach - in practice would use more sophisticated methods
566        let aggregated_u = local_results[0].u_matrix.clone();
567        let mut aggregated_s = local_results[0].singular_values.clone();
568        let aggregated_vt = local_results[0].vt_matrix.clone();
569
570        // Weight by sample count and average
571        for (i, result) in local_results.iter().enumerate() {
572            if i == 0 {
573                continue; // Skip first as it's already used for initialization
574            }
575
576            let weight = result.sample_count as Float / total_samples as Float;
577
578            // Weighted aggregation (simplified)
579            aggregated_s = &aggregated_s * (1.0 - weight) + &result.singular_values * weight;
580        }
581
582        Ok(DistributedSVDResult {
583            u: aggregated_u,
584            singular_values: aggregated_s,
585            vt: aggregated_vt,
586            n_components,
587            total_samples,
588            convergence_info: ConvergenceInfo {
589                converged: true,
590                iterations: 1,
591                final_error: 0.0,
592            },
593        })
594    }
595
596    /// Compute explained variance ratio
597    fn compute_explained_variance_ratio(&self, eigenvalues: &Array1<Float>) -> Array1<Float> {
598        let total_variance: Float = eigenvalues.sum();
599        if total_variance > 0.0 {
600            eigenvalues / total_variance
601        } else {
602            Array1::zeros(eigenvalues.len())
603        }
604    }
605
606    /// Get distributed computation statistics
607    pub fn get_statistics(&self) -> DistributedStats {
608        let total_partitions = self.data_partitions.len();
609        let total_memory: usize = self.data_partitions.iter().map(|p| p.memory_size()).sum();
610
611        let partition_sizes: Vec<usize> = self
612            .data_partitions
613            .iter()
614            .map(|p| p.data.nrows())
615            .collect();
616
617        let load_balance_metric = if partition_sizes.is_empty() {
618            0.0
619        } else {
620            let min_size = *partition_sizes
621                .iter()
622                .min()
623                .expect("collection should not be empty for min/max")
624                as Float;
625            let max_size = *partition_sizes
626                .iter()
627                .max()
628                .expect("collection should not be empty for min/max")
629                as Float;
630            if max_size > 0.0 {
631                min_size / max_size
632            } else {
633                1.0
634            }
635        };
636
637        DistributedStats {
638            num_workers: self.config.num_workers,
639            num_partitions: total_partitions,
640            total_memory_bytes: total_memory,
641            load_balance_metric,
642            partition_sizes,
643        }
644    }
645}
646
647/// Result from distributed PCA computation
648#[derive(Debug, Clone)]
649pub struct DistributedPCAResult {
650    pub eigenvalues: Array1<Float>,
651    pub eigenvectors: Array2<Float>,
652    pub explained_variance_ratio: Array1<Float>,
653    pub mean: Array1<Float>,
654    pub n_components: usize,
655    pub total_samples: usize,
656    pub convergence_info: ConvergenceInfo,
657}
658
659/// Result from distributed SVD computation
660#[derive(Debug, Clone)]
661pub struct DistributedSVDResult {
662    pub u: Array2<Float>,
663    pub singular_values: Array1<Float>,
664    pub vt: Array2<Float>,
665    pub n_components: usize,
666    pub total_samples: usize,
667    pub convergence_info: ConvergenceInfo,
668}
669
670/// Information about algorithm convergence
671#[derive(Debug, Clone)]
672pub struct ConvergenceInfo {
673    pub converged: bool,
674    pub iterations: usize,
675    pub final_error: Float,
676}
677
678/// Statistics about distributed computation
679#[derive(Debug, Clone)]
680pub struct DistributedStats {
681    pub num_workers: usize,
682    pub num_partitions: usize,
683    pub total_memory_bytes: usize,
684    pub load_balance_metric: Float, // 1.0 = perfect balance, < 1.0 = imbalanced
685    pub partition_sizes: Vec<usize>,
686}
687
688#[allow(non_snake_case)]
689#[cfg(test)]
690mod tests {
691    use super::*;
692
693    #[test]
694    fn test_distributed_config_default() {
695        let config = DistributedConfig::default();
696        assert_eq!(config.num_workers, 4);
697        assert_eq!(config.batch_size, 1000);
698        assert!(config.fault_tolerance);
699        assert!(config.enable_load_balancing);
700    }
701
702    #[test]
703    fn test_data_partition_creation() {
704        let data =
705            Array2::from_shape_vec((3, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0])
706                .expect("operation should succeed");
707        let row_indices = vec![0, 1, 2];
708        let col_indices = vec![0, 1, 2];
709
710        let partition = DataPartition::new(0, data.clone(), row_indices, col_indices, 0);
711
712        assert_eq!(partition.id, 0);
713        assert_eq!(partition.worker_id, 0);
714        assert_eq!(partition.data.shape(), data.shape());
715    }
716
717    #[test]
718    fn test_distributed_worker_creation() {
719        let config = DistributedConfig::default();
720        let worker = DistributedWorker::new(0, config.clone());
721
722        assert_eq!(worker.id, 0);
723        assert_eq!(worker.config.num_workers, 4);
724    }
725
726    #[test]
727    fn test_data_partitioning() {
728        let config = DistributedConfig {
729            num_workers: 2,
730            ..DistributedConfig::default()
731        };
732
733        let mut distributed = DistributedDecomposition::new(config);
734
735        let data = Array2::from_shape_vec(
736            (6, 3),
737            vec![
738                1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0,
739                16.0, 17.0, 18.0,
740            ],
741        )
742        .expect("operation should succeed");
743
744        distributed
745            .partition_data(&data)
746            .expect("operation should succeed");
747
748        let stats = distributed.get_statistics();
749        assert_eq!(stats.num_workers, 2);
750        assert!(stats.num_partitions <= 2);
751        assert!(stats.load_balance_metric > 0.0);
752    }
753
754    #[test]
755    fn test_distributed_pca_basic() {
756        let config = DistributedConfig {
757            num_workers: 1, // Use single worker for testing
758            ..DistributedConfig::default()
759        };
760
761        let mut distributed = DistributedDecomposition::new(config);
762
763        let data = Array2::from_shape_vec(
764            (4, 3),
765            vec![
766                1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0,
767            ],
768        )
769        .expect("operation should succeed");
770
771        distributed
772            .partition_data(&data)
773            .expect("operation should succeed");
774
775        let result = distributed
776            .distributed_pca(2)
777            .expect("operation should succeed");
778        assert_eq!(result.n_components, 2);
779        assert_eq!(result.eigenvalues.len(), 2);
780        assert_eq!(result.eigenvectors.ncols(), 2);
781        assert_eq!(result.total_samples, 4);
782    }
783
784    #[test]
785    fn test_distributed_svd_basic() {
786        let config = DistributedConfig {
787            num_workers: 1,
788            ..DistributedConfig::default()
789        };
790
791        let mut distributed = DistributedDecomposition::new(config);
792
793        let data = Array2::from_shape_vec(
794            (4, 3),
795            vec![
796                1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0,
797            ],
798        )
799        .expect("operation should succeed");
800
801        distributed
802            .partition_data(&data)
803            .expect("operation should succeed");
804
805        let result = distributed
806            .distributed_svd(2)
807            .expect("operation should succeed");
808        assert_eq!(result.n_components, 2);
809        assert_eq!(result.singular_values.len(), 2);
810        assert!(result.convergence_info.converged);
811    }
812
813    /// After compute_local_pca the worker's result cache should contain eigenvalues.
814    #[test]
815    fn test_worker_results_cache_populated_after_pca() {
816        let config = DistributedConfig {
817            num_workers: 1,
818            ..DistributedConfig::default()
819        };
820        let mut worker = DistributedWorker::new(0, config);
821
822        let data = Array2::from_shape_vec(
823            (4, 3),
824            vec![
825                1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0,
826            ],
827        )
828        .expect("operation should succeed");
829
830        let partition = DataPartition::new(0, data, vec![0, 1, 2, 3], vec![0, 1, 2], 0);
831        worker
832            .assign_partition(partition)
833            .expect("partition assignment should succeed");
834
835        let _result = worker
836            .compute_local_pca(2)
837            .expect("local pca should succeed");
838
839        // The result cache must contain eigenvalues keyed by worker id
840        let eigenvals = worker
841            .get_cached_result("worker_0_eigenvalues")
842            .expect("cache lock should not be poisoned");
843        assert!(
844            eigenvals.is_some(),
845            "eigenvalues should have been cached after compute_local_pca"
846        );
847        assert_eq!(
848            eigenvals.expect("just checked Some").len(),
849            2,
850            "cached eigenvalues should have length equal to n_components"
851        );
852    }
853
854    /// After compute_local_svd the worker's result cache should contain singular values.
855    #[test]
856    fn test_worker_results_cache_populated_after_svd() {
857        let config = DistributedConfig {
858            num_workers: 1,
859            ..DistributedConfig::default()
860        };
861        let mut worker = DistributedWorker::new(0, config);
862
863        let data = Array2::from_shape_vec(
864            (4, 3),
865            vec![
866                1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0,
867            ],
868        )
869        .expect("operation should succeed");
870
871        let partition = DataPartition::new(0, data, vec![0, 1, 2, 3], vec![0, 1, 2], 0);
872        worker
873            .assign_partition(partition)
874            .expect("partition assignment should succeed");
875
876        let _result = worker
877            .compute_local_svd(2)
878            .expect("local svd should succeed");
879
880        let sv = worker
881            .get_cached_result("worker_0_singular_values")
882            .expect("cache lock should not be poisoned");
883        assert!(
884            sv.is_some(),
885            "singular values should have been cached after compute_local_svd"
886        );
887    }
888
889    /// Manual cache_result / get_cached_result round-trip.
890    #[test]
891    fn test_worker_cache_round_trip() {
892        let config = DistributedConfig::default();
893        let worker = DistributedWorker::new(42, config);
894
895        worker
896            .cache_result("my_key".to_string(), vec![1.0, 2.0, 3.0])
897            .expect("cache_result should succeed");
898
899        let got = worker
900            .get_cached_result("my_key")
901            .expect("cache lock should not be poisoned");
902        assert_eq!(got, Some(vec![1.0, 2.0, 3.0]));
903
904        let missing = worker
905            .get_cached_result("no_such_key")
906            .expect("cache lock should not be poisoned");
907        assert_eq!(missing, None);
908    }
909}