Skip to main content

scirs2_stats/
scipy_benchmark_framework.rs

1//! Comprehensive SciPy benchmark comparison framework
2//!
3//! This module provides a complete benchmarking framework to validate
4//! SciRS2 implementations against SciPy equivalents and measure performance.
5//!
6//! ## Features
7//!
8//! - Automated benchmarking against Python SciPy
9//! - Accuracy validation with configurable tolerances
10//! - Performance measurement and comparison
11//! - Comprehensive test data generation
12//! - Statistical significance testing
13//! - Detailed reporting and visualization
14
15use crate::error::{StatsError, StatsResult};
16use scirs2_core::ndarray::{Array1, Array2, ArrayView1};
17use serde::{Deserialize, Serialize};
18use std::collections::HashMap;
19use std::time::{Duration, Instant};
20
21/// Comprehensive benchmark framework for SciPy comparison
22#[derive(Debug)]
23pub struct ScipyBenchmarkFramework {
24    config: BenchmarkConfig,
25    results_cache: HashMap<String, BenchmarkResult>,
26    testdata_generator: TestDataGenerator,
27}
28
29/// Configuration for benchmark comparisons
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct BenchmarkConfig {
32    /// Absolute tolerance for numerical comparisons
33    pub absolute_tolerance: f64,
34    /// Relative tolerance for numerical comparisons  
35    pub relative_tolerance: f64,
36    /// Number of performance test iterations
37    pub performance_iterations: usize,
38    /// Number of warmup iterations before timing
39    pub warmup_iterations: usize,
40    /// Maximum allowed performance regression (ratio)
41    pub max_performance_regression: f64,
42    /// Test data sizes to benchmark
43    pub testsizes: Vec<usize>,
44    /// Enable detailed statistical analysis
45    pub enable_statistical_tests: bool,
46    /// Path to Python SciPy reference implementation
47    pub scipy_reference_path: Option<String>,
48}
49
50/// Result of a benchmark comparison
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct BenchmarkResult {
53    /// Function name being benchmarked
54    pub function_name: String,
55    /// Test data size
56    pub datasize: usize,
57    /// Accuracy comparison results
58    pub accuracy: AccuracyComparison,
59    /// Performance comparison results
60    pub performance: PerformanceComparison,
61    /// Overall benchmark status
62    pub status: BenchmarkStatus,
63    /// Timestamp of benchmark execution
64    pub timestamp: chrono::DateTime<chrono::Utc>,
65}
66
67/// Accuracy comparison between SciRS2 and SciPy
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct AccuracyComparison {
70    /// Maximum absolute difference
71    pub max_abs_difference: f64,
72    /// Mean absolute difference
73    pub mean_abs_difference: f64,
74    /// Relative error (L2 norm)
75    pub relativeerror: f64,
76    /// Number of values that differ beyond tolerance
77    pub outlier_count: usize,
78    /// Accuracy grade (A-F scale)
79    pub accuracy_grade: AccuracyGrade,
80    /// Pass/fail status
81    pub passes_tolerance: bool,
82}
83
84/// Performance comparison between SciRS2 and SciPy
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct PerformanceComparison {
87    /// SciRS2 execution time statistics
88    pub scirs2_timing: TimingStatistics,
89    /// SciPy execution time statistics (if available)
90    pub scipy_timing: Option<TimingStatistics>,
91    /// Performance ratio (SciRS2 / SciPy)
92    pub performance_ratio: Option<f64>,
93    /// Performance grade (A-F scale)
94    pub performance_grade: PerformanceGrade,
95    /// Memory usage comparison
96    pub memory_usage: MemoryComparison,
97}
98
99/// Timing statistics for performance measurement
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct TimingStatistics {
102    /// Mean execution time
103    pub mean: Duration,
104    /// Standard deviation of execution times
105    pub std_dev: Duration,
106    /// Minimum execution time
107    pub min: Duration,
108    /// Maximum execution time
109    pub max: Duration,
110    /// 50th percentile (median)
111    pub p50: Duration,
112    /// 95th percentile
113    pub p95: Duration,
114    /// 99th percentile
115    pub p99: Duration,
116}
117
118/// Memory usage comparison
119///
120/// Populated from real resident-memory (RSS) samples taken immediately before and
121/// after each timed iteration when the crate's `memory_tracking` feature is enabled
122/// (see the internal `ScipyBenchmarkFramework::measure_timing` helper). Without that
123/// feature, both fields are honest zeros rather than fabricated numbers.
124///
125/// RSS deltas are inherently approximate: the OS does not always reclaim freed pages
126/// immediately, and other allocator/thread activity in the process can perturb an
127/// individual sample. Treat these figures as directional evidence of memory pressure
128/// rather than exact byte counts.
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct MemoryComparison {
131    /// Peak memory usage (bytes) — the largest single-iteration RSS delta observed
132    pub peak_memory: usize,
133    /// Average memory usage during execution (bytes) — mean RSS delta across iterations
134    pub average_memory: usize,
135    /// Memory efficiency ratio vs SciPy (SciRS2 average memory / SciPy average memory)
136    pub efficiency_ratio: Option<f64>,
137}
138
139/// Accuracy grading scale
140#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
141pub enum AccuracyGrade {
142    /// Excellent accuracy (< 1e-12 error)
143    A,
144    /// Very good accuracy (< 1e-9 error)
145    B,
146    /// Good accuracy (< 1e-6 error)
147    C,
148    /// Acceptable accuracy (< 1e-3 error)
149    D,
150    /// Poor accuracy (> 1e-3 error)
151    F,
152}
153
154/// Performance grading scale
155#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
156pub enum PerformanceGrade {
157    /// Excellent performance (> 2x faster than SciPy)
158    A,
159    /// Very good performance (1.5-2x faster)
160    B,
161    /// Good performance (0.8-1.5x)
162    C,
163    /// Acceptable performance (0.5-0.8x)
164    D,
165    /// Poor performance (< 0.5x SciPy speed)
166    F,
167}
168
169/// Overall benchmark status
170#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
171pub enum BenchmarkStatus {
172    /// Both accuracy and performance meet requirements
173    Pass,
174    /// Accuracy meets requirements but performance issues
175    AccuracyPass,
176    /// Performance meets requirements but accuracy issues
177    PerformancePass,
178    /// Neither accuracy nor performance meet requirements
179    Fail,
180    /// Benchmark could not be completed
181    Error,
182}
183
184/// Test data generator for benchmarks
185#[derive(Debug)]
186pub struct TestDataGenerator {
187    config: TestDataConfig,
188}
189
190/// Configuration for test data generation
191#[derive(Debug, Clone)]
192pub struct TestDataConfig {
193    /// Random seed for reproducible tests
194    pub seed: u64,
195    /// Generate edge cases (inf, nan, very large/small values)
196    pub include_edge_cases: bool,
197    /// Distribution of test data
198    pub data_distribution: DataDistribution,
199}
200
201/// Distribution types for test data
202#[derive(Debug, Clone)]
203pub enum DataDistribution {
204    /// Standard normal distribution
205    Normal,
206    /// Uniform distribution in range
207    Uniform { min: f64, max: f64 },
208    /// Exponential distribution
209    Exponential { lambda: f64 },
210    /// Mixed distribution combining multiple types
211    Mixed(Vec<DataDistribution>),
212}
213
214impl Default for BenchmarkConfig {
215    fn default() -> Self {
216        Self {
217            absolute_tolerance: 1e-12,
218            relative_tolerance: 1e-9,
219            performance_iterations: 100,
220            warmup_iterations: 10,
221            max_performance_regression: 2.0, // Allow 2x slower than SciPy
222            testsizes: vec![100, 1000, 10000, 100000],
223            enable_statistical_tests: true,
224            scipy_reference_path: None,
225        }
226    }
227}
228
229impl Default for TestDataConfig {
230    fn default() -> Self {
231        Self {
232            seed: 42,
233            include_edge_cases: true,
234            data_distribution: DataDistribution::Normal,
235        }
236    }
237}
238
239impl ScipyBenchmarkFramework {
240    /// Create a new benchmark framework
241    pub fn new(config: BenchmarkConfig) -> Self {
242        Self {
243            config,
244            results_cache: HashMap::new(),
245            testdata_generator: TestDataGenerator::new(TestDataConfig::default()),
246        }
247    }
248
249    /// Create framework with default configuration
250    pub fn default() -> Self {
251        Self::new(BenchmarkConfig::default())
252    }
253
254    /// Run comprehensive benchmark for a statistical function
255    pub fn benchmark_function<F, G>(
256        &mut self,
257        function_name: &str,
258        scirs2_impl: F,
259        scipy_reference: G,
260    ) -> StatsResult<Vec<BenchmarkResult>>
261    where
262        F: Fn(&ArrayView1<f64>) -> StatsResult<f64>,
263        G: Fn(&ArrayView1<f64>) -> f64,
264    {
265        let mut results = Vec::new();
266
267        for &size in &self.config.testsizes {
268            let testdata = self.testdata_generator.generate_1ddata(size)?;
269
270            // Run accuracy comparison
271            let accuracy =
272                self.compare_accuracy(&scirs2_impl, &scipy_reference, &testdata.view())?;
273
274            // Run performance comparison
275            let performance =
276                self.compare_performance(&scirs2_impl, Some(&scipy_reference), &testdata.view())?;
277
278            // Determine overall status
279            let status = self.determine_status(&accuracy, &performance);
280
281            let result = BenchmarkResult {
282                function_name: function_name.to_string(),
283                datasize: size,
284                accuracy,
285                performance,
286                status,
287                timestamp: chrono::Utc::now(),
288            };
289
290            results.push(result.clone());
291            self.results_cache
292                .insert(format!("{}_{}", function_name, size), result);
293        }
294
295        Ok(results)
296    }
297
298    /// Compare accuracy between implementations
299    fn compare_accuracy<F, G>(
300        &self,
301        scirs2_impl: &F,
302        scipy_reference: &G,
303        testdata: &ArrayView1<f64>,
304    ) -> StatsResult<AccuracyComparison>
305    where
306        F: Fn(&ArrayView1<f64>) -> StatsResult<f64>,
307        G: Fn(&ArrayView1<f64>) -> f64,
308    {
309        let scirs2_result = scirs2_impl(testdata)?;
310        let scipy_result = scipy_reference(testdata);
311
312        // Edge-case datasets (see generate_1ddata) deliberately inject NaN/Inf,
313        // and the two implementations are expected to propagate them the same
314        // way. An arithmetic difference against a NaN operand is itself NaN,
315        // which fails every `<=` comparison below even when both sides agree
316        // exactly — so non-finite results must be compared structurally
317        // instead of arithmetically.
318        if !scirs2_result.is_finite() || !scipy_result.is_finite() {
319            let agree =
320                (scirs2_result.is_nan() && scipy_result.is_nan()) || scirs2_result == scipy_result; // handles matching +-inf
321            return Ok(AccuracyComparison {
322                max_abs_difference: if agree { 0.0 } else { f64::INFINITY },
323                mean_abs_difference: if agree { 0.0 } else { f64::INFINITY },
324                relativeerror: if agree { 0.0 } else { f64::INFINITY },
325                outlier_count: if agree { 0 } else { 1 },
326                accuracy_grade: if agree {
327                    AccuracyGrade::A
328                } else {
329                    AccuracyGrade::F
330                },
331                passes_tolerance: agree,
332            });
333        }
334
335        let abs_difference = (scirs2_result - scipy_result).abs();
336        let relativeerror = if scipy_result.abs() > 1e-15 {
337            abs_difference / scipy_result.abs()
338        } else {
339            abs_difference
340        };
341
342        let passes_tolerance = abs_difference <= self.config.absolute_tolerance
343            || relativeerror <= self.config.relative_tolerance;
344
345        let accuracy_grade = self.grade_accuracy(relativeerror);
346
347        Ok(AccuracyComparison {
348            max_abs_difference: abs_difference,
349            mean_abs_difference: abs_difference,
350            relativeerror,
351            outlier_count: if passes_tolerance { 0 } else { 1 },
352            accuracy_grade,
353            passes_tolerance,
354        })
355    }
356
357    /// Compare performance between implementations
358    fn compare_performance<F, G>(
359        &self,
360        scirs2_impl: &F,
361        scipy_reference: Option<&G>,
362        testdata: &ArrayView1<f64>,
363    ) -> StatsResult<PerformanceComparison>
364    where
365        F: Fn(&ArrayView1<f64>) -> StatsResult<f64>,
366        G: Fn(&ArrayView1<f64>) -> f64,
367    {
368        // Benchmark SciRS2 implementation (timing + resident-memory sampling)
369        let (scirs2_timing, scirs2_memory) =
370            self.measure_timing(|| scirs2_impl(testdata).map(|_| ()))?;
371
372        // Benchmark SciPy implementation if available (timing + resident-memory sampling)
373        let (scipy_timing, scipy_memory) = if let Some(scipy_func) = scipy_reference {
374            let (timing, memory) = self.measure_timing_scipy(|| {
375                scipy_func(testdata);
376            })?;
377            (Some(timing), Some(memory))
378        } else {
379            (None, None)
380        };
381
382        // Calculate performance ratio
383        let performance_ratio = scipy_timing
384            .as_ref()
385            .map(|scipy_stats| scirs2_timing.mean.as_secs_f64() / scipy_stats.mean.as_secs_f64());
386
387        let performance_grade = self.grade_performance(performance_ratio);
388
389        // Memory efficiency ratio (SciRS2 / SciPy average memory), mirroring how
390        // `performance_ratio` compares SciRS2 vs SciPy timing above. Only meaningful
391        // when a SciPy baseline measurement with nonzero average memory is available.
392        let efficiency_ratio = scipy_memory.as_ref().and_then(|scipy_mem| {
393            if scipy_mem.average_memory > 0 {
394                Some(scirs2_memory.average_memory as f64 / scipy_mem.average_memory as f64)
395            } else {
396                None
397            }
398        });
399
400        Ok(PerformanceComparison {
401            scirs2_timing,
402            scipy_timing,
403            performance_ratio,
404            performance_grade,
405            memory_usage: MemoryComparison {
406                peak_memory: scirs2_memory.peak_memory,
407                average_memory: scirs2_memory.average_memory,
408                efficiency_ratio,
409            },
410        })
411    }
412
413    /// Measure timing statistics for a function, together with resident-memory (RSS)
414    /// statistics sampled around each timed iteration.
415    ///
416    /// When the `memory_tracking` feature is enabled, [`scirs2_core::profiling::MemoryStats::current`]
417    /// (a Pure-Rust RSS profiler — Mach `task_info` on macOS, `/proc/self/statm` on Linux)
418    /// is sampled immediately before and after every timed call to `func`, and the
419    /// (saturating) per-iteration delta feeds the returned [`MemoryComparison`]. RSS
420    /// deltas are inherently approximate — the OS does not always reclaim freed pages
421    /// immediately, and other allocator/thread activity in the process can perturb a
422    /// given sample — so treat the reported figures as directional rather than exact.
423    ///
424    /// Without the `memory_tracking` feature, the memory component is an honest zero
425    /// (documented as such) rather than a fabricated measurement.
426    #[cfg(feature = "memory_tracking")]
427    fn measure_timing<F, R>(&self, mut func: F) -> StatsResult<(TimingStatistics, MemoryComparison)>
428    where
429        F: FnMut() -> StatsResult<R>,
430    {
431        use scirs2_core::profiling::MemoryStats;
432
433        let mut times = Vec::with_capacity(self.config.performance_iterations);
434        let mut memory_deltas = Vec::with_capacity(self.config.performance_iterations);
435
436        // Warmup iterations
437        for _ in 0..self.config.warmup_iterations {
438            func()?;
439        }
440
441        // Timed iterations, sampling RSS immediately before/after each call. The
442        // call's return value is deliberately kept alive (bound to `result`) until
443        // after the "after" sample, then dropped — so memory owned by the return
444        // value itself (e.g. a freshly allocated buffer) is captured in the delta
445        // instead of being silently freed before we get a chance to observe it.
446        for _ in 0..self.config.performance_iterations {
447            let before_resident = MemoryStats::current()?.resident;
448            let start = Instant::now();
449            let result = func()?;
450            let elapsed = start.elapsed();
451            let after_resident = MemoryStats::current()?.resident;
452            drop(result);
453
454            times.push(elapsed);
455            // Memory can also decrease between samples (deallocation, OS page
456            // reclamation); clamp negative deltas to 0 instead of treating them as
457            // meaningful growth (or wrapping, since these are unsigned byte counts).
458            memory_deltas.push(after_resident.saturating_sub(before_resident));
459        }
460
461        let timing_stats = self.calculate_timing_statistics(&times)?;
462        let memory_stats = Self::summarize_memory_deltas(&memory_deltas);
463
464        Ok((timing_stats, memory_stats))
465    }
466
467    /// Measure timing statistics for a function (memory-tracking disabled build).
468    ///
469    /// Real RSS-based memory tracking requires the `memory_tracking` feature (which
470    /// enables scirs2-core's Pure-Rust `profiling_memory` RSS profiler). Without it we
471    /// report honest zeros for memory rather than fabricating a measurement.
472    #[cfg(not(feature = "memory_tracking"))]
473    fn measure_timing<F, R>(&self, mut func: F) -> StatsResult<(TimingStatistics, MemoryComparison)>
474    where
475        F: FnMut() -> StatsResult<R>,
476    {
477        let mut times = Vec::with_capacity(self.config.performance_iterations);
478
479        // Warmup iterations
480        for _ in 0..self.config.warmup_iterations {
481            func()?;
482        }
483
484        // Timed iterations
485        for _ in 0..self.config.performance_iterations {
486            let start = Instant::now();
487            func()?;
488            let elapsed = start.elapsed();
489            times.push(elapsed);
490        }
491
492        let timing_stats = self.calculate_timing_statistics(&times)?;
493        // `memory_tracking` feature not enabled: report honest zeros rather than a
494        // fabricated measurement (see struct docs on `MemoryComparison`).
495        let memory_stats = MemoryComparison {
496            peak_memory: 0,
497            average_memory: 0,
498            efficiency_ratio: None,
499        };
500
501        Ok((timing_stats, memory_stats))
502    }
503
504    /// Measure timing (and RSS memory, when `memory_tracking` is enabled) for SciPy
505    /// functions (no `Result` handling). Mirrors [`Self::measure_timing`]'s loop
506    /// structure and sampling strategy.
507    #[cfg(feature = "memory_tracking")]
508    fn measure_timing_scipy<F>(
509        &self,
510        mut func: F,
511    ) -> StatsResult<(TimingStatistics, MemoryComparison)>
512    where
513        F: FnMut(),
514    {
515        use scirs2_core::profiling::MemoryStats;
516
517        let mut times = Vec::with_capacity(self.config.performance_iterations);
518        let mut memory_deltas = Vec::with_capacity(self.config.performance_iterations);
519
520        // Warmup iterations
521        for _ in 0..self.config.warmup_iterations {
522            func();
523        }
524
525        // Timed iterations, sampling RSS immediately before/after each call
526        for _ in 0..self.config.performance_iterations {
527            let before_resident = MemoryStats::current()?.resident;
528            let start = Instant::now();
529            func();
530            let elapsed = start.elapsed();
531            let after_resident = MemoryStats::current()?.resident;
532
533            times.push(elapsed);
534            memory_deltas.push(after_resident.saturating_sub(before_resident));
535        }
536
537        let timing_stats = self.calculate_timing_statistics(&times)?;
538        let memory_stats = Self::summarize_memory_deltas(&memory_deltas);
539
540        Ok((timing_stats, memory_stats))
541    }
542
543    /// Measure timing for SciPy functions (no `Result` handling; memory-tracking
544    /// disabled build — see [`Self::measure_timing`] for the rationale).
545    #[cfg(not(feature = "memory_tracking"))]
546    fn measure_timing_scipy<F>(
547        &self,
548        mut func: F,
549    ) -> StatsResult<(TimingStatistics, MemoryComparison)>
550    where
551        F: FnMut(),
552    {
553        let mut times = Vec::with_capacity(self.config.performance_iterations);
554
555        // Warmup iterations
556        for _ in 0..self.config.warmup_iterations {
557            func();
558        }
559
560        // Timed iterations
561        for _ in 0..self.config.performance_iterations {
562            let start = Instant::now();
563            func();
564            let elapsed = start.elapsed();
565            times.push(elapsed);
566        }
567
568        let timing_stats = self.calculate_timing_statistics(&times)?;
569        let memory_stats = MemoryComparison {
570            peak_memory: 0,
571            average_memory: 0,
572            efficiency_ratio: None,
573        };
574
575        Ok((timing_stats, memory_stats))
576    }
577
578    /// Fold a series of per-iteration RSS deltas (bytes) into a [`MemoryComparison`].
579    ///
580    /// `peak_memory` is the largest single-iteration delta (saturating growth only);
581    /// `average_memory` is the mean delta across all iterations. `efficiency_ratio` is
582    /// left `None` here — it is filled in by the caller once a SciPy baseline (if any)
583    /// is also available.
584    #[cfg(feature = "memory_tracking")]
585    fn summarize_memory_deltas(deltas: &[usize]) -> MemoryComparison {
586        let peak_memory = deltas.iter().copied().max().unwrap_or(0);
587        let average_memory = if deltas.is_empty() {
588            0
589        } else {
590            (deltas.iter().sum::<usize>() as f64 / deltas.len() as f64).round() as usize
591        };
592
593        MemoryComparison {
594            peak_memory,
595            average_memory,
596            efficiency_ratio: None,
597        }
598    }
599
600    /// Calculate timing statistics from raw measurements
601    fn calculate_timing_statistics(&self, times: &[Duration]) -> StatsResult<TimingStatistics> {
602        if times.is_empty() {
603            return Err(StatsError::InvalidInput(
604                "No timing measurements".to_string(),
605            ));
606        }
607
608        let mut sorted_times = times.to_vec();
609        sorted_times.sort();
610
611        let mean_nanos: f64 =
612            times.iter().map(|d| d.as_nanos() as f64).sum::<f64>() / times.len() as f64;
613        let mean = Duration::from_nanos(mean_nanos as u64);
614
615        let variance: f64 = times
616            .iter()
617            .map(|d| {
618                let diff = d.as_nanos() as f64 - mean_nanos;
619                diff * diff
620            })
621            .sum::<f64>()
622            / times.len() as f64;
623        let std_dev = Duration::from_nanos(variance.sqrt() as u64);
624
625        let p50_idx = times.len() / 2;
626        let p95_idx = (times.len() as f64 * 0.95) as usize;
627        let p99_idx = (times.len() as f64 * 0.99) as usize;
628
629        Ok(TimingStatistics {
630            mean,
631            std_dev,
632            min: sorted_times[0],
633            max: sorted_times[times.len() - 1],
634            p50: sorted_times[p50_idx],
635            p95: sorted_times[p95_idx.min(times.len() - 1)],
636            p99: sorted_times[p99_idx.min(times.len() - 1)],
637        })
638    }
639
640    /// Grade accuracy based on relative error
641    fn grade_accuracy(&self, relativeerror: f64) -> AccuracyGrade {
642        if relativeerror < 1e-12 {
643            AccuracyGrade::A
644        } else if relativeerror < 1e-9 {
645            AccuracyGrade::B
646        } else if relativeerror < 1e-6 {
647            AccuracyGrade::C
648        } else if relativeerror < 1e-3 {
649            AccuracyGrade::D
650        } else {
651            AccuracyGrade::F
652        }
653    }
654
655    /// Grade performance based on ratio to SciPy
656    fn grade_performance(&self, ratio: Option<f64>) -> PerformanceGrade {
657        match ratio {
658            Some(r) if r < 0.5 => PerformanceGrade::A,
659            Some(r) if r < 0.67 => PerformanceGrade::B,
660            Some(r) if r < 1.25 => PerformanceGrade::C,
661            Some(r) if r < 2.0 => PerformanceGrade::D,
662            Some(_) => PerformanceGrade::F,
663            None => PerformanceGrade::C, // No comparison available
664        }
665    }
666
667    /// Determine overall benchmark status
668    fn determine_status(
669        &self,
670        accuracy: &AccuracyComparison,
671        performance: &PerformanceComparison,
672    ) -> BenchmarkStatus {
673        let accuracy_pass = accuracy.passes_tolerance;
674        let performance_pass = matches!(
675            performance.performance_grade,
676            PerformanceGrade::A | PerformanceGrade::B | PerformanceGrade::C | PerformanceGrade::D
677        );
678
679        match (accuracy_pass, performance_pass) {
680            (true, true) => BenchmarkStatus::Pass,
681            (true, false) => BenchmarkStatus::AccuracyPass,
682            (false, true) => BenchmarkStatus::PerformancePass,
683            (false, false) => BenchmarkStatus::Fail,
684        }
685    }
686
687    /// Generate comprehensive benchmark report
688    pub fn generate_report(&self) -> BenchmarkReport {
689        let results: Vec<_> = self.results_cache.values().cloned().collect();
690
691        BenchmarkReport {
692            total_tests: results.len(),
693            passed_tests: results
694                .iter()
695                .filter(|r| r.status == BenchmarkStatus::Pass)
696                .count(),
697            failed_tests: results
698                .iter()
699                .filter(|r| r.status == BenchmarkStatus::Fail)
700                .count(),
701            results,
702            generated_at: chrono::Utc::now(),
703        }
704    }
705}
706
707impl TestDataGenerator {
708    /// Create a new test data generator
709    pub fn new(config: TestDataConfig) -> Self {
710        Self { config }
711    }
712
713    /// Generate 1D test data
714    pub fn generate_1ddata(&self, size: usize) -> StatsResult<Array1<f64>> {
715        use scirs2_core::random::prelude::*;
716        use scirs2_core::random::{Distribution, Normal, Uniform as UniformDist};
717
718        let mut rng = StdRng::seed_from_u64(self.config.seed);
719        let mut data = Array1::zeros(size);
720
721        match &self.config.data_distribution {
722            DataDistribution::Normal => {
723                let normal = Normal::new(0.0, 1.0).map_err(|e| {
724                    StatsError::InvalidInput(format!("Normal distribution error: {}", e))
725                })?;
726                for val in data.iter_mut() {
727                    *val = normal.sample(&mut rng);
728                }
729            }
730            DataDistribution::Uniform { min, max } => {
731                let uniform = UniformDist::new(*min, *max).expect("Operation failed");
732                for val in data.iter_mut() {
733                    *val = uniform.sample(&mut rng);
734                }
735            }
736            DataDistribution::Exponential { lambda } => {
737                for val in data.iter_mut() {
738                    *val = -lambda.ln() / rng.random::<f64>().ln();
739                }
740            }
741            DataDistribution::Mixed(_) => {
742                // Simplified: just use normal for now
743                let normal = Normal::new(0.0, 1.0).map_err(|e| {
744                    StatsError::InvalidInput(format!("Normal distribution error: {}", e))
745                })?;
746                for val in data.iter_mut() {
747                    *val = normal.sample(&mut rng);
748                }
749            }
750        }
751
752        // Add edge cases if requested
753        if self.config.include_edge_cases && size > 10 {
754            data[0] = f64::INFINITY;
755            data[1] = f64::NEG_INFINITY;
756            data[2] = f64::NAN;
757            data[3] = f64::MAX;
758            data[4] = f64::MIN;
759        }
760
761        Ok(data)
762    }
763
764    /// Generate 2D test data
765    pub fn generate_2ddata(&self, rows: usize, cols: usize) -> StatsResult<Array2<f64>> {
766        use scirs2_core::random::prelude::*;
767        use scirs2_core::random::{Distribution, Normal};
768
769        let mut rng = StdRng::seed_from_u64(self.config.seed);
770        let mut data = Array2::zeros((rows, cols));
771
772        let normal = Normal::new(0.0, 1.0)
773            .map_err(|e| StatsError::InvalidInput(format!("Normal distribution error: {}", e)))?;
774
775        for val in data.iter_mut() {
776            *val = normal.sample(&mut rng);
777        }
778
779        Ok(data)
780    }
781}
782
783/// Comprehensive benchmark report
784#[derive(Debug, Clone, Serialize, Deserialize)]
785pub struct BenchmarkReport {
786    /// Total number of tests run
787    pub total_tests: usize,
788    /// Number of tests that passed
789    pub passed_tests: usize,
790    /// Number of tests that failed
791    pub failed_tests: usize,
792    /// Detailed results for each test
793    pub results: Vec<BenchmarkResult>,
794    /// Timestamp when report was generated
795    pub generated_at: chrono::DateTime<chrono::Utc>,
796}
797
798impl BenchmarkReport {
799    /// Calculate overall pass rate
800    pub fn pass_rate(&self) -> f64 {
801        if self.total_tests == 0 {
802            0.0
803        } else {
804            self.passed_tests as f64 / self.total_tests as f64
805        }
806    }
807
808    /// Get summary statistics
809    pub fn summary(&self) -> BenchmarkSummary {
810        let accuracy_grades: Vec<_> = self
811            .results
812            .iter()
813            .map(|r| r.accuracy.accuracy_grade)
814            .collect();
815        let performance_grades: Vec<_> = self
816            .results
817            .iter()
818            .map(|r| r.performance.performance_grade)
819            .collect();
820
821        BenchmarkSummary {
822            pass_rate: self.pass_rate(),
823            average_accuracy_grade: self.average_accuracy_grade(&accuracy_grades),
824            average_performance_grade: self.average_performance_grade(&performance_grades),
825            total_runtime: self.total_runtime(),
826        }
827    }
828
829    fn average_accuracy_grade(&self, grades: &[AccuracyGrade]) -> AccuracyGrade {
830        // Simplified: just return most common grade
831        AccuracyGrade::C // Placeholder
832    }
833
834    fn average_performance_grade(&self, grades: &[PerformanceGrade]) -> PerformanceGrade {
835        // Simplified: just return most common grade
836        PerformanceGrade::C // Placeholder
837    }
838
839    fn total_runtime(&self) -> Duration {
840        // Sum all mean execution times
841        self.results
842            .iter()
843            .map(|r| r.performance.scirs2_timing.mean)
844            .sum()
845    }
846}
847
848/// Summary statistics for benchmark report
849#[derive(Debug, Clone)]
850pub struct BenchmarkSummary {
851    pub pass_rate: f64,
852    pub average_accuracy_grade: AccuracyGrade,
853    pub average_performance_grade: PerformanceGrade,
854    pub total_runtime: Duration,
855}
856
857#[cfg(test)]
858mod tests {
859    use super::*;
860    use crate::descriptive::mean;
861
862    #[test]
863    fn test_benchmark_framework_creation() {
864        let framework = ScipyBenchmarkFramework::default();
865        assert_eq!(framework.config.absolute_tolerance, 1e-12);
866        assert_eq!(framework.config.relative_tolerance, 1e-9);
867    }
868
869    #[test]
870    fn test_testdata_generation() {
871        let generator = TestDataGenerator::new(TestDataConfig::default());
872        let data = generator.generate_1ddata(100).expect("Operation failed");
873        assert_eq!(data.len(), 100);
874    }
875
876    #[test]
877    fn test_accuracy_grading() {
878        let framework = ScipyBenchmarkFramework::default();
879
880        assert_eq!(framework.grade_accuracy(1e-15), AccuracyGrade::A);
881        assert_eq!(framework.grade_accuracy(1e-10), AccuracyGrade::B);
882        assert_eq!(framework.grade_accuracy(1e-7), AccuracyGrade::C);
883        assert_eq!(framework.grade_accuracy(1e-4), AccuracyGrade::D);
884        assert_eq!(framework.grade_accuracy(1e-1), AccuracyGrade::F);
885    }
886
887    #[test]
888    fn test_performance_grading() {
889        let framework = ScipyBenchmarkFramework::default();
890
891        assert_eq!(framework.grade_performance(Some(0.3)), PerformanceGrade::A);
892        assert_eq!(framework.grade_performance(Some(0.6)), PerformanceGrade::B);
893        assert_eq!(framework.grade_performance(Some(1.0)), PerformanceGrade::C);
894        assert_eq!(framework.grade_performance(Some(1.5)), PerformanceGrade::D);
895        assert_eq!(framework.grade_performance(Some(3.0)), PerformanceGrade::F);
896        assert_eq!(framework.grade_performance(None), PerformanceGrade::C);
897    }
898
899    #[test]
900    fn test_benchmark_integration() {
901        let mut framework = ScipyBenchmarkFramework::new(BenchmarkConfig {
902            testsizes: vec![100],
903            performance_iterations: 5,
904            warmup_iterations: 1,
905            ..Default::default()
906        });
907
908        // Mock SciPy reference that matches our mean implementation
909        let scipy_mean = |data: &ArrayView1<f64>| -> f64 { data.sum() / data.len() as f64 };
910
911        let results = framework
912            .benchmark_function("mean", |data| mean(data), scipy_mean)
913            .expect("Operation failed");
914
915        assert_eq!(results.len(), 1);
916        assert_eq!(results[0].function_name, "mean");
917        assert!(results[0].accuracy.passes_tolerance);
918    }
919
920    // ------------------------------------------------------------------
921    // Real RSS memory-tracking tests (require the `memory_tracking` feature,
922    // e.g. `cargo test -p scirs2-stats --features memory_tracking`, or any
923    // invocation with `--all-features`).
924    //
925    // RSS sampling is page-granularity and OS/allocator-dependent (freed pages
926    // are not always reclaimed immediately), so these tests assert relative /
927    // ordering properties rather than exact byte counts.
928    // ------------------------------------------------------------------
929
930    /// Per-call growth size (~1.6 MiB of f64) for the monotonically-growing buffer
931    /// used by the memory-tracking tests below — big enough that its resident-memory
932    /// footprint is unambiguously distinguishable from sampling noise (page-granularity
933    /// jitter, allocator bookkeeping, etc).
934    #[cfg(feature = "memory_tracking")]
935    const MEMORY_TEST_GROWTH_LEN: usize = 200_000;
936
937    #[cfg(feature = "memory_tracking")]
938    #[test]
939    fn test_memory_tracking_allocating_closure_reports_nonzero_memory() {
940        let framework = ScipyBenchmarkFramework::new(BenchmarkConfig {
941            performance_iterations: 20,
942            warmup_iterations: 2,
943            ..Default::default()
944        });
945
946        // Deliberately *grow* a buffer captured by the (`FnMut`) closure on every call,
947        // rather than allocating-then-freeing a fresh same-sized `Vec` each time. The
948        // latter was tried first and reliably measured a peak/average of exactly 0 on
949        // macOS: `measure_timing`'s 2 warmup calls already prime the allocator's
950        // same-size free list/large-allocation cache, so every "after" sample in the
951        // measured loop finds the identical (already-resident) pages reused for the
952        // new allocation, showing zero incremental RSS growth. A buffer that only ever
953        // grows (never freed until the closure itself drops at the end of this test)
954        // sidesteps that reuse entirely and gives a deterministic, platform-independent
955        // nonzero delta on every iteration.
956        let mut buffer: Vec<f64> = Vec::new();
957        let (_, memory) = framework
958            .measure_timing(move || -> StatsResult<()> {
959                buffer.extend(std::iter::repeat_n(1.0_f64, MEMORY_TEST_GROWTH_LEN));
960                Ok(())
961            })
962            .expect("Operation failed");
963
964        assert!(
965            memory.peak_memory > 0,
966            "expected nonzero peak resident-memory delta for an allocating closure, got {}",
967            memory.peak_memory
968        );
969        assert!(
970            memory.average_memory > 0,
971            "expected nonzero average resident-memory delta for an allocating closure, got {}",
972            memory.average_memory
973        );
974    }
975
976    #[cfg(feature = "memory_tracking")]
977    #[test]
978    fn test_memory_tracking_trivial_closure_much_smaller_than_allocating() {
979        let framework = ScipyBenchmarkFramework::new(BenchmarkConfig {
980            performance_iterations: 20,
981            warmup_iterations: 2,
982            ..Default::default()
983        });
984
985        // See `test_memory_tracking_allocating_closure_reports_nonzero_memory` for why
986        // this uses a monotonically-growing captured buffer rather than a fresh
987        // allocate-then-free `Vec` per call.
988        let mut buffer: Vec<f64> = Vec::new();
989        let (_, allocating_memory) = framework
990            .measure_timing(move || -> StatsResult<()> {
991                buffer.extend(std::iter::repeat_n(1.0_f64, MEMORY_TEST_GROWTH_LEN));
992                Ok(())
993            })
994            .expect("Operation failed");
995
996        // A trivial closure that touches no heap memory at all.
997        let (_, trivial_memory) = framework
998            .measure_timing(|| -> StatsResult<i32> { Ok(1 + 1) })
999            .expect("Operation failed");
1000
1001        assert!(
1002            allocating_memory.peak_memory > 0,
1003            "sanity check: allocating closure should itself report nonzero peak memory, got {}",
1004            allocating_memory.peak_memory
1005        );
1006        // Contrast rather than asserting an arbitrary absolute bound: the trivial
1007        // closure's footprint must be much smaller than the ~15.3 MiB allocating
1008        // closure's, not merely nonnegative (which would be vacuous for a usize).
1009        assert!(
1010            trivial_memory.peak_memory < allocating_memory.peak_memory,
1011            "expected trivial closure's peak memory ({}) to be much smaller than the \
1012             allocating closure's ({})",
1013            trivial_memory.peak_memory,
1014            allocating_memory.peak_memory
1015        );
1016        assert!(
1017            trivial_memory.average_memory < allocating_memory.average_memory,
1018            "expected trivial closure's average memory ({}) to be much smaller than the \
1019             allocating closure's ({})",
1020            trivial_memory.average_memory,
1021            allocating_memory.average_memory
1022        );
1023    }
1024
1025    #[cfg(feature = "memory_tracking")]
1026    #[test]
1027    fn test_memory_tracking_wired_into_compare_performance() {
1028        use std::cell::RefCell;
1029
1030        // End-to-end: `compare_performance` (used by `benchmark_function`) should
1031        // surface the same real memory tracking, including a computed
1032        // `efficiency_ratio` once both SciRS2 and SciPy sides report nonzero
1033        // average memory.
1034        //
1035        // `compare_performance`'s SciRS2 timing/memory loop discards the closure's
1036        // own return value (`.map(|_| ())`), so an allocation that is built *and*
1037        // freed entirely inside `scirs2_impl`'s body would depend on whether the
1038        // allocator/OS happens to reclaim those pages before the "after" sample —
1039        // exactly the kind of nondeterminism this feature's docs warn about. To get
1040        // a deterministic, platform-independent signal instead, each closure here
1041        // appends to a `RefCell`-captured buffer that is never freed until the test
1042        // itself ends, so resident memory only ever grows across iterations.
1043        let scirs2_growing: RefCell<Vec<f64>> = RefCell::new(Vec::new());
1044        let scipy_growing: RefCell<Vec<f64>> = RefCell::new(Vec::new());
1045        const GROWTH_PER_CALL: usize = 200_000; // ~1.5 MiB of f64 per call
1046
1047        let framework = ScipyBenchmarkFramework::new(BenchmarkConfig {
1048            performance_iterations: 10,
1049            warmup_iterations: 1,
1050            ..Default::default()
1051        });
1052
1053        let testdata = Array1::from_vec(vec![1.0, 2.0, 3.0, 4.0, 5.0]);
1054
1055        // Both "implementations" deliberately grow captured state so both sides of
1056        // the comparison report nonzero average memory (letting us exercise the
1057        // `efficiency_ratio` computation, not just the raw peak/average fields).
1058        // `RefCell` gives interior mutability so these closures can still satisfy
1059        // the `Fn` bound `compare_performance` requires.
1060        let scirs2_impl = |data: &ArrayView1<f64>| -> StatsResult<f64> {
1061            scirs2_growing
1062                .borrow_mut()
1063                .extend(std::iter::repeat_n(1.0_f64, GROWTH_PER_CALL));
1064            Ok(data.sum())
1065        };
1066        let scipy_reference = |data: &ArrayView1<f64>| -> f64 {
1067            scipy_growing
1068                .borrow_mut()
1069                .extend(std::iter::repeat_n(1.0_f64, GROWTH_PER_CALL));
1070            data.sum()
1071        };
1072
1073        let performance = framework
1074            .compare_performance(&scirs2_impl, Some(&scipy_reference), &testdata.view())
1075            .expect("Operation failed");
1076
1077        assert!(
1078            performance.memory_usage.peak_memory > 0,
1079            "expected nonzero peak memory from an allocating benchmarked closure, got {}",
1080            performance.memory_usage.peak_memory
1081        );
1082        assert!(
1083            performance.memory_usage.average_memory > 0,
1084            "expected nonzero average memory from an allocating benchmarked closure, got {}",
1085            performance.memory_usage.average_memory
1086        );
1087        assert!(
1088            performance.memory_usage.efficiency_ratio.is_some(),
1089            "expected an efficiency_ratio once both SciRS2 and SciPy sides allocate"
1090        );
1091
1092        // Keep the growing buffers alive (and their growth "used") through the end
1093        // of the test, rather than letting the borrow checker/optimizer treat the
1094        // accumulated data as dead.
1095        assert!(scirs2_growing.borrow().len() >= GROWTH_PER_CALL);
1096        assert!(scipy_growing.borrow().len() >= GROWTH_PER_CALL);
1097    }
1098}