SMT E3a: expose Mamba's recurrent state as a single-step updater (equivalence-pinned)

MambaRecurrence reconstructs MambaBlock's selective-scan as an explicit
step(state, x_t) -> (state', y_t) recurrence over host f32 weights, with the
recurrent state (h, conv-ring) carried between steps. This is the substrate the
SMT memory updater is behaviorally cloned on (E3b) — no BPTT, one step at a time.

Dimensions are derived from the persisted tensor shapes, so mamba.rs is left
untouched (it is near the 1250-LOC cap). silu/softplus are byte-identical copies
of the forward's.

Equivalence pin (the plan's highest-risk item): stepping a window one token at a
time from a zero state reproduces the full-sequence forward EXACTLY —
max_abs_diff = 0.0 (bit-identical), on an active_block with wide Delta so the
scan genuinely drives the output. Plus a fresh-state determinism test.

clippy(-D warnings) + fmt clean on the new module and test.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-06-16 11:00:12 -07:00
co-authored by Claude Opus 4.8
parent 1b1ce0604a
commit 51a9185056
3 changed files with 401 additions and 0 deletions
@@ -0,0 +1,274 @@
//! `MambaRecurrence` — the single-timestep recurrent view of a [`MambaBlock`]
//! (Supervised Memory Training, Phase E3).
//!
//! The full-sequence [`MambaBlock::forward`] runs the selective scan inline over
//! a whole window. To behaviorally-clone Mamba as a *memory updater*
//! `f(state, x_t) → (state', y_t)` (the SMT core — no backprop-through-time), we
//! need the recurrence exposed one step at a time. This module reconstructs
//! exactly that recurrence from a block's persisted weights, with **bit-level
//! fidelity** to the full forward: stepping a sequence through `step` and
//! concatenating the per-step outputs reproduces `forward` (pinned by
//! `tests/mamba_step_equivalence.rs`).
//!
//! The recurrent state is `(h, conv)`:
//! - `h ∈ ^{d_inner · d_state}` — the selective-scan SSM state `h[j·n + k]`.
//! - `conv ∈ ^{(d_conv 1) · d_inner}` — the causal depthwise-conv ring of the
//! last `d_conv 1` pre-activation inputs (zero-initialized = the forward's
//! left zero-padding).
//!
//! Dimensions are derived from the persisted tensor shapes, so no access to the
//! block's private config is needed and `mamba.rs` is left untouched.
use crate::Result;
use crate::layers::mamba::MambaBlock;
/// Numerically-stable SiLU `x·σ(x)` — identical to the one in `mamba.rs` so the
/// stepwise path matches the forward bit-for-bit.
#[inline]
fn silu(x: f32) -> f32 {
let s = if x >= 0.0 {
1.0 / (1.0 + (-x).exp())
} else {
let e = x.exp();
e / (1.0 + e)
};
x * s
}
/// Numerically-stable softplus `ln(1 + eˣ)` — identical to the one in `mamba.rs`.
#[inline]
fn softplus(x: f32) -> f32 {
x.max(0.0) + (1.0 + (-x.abs()).exp()).ln()
}
/// The recurrent state carried between [`MambaRecurrence::step`] calls.
#[derive(Clone, Debug)]
pub struct MambaState {
/// Selective-scan SSM state, row-major `[d_inner, d_state]` as `h[j*n + k]`.
h: Vec<f32>,
/// Causal-conv ring of the last `d_conv 1` pre-activation inputs,
/// row-major `[d_conv - 1, d_inner]` (index 0 = oldest).
conv: Vec<f32>,
}
impl MambaState {
/// Read-only view of the SSM state `h` (`[d_inner · d_state]`). This is the
/// "memory" the SMT cloning supervises.
#[must_use]
pub fn hidden(&self) -> &[f32] {
&self.h
}
}
/// A `MambaBlock` reconstituted as an explicit step-by-step recurrence over host
/// `f32` weights.
pub struct MambaRecurrence {
d_model: usize,
d_inner: usize,
d_state: usize,
dt_rank: usize,
d_conv: usize,
in_proj: Vec<f32>, // [d_model, 2*d_inner]
conv_w: Vec<f32>, // [d_inner, d_conv]
conv_b: Vec<f32>, // [d_inner] (zeros if the block had no conv bias)
a_log: Vec<f32>, // [d_inner, d_state]
x_proj: Vec<f32>, // [d_inner, dt_rank + 2*d_state]
dt_proj: Vec<f32>, // [dt_rank, d_inner]
dt_bias: Vec<f32>, // [d_inner]
d_skip: Vec<f32>, // [d_inner]
out_proj: Vec<f32>, // [d_inner, d_model]
}
impl MambaRecurrence {
/// Reconstruct the recurrence from a block's persisted weights.
///
/// # Errors
/// Propagates any tensor `to_vec` failure.
pub fn from_block(block: &MambaBlock) -> Result<Self> {
let mut in_proj = Vec::new();
let mut conv_w = Vec::new();
let mut conv_b = None;
let mut a_log = Vec::new();
let mut x_proj = Vec::new();
let mut dt_proj = Vec::new();
let mut dt_bias = Vec::new();
let mut d_skip = Vec::new();
let mut out_proj = Vec::new();
// Shapes we recover dimensions from.
let mut in_proj_shape = Vec::new();
let mut conv_shape = Vec::new();
let mut a_shape = Vec::new();
let mut x_proj_cols = 0usize;
for (name, tensor) in block.persistence_tensors() {
let dims = tensor.shape().dims().to_vec();
let data = tensor.to_vec()?;
match name {
"in_proj" => {
in_proj_shape = dims;
in_proj = data;
}
"conv1d_weight" => {
conv_shape = dims;
conv_w = data;
}
"conv1d_bias" => conv_b = Some(data),
"A_log" => {
a_shape = dims;
a_log = data;
}
"x_proj" => {
x_proj_cols = dims[1];
x_proj = data;
}
"dt_proj" => dt_proj = data,
"dt_bias" => dt_bias = data,
"D" => d_skip = data,
"out_proj" => out_proj = data,
_ => {}
}
}
let d_model = in_proj_shape[0];
let d_inner = in_proj_shape[1] / 2;
let d_conv = conv_shape[2]; // [d_inner, 1, d_conv]
let d_state = a_shape[1];
let dt_rank = x_proj_cols - 2 * d_state;
let conv_b = conv_b.unwrap_or_else(|| vec![0.0f32; d_inner]);
Ok(Self {
d_model,
d_inner,
d_state,
dt_rank,
d_conv,
in_proj,
conv_w,
conv_b,
a_log,
x_proj,
dt_proj,
dt_bias,
d_skip,
out_proj,
})
}
/// Input/output token width (`d_model`).
#[must_use]
pub const fn d_model(&self) -> usize {
self.d_model
}
/// Size of the recurrent SSM state `h` (`d_inner · d_state`).
#[must_use]
pub const fn state_size(&self) -> usize {
self.d_inner * self.d_state
}
/// A fresh zero state (matches the forward's zero conv-padding + zero `h`).
#[must_use]
pub fn init_state(&self) -> MambaState {
MambaState {
h: vec![0.0f32; self.d_inner * self.d_state],
conv: vec![0.0f32; self.d_conv.saturating_sub(1) * self.d_inner],
}
}
/// Advance one timestep: consume `x_t` (`[d_model]`), mutate `state`, and
/// return the output `y_t` (`[d_model]`). Bit-identical to the corresponding
/// timestep of [`MambaBlock::forward`].
///
/// # Panics
/// Panics if `x_t.len() != d_model`.
#[must_use]
pub fn step(&self, x_t: &[f32], state: &mut MambaState) -> Vec<f32> {
let d = self.d_inner;
let n = self.d_state;
let kc = self.d_conv;
let dbc = self.dt_rank + 2 * n;
assert_eq!(x_t.len(), self.d_model, "x_t must be [d_model]");
// (1) in_proj → x_in (scan branch) + z (gate branch).
let mut x_in = vec![0.0f32; d];
let mut z = vec![0.0f32; d];
for (j, (xij, zj)) in x_in.iter_mut().zip(z.iter_mut()).enumerate() {
let mut sx = 0.0f32;
let mut sz = 0.0f32;
for (m, &xm) in x_t.iter().enumerate() {
sx += xm * self.in_proj[m * (2 * d) + j];
sz += xm * self.in_proj[m * (2 * d) + d + j];
}
*xij = sx;
*zj = sz;
}
// (2-3) causal depthwise conv over the last kc pre-activation inputs,
// then SiLU. window[kk] = conv-ring for kk < kc-1, else the current x_in.
let mut u = vec![0.0f32; d];
for j in 0..d {
let mut acc = self.conv_b[j];
for kk in 0..kc {
let v = if kk + 1 < kc {
state.conv[kk * d + j]
} else {
x_in[j]
};
acc += self.conv_w[j * kc + kk] * v;
}
u[j] = silu(acc);
}
// Slide the conv ring: drop the oldest, append the current x_in.
if kc > 1 {
for k in 0..(kc - 2) {
for j in 0..d {
state.conv[k * d + j] = state.conv[(k + 1) * d + j];
}
}
for j in 0..d {
state.conv[(kc - 2) * d + j] = x_in[j];
}
}
// (4) x_proj → (dt_logits, B, C).
let mut xdbl = vec![0.0f32; dbc];
for (q, xq) in xdbl.iter_mut().enumerate() {
let mut s = 0.0f32;
for (j, &uj) in u.iter().enumerate() {
s += uj * self.x_proj[j * dbc + q];
}
*xq = s;
}
// (5) delta = softplus(dt_bias + dt_logits @ dt_proj).
let mut delta = vec![0.0f32; d];
for (j, dj) in delta.iter_mut().enumerate() {
let mut s = self.dt_bias[j];
for r in 0..self.dt_rank {
s += xdbl[r] * self.dt_proj[r * d + j];
}
*dj = softplus(s);
}
// (6-9) selective scan + skip + gate + out_proj.
let mut out = vec![0.0f32; self.d_model];
for j in 0..d {
let dj = delta[j];
let uj = u[j];
let mut y = self.d_skip[j] * uj;
for k in 0..n {
let a = -self.a_log[j * n + k].exp();
let da = (dj * a).exp();
let dbu = dj * xdbl[self.dt_rank + k] * uj; // dj * B[k] * uj
let hv = da * state.h[j * n + k] + dbu;
state.h[j * n + k] = hv;
y += xdbl[self.dt_rank + n + k] * hv; // C[k] * h'
}
let yg = y * silu(z[j]);
for m in 0..self.d_model {
out[m] += yg * self.out_proj[j * self.d_model + m];
}
}
out
}
}
@@ -55,6 +55,7 @@ pub mod mixture_of_experts;
// State-space models (Mamba) // State-space models (Mamba)
pub mod mamba; pub mod mamba;
pub mod mamba_step;
pub mod metal_mamba; pub mod metal_mamba;
// SMT predictive-state teacher (the time-parallel oracle for Supervised // SMT predictive-state teacher (the time-parallel oracle for Supervised
@@ -0,0 +1,126 @@
//! D309 equivalence pin — the single-step `MambaRecurrence` is numerically
//! identical to the full-sequence `MambaBlock::forward`.
//!
//! This is the precondition the SMT plan flags as the highest remaining risk:
//! before a recurrent updater can be behaviorally cloned, its exposed
//! `(state, x_t) → (state', y_t)` step MUST reproduce the forward exactly.
//! We run a window through `forward`, then through `step` one timestep at a
//! time from a zero state, and require the outputs to match to the f32 floor.
//!
//! The block uses the `active_block` trick (wide `Δ`) so the scan genuinely
//! contributes — otherwise the near-identity default init would make the test
//! pass trivially without exercising the recurrence.
use std::collections::HashMap;
use rtx_tensor::{Device, Tensor};
use rtx_transformers::layers::mamba::{MambaBlock, MambaConfig};
use rtx_transformers::layers::mamba_step::MambaRecurrence;
const D_MODEL: usize = 8;
const D_STATE: usize = 16;
const D_CONV: usize = 4;
const L: usize = 7;
fn max_abs_diff(a: &[f32], b: &[f32]) -> f32 {
a.iter()
.zip(b)
.map(|(x, y)| (x - y).abs())
.fold(0.0, f32::max)
}
/// A seeded block with `dt_bias` zeroed (`Δ = softplus(0) ≈ 0.69`) so the scan
/// genuinely drives the output — mirrors `real_selective_scan.rs::active_block`.
fn active_block(dev: &Device, seed: u64) -> MambaBlock {
let cfg = MambaConfig::new(D_MODEL, D_STATE, D_CONV);
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, dev, map).expect("rebuild")
}
/// Deterministic input window `[1, L, D_MODEL]`.
fn input(dev: &Device) -> (Tensor, Vec<f32>) {
let mut s = 0x57E9u64;
let data: Vec<f32> = (0..L * D_MODEL)
.map(|_| {
s = s
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
((s >> 40) as f32 / (1u64 << 24) as f32) * 2.0 - 1.0
})
.collect();
let t = Tensor::from_vec(data.clone(), &[1, L, D_MODEL], dev).expect("input");
(t, data)
}
#[test]
fn step_matches_full_forward() {
let dev = Device::cpu();
let block = active_block(&dev, 0xD309);
let (x, flat) = input(&dev);
// Full-sequence reference.
let full = block
.forward(&x)
.expect("forward")
.output
.to_vec()
.expect("vec");
assert_eq!(full.len(), L * D_MODEL);
// Stepwise: feed one token at a time from a zero state.
let rec = MambaRecurrence::from_block(&block).expect("recurrence");
assert_eq!(rec.d_model(), D_MODEL);
let mut state = rec.init_state();
let mut stepwise = Vec::with_capacity(L * D_MODEL);
for li in 0..L {
let x_t = &flat[li * D_MODEL..(li + 1) * D_MODEL];
stepwise.extend(rec.step(x_t, &mut state));
}
let diff = max_abs_diff(&full, &stepwise);
eprintln!("D309 step-vs-forward max_abs_diff = {diff:.2e}");
assert!(
diff < 1e-4,
"stepwise recurrence must match the full forward (max_abs_diff {diff:.2e})"
);
// The state must be non-trivial (the scan actually ran).
assert_eq!(rec.state_size(), state.hidden().len());
assert!(
state.hidden().iter().any(|&v| v.abs() > 1e-6),
"SSM state should be populated after stepping"
);
}
#[test]
fn fresh_state_is_zero_and_deterministic() {
let dev = Device::cpu();
let block = active_block(&dev, 7);
let rec = MambaRecurrence::from_block(&block).expect("recurrence");
let s = rec.init_state();
assert!(s.hidden().iter().all(|&v| v == 0.0), "init state h is zero");
// Same input from a fresh state → identical output (stateless determinism).
let (_, flat) = input(&dev);
let run = |rec: &MambaRecurrence| {
let mut st = rec.init_state();
let mut out = Vec::new();
for li in 0..L {
out.extend(rec.step(&flat[li * D_MODEL..(li + 1) * D_MODEL], &mut st));
}
out
};
assert_eq!(run(&rec), run(&rec), "stepping must be deterministic");
}