gradcheck 0.1.0

Finite-difference gradient checking for Rust ML frameworks. Verifies an autodiff engine against an independent numerical oracle, with a negative control that must fail.
// gradcheck — finite-difference gradient checking for Rust ML frameworks.
// Copyright (c) 2026 Henos D <henosd19@gmail.com> (GitHub: @4ktLuffy)
// Repository: https://github.com/4ktLuffy/gradcheck
// SPDX-License-Identifier: MIT OR Apache-2.0

//! What a check produces.
//!
//! # The three-way rule
//!
//! A comparison has three honest outcomes, not two. The governing principle:
//!
//! > **The additive tolerance may excuse a discrepancy; it may never certify agreement.**
//!
//! An additive tolerance exists to stop float noise near zero from raising false alarms.
//! If it is also allowed to *grant* a pass, then any two values that both sit under it are
//! declared equal — including a gradient that is 100% wrong. That is a real hole, not a
//! hypothetical: with `atol = 1e-4`, `rtol = 1e-3`, a numeric value of `1.001e-4` against
//! an analytic value exactly twice that passes a combined `atol + rtol·M` test, because
//! the difference (`1.001e-4`) is under the bound (`1.002002e-4`).
//!
//! So `Pass` requires the *pure relative* criterion, and the band between `rtol·M` and
//! `atol + rtol·M` is [`UncheckedReason::AmbiguousAtFloor`] — we abstain, by design.

use core::fmt;

/// Why a comparison could not be adjudicated.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UncheckedReason {
    /// The values are too close to the noise floor to certify, but not far enough apart
    /// to reject. The middle branch of the three-way rule.
    AmbiguousAtFloor,
    /// Below the resolution of this oracle; should be routed to a reference backend or a
    /// cross-backend differential instead.
    BelowResolution,
    /// The function is not differentiable at this point (a kink or a pole).
    NonSmooth,
    /// A layout probe could not be delivered — the framework canonicalised the cotangent
    /// before it reached the backward under test, so the probe proves nothing.
    LayoutUnreachable,
    /// A verified-clean case was rejected because the tolerance is too strict. Recorded
    /// rather than "fixed" by loosening the caps.
    NumericalInstability,
    /// Only part of the case was adjudicated.
    Partial {
        /// Components that were adjudicated.
        checked: u32,
        /// Components in the case, adjudicated or not.
        total: u32,
    },
}

/// Which stage produced an invalid state.
///
/// Recorded so a failure can be attributed. "The gradient was NaN" and "the perturbed
/// forward pass was NaN" have different causes and different fixes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Stage {
    /// Building the input tensor from the supplied data and shape.
    Input,
    /// Evaluating `f(x)` at the unperturbed input.
    Forward,
    /// The framework's own backward pass.
    Analytic,
    /// A perturbed forward evaluation, `f(x ± h·eᵢ)`.
    Probe,
    /// Adjudicating one component against the three-way rule.
    Comparator,
    /// Deriving the realized step from the requested one.
    Calibration,
}

/// Why a stage is invalid.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InvalidReason {
    /// NaN or infinity where a finite number was required.
    NonFinite,
    /// The requested perturbation could not be represented — `x + h == x` in working
    /// precision, so the realized step is zero and the difference quotient is meaningless.
    Unrepresentable,
}

/// Outcome of a single gradient check.
///
/// Precedence, highest first: `StructuralMismatch` > `Invalid` > `Mismatch` >
/// `Unchecked` > `Pass`. A case is only `Pass` when nothing else applies anywhere in it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Verdict {
    /// Every compared component met the pure relative criterion.
    Pass,
    /// At least one component exceeded the combined tolerance.
    Mismatch,
    /// The check ran but could not adjudicate.
    Unchecked(UncheckedReason),
    /// The check could not be trusted: a non-finite value or an unrepresentable step.
    Invalid {
        /// Where the invalid state arose.
        stage: Stage,
        /// What was wrong with it.
        reason: InvalidReason,
    },
    /// Shapes or lengths disagree, or the gradient was empty. An empty fold silently
    /// recreates the "worst stays zero" bug, so emptiness is structural, not a pass.
    StructuralMismatch,
}

impl Verdict {
    /// Rank for the precedence rule. Higher wins.
    fn rank(&self) -> u8 {
        match self {
            Verdict::Pass => 0,
            Verdict::Unchecked(_) => 1,
            Verdict::Mismatch => 2,
            Verdict::Invalid { .. } => 3,
            Verdict::StructuralMismatch => 4,
        }
    }

    /// Combine two verdicts, keeping the higher-precedence one.
    pub fn combine(self, other: Verdict) -> Verdict {
        if other.rank() > self.rank() {
            other
        } else {
            self
        }
    }

    /// True only for [`Verdict::Pass`]. Note that `Unchecked` is **not** a pass.
    pub fn is_pass(&self) -> bool {
        matches!(self, Verdict::Pass)
    }
}

/// The per-component detail of a comparison.
#[derive(Debug, Clone)]
pub struct ComponentRecord {
    /// The framework's analytic value.
    pub a: f64,
    /// The oracle's numeric value.
    pub n: f64,
    /// `|a - n|`.
    pub diff: f64,
    /// The combined bound this component was judged against.
    pub tol_used: f64,
    /// This component's own outcome.
    pub outcome: Verdict,
    /// `a` and `n` are both zero but differ in sign bit. Never adjudicated by finite
    /// differences — the framework spec is authoritative for signed zero.
    pub signed_zero_discrepancy: bool,
}

/// The result of a single case.
#[derive(Debug, Clone)]
pub struct Report {
    /// Label supplied by the caller.
    pub name: String,
    /// Backend name, from [`crate::Backend::name`].
    pub backend: &'static str,
    /// Shape of the input that was differentiated.
    pub shape: Vec<usize>,
    /// The framework's gradient, row-major.
    pub analytic: Vec<f64>,
    /// The oracle's gradient, row-major.
    pub numeric: Vec<f64>,
    /// Per-component detail. **Always populated**, even when the case verdict is
    /// `Invalid` — the precedence rule chooses the verdict, never the record.
    pub components: Vec<ComponentRecord>,
    /// Index of the worst *adjudicable* component, if any.
    pub worst_index: usize,
    /// Relative error at `worst_index`.
    pub worst_rel_error: f64,
    /// The case verdict.
    pub verdict: Verdict,
    /// Set by repeat-N runs; orthogonal to the verdict.
    pub nondeterministic: bool,
}

impl Report {
    /// True only when the verdict is `Pass`.
    ///
    /// `Unchecked` is deliberately not a pass: the whole point of the third outcome is
    /// that abstention must not be silently counted as agreement.
    pub fn passed(&self) -> bool {
        self.verdict.is_pass()
    }

    /// How many components were actually adjudicated (Pass or Mismatch), out of the total.
    pub fn checked_fraction(&self) -> (u32, u32) {
        let checked = self
            .components
            .iter()
            .filter(|c| matches!(c.outcome, Verdict::Pass | Verdict::Mismatch))
            .count() as u32;
        (checked, self.components.len() as u32)
    }

    /// Indices of every component that was rejected, worst first.
    pub fn failing_indices(&self) -> Vec<usize> {
        let mut idx: Vec<usize> = (0..self.components.len())
            .filter(|&i| self.components[i].outcome == Verdict::Mismatch)
            .collect();
        idx.sort_by(|a, b| {
            self.components[*b]
                .diff
                .partial_cmp(&self.components[*a].diff)
                .unwrap_or(core::cmp::Ordering::Equal)
        });
        idx
    }

    /// Indices of every component that abstained.
    pub fn unchecked_indices(&self) -> Vec<usize> {
        (0..self.components.len())
            .filter(|&i| matches!(self.components[i].outcome, Verdict::Unchecked(_)))
            .collect()
    }

    /// Panic with a readable message unless the verdict is `Pass`.
    ///
    /// Because `Unchecked` is not a pass, this also fires on abstention — which is
    /// intended: a suite that silently accepts "we could not tell" is back to the hole
    /// this design exists to close. Use [`Report::assert_no_mismatch`] when abstention
    /// is acceptable.
    pub fn assert_pass(&self) {
        if !self.passed() {
            panic!("{self}");
        }
    }

    /// Panic only on an actual rejection or invalid state; tolerate abstention.
    pub fn assert_no_mismatch(&self) {
        match self.verdict {
            Verdict::Pass | Verdict::Unchecked(_) => {}
            _ => panic!("{self}"),
        }
    }
}

impl fmt::Display for Report {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let (checked, total) = self.checked_fraction();
        write!(
            f,
            "gradcheck {:?} [{}] {} shape={:?} checked={checked}/{total} worst_rel={:.6e}",
            self.verdict, self.backend, self.name, self.shape, self.worst_rel_error,
        )?;
        if let Some(c) = self.components.get(self.worst_index) {
            write!(
                f,
                " at[{}] analytic={:.9} numeric={:.9} diff={:.3e} tol={:.3e}",
                self.worst_index, c.a, c.n, c.diff, c.tol_used
            )?;
        }
        if self.nondeterministic {
            write!(f, " NONDETERMINISTIC")?;
        }
        Ok(())
    }
}