Skip to main content

torsh_nn/
gradcheck.rs

1//! Gradient checking utilities for neural network layers
2//!
3//! This module provides utilities for numerical gradient checking to validate
4//! automatic differentiation implementations.
5
6use crate::{Module, Parameter};
7use scirs2_core::random::Random;
8use torsh_core::error::{Result, TorshError};
9use torsh_tensor::Tensor;
10
11// Conditional imports for std/no_std compatibility
12#[cfg(feature = "std")]
13use std::{collections::HashSet, string::String, vec::Vec};
14
15#[cfg(not(feature = "std"))]
16use alloc::{string::String, vec::Vec};
17
18#[cfg(not(feature = "std"))]
19use hashbrown::{HashMap, HashSet};
20
21/// Gradient checking configuration
22#[derive(Debug, Clone)]
23pub struct GradCheckConfig {
24    /// Epsilon for finite differences
25    pub eps: f64,
26    /// Relative tolerance for gradient comparison
27    pub rtol: f64,
28    /// Absolute tolerance for gradient comparison
29    pub atol: f64,
30    /// Whether to use double precision for calculations
31    pub double_precision: bool,
32    /// Maximum number of elements to check (for large tensors)
33    pub max_elements: Option<usize>,
34    /// Random seed for sampling elements to check
35    pub seed: Option<u64>,
36}
37
38impl Default for GradCheckConfig {
39    fn default() -> Self {
40        Self {
41            eps: 1e-6,
42            rtol: 1e-3,
43            atol: 1e-5,
44            double_precision: false,
45            max_elements: Some(100),
46            seed: Some(42),
47        }
48    }
49}
50
51/// Gradient check result for a single parameter
52#[derive(Debug, Clone)]
53pub struct ParameterGradCheckResult {
54    /// Parameter name
55    pub name: String,
56    /// Whether the gradient check passed
57    pub passed: bool,
58    /// Maximum absolute difference
59    pub max_abs_diff: f64,
60    /// Maximum relative difference
61    pub max_rel_diff: f64,
62    /// Number of elements checked
63    pub elements_checked: usize,
64    /// Error message if check failed
65    pub error: Option<String>,
66}
67
68/// Overall gradient check result
69#[derive(Debug, Clone)]
70pub struct GradCheckResult {
71    /// Whether all parameters passed
72    pub passed: bool,
73    /// Results for individual parameters
74    pub parameter_results: Vec<ParameterGradCheckResult>,
75    /// Overall summary
76    pub summary: String,
77}
78
79impl GradCheckResult {
80    /// Get parameters that failed the gradient check
81    pub fn failed_parameters(&self) -> Vec<&ParameterGradCheckResult> {
82        self.parameter_results
83            .iter()
84            .filter(|r| !r.passed)
85            .collect()
86    }
87
88    /// Get the worst parameter (highest error)
89    ///
90    /// A `NaN` difference is the most severe possible outcome — it is exactly
91    /// the broken-gradient case gradcheck exists to surface — so it sorts above
92    /// every finite difference instead of aborting the comparison.
93    /// [`f64::total_cmp`] orders `NaN` last, which is the ordering we want.
94    pub fn worst_parameter(&self) -> Option<&ParameterGradCheckResult> {
95        self.parameter_results
96            .iter()
97            .max_by(|a, b| a.max_abs_diff.total_cmp(&b.max_abs_diff))
98    }
99
100    /// Parameters whose gradient difference is `NaN`.
101    ///
102    /// These are the most severe failures: the analytical or numerical gradient
103    /// itself is not a number, so no tolerance comparison is meaningful.
104    pub fn nan_parameters(&self) -> Vec<&ParameterGradCheckResult> {
105        self.parameter_results
106            .iter()
107            .filter(|result| result.max_abs_diff.is_nan() || result.max_rel_diff.is_nan())
108            .collect()
109    }
110}
111
112/// Gradient checker for neural network modules
113pub struct GradChecker {
114    config: GradCheckConfig,
115}
116
117impl GradChecker {
118    /// Create a new gradient checker with default configuration
119    pub fn new() -> Self {
120        Self {
121            config: GradCheckConfig::default(),
122        }
123    }
124
125    /// Create a gradient checker with custom configuration
126    pub fn with_config(config: GradCheckConfig) -> Self {
127        Self { config }
128    }
129
130    /// Check gradients for a module
131    pub fn check_module<M: Module, F>(
132        &self,
133        module: &M,
134        input: &Tensor<f32>,
135        loss_fn: F,
136    ) -> Result<GradCheckResult>
137    where
138        F: Fn(&Tensor<f32>) -> Result<Tensor<f32>>,
139    {
140        let parameters = module.named_parameters();
141        let mut parameter_results = Vec::new();
142        let mut all_passed = true;
143
144        for (name, param) in parameters.iter() {
145            match self.check_parameter(module, input, param, &name, &loss_fn) {
146                Ok(result) => {
147                    if !result.passed {
148                        all_passed = false;
149                    }
150                    parameter_results.push(result);
151                }
152                Err(e) => {
153                    all_passed = false;
154                    parameter_results.push(ParameterGradCheckResult {
155                        name: name.clone(),
156                        passed: false,
157                        max_abs_diff: f64::INFINITY,
158                        max_rel_diff: f64::INFINITY,
159                        elements_checked: 0,
160                        error: Some(e.to_string()),
161                    });
162                }
163            }
164        }
165
166        let summary = if all_passed {
167            format!(
168                "All {} parameters passed gradient check",
169                parameter_results.len()
170            )
171        } else {
172            let failed_count = parameter_results.iter().filter(|r| !r.passed).count();
173            let nan_count = parameter_results
174                .iter()
175                .filter(|r| r.max_abs_diff.is_nan() || r.max_rel_diff.is_nan())
176                .count();
177            if nan_count > 0 {
178                format!(
179                    "{} out of {} parameters failed gradient check ({} produced NaN differences)",
180                    failed_count,
181                    parameter_results.len(),
182                    nan_count
183                )
184            } else {
185                format!(
186                    "{} out of {} parameters failed gradient check",
187                    failed_count,
188                    parameter_results.len()
189                )
190            }
191        };
192
193        Ok(GradCheckResult {
194            passed: all_passed,
195            parameter_results,
196            summary,
197        })
198    }
199
200    /// Check gradients for a pure function (no module parameters)
201    pub fn check_function<F>(&self, func: F, input: &Tensor<f32>) -> Result<GradCheckResult>
202    where
203        F: Fn(&Tensor<f32>) -> Result<Tensor<f32>>,
204    {
205        // For pure functions, we check the gradient with respect to the input
206        let input_with_grad = input.clone().requires_grad_(true);
207
208        // Compute function output
209        let output = func(&input_with_grad)?;
210
211        // Perform backward pass to get analytical gradient
212        output.backward()?;
213        let analytical_grad = input_with_grad
214            .grad()
215            .ok_or_else(|| TorshError::AutogradError("No gradient computed".to_string()))?;
216
217        // Compute numerical gradient
218        let numerical_grad = self.compute_numerical_gradient_function(&func, input)?;
219
220        // Compare gradients
221        let comparison = self.compare_gradients(&analytical_grad, &numerical_grad)?;
222
223        let param_result = ParameterGradCheckResult {
224            name: "input".to_string(),
225            passed: comparison.0,
226            max_abs_diff: comparison.1,
227            max_rel_diff: comparison.2,
228            elements_checked: comparison.3,
229            error: None,
230        };
231
232        let summary = if comparison.0 {
233            "Function gradient check passed".to_string()
234        } else {
235            "Function gradient check failed".to_string()
236        };
237
238        Ok(GradCheckResult {
239            passed: comparison.0,
240            parameter_results: vec![param_result],
241            summary,
242        })
243    }
244
245    /// Check gradients for a single parameter
246    fn check_parameter<M: Module, F>(
247        &self,
248        module: &M,
249        input: &Tensor<f32>,
250        parameter: &Parameter,
251        param_name: &str,
252        loss_fn: &F,
253    ) -> Result<ParameterGradCheckResult>
254    where
255        F: Fn(&Tensor<f32>) -> Result<Tensor<f32>>,
256    {
257        // Get analytical gradient (this would come from autograd)
258        // For now, we'll use a placeholder implementation
259        let analytical_grad =
260            self.compute_analytical_gradient(module, input, parameter, loss_fn)?;
261
262        // Compute numerical gradient
263        let numerical_grad =
264            self.compute_numerical_gradient(module, input, parameter, param_name, loss_fn)?;
265
266        // Compare gradients
267        let comparison = self.compare_gradients(&analytical_grad, &numerical_grad)?;
268
269        Ok(ParameterGradCheckResult {
270            name: param_name.to_string(),
271            passed: comparison.0,
272            max_abs_diff: comparison.1,
273            max_rel_diff: comparison.2,
274            elements_checked: comparison.3,
275            error: None,
276        })
277    }
278
279    /// Compute the analytical gradient of the loss with respect to a module
280    /// parameter via autograd backward.
281    ///
282    /// # Honest-failure contract
283    ///
284    /// Module-based gradient checking requires that the gradient checker be able
285    /// to (a) run the forward pass with autograd tracking enabled through the
286    /// module's parameters and (b) read the accumulated `.grad()` off the
287    /// specific [`Parameter`]. The current `Module` trait exposes only an
288    /// immutable `forward(&self, &Tensor)` and does not give the checker a way
289    /// to swap in autograd-tracked parameters or recover their gradients, so a
290    /// genuine analytical gradient cannot be produced here.
291    ///
292    /// Rather than returning a zero tensor — which would make every module
293    /// gradient check pass *trivially* (`0 ≈ 0` against a numerical gradient
294    /// that is likewise unable to perturb the live parameter), giving false
295    /// confidence that gradients are correct — this returns a loud
296    /// [`TorshError::NotImplemented`]. A gradient checker that always passes is
297    /// strictly worse than no checker.
298    ///
299    /// Use [`GradChecker::check_function`] / [`gradcheck_function`] for the
300    /// pure-function path, which performs a real autograd `backward()` and
301    /// compares it against a finite-difference gradient.
302    fn compute_analytical_gradient<M: Module, F>(
303        &self,
304        _module: &M,
305        _input: &Tensor<f32>,
306        _parameter: &Parameter,
307        _loss_fn: &F,
308    ) -> Result<Tensor<f32>>
309    where
310        F: Fn(&Tensor<f32>) -> Result<Tensor<f32>>,
311    {
312        Err(TorshError::NotImplemented(
313            "module-parameter analytical gradient requires autograd backward through \
314             the module's parameters; the Module trait does not yet expose autograd-tracked \
315             parameter access, so gradcheck cannot run on modules. Use gradcheck_function \
316             for the pure-function path."
317                .to_string(),
318        ))
319    }
320
321    /// Compute numerical gradient using finite differences
322    fn compute_numerical_gradient<M: Module, F>(
323        &self,
324        module: &M,
325        input: &Tensor<f32>,
326        parameter: &Parameter,
327        _param_name: &str,
328        loss_fn: &F,
329    ) -> Result<Tensor<f32>>
330    where
331        F: Fn(&Tensor<f32>) -> Result<Tensor<f32>>,
332    {
333        let param_data = parameter.tensor().read().clone();
334        let param_shape = param_data.shape().dims().to_vec();
335        let numel = param_data.numel();
336
337        // Determine which elements to check
338        let indices_to_check = self.get_indices_to_check(numel);
339
340        // Initialize gradient tensor
341        let mut grad_data = vec![0.0f32; numel];
342
343        for &idx in &indices_to_check {
344            // Forward difference: f(x + h) - f(x - h) / (2h)
345            let grad = self.compute_finite_difference(module, input, parameter, idx, loss_fn)?;
346            grad_data[idx] = grad;
347        }
348
349        Ok(
350            Tensor::from_data(grad_data, param_shape, param_data.device())
351                .expect("tensor creation from grad_data should succeed"),
352        )
353    }
354
355    /// Compute finite difference for a single parameter element
356    fn compute_finite_difference<M: Module, F>(
357        &self,
358        module: &M,
359        input: &Tensor<f32>,
360        parameter: &Parameter,
361        idx: usize,
362        loss_fn: &F,
363    ) -> Result<f32>
364    where
365        F: Fn(&Tensor<f32>) -> Result<Tensor<f32>>,
366    {
367        let param_data = parameter.tensor().read().clone();
368        let original_value = param_data.get_item(&[idx])?;
369
370        // Compute f(x + h)
371        let mut param_plus = param_data.clone();
372        param_plus.set_item(&[idx], original_value + self.config.eps as f32)?;
373
374        // Update module parameter (this is a simplified approach)
375        // In practice, you'd need to temporarily modify the module's parameter
376        let output_plus = module.forward(input)?;
377        let loss_plus = loss_fn(&output_plus)?;
378        let loss_plus_scalar = loss_plus.item();
379
380        // Compute f(x - h)
381        let mut param_minus = param_data.clone();
382        param_minus.set_item(&[idx], original_value - self.config.eps as f32)?;
383
384        let output_minus = module.forward(input)?;
385        let loss_minus = loss_fn(&output_minus)?;
386        let loss_minus_scalar = loss_minus.item();
387
388        // Central difference
389        let grad = (loss_plus_scalar? - loss_minus_scalar?) / (2.0 * self.config.eps as f32);
390
391        Ok(grad)
392    }
393
394    /// Compute numerical gradient for a function with respect to input
395    fn compute_numerical_gradient_function<F>(
396        &self,
397        func: &F,
398        input: &Tensor<f32>,
399    ) -> Result<Tensor<f32>>
400    where
401        F: Fn(&Tensor<f32>) -> Result<Tensor<f32>>,
402    {
403        let input_shape = input.shape().dims().to_vec();
404        let numel = input.numel();
405
406        // Determine which elements to check
407        let indices_to_check = self.get_indices_to_check(numel);
408
409        // Initialize gradient tensor
410        let mut grad_data = vec![0.0f32; numel];
411
412        for &idx in &indices_to_check {
413            // Central difference: f(x + h) - f(x - h) / (2h)
414            let original_value = input.get_item(&[idx])?;
415
416            // Compute f(x + h)
417            let mut input_plus = input.clone();
418            input_plus.set_item(&[idx], original_value + self.config.eps as f32)?;
419            let output_plus = func(&input_plus)?;
420            let loss_plus_scalar = output_plus.item()?;
421
422            // Compute f(x - h)
423            let mut input_minus = input.clone();
424            input_minus.set_item(&[idx], original_value - self.config.eps as f32)?;
425            let output_minus = func(&input_minus)?;
426            let loss_minus_scalar = output_minus.item()?;
427
428            // Central difference
429            let grad = (loss_plus_scalar - loss_minus_scalar) / (2.0 * self.config.eps as f32);
430            grad_data[idx] = grad;
431        }
432
433        Ok(Tensor::from_data(grad_data, input_shape, input.device())
434            .expect("tensor creation from grad_data should succeed"))
435    }
436
437    /// Get indices to check (sampling for large tensors)
438    fn get_indices_to_check(&self, numel: usize) -> Vec<usize> {
439        if let Some(max_elements) = self.config.max_elements {
440            if numel <= max_elements {
441                (0..numel).collect()
442            } else {
443                // Sample random indices
444                #[cfg(feature = "std")]
445                {
446                    let mut rng = self.get_rng();
447                    let mut indices = HashSet::new();
448
449                    while indices.len() < max_elements {
450                        let idx = rng.gen_range(0..numel);
451                        indices.insert(idx);
452                    }
453
454                    indices.into_iter().collect()
455                }
456                #[cfg(not(feature = "std"))]
457                {
458                    // For no_std, use a simple linear sampling approach
459                    let mut rng = self.get_rng();
460                    let mut indices = Vec::new();
461
462                    for _ in 0..max_elements.min(numel) {
463                        let idx = rng.gen_range(0..numel);
464                        if !indices.contains(&idx) {
465                            indices.push(idx);
466                        }
467                    }
468
469                    indices
470                }
471            }
472        } else {
473            (0..numel).collect()
474        }
475    }
476
477    /// Get random number generator
478    fn get_rng(&self) -> Random {
479        if let Some(_seed) = self.config.seed {
480            // For seeded random generation, use SciRS2 Random type
481            // Note: Using default for now due to type compatibility
482            Random::default()
483        } else {
484            Random::default()
485        }
486    }
487
488    /// Compare analytical and numerical gradients
489    fn compare_gradients(
490        &self,
491        analytical: &Tensor<f32>,
492        numerical: &Tensor<f32>,
493    ) -> Result<(bool, f64, f64, usize)> {
494        let anal_data = analytical.data()?;
495        let num_data = numerical.data()?;
496
497        if anal_data.len() != num_data.len() {
498            return Err(TorshError::InvalidArgument(
499                "Gradient tensors have different sizes".to_string(),
500            ));
501        }
502
503        let mut max_abs_diff: f64 = 0.0;
504        let mut max_rel_diff: f64 = 0.0;
505        let mut all_within_tolerance = true;
506        // A NaN difference can never satisfy a tolerance comparison, and
507        // `f64::max` would quietly discard it (it returns the non-NaN operand).
508        // Track it explicitly so the reported difference stays NaN.
509        let mut saw_nan = false;
510
511        for (_i, (&a, &n)) in anal_data.iter().zip(num_data.iter()).enumerate() {
512            let abs_diff = (a as f64 - n as f64).abs();
513            let rel_diff = if n.abs() > 1e-8 {
514                abs_diff / (n as f64).abs()
515            } else {
516                abs_diff
517            };
518
519            if abs_diff.is_nan() || rel_diff.is_nan() {
520                saw_nan = true;
521                all_within_tolerance = false;
522                continue;
523            }
524
525            max_abs_diff = max_abs_diff.max(abs_diff);
526            max_rel_diff = max_rel_diff.max(rel_diff);
527
528            if abs_diff > self.config.atol && rel_diff > self.config.rtol {
529                all_within_tolerance = false;
530            }
531        }
532
533        if saw_nan {
534            max_abs_diff = f64::NAN;
535            max_rel_diff = f64::NAN;
536        }
537
538        Ok((
539            all_within_tolerance,
540            max_abs_diff,
541            max_rel_diff,
542            anal_data.len(),
543        ))
544    }
545}
546
547impl Default for GradChecker {
548    fn default() -> Self {
549        Self::new()
550    }
551}
552
553/// Convenience functions for gradient checking
554pub fn gradcheck<M: Module, F>(
555    module: &M,
556    input: &Tensor<f32>,
557    loss_fn: F,
558) -> Result<GradCheckResult>
559where
560    F: Fn(&Tensor<f32>) -> Result<Tensor<f32>>,
561{
562    let checker = GradChecker::new();
563    checker.check_module(module, input, loss_fn)
564}
565
566/// Fast gradient check with relaxed tolerances
567pub fn fast_gradcheck<M: Module, F>(
568    module: &M,
569    input: &Tensor<f32>,
570    loss_fn: F,
571) -> Result<GradCheckResult>
572where
573    F: Fn(&Tensor<f32>) -> Result<Tensor<f32>>,
574{
575    let config = GradCheckConfig {
576        eps: 1e-4,
577        rtol: 1e-2,
578        atol: 1e-3,
579        max_elements: Some(10),
580        ..Default::default()
581    };
582
583    let checker = GradChecker::with_config(config);
584    checker.check_module(module, input, loss_fn)
585}
586
587/// High precision gradient check
588pub fn precise_gradcheck<M: Module, F>(
589    module: &M,
590    input: &Tensor<f32>,
591    loss_fn: F,
592) -> Result<GradCheckResult>
593where
594    F: Fn(&Tensor<f32>) -> Result<Tensor<f32>>,
595{
596    let config = GradCheckConfig {
597        eps: 1e-8,
598        rtol: 1e-5,
599        atol: 1e-7,
600        double_precision: true,
601        max_elements: None,
602        ..Default::default()
603    };
604
605    let checker = GradChecker::with_config(config);
606    checker.check_module(module, input, loss_fn)
607}
608
609/// Functional gradient check for pure functions (not modules)
610///
611/// This function checks gradients of pure functions that take a tensor and return a scalar tensor.
612/// It performs numerical differentiation to verify automatic differentiation gradients.
613pub fn gradcheck_function<F>(
614    func: F,
615    input: &Tensor<f32>,
616    config: &GradCheckConfig,
617) -> Result<GradCheckResult>
618where
619    F: Fn(&Tensor<f32>) -> Result<Tensor<f32>>,
620{
621    let checker = GradChecker::with_config(config.clone());
622    checker.check_function(func, input)
623}
624
625/// Fast functional gradient check with relaxed tolerances
626pub fn fast_gradcheck_function<F>(func: F, input: &Tensor<f32>) -> Result<GradCheckResult>
627where
628    F: Fn(&Tensor<f32>) -> Result<Tensor<f32>>,
629{
630    let config = GradCheckConfig {
631        eps: 1e-4,
632        rtol: 1e-2,
633        atol: 1e-3,
634        max_elements: Some(10),
635        ..Default::default()
636    };
637    gradcheck_function(func, input, &config)
638}
639
640// Helper trait for tensors to support item access and modification
641#[allow(dead_code)]
642trait TensorItemAccess<T> {
643    fn get_item(&self, idx: usize) -> Result<T>;
644    fn set_item(&mut self, idx: usize, value: T) -> Result<()>;
645}
646
647#[allow(dead_code)]
648impl TensorItemAccess<f32> for Tensor<f32> {
649    fn get_item(&self, idx: usize) -> Result<f32> {
650        let data = self.data()?;
651        if idx >= data.len() {
652            return Err(TorshError::InvalidArgument(format!(
653                "Index {} out of bounds for tensor with {} elements",
654                idx,
655                data.len()
656            )));
657        }
658        Ok(data[idx])
659    }
660
661    fn set_item(&mut self, _idx: usize, _value: f32) -> Result<()> {
662        // This is a simplified implementation
663        // In practice, you'd need proper tensor mutation support
664        Err(TorshError::UnsupportedOperation {
665            op: "set_item".to_string(),
666            dtype: "tensor mutation not yet supported".to_string(),
667        })
668    }
669}
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674    use torsh_tensor::creation::*;
675
676    // Conditional imports for std/no_std compatibility
677    #[cfg(feature = "std")]
678    use std::collections::HashMap;
679
680    #[cfg(not(feature = "std"))]
681    use hashbrown::HashMap;
682
683    // Mock module for testing
684    #[allow(dead_code)]
685    struct LinearModule {
686        weight: Tensor<f32>,
687        bias: Option<Tensor<f32>>,
688    }
689
690    impl LinearModule {
691        #[allow(dead_code)]
692        fn new(in_features: usize, out_features: usize, bias: bool) -> Result<Self> {
693            let weight = randn(&[out_features, in_features])?;
694            let bias = if bias {
695                Some(zeros(&[out_features])?)
696            } else {
697                None
698            };
699
700            Ok(Self { weight, bias })
701        }
702    }
703
704    impl Module for LinearModule {
705        fn forward(&self, input: &Tensor<f32>) -> Result<Tensor<f32>> {
706            let output = input.matmul(&self.weight.transpose(-1, -2)?)?;
707            if let Some(ref bias) = self.bias {
708                output.add_op(bias)
709            } else {
710                Ok(output)
711            }
712        }
713
714        fn parameters(&self) -> HashMap<String, Parameter> {
715            let mut params = HashMap::new();
716            params.insert("weight".to_string(), Parameter::new(self.weight.clone()));
717            if let Some(ref bias) = self.bias {
718                params.insert("bias".to_string(), Parameter::new(bias.clone()));
719            }
720            params
721        }
722
723        fn named_parameters(&self) -> HashMap<String, Parameter> {
724            self.parameters()
725        }
726
727        fn training(&self) -> bool {
728            true
729        }
730        fn train(&mut self) {}
731        fn eval(&mut self) {}
732        fn set_training(&mut self, _training: bool) {}
733        fn to_device(&mut self, _device: torsh_core::DeviceType) -> Result<()> {
734            Ok(())
735        }
736    }
737
738    #[test]
739    fn test_gradcheck_config() {
740        let config = GradCheckConfig::default();
741        assert_eq!(config.eps, 1e-6);
742        assert_eq!(config.rtol, 1e-3);
743        assert_eq!(config.atol, 1e-5);
744        assert_eq!(config.max_elements, Some(100));
745    }
746
747    #[test]
748    fn test_grad_checker_creation() {
749        let checker = GradChecker::new();
750        assert_eq!(checker.config.eps, 1e-6);
751
752        let custom_config = GradCheckConfig {
753            eps: 1e-4,
754            ..Default::default()
755        };
756        let custom_checker = GradChecker::with_config(custom_config);
757        assert_eq!(custom_checker.config.eps, 1e-4);
758    }
759
760    #[test]
761    fn test_parameter_grad_check_result() {
762        let result = ParameterGradCheckResult {
763            name: "test_param".to_string(),
764            passed: true,
765            max_abs_diff: 1e-6,
766            max_rel_diff: 1e-5,
767            elements_checked: 100,
768            error: None,
769        };
770
771        assert_eq!(result.name, "test_param");
772        assert!(result.passed);
773        assert_eq!(result.elements_checked, 100);
774        assert!(result.error.is_none());
775    }
776
777    #[test]
778    fn test_grad_check_result() {
779        let param_results = vec![
780            ParameterGradCheckResult {
781                name: "param1".to_string(),
782                passed: true,
783                max_abs_diff: 1e-6,
784                max_rel_diff: 1e-5,
785                elements_checked: 50,
786                error: None,
787            },
788            ParameterGradCheckResult {
789                name: "param2".to_string(),
790                passed: false,
791                max_abs_diff: 1e-2,
792                max_rel_diff: 1e-1,
793                elements_checked: 50,
794                error: None,
795            },
796        ];
797
798        let result = GradCheckResult {
799            passed: false,
800            parameter_results: param_results,
801            summary: "1 out of 2 parameters failed gradient check".to_string(),
802        };
803
804        assert!(!result.passed);
805        assert_eq!(result.failed_parameters().len(), 1);
806        assert_eq!(result.worst_parameter().unwrap().name, "param2");
807    }
808
809    #[test]
810    fn test_indices_selection() {
811        let checker = GradChecker::new();
812
813        // Small tensor - should check all elements
814        let indices = checker.get_indices_to_check(50);
815        assert_eq!(indices.len(), 50);
816
817        // Large tensor - should sample
818        let indices = checker.get_indices_to_check(1000);
819        assert_eq!(indices.len(), 100); // max_elements is 100 by default
820    }
821
822    #[test]
823    fn test_convenience_functions() {
824        // These would require working tensor operations, so we just test they exist
825        assert!(true); // Placeholder for actual tests when tensor ops work
826    }
827
828    /// A module-based gradient check must NOT silently pass by returning a zero
829    /// analytical gradient. Until autograd-tracked parameter access is wired
830    /// into the `Module` trait, every module parameter must be reported as
831    /// *failed* with an honest, explanatory error — never as passed.
832    #[test]
833    fn test_module_gradcheck_fails_loudly_not_silently() {
834        let module = LinearModule::new(3, 2, true).expect("module creation should succeed");
835        let input = randn(&[1, 3]).expect("input creation should succeed");
836
837        let result = gradcheck(&module, &input, |output| output.sum());
838
839        // The check itself returns Ok (it aggregates per-parameter results),
840        // but EVERY parameter must be marked failed with an honest error —
841        // never silently passed via zero analytical gradients.
842        let result = result.expect("check_module aggregates results into Ok");
843        assert!(
844            !result.passed,
845            "module gradcheck must not pass while analytical gradients are unimplemented"
846        );
847        assert!(
848            !result.parameter_results.is_empty(),
849            "expected at least one parameter to be checked"
850        );
851        for param_result in &result.parameter_results {
852            assert!(
853                !param_result.passed,
854                "parameter '{}' was silently passed; a zero analytical gradient must never \
855                 trivially pass a gradient check",
856                param_result.name
857            );
858            let err = param_result
859                .error
860                .as_ref()
861                .expect("a failed module parameter must carry an explanatory error");
862            assert!(
863                err.contains("autograd") || err.contains("gradcheck_function"),
864                "error should explain why module gradcheck cannot run, got: {err}"
865            );
866        }
867    }
868}