A ~670-line if/else chain matched op names as strings. It recognized 58 of 281. Every op it didn’t recognize returned the input gradient unchanged — no error, no warning, no debug_assert — and the test suite was green the whole time.
Today we released SciRS2 0.6.5 — a defect-hunting cycle rather than a features cycle. It started as a routine cleanup: audit every #[ignore]d test in the workspace (132 of them, roughly a third with no reason attached) and either fix what they were skipping or document why. Followed all the way to ground, that audit surfaced real bugs across autograd, linalg, neural, stats, graph, io, integrate, series, spatial, ndimage, and special — the single biggest of which is that scirs2-autograd’s live backward pass was, for the majority of operators, not actually differentiating anything.
SciRS2 stays what it has always been: no C, no Fortran, no CUDA Toolkit, no system BLAS — a single static binary (or WASM module) that compiles and runs everywhere. 0.6.5 makes sure that when you ask it for a gradient, you get the real one.
Why SciRS2 0.6.5 is a game changer
A gradient that’s silently wrong is worse than one that crashes:
Op::grad()— a correct, per-operator gradient implementation — existed for essentially every differentiable op in the crate. Nothing called it.- The actual backward pass lived in a completely separate ~670-line
if/elsechain ingradient.rs, keyed onOp::name()string matching, hand-maintained since the crate’s earliest days and never kept in sync with new ops. - Every elementary math function —
sqrt,exp,ln, every trig and hyperbolic-trig function,log2/log10/exp2/exp10,abs— and every activation (softplus,elu,swish,gelu,mish) fell through toSome(gy): the incoming gradient, passed straight through unchanged. An identity gradient. For asqrt. - The public custom-gradient API —
custom_op,scale_gradient,selective_stop_gradient,detach— was a complete no-op. Whatever backward closure you supplied, the crate ignored it and returned the identity gradient anyway. - The existing gradient tests used an all-ones cotangent almost everywhere, which is exactly the one input that makes a broken
transpose,gather, orreduce_sumVJP look correct by coincidence — permuting or broadcasting a constant vector is a no-op regardless of whether the permutation/broadcast logic is right.
SciRS2 0.6.5 ends all of that:
- Dispatch now consults
Op::grad(). Anything the override table doesn’t special-case for higher-order-safety falls through to the op’s own, already-correct gradient implementation — converting roughly 223 wrong-or-absent gradients to correct ones in one release. - The custom-gradient API is genuinely functional.
custom_op,scale_gradient,selective_stop_gradient, anddetachnow route through the same fixed dispatch and actually apply the closure you give them. - A new permanent regression harness (
tests/gradient_fd_harness.rs,tests/gradient_fd_harness_matrix.rs) checks every gradient against a central finite difference using a non-uniform cotangent — the one thing the old tests never did. - Independently found and fixed in the same pass:
reduce_meanwas missing its1/Nfactor;sigmoid_cross_entropy(matched viacontains("Sigmoid")) andBatchMatMul(matched viaends_with("MatMul"), applying a 2-D transpose rule to 3-D batched tensors) were misidentified by fragile substring matching;concat/einsum/tensordotpanicked during backprop instead of returning a gradient. - Five other crates, real bugs: a general (non-symmetric) eigenvalue/Schur engine in
scirs2-linalgthat actually checks for convergence; an invertedOrdonscirs2-spatial’s k-NNBinaryHeapthat was returning the farthest candidates instead of the nearest; ascirs2-graphrewiring step that hung on 99.8% of seeds; a backwards Kolmogorov-Smirnov p-value inscirs2-statsthat printed logically-inverted conclusions; and a NetCDF3 backend inscirs2-iothat went from a no-op stand-in to a real implementation.
Technical Deep Dive: the trait method nobody called
The core issue is a class of bug that’s easy to introduce and hard to catch: two independent code paths that are supposed to agree, where only one of them is ever exercised at runtime.
// gradient.rs (simplified) — the path that actually ran:
fn compute_grad_for_input(op_name: &str, gy: Tensor, ...) -> Option<Tensor> {
if op_name == "Sqrt" {
// correct: gy * 0.5 / sqrt(x)
Some(gy * T::scalar_mul(T::inv_sqrt(x), 0.5))
} else if op_name == "Neg" {
Some(T::neg(gy))
}
// ... ~60 more arms ...
else {
// silently wrong for the other 220+ ops
Some(gy)
}
}
// op.rs — the path with the real implementation, never called from here:
impl Op for SqrtOp {
fn grad(&self, gy: Tensor, ctx: &mut GradContext) {
// correct, tested in isolation, just never invoked by gradient.rs
ctx.append_input_grad(0, Some(gy * ...));
}
}
Every Op implementation’s own grad() method was correct — each was written and tested against its individual forward pass. The bug was entirely in the wiring: the live dispatcher never called it. Fixing this meant changing compute_grad_for_input to consult op.grad() as its fallback instead of Some(gy), keeping the hand-written override table only for the handful of ops (higher-order derivatives, ops needing extra context) that genuinely need special-casing. The finite-difference harness that verifies this is now permanent, and it deliberately uses a non-uniform cotangent for exactly the reason the old tests couldn’t catch this in the first place.
Getting Started
The custom-gradient API is a good way to see the fix directly — scale_gradient is a gradient-reversal-style layer that scales the backward gradient by a constant without touching the forward value. Before 0.6.5 this silently did nothing; now it works:
cargo add scirs2-autograd
use scirs2_autograd as ag;
use ag::tensor_ops::{self as T, scale_gradient};
use scirs2_core::ndarray::{ArrayD, IxDyn};
fn main() {
ag::run(|g| {
let x = T::variable(
ArrayD::from_shape_vec(IxDyn(&[4]), vec![0.5, 1.5, -2.0, 3.25]).expect("x"),
g,
);
// Forward value is unaffected; the backward gradient through `y` is
// multiplied by -2.0 -- a textbook gradient-reversal layer.
let y = scale_gradient(T::square(x), -2.0, g);
let loss = T::sum_all(T::scalar_mul(y, 2.5));
let grads = T::grad(&[loss], &[x]);
let gx = grads[0].eval(g).expect("gradient eval");
// True gradient of sum(2.5 * x^2) is 5*x; scale_gradient(-2.0) then
// flips and doubles it. Before 0.6.5 this printed the un-reversed 5*x.
println!("{:?}", gx); // [-5.0, -15.0, 20.0, -32.5]
});
}
What’s New in 0.6.5
Fixed
scirs2-autograd: the live backward-pass dispatcher now consults each op’s ownOp::grad(), fixing roughly 223 wrong-or-absent gradients — every elementary math function, every listed activation,reduce_sum/transpose/gather(previously only correct under an all-ones cotangent),reduce_mean’s missing1/N, misidentifiedsigmoid_cross_entropy/BatchMatMul, and panickingconcat/einsum/tensordot.scirs2-autograd: the public custom-gradient API (custom_op,scale_gradient,selective_stop_gradient,detach) is now genuinely functional instead of a no-op;SymmetricEigenOpnow diagonalizes via a real cyclic-Jacobi algorithm shared across all matrix sizes.scirs2-linalg: a real, convergence-checked general (non-symmetric) eigenvalue/Schur engine now backsdecomposition::schur,lapack::eig, andeigen::advanced_precision_eig.scirs2-stats: fixed a backwards one-sided Kolmogorov-Smirnov p-value, a hardcoded-zero F-test p-value inpolyfit, a self-deadlockingErrorMonitor, and garbage-output Niederreiter/Sobol QMC generators.scirs2-graph: spectral clustering and Hungarian matching now compute real answers instead of a stand-in;watts_strogatz_graphno longer hangs on ~99.8% of seeds (its rewiring step checkedhas_node, alwaystrue, instead ofhas_edge).scirs2-io: NetCDF3 read/write is now a real implementation instead of a no-op stand-in.scirs2-integrate: DOP853 and RK23 now implement real embedded error estimators and step-size control.scirs2-spatial:octree/quadtreek-NNBinaryHeaphad an invertedOrd, returning the farthest candidates; A*’sreconstruct_pathalways reported a path cost of0.0.scirs2-ndimage: the regular-array mmap loader ignored its own variable-length header, shifting every element.scirs2-special:gamma(x)silently returnedinfforxin ~[140.5, 171].
Added
- A new
ignore_auditpolicy lint enforcing a#[ignore]reason taxonomy (requires-gpu:/requires-env:/slow:/bench:/not-implemented:) and banning two “fake-passing” test patterns found during the audit. scirs2-signal: 83 previously-orphaned-but-real files wired back into the crate — a full Kalman filter family, a BSS/ICA toolkit, compressed-sensing sparse recovery, and a SciPy-ShortTimeFFT-class STFT port.
Changed
scirs2-signal: deleted ~167 unreachable legacy/duplicate files (541 total, 255 unreachable — 47% of the crate).- Workspace-wide
#[ignore]count: 132 (~31% bare) → 59, every one now reason-tagged.
See CHANGELOG.md [0.6.5] for the complete list.
Tips
- If your autograd-based training loop uses
custom_op,scale_gradient,selective_stop_gradient, ordetach, re-check results computed before 0.6.5. These previously ignored your backward closure entirely; anything relying on gradient reversal, gradient scaling, or selective stop-gradients was silently training on the wrong signal. - When you write a gradient test, use a non-uniform cotangent. An all-ones cotangent makes broadcast/permutation-shaped bugs (
transpose,gather,reduce_sumover an axis) invisible, because permuting or broadcasting a constant is a no-op whether or not your VJP logic is correct. - When a dispatcher has two ways to compute the same thing — a fast-path override table and a “real” per-case implementation — assert that the fallback path is actually reachable. A trait method that’s correct but never called is functionally the same as a bug.
- If you use
scirs2-linalg’sschur/eigon non-symmetric matrices with closely-spaced eigenvalues, re-run affected pipelines. The new engine actually checks for QR convergence instead of running a fixed iteration count. - If you run one-sided Kolmogorov-Smirnov tests via
scirs2-stats, re-check any “Rejected”/“Not rejected” conclusion from before 0.6.5 — the p-value formula was backwards. #[ignore]a test only with a reason prefix (requires-gpu:,requires-env:,slow:,bench:,not-implemented:) if you’re contributing to SciRS2 — the newignore_auditlint enforces this, and it exists precisely because a bare#[ignore]is where this release’s headline bug was hiding.
This is the foundation
SciRS2 0.6.5 is the sovereign scientific-computing layer of the COOLJAPAN ecosystem, and a release that fixes the actual gradient computation matters most for everything that trains on top of it:
- NumRS2 — NumPy-compatible N-dimensional arrays in pure Rust.
- PandRS — Pandas-compatible DataFrames.
- OptiRS — advanced ML optimizers extending SciRS2.
- ToRSh — a PyTorch-compatible deep-learning framework.
- SkleaRS — a scikit-learn-compatible ML library.
- TrustformeRS — Hugging Face Transformers in pure Rust.
Repository: https://github.com/cool-japan/scirs
Star the repo if you’d rather a silently-identity gradient get caught by a finite-difference harness with a non-uniform cotangent than by a model that quietly never learns.
The era of “the tests are green, so the gradients must be right” is over. Pure Rust scientific computing is here — fast, safe, and now provably differentiating what it claims to.
— KitaSan at COOLJAPAN OÜ July 31, 2026