Files
rustytorch/crates/training/rtx-transformers/tests/real_selective_scan.rs
T
Omar SobhandClaude Sonnet 4.6 a08adfbf57 fix(gaps): G4 — re-enable all rtx-transformers Phase 2/3 modules (222 compile errors fixed)
Uncommented all deferred modules in lib.rs and fixed API drift across ~60 files in
9 module groups: continual, curriculum, meta, modular, neural_ode, graph, kan,
perceiver, distributed/pipeline_parallelism.

Common patterns fixed across modules:
- Tensor::randn/zeros/ones([a,b]) → (&[a,b], device)? (slice + Result)
- Result<T, TensorError> → .map_err(Into::into)? in TransformerError contexts
- Device by value → &device references
- &Tensor where Tensor expected → .clone()
- tensor.relu()/tanh()/sigmoid() as methods not ops functions
- Tensor arithmetic returning Result: (a + b)? → (a.clone() + b)?
- shape literals → shape.dims() for Shape type
- sum(n) → sum(Some(n)), mean(None) → mean(&[], false)
- i64 indices → usize where required
- backward(x) → backward(x, None)
- Borrow conflicts on self.field resolved by extracting to locals before mut borrow
- BatchingStats private fields → pub(crate)
- TransformerError::Serialization → ::SerializationError
- Add scalar to tensor: (t + 0.1)? → t.add_scalar(0.1)?

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-26 16:08:02 +00:00

388 lines
13 KiB
Rust

//! Milestone-1 gate for the real selective-scan Mamba (Phase-3 spec).
//!
//! These tests prove the `MambaBlock` forward is now a genuine S6
//! selective-state-space model — not the prior passthrough stub:
//! - **Liveness**: every weight that was *dead* in the stub
//! (`conv1d_weight`, `A_log`, `dt_proj`, `x_proj`, `D`) now changes
//! the output when perturbed.
//! - **Causality**: output at step `l` depends only on inputs `≤ l`
//! (no future leakage through the causal conv + scan).
//! - **Determinism**: a seeded block is bit-stable across builds.
//! - **Scan dynamics**: a non-trivial input produces non-constant,
//! finite output with real temporal mixing.
use std::collections::HashMap;
use rtx_tensor::{Device, Tensor};
use rtx_transformers::layers::mamba::{MambaBlock, MambaConfig};
const D_MODEL: usize = 8;
const D_STATE: usize = 16;
const D_CONV: usize = 4;
const L: usize = 6;
fn cfg() -> MambaConfig {
MambaConfig::new(D_MODEL, D_STATE, D_CONV)
}
fn fwd(block: &MambaBlock, x: &Tensor) -> Vec<f32> {
block
.forward(x)
.expect("forward")
.output
.to_vec()
.expect("to_vec")
}
fn max_abs_diff(a: &[f32], b: &[f32]) -> f32 {
a.iter()
.zip(b)
.map(|(x, y)| (x - y).abs())
.fold(0.0, f32::max)
}
/// Clone all persistence tensors into a map, replacing `target` with a
/// copy whose every element is shifted by `delta`.
fn perturbed_map(
block: &MambaBlock,
target: &str,
delta: f32,
dev: &Device,
) -> HashMap<String, Tensor> {
let mut map = HashMap::new();
for (name, t) in block.persistence_tensors() {
if name == target {
let mut v = t.to_vec().expect("to_vec");
for x in v.iter_mut() {
*x += delta;
}
let pt = Tensor::from_vec(v, t.shape().dims(), dev).expect("from_vec");
map.insert(name.to_string(), pt);
} else {
map.insert(name.to_string(), t.clone());
}
}
map
}
/// A seeded block with `dt_bias` overridden to 0 ⇒ `Δ = softplus(0) ≈
/// 0.69`. The default init uses a deliberately small `Δ≈0.01` (the scan
/// is near-identity — correct for training stability), which makes the
/// scan-only weights' influence vanish below the f32 floor. This widens
/// `Δ` so the scan genuinely contributes to the output, letting us
/// observe each weight's effect.
fn active_block(cfg: &MambaConfig, dev: &Device, seed: u64) -> MambaBlock {
let base = MambaBlock::new_seeded(cfg.clone(), dev, seed).expect("base");
let d = cfg.get_d_inner();
let mut map = HashMap::new();
for (name, t) in base.persistence_tensors() {
if name == "dt_bias" {
map.insert(
name.to_string(),
Tensor::from_vec(vec![0.0f32; d], &[d], dev).expect("dtb"),
);
} else {
map.insert(name.to_string(), t.clone());
}
}
MambaBlock::from_persistence_tensors(cfg.clone(), dev, map).expect("rebuild")
}
#[test]
fn every_formerly_dead_weight_is_live() {
let dev = Device::cpu();
let block = active_block(&cfg(), &dev, 42);
let x = Tensor::randn_seeded(&[1, L, D_MODEL], &dev, 7).expect("x");
let y0 = fwd(&block, &x);
// In the stub these were all discarded; each must now move the output.
for name in ["conv1d_weight", "A_log", "dt_proj", "x_proj", "D"] {
let map = perturbed_map(&block, name, 0.5, &dev);
let b2 = MambaBlock::from_persistence_tensors(cfg(), &dev, map).expect("rebuild");
let y2 = fwd(&b2, &x);
let diff = max_abs_diff(&y0, &y2);
assert!(
diff > 1e-4,
"weight `{name}` is DEAD — perturbing it left the output unchanged (max_abs_diff={diff:.2e})"
);
}
}
#[test]
fn forward_is_causal() {
let dev = Device::cpu();
let block = MambaBlock::new_seeded(cfg(), &dev, 11).expect("block");
let base = Tensor::randn_seeded(&[1, L, D_MODEL], &dev, 3).expect("x");
let y0 = fwd(&block, &base);
// Perturb ONLY the last timestep.
let mut xv = base.to_vec().expect("to_vec");
for m in 0..D_MODEL {
xv[(L - 1) * D_MODEL + m] += 1.0;
}
let x2 = Tensor::from_vec(xv, &[1, L, D_MODEL], &dev).expect("x2");
let y2 = fwd(&block, &x2);
// Outputs for steps 0..L-1 must be identical (no future leakage);
// the last step must change.
let prefix0 = &y0[..(L - 1) * D_MODEL];
let prefix2 = &y2[..(L - 1) * D_MODEL];
assert!(
max_abs_diff(prefix0, prefix2) < 1e-6,
"non-causal: changing input[L-1] altered an earlier output"
);
let last0 = &y0[(L - 1) * D_MODEL..];
let last2 = &y2[(L - 1) * D_MODEL..];
assert!(
max_abs_diff(last0, last2) > 1e-4,
"last output did not respond to the last input"
);
}
#[test]
fn seeded_forward_is_deterministic() {
let dev = Device::cpu();
let a = MambaBlock::new_seeded(cfg(), &dev, 99).expect("a");
let b = MambaBlock::new_seeded(cfg(), &dev, 99).expect("b");
let x = Tensor::randn_seeded(&[1, L, D_MODEL], &dev, 5).expect("x");
assert_eq!(
fwd(&a, &x),
fwd(&b, &x),
"same (config, seed) must be bit-exact"
);
}
/// Build a seeded block whose every weight is scaled by `scale`, so the
/// SSM operates in a well-conditioned regime (small `A_log` ⇒ `A≈-1` ⇒
/// the scan neither saturates nor underflows), giving a meaningful
/// finite-difference signal.
fn scaled_block(cfg: &MambaConfig, dev: &Device, seed: u64, scale: f32) -> MambaBlock {
let base = MambaBlock::new_seeded(cfg.clone(), dev, seed).expect("base");
let mut map = HashMap::new();
for (name, t) in base.persistence_tensors() {
let mut v = t.to_vec().expect("to_vec");
for x in v.iter_mut() {
*x *= scale;
}
map.insert(
name.to_string(),
Tensor::from_vec(v, t.shape().dims(), dev).expect("from_vec"),
);
}
MambaBlock::from_persistence_tensors(cfg.clone(), dev, map).expect("rebuild")
}
fn loss(block: &MambaBlock, x: &Tensor, dov: &[f32]) -> f32 {
let out = block
.forward(x)
.expect("fwd")
.output
.to_vec()
.expect("to_vec");
out.iter().zip(dov).map(|(o, g)| o * g).sum::<f32>()
}
fn perturbed_one(
block: &MambaBlock,
name: &str,
idx: usize,
eps: f32,
cfg: &MambaConfig,
dev: &Device,
) -> MambaBlock {
let mut map = HashMap::new();
for (nm, t) in block.persistence_tensors() {
let mut v = t.to_vec().expect("to_vec");
if nm == name {
v[idx] += eps;
}
map.insert(
nm.to_string(),
Tensor::from_vec(v, t.shape().dims(), dev).expect("from_vec"),
);
}
MambaBlock::from_persistence_tensors(cfg.clone(), dev, map).expect("rebuild")
}
#[test]
fn analytic_gradients_match_finite_differences() {
let dev = Device::cpu();
// Small, well-conditioned instance for a clean f32 FD signal.
let cfg = MambaConfig::new(4, 4, 3);
let block = scaled_block(&cfg, &dev, 21, 0.2);
let ll = 4usize;
let dmodel = 4usize;
// Input and a fixed upstream gradient d_out (so L = Σ d_out·out).
let xvec: Vec<f32> = (0..ll * dmodel)
.map(|i| 0.5 * ((i as f32 * 0.37).sin()))
.collect();
let x = Tensor::from_vec(xvec, &[1, ll, dmodel], &dev).expect("x");
let dov: Vec<f32> = (0..ll * dmodel)
.map(|i| ((i as f32 * 0.91).cos()))
.collect();
let grads = block
.backward(
&x,
&Tensor::from_vec(dov.clone(), &[1, ll, dmodel], &dev).expect("dov"),
)
.expect("backward");
let eps = 1e-2f32;
let mut checked = 0;
for name in [
"in_proj",
"conv1d_weight",
"conv1d_bias",
"A_log",
"x_proj",
"dt_proj",
"dt_bias",
"D",
"out_proj",
] {
let g = grads
.get(name)
.unwrap_or_else(|| panic!("missing grad {name}"))
.to_vec()
.expect("g");
let len = g.len();
// Sample up to 4 spread-out indices per parameter.
let idxs: Vec<usize> = if len <= 4 {
(0..len).collect()
} else {
vec![0, len / 4, len / 2, (3 * len) / 4]
};
for &idx in &idxs {
let bp = perturbed_one(&block, name, idx, eps, &cfg, &dev);
let bm = perturbed_one(&block, name, idx, -eps, &cfg, &dev);
let fd = (loss(&bp, &x, &dov) - loss(&bm, &x, &dov)) / (2.0 * eps);
let an = g[idx];
let tol = 5e-3 + 5e-2 * fd.abs();
assert!(
(an - fd).abs() <= tol,
"grad mismatch for `{name}`[{idx}]: analytic={an:.6} finite-diff={fd:.6} (tol {tol:.4})"
);
checked += 1;
}
}
assert!(
checked >= 30,
"expected to check ≥30 grad elements, got {checked}"
);
}
fn named_param(block: &MambaBlock, name: &str) -> Vec<f32> {
block
.persistence_tensors()
.into_iter()
.find(|(nm, _)| *nm == name)
.map(|(_, t)| t.to_vec().expect("to_vec"))
.expect("param")
}
/// Milestone-3: a self-contained Adam loop drives the loss down and
/// actually moves the backbone weights — i.e. the block is *trainable*.
/// The target is a teacher block's output on the same input (a learnable
/// regression at matched capacity), so a real gradient signal must shrink
/// the loss substantially.
#[test]
fn training_loop_reduces_loss_and_moves_weights() {
let dev = Device::cpu();
let cfg = MambaConfig::new(4, 4, 3);
let ll = 4usize;
let dmodel = 4usize;
let teacher = scaled_block(&cfg, &dev, 99, 0.2);
let xvec: Vec<f32> = (0..ll * dmodel)
.map(|i| 0.5 * ((i as f32 * 0.41).sin()))
.collect();
let x = Tensor::from_vec(xvec, &[1, ll, dmodel], &dev).expect("x");
let target = teacher
.forward(&x)
.expect("teacher")
.output
.to_vec()
.expect("t");
let mut block = scaled_block(&cfg, &dev, 5, 0.2);
// conv1d_weight is a seed-varying backbone weight (A_log/dt_bias/D
// are deterministic init, identical between teacher and student, so
// they carry little gradient here) — it must move if the SSM block
// (not just a head) is being trained.
let cw_before = named_param(&block, "conv1d_weight");
let (lr, b1, b2, eps) = (0.03f32, 0.9f32, 0.999f32, 1e-8f32);
let mut state: HashMap<String, (Vec<f32>, Vec<f32>)> = HashMap::new();
let mut first_loss = 0.0f32;
let mut last_loss = 0.0f32;
for step in 1..=200i32 {
let out = block.forward(&x).expect("fwd").output.to_vec().expect("o");
let ne = out.len();
let mut dov = vec![0.0f32; ne];
let mut loss = 0.0f32;
for i in 0..ne {
let e = out[i] - target[i];
loss += e * e;
dov[i] = 2.0 * e / (ne as f32);
}
loss /= ne as f32;
if step == 1 {
first_loss = loss;
}
last_loss = loss;
let d_out = Tensor::from_vec(dov, &[1, ll, dmodel], &dev).expect("dov");
let grads = block.backward(&x, &d_out).expect("backward");
let mut map = HashMap::new();
for (name, t) in block.persistence_tensors() {
let p = t.to_vec().expect("p");
let g = grads.get(name).expect("g").to_vec().expect("gv");
let entry = state
.entry(name.to_string())
.or_insert_with(|| (vec![0.0f32; p.len()], vec![0.0f32; p.len()]));
let (m, v) = entry;
let mut np = vec![0.0f32; p.len()];
for i in 0..p.len() {
m[i] = b1 * m[i] + (1.0 - b1) * g[i];
v[i] = b2 * v[i] + (1.0 - b2) * g[i] * g[i];
let mhat = m[i] / (1.0 - b1.powi(step));
let vhat = v[i] / (1.0 - b2.powi(step));
np[i] = p[i] - lr * mhat / (vhat.sqrt() + eps);
}
map.insert(
name.to_string(),
Tensor::from_vec(np, t.shape().dims(), &dev).expect("np"),
);
}
block = MambaBlock::from_persistence_tensors(cfg.clone(), &dev, map).expect("rebuild");
}
assert!(
last_loss < 0.5 * first_loss,
"training did not reduce loss enough: {first_loss:.5} → {last_loss:.5}"
);
let cw_after = named_param(&block, "conv1d_weight");
assert!(
max_abs_diff(&cw_before, &cw_after) > 1e-3,
"backbone weight conv1d_weight did not move during training"
);
}
#[test]
fn output_is_finite_and_non_constant() {
let dev = Device::cpu();
let block = MambaBlock::new_seeded(cfg(), &dev, 1).expect("block");
let x = Tensor::randn_seeded(&[1, L, D_MODEL], &dev, 2).expect("x");
let y = fwd(&block, &x);
assert!(y.iter().all(|v| v.is_finite()), "non-finite output");
let spread = max_abs_diff(&y, &vec![y[0]; y.len()]);
assert!(
spread > 1e-4,
"output is suspiciously constant ({spread:.2e})"
);
}