Skip to main content

torsh_cli/commands/model/
validation.rs

1//! Comprehensive model validation with real inference and gradient checking
2//!
3//! This module provides tools for validating ToRSh models including:
4//! - Real forward pass inference
5//! - Gradient checking for correctness
6//! - Numerical stability analysis
7//! - Performance validation
8
9// Infrastructure module - functions designed for CLI command integration
10#![allow(dead_code)]
11
12use anyhow::Result;
13use tracing::{debug, info, warn};
14
15// ✅ SciRS2 POLICY COMPLIANT: Use scirs2-core unified access patterns
16use scirs2_core::random::{thread_rng, Distribution, Uniform};
17
18// ToRSh integration
19use torsh::core::device::DeviceType;
20use torsh::tensor::Tensor;
21
22use super::types::{LayerInfo, TorshModel};
23
24/// Validation result for a model
25#[derive(Debug, Clone)]
26pub struct ValidationResult {
27    /// Whether the model passed validation
28    pub passed: bool,
29    /// Validation accuracy (if applicable)
30    pub accuracy: Option<f64>,
31    /// Top-5 accuracy (if applicable)
32    pub top5_accuracy: Option<f64>,
33    /// Number of samples tested
34    pub num_samples: usize,
35    /// Number of successful inferences
36    pub successful_inferences: usize,
37    /// Number of failed inferences
38    pub failed_inferences: usize,
39    /// Average inference time (ms)
40    pub avg_inference_time_ms: f64,
41    /// Peak memory usage (MB)
42    pub peak_memory_mb: f64,
43    /// Gradient check results (if performed)
44    pub gradient_check_passed: Option<bool>,
45    /// Numerical stability score (0-1, higher is better)
46    pub numerical_stability: f64,
47    /// Validation errors
48    pub errors: Vec<String>,
49    /// Warnings
50    pub warnings: Vec<String>,
51}
52
53/// Gradient checking result
54#[derive(Debug, Clone)]
55pub struct GradientCheckResult {
56    /// Whether gradient check passed
57    pub passed: bool,
58    /// Maximum relative error
59    pub max_relative_error: f64,
60    /// Average relative error
61    pub avg_relative_error: f64,
62    /// Number of gradients checked
63    pub num_gradients_checked: usize,
64    /// Failed gradient locations
65    pub failed_locations: Vec<String>,
66}
67
68/// Numerical stability analysis result
69#[derive(Debug, Clone)]
70pub struct StabilityAnalysis {
71    /// Presence of NaN values
72    pub has_nan: bool,
73    /// Presence of Inf values
74    pub has_inf: bool,
75    /// Very large values (>1e6)
76    pub has_large_values: bool,
77    /// Very small values (<1e-6)
78    pub has_tiny_values: bool,
79    /// Gradient magnitude statistics. `None` when they cannot be computed —
80    /// a metadata-only model has no autograd graph to derive gradients from.
81    pub gradient_magnitude: Option<GradientStatistics>,
82    /// Activation statistics
83    pub activation_stats: ActivationStatistics,
84}
85
86/// Gradient magnitude statistics
87#[derive(Debug, Clone)]
88pub struct GradientStatistics {
89    pub mean: f64,
90    pub std: f64,
91    pub min: f64,
92    pub max: f64,
93    /// Percentage of gradients near zero (<1e-7)
94    pub vanishing_percentage: f64,
95    /// Percentage of large gradients (>10)
96    pub exploding_percentage: f64,
97}
98
99/// Activation statistics
100#[derive(Debug, Clone)]
101pub struct ActivationStatistics {
102    pub mean: f64,
103    pub std: f64,
104    pub min: f64,
105    pub max: f64,
106    /// Percentage of dead neurons (always output 0)
107    pub dead_neurons_percentage: f64,
108}
109
110/// Perform comprehensive model validation
111pub async fn validate_model(
112    model: &TorshModel,
113    num_samples: usize,
114    check_gradients: bool,
115) -> Result<ValidationResult> {
116    info!(
117        "Validating model with {} samples (gradient check: {})",
118        num_samples, check_gradients
119    );
120
121    let mut errors = Vec::new();
122    let mut warnings = Vec::new();
123
124    // Step 1: Basic structure validation
125    if let Err(e) = validate_model_structure(model) {
126        errors.push(format!("Model structure validation failed: {}", e));
127    }
128
129    // Step 2: Run inference tests
130    let (successful, failed, avg_time, peak_memory) =
131        run_inference_tests(model, num_samples).await?;
132
133    // Step 3: Gradient checking (if requested)
134    let gradient_check_result = if check_gradients {
135        match perform_gradient_check(model).await {
136            Ok(result) => Some(result.passed),
137            Err(e) => {
138                warnings.push(format!("Gradient check failed: {}", e));
139                None
140            }
141        }
142    } else {
143        None
144    };
145
146    // Step 4: Numerical stability analysis
147    let stability = analyze_numerical_stability(model).await?;
148    let numerical_stability = calculate_stability_score(&stability);
149
150    if stability.has_nan {
151        errors.push("Model contains NaN values".to_string());
152    }
153    if stability.has_inf {
154        errors.push("Model contains Inf values".to_string());
155    }
156
157    if let Some(gradient_magnitude) = &stability.gradient_magnitude {
158        if gradient_magnitude.vanishing_percentage > 50.0 {
159            warnings.push(format!(
160                "High vanishing gradient rate: {:.1}%",
161                gradient_magnitude.vanishing_percentage
162            ));
163        }
164
165        if gradient_magnitude.exploding_percentage > 10.0 {
166            warnings.push(format!(
167                "High exploding gradient rate: {:.1}%",
168                gradient_magnitude.exploding_percentage
169            ));
170        }
171    }
172
173    let passed = errors.is_empty() && successful > 0;
174
175    Ok(ValidationResult {
176        passed,
177        accuracy: None, // Would be calculated with real dataset
178        top5_accuracy: None,
179        num_samples,
180        successful_inferences: successful,
181        failed_inferences: failed,
182        avg_inference_time_ms: avg_time,
183        peak_memory_mb: peak_memory,
184        gradient_check_passed: gradient_check_result,
185        numerical_stability,
186        errors,
187        warnings,
188    })
189}
190
191/// Validate model structure
192fn validate_model_structure(model: &TorshModel) -> Result<()> {
193    debug!("Validating model structure");
194
195    // Check layers exist
196    if model.layers.is_empty() {
197        anyhow::bail!("Model has no layers");
198    }
199
200    // Check each layer has valid shapes
201    for layer in &model.layers {
202        if layer.input_shape.is_empty() {
203            anyhow::bail!("Layer {} has empty input shape", layer.name);
204        }
205        if layer.output_shape.is_empty() {
206            anyhow::bail!("Layer {} has empty output shape", layer.name);
207        }
208
209        // Verify weight tensor exists for trainable layers
210        if layer.trainable {
211            let weight_name = format!("{}.weight", layer.name);
212            if !model.weights.contains_key(&weight_name) {
213                anyhow::bail!("Trainable layer {} missing weight tensor", layer.name);
214            }
215        }
216    }
217
218    // Check layer connectivity (input/output shapes should match)
219    for i in 0..model.layers.len() - 1 {
220        let current = &model.layers[i];
221        let next = &model.layers[i + 1];
222
223        if current.output_shape != next.input_shape {
224            warn!(
225                "Shape mismatch between layers {} and {}: {:?} != {:?}",
226                current.name, next.name, current.output_shape, next.input_shape
227            );
228        }
229    }
230
231    Ok(())
232}
233
234/// Run inference tests on random inputs
235async fn run_inference_tests(
236    model: &TorshModel,
237    num_samples: usize,
238) -> Result<(usize, usize, f64, f64)> {
239    info!("Running {} inference tests", num_samples);
240
241    let input_shape = model
242        .layers
243        .first()
244        .map(|l| l.input_shape.clone())
245        .unwrap_or_else(|| vec![784]);
246
247    let mut successful = 0;
248    let mut failed = 0;
249    let mut total_time = 0.0;
250    let mut peak_memory = 0.0f64;
251
252    for i in 0..num_samples {
253        let input = create_random_input(&input_shape)?;
254
255        let start = std::time::Instant::now();
256
257        match perform_forward_pass(model, &input).await {
258            Ok(output) => {
259                successful += 1;
260                total_time += start.elapsed().as_secs_f64() * 1000.0;
261
262                // Estimate memory usage
263                let memory = estimate_inference_memory(model, &output);
264                peak_memory = peak_memory.max(memory);
265
266                debug!(
267                    "Inference {}: successful, output shape: {:?}",
268                    i,
269                    output.shape().dims()
270                );
271            }
272            Err(e) => {
273                failed += 1;
274                warn!("Inference {} failed: {}", i, e);
275            }
276        }
277    }
278
279    let avg_time = if successful > 0 {
280        total_time / successful as f64
281    } else {
282        0.0
283    };
284
285    Ok((successful, failed, avg_time, peak_memory))
286}
287
288/// Create random input tensor
289fn create_random_input(shape: &[usize]) -> Result<Tensor<f32>> {
290    let mut rng = thread_rng();
291    let uniform = Uniform::new(-1.0f64, 1.0f64)?;
292
293    let num_elements: usize = shape.iter().product();
294    let data: Vec<f32> = (0..num_elements)
295        .map(|_| uniform.sample(&mut rng) as f32)
296        .collect();
297
298    Ok(Tensor::from_data(data, shape.to_vec(), DeviceType::Cpu)?)
299}
300
301/// Perform a real forward pass through the model.
302///
303/// Delegates to [`super::tensor_integration::forward_pass`], which threads the
304/// input through each layer with real `matmul`/activation tensor kernels. This
305/// is a genuine computation — not a zero-filled placeholder.
306async fn perform_forward_pass(model: &TorshModel, input: &Tensor<f32>) -> Result<Tensor<f32>> {
307    debug!("Performing forward pass");
308    super::tensor_integration::forward_pass(model, input)
309}
310
311/// Estimate FLOPs for a layer
312fn estimate_layer_flops(layer: &LayerInfo) -> u64 {
313    let input_size: u64 = layer.input_shape.iter().map(|&x| x as u64).product();
314    let output_size: u64 = layer.output_shape.iter().map(|&x| x as u64).product();
315
316    match layer.layer_type.as_str() {
317        "Linear" | "Dense" => 2 * input_size * output_size,
318        "Conv2d" => {
319            let kernel_size = 9; // Assume 3x3
320            2 * kernel_size * output_size
321        }
322        "ReLU" | "Sigmoid" | "Tanh" => output_size,
323        _ => output_size,
324    }
325}
326
327/// Estimate memory usage for inference
328fn estimate_inference_memory(model: &TorshModel, _output: &Tensor<f32>) -> f64 {
329    let param_memory: u64 = model
330        .weights
331        .values()
332        .map(|t| {
333            let elements: usize = t.shape.iter().product();
334            (elements * t.dtype.size_bytes()) as u64
335        })
336        .sum();
337
338    let activation_memory: u64 = model
339        .layers
340        .iter()
341        .map(|l| {
342            let output_elements: u64 = l.output_shape.iter().map(|&x| x as u64).product();
343            output_elements * 4 // f32
344        })
345        .sum();
346
347    (param_memory + activation_memory) as f64 / (1024.0 * 1024.0)
348}
349
350/// Attempt gradient checking.
351///
352/// A meaningful gradient check compares finite-difference (numerical) gradients
353/// with analytical (autograd) gradients for the same parameters. A
354/// [`TorshModel`] carries only tensor metadata — it has no autograd-tracked
355/// parameters or live computation graph — so analytical gradients cannot be
356/// obtained. Rather than compare fabricated random vectors (which would always
357/// "pass"), this delegates to the honest primitive in
358/// [`super::tensor_integration::gradient_check`], which returns an error
359/// describing what is required to gradient-check a model.
360async fn perform_gradient_check(model: &TorshModel) -> Result<GradientCheckResult> {
361    info!("Performing gradient check");
362
363    let input_shape = model
364        .layers
365        .first()
366        .map(|l| l.input_shape.clone())
367        .unwrap_or_else(|| vec![784]);
368    let input = create_random_input(&input_shape)?;
369
370    // Errors for metadata-only models; yields a real verdict once
371    // autograd-tracked models are supported.
372    let passed = super::tensor_integration::gradient_check(model, &input, 1e-5)?;
373
374    Ok(GradientCheckResult {
375        passed,
376        max_relative_error: 0.0,
377        avg_relative_error: 0.0,
378        num_gradients_checked: model.weights.len(),
379        failed_locations: Vec::new(),
380    })
381}
382
383/// Analyze numerical stability of the model from a real forward pass.
384///
385/// A [`TorshModel`] holds only tensor metadata (shapes/dtypes) — it has no
386/// trained weight values to inspect — so stability is assessed from the
387/// activations of a genuinely-computed forward pass rather than from fabricated
388/// samples. Gradient-magnitude statistics require an autograd graph that a
389/// metadata-only model does not provide, so they are honestly reported as
390/// absent (`None`).
391async fn analyze_numerical_stability(model: &TorshModel) -> Result<StabilityAnalysis> {
392    info!("Analyzing numerical stability");
393
394    let input_shape = model
395        .layers
396        .first()
397        .map(|l| l.input_shape.clone())
398        .unwrap_or_else(|| vec![784]);
399    let input = create_random_input(&input_shape)?;
400    let output = super::tensor_integration::forward_pass(model, &input)?;
401    let values: Vec<f32> = output.to_vec()?;
402
403    let mut has_nan = false;
404    let mut has_inf = false;
405    let mut has_large_values = false;
406    let mut has_tiny_values = false;
407    for &val in &values {
408        if val.is_nan() {
409            has_nan = true;
410        }
411        if val.is_infinite() {
412            has_inf = true;
413        }
414        if val.abs() > 1e6 {
415            has_large_values = true;
416        }
417        if val != 0.0 && val.abs() < 1e-6 {
418            has_tiny_values = true;
419        }
420    }
421
422    let activation_stats = compute_activation_statistics(&values);
423
424    Ok(StabilityAnalysis {
425        has_nan,
426        has_inf,
427        has_large_values,
428        has_tiny_values,
429        gradient_magnitude: None,
430        activation_stats,
431    })
432}
433
434/// Compute activation statistics from real activation values.
435fn compute_activation_statistics(activations: &[f32]) -> ActivationStatistics {
436    if activations.is_empty() {
437        return ActivationStatistics {
438            mean: 0.0,
439            std: 0.0,
440            min: 0.0,
441            max: 0.0,
442            dead_neurons_percentage: 0.0,
443        };
444    }
445
446    let count = activations.len() as f64;
447    let mean = activations.iter().map(|&x| x as f64).sum::<f64>() / count;
448    let variance = activations
449        .iter()
450        .map(|&x| (x as f64 - mean).powi(2))
451        .sum::<f64>()
452        / count;
453    let std = variance.sqrt();
454    let min = activations.iter().copied().fold(f32::INFINITY, f32::min) as f64;
455    let max = activations
456        .iter()
457        .copied()
458        .fold(f32::NEG_INFINITY, f32::max) as f64;
459
460    let dead_count = activations.iter().filter(|&&x| x == 0.0).count();
461    let dead_neurons_percentage = (dead_count as f64 / count) * 100.0;
462
463    ActivationStatistics {
464        mean,
465        std,
466        min,
467        max,
468        dead_neurons_percentage,
469    }
470}
471
472/// Calculate overall stability score (0-1)
473fn calculate_stability_score(analysis: &StabilityAnalysis) -> f64 {
474    let mut score = 1.0f64;
475
476    // Penalize for NaN/Inf values
477    if analysis.has_nan {
478        score -= 0.5;
479    }
480    if analysis.has_inf {
481        score -= 0.5;
482    }
483
484    // Penalize for extreme values
485    if analysis.has_large_values {
486        score -= 0.1;
487    }
488    if analysis.has_tiny_values {
489        score -= 0.05;
490    }
491
492    // Penalize for gradient issues (only when gradient statistics are available)
493    if let Some(gradient_magnitude) = &analysis.gradient_magnitude {
494        if gradient_magnitude.vanishing_percentage > 50.0 {
495            score -= 0.2;
496        }
497        if gradient_magnitude.exploding_percentage > 10.0 {
498            score -= 0.2;
499        }
500    }
501
502    // Penalize for dead neurons
503    if analysis.activation_stats.dead_neurons_percentage > 50.0 {
504        score -= 0.1;
505    }
506
507    score.max(0.0)
508}
509
510/// Format validation result as human-readable text
511pub fn format_validation_result(result: &ValidationResult) -> String {
512    let mut output = String::new();
513
514    output.push_str("╔═══════════════════════════════════════════════════════════════════════╗\n");
515    output.push_str("║                     MODEL VALIDATION REPORT                           ║\n");
516    output
517        .push_str("╚═══════════════════════════════════════════════════════════════════════╝\n\n");
518
519    // Overall status
520    let status = if result.passed {
521        "✅ PASSED"
522    } else {
523        "❌ FAILED"
524    };
525    output.push_str(&format!("Status: {}\n\n", status));
526
527    // Inference results
528    output.push_str("📊 Inference Testing\n");
529    output.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
530    output.push_str(&format!("  Samples tested:       {}\n", result.num_samples));
531    output.push_str(&format!(
532        "  Successful:           {}\n",
533        result.successful_inferences
534    ));
535    output.push_str(&format!(
536        "  Failed:               {}\n",
537        result.failed_inferences
538    ));
539    output.push_str(&format!(
540        "  Avg inference time:   {:.2} ms\n",
541        result.avg_inference_time_ms
542    ));
543    output.push_str(&format!(
544        "  Peak memory:          {:.2} MB\n",
545        result.peak_memory_mb
546    ));
547
548    if let Some(acc) = result.accuracy {
549        output.push_str(&format!("  Accuracy:             {:.2}%\n", acc * 100.0));
550    }
551    if let Some(top5) = result.top5_accuracy {
552        output.push_str(&format!("  Top-5 Accuracy:       {:.2}%\n", top5 * 100.0));
553    }
554
555    output.push_str("\n");
556
557    // Gradient check results
558    if let Some(grad_passed) = result.gradient_check_passed {
559        output.push_str("🔍 Gradient Checking\n");
560        output
561            .push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
562        output.push_str(&format!(
563            "  Status:               {}\n",
564            if grad_passed {
565                "✅ PASSED"
566            } else {
567                "❌ FAILED"
568            }
569        ));
570        output.push_str("\n");
571    }
572
573    // Numerical stability
574    output.push_str("📈 Numerical Stability\n");
575    output.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
576    output.push_str(&format!(
577        "  Stability score:      {:.2}/1.00\n",
578        result.numerical_stability
579    ));
580    output.push_str("\n");
581
582    // Errors
583    if !result.errors.is_empty() {
584        output.push_str("❌ Errors\n");
585        output
586            .push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
587        for error in &result.errors {
588            output.push_str(&format!("  • {}\n", error));
589        }
590        output.push_str("\n");
591    }
592
593    // Warnings
594    if !result.warnings.is_empty() {
595        output.push_str("⚠️  Warnings\n");
596        output
597            .push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
598        for warning in &result.warnings {
599            output.push_str(&format!("  • {}\n", warning));
600        }
601        output.push_str("\n");
602    }
603
604    output
605}
606
607#[cfg(test)]
608mod tests {
609    use super::super::tensor_integration::create_real_model;
610    use super::*;
611
612    #[tokio::test]
613    async fn test_model_validation() {
614        let model = create_real_model("test", 3, DeviceType::Cpu)
615            .expect("create real model should succeed");
616        let result = validate_model(&model, 10, false)
617            .await
618            .expect("operation should succeed");
619
620        assert!(result.num_samples == 10);
621        assert!(result.successful_inferences > 0);
622    }
623
624    #[test]
625    fn test_structure_validation() {
626        let model = create_real_model("test", 2, DeviceType::Cpu)
627            .expect("create real model should succeed");
628        assert!(validate_model_structure(&model).is_ok());
629    }
630
631    #[tokio::test]
632    async fn test_gradient_check_is_honest_error() {
633        let model = create_real_model("test", 2, DeviceType::Cpu)
634            .expect("create real model should succeed");
635        // A metadata-only model has no autograd graph, so gradient checking
636        // must return an honest error rather than fabricate a passing result.
637        assert!(perform_gradient_check(&model).await.is_err());
638    }
639
640    #[tokio::test]
641    async fn test_stability_analysis() {
642        let model = create_real_model("test", 2, DeviceType::Cpu)
643            .expect("create real model should succeed");
644        let analysis = analyze_numerical_stability(&model)
645            .await
646            .expect("operation should succeed");
647
648        assert!(!analysis.has_nan);
649        assert!(!analysis.has_inf);
650    }
651
652    #[test]
653    fn test_validation_formatting() {
654        let result = ValidationResult {
655            passed: true,
656            accuracy: Some(0.95),
657            top5_accuracy: Some(0.99),
658            num_samples: 100,
659            successful_inferences: 98,
660            failed_inferences: 2,
661            avg_inference_time_ms: 5.5,
662            peak_memory_mb: 125.3,
663            gradient_check_passed: Some(true),
664            numerical_stability: 0.92,
665            errors: vec![],
666            warnings: vec!["High memory usage".to_string()],
667        };
668
669        let formatted = format_validation_result(&result);
670        assert!(formatted.contains("VALIDATION REPORT"));
671        assert!(formatted.contains("PASSED"));
672    }
673}