Merge pull request 'SMT D314: gradcheck tanh/sigmoid + GatedMemoryUpdater (GRU cell)' (#8) from d314-gated-updater into main
CI / Build CPU-Only (Explicit) (push) Has been cancelled
Performance Benchmarks / Run Benchmarks (push) Has been cancelled
CI / Format Check (push) Has been cancelled
CI / Clippy Check (push) Has been cancelled
CI / Build (macos-latest) (push) Has been cancelled
CI / Build (ubuntu-latest) (push) Has been cancelled
Documentation / Build API Documentation (push) Has been cancelled
Documentation / Build User Guide (push) Has been cancelled
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / CI Success (push) Has been cancelled

This commit was merged in pull request #8.
This commit is contained in:
2026-06-17 04:28:33 +00:00
3 changed files with 469 additions and 0 deletions
@@ -172,3 +172,96 @@ fn gradcheck_softmax_sum() {
let numerical = fd_grad(&x, loss_of_x); let numerical = fd_grad(&x, loss_of_x);
assert_close(&analytical, &numerical, "softmax_sum d/dX"); assert_close(&analytical, &numerical, "softmax_sum d/dX");
} }
/// `loss = sum(tanh(X))` — tanh VJP (`grad_out * (1 - tanh(x)^2)`). Needed for
/// the gated memory cell's candidate state.
#[test]
fn gradcheck_tanh_sum() {
let x = [0.5f32, -1.0, 2.0, 0.25, -0.5, 1.5]; // [2,3]
let loss_of_x = |xv: &[f32]| -> f32 {
let xt = CpuBackend::from_data(xv, [2, 3], &cpu_dev());
CpuBackend::to_data(&CpuBackend::sum(CpuBackend::tanh(xt)))[0]
};
let xt = leaf2(&x, [2, 3]);
let x_id = xt.id().0;
let loss = Ad::sum(Ad::tanh(xt));
let storage = backward_impl(
&loss,
Some(GradTensor::from_d1(CpuBackend::ones([1], &cpu_dev()))),
)
.expect("backward");
let analytical = grad2(&storage, x_id);
let numerical = fd_grad(&x, loss_of_x);
assert_close(&analytical, &numerical, "tanh_sum d/dX");
}
/// `loss = sum(sigmoid(X))` — sigmoid VJP (`grad_out * σ(x) * (1 - σ(x))`).
/// Needed for the gated memory cell's update gate.
#[test]
fn gradcheck_sigmoid_sum() {
let x = [0.5f32, -1.0, 2.0, 0.25, -0.5, 1.5]; // [2,3]
let loss_of_x = |xv: &[f32]| -> f32 {
let xt = CpuBackend::from_data(xv, [2, 3], &cpu_dev());
CpuBackend::to_data(&CpuBackend::sum(CpuBackend::sigmoid(xt)))[0]
};
let xt = leaf2(&x, [2, 3]);
let x_id = xt.id().0;
let loss = Ad::sum(Ad::sigmoid(xt));
let storage = backward_impl(
&loss,
Some(GradTensor::from_d1(CpuBackend::ones([1], &cpu_dev()))),
)
.expect("backward");
let analytical = grad2(&storage, x_id);
let numerical = fd_grad(&x, loss_of_x);
assert_close(&analytical, &numerical, "sigmoid_sum d/dX");
}
/// The exact composite the gated memory cell uses:
/// `loss = sum((1 - z) ⊙ m + z ⊙ c)` with `z = sigmoid(A)`, `c = tanh(B)`, `m`
/// constant — checks sigmoid·tanh·sub·mul·add compose with correct gradients
/// (vary the gate logits `A`).
#[test]
fn gradcheck_gated_update() {
let a = [0.3f32, -0.7, 1.2, 0.1]; // gate logits [2,2]
let bb = [0.5f32, -1.0, 0.25, 2.0]; // candidate logits [2,2]
let m = [0.4f32, -0.2, 0.9, -0.6]; // previous memory (constant) [2,2]
let loss_of_a = |av: &[f32]| -> f32 {
let at = CpuBackend::from_data(av, [2, 2], &cpu_dev());
let bt = CpuBackend::from_data(&bb, [2, 2], &cpu_dev());
let mt = CpuBackend::from_data(&m, [2, 2], &cpu_dev());
let z = CpuBackend::sigmoid(at);
let c = CpuBackend::tanh(bt);
let one = CpuBackend::ones([2, 2], &cpu_dev());
let keep = CpuBackend::sub(one, z.clone());
let new = CpuBackend::add(CpuBackend::mul(keep, mt), CpuBackend::mul(z, c));
CpuBackend::to_data(&CpuBackend::sum(new))[0]
};
let at = leaf2(&a, [2, 2]);
let bt = leaf2(&bb, [2, 2]);
let mt = Ad::from_data(&m, [2, 2], &ad_dev());
let a_id = at.id().0;
let z = Ad::sigmoid(at);
let c = Ad::tanh(bt);
let one = Ad::from_data(&[1.0f32; 4], [2, 2], &ad_dev());
let keep = Ad::sub(one, z.clone());
let new = Ad::add(Ad::mul(keep, mt), Ad::mul(z, c));
let loss = Ad::sum(new);
let storage = backward_impl(
&loss,
Some(GradTensor::from_d1(CpuBackend::ones([1], &cpu_dev()))),
)
.expect("backward");
let analytical = grad2(&storage, a_id);
let numerical = fd_grad(&a, loss_of_a);
assert_close(&analytical, &numerical, "gated_update d/d(gate logits)");
}
@@ -0,0 +1,372 @@
//! `GatedMemoryUpdater` — a GRU-style **gated** recurrent memory cell for
//! Supervised Memory Training (Phase F / D314).
//!
//! The residual-MLP [`ClonedMemoryUpdater`](super::cloned_memory_updater) cell
//! (`M_t = ρ·M_{t-1} + Δ` + clamp) cannot both *jump* at a regime switch and
//! *average* in steady state, so when streamed it lags through non-stationary
//! switches. This cell replaces the fixed leaky decay with a learned per-dim
//! **update gate**, so the recurrence adapts step by step:
//! ```text
//! z = sigmoid([M_{t-1} ‖ x] · W_z) # update gate ∈ (0,1), per memory dim
//! c = tanh([M_{t-1} ‖ x] · W_c) # candidate state ∈ (-1,1)
//! M_t = (1 - z) ⊙ M_{t-1} + z ⊙ c # z≈1 → jump; z≈0 → hold + denoise
//! x̂ = M_t · W_read # predict-the-future readout
//! ```
//! Because `M_t` is a **convex combination** of `M_{t-1}` and the bounded
//! candidate `c ∈ (-1,1)`, `|M_t| ≤ max(|M_0|, 1)` for all `t` — the recurrence
//! is a non-expansion, so a free autoregressive rollout stays bounded **without**
//! a clamp.
//!
//! Trains on `Autodiff<CpuBackend>` with a self-contained deterministic Adam,
//! exposing the same method surface as `ClonedMemoryUpdater` (so the omni-think
//! facade is cell-generic). sigmoid/tanh/mul/add/sub VJPs are finite-difference
//! gated in `rtx-autograd/tests/tape_cpu_gradcheck.rs`.
use rtx_autograd::autodiff::{Autodiff, AutodiffDevice, GradTensor, TensorId, backward_impl};
use rtx_backend::{AutodiffBackend, Backend};
use rtx_backend_cpu::CpuBackend;
use super::cloned_memory_updater::ClonedUpdaterConfig;
type Ad = Autodiff<CpuBackend>;
/// Deterministic init in `[-scale, scale)` via a SplitMix64-style LCG.
fn lcg_init(n: usize, scale: f32, seed: u64) -> Vec<f32> {
let mut s = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
(0..n)
.map(|_| {
s = s
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
let u = ((s >> 33) as f32) / ((1u64 << 31) as f32);
(u * 2.0 - 1.0) * scale
})
.collect()
}
/// Numerically-naive sigmoid matching `rtx-backend-cpu`'s, so off-tape `step`
/// agrees with the trained graph.
#[inline]
fn sigmoid(x: f32) -> f32 {
1.0 / (1.0 + (-x).exp())
}
/// A trainable parameter matrix with its own Adam moments (host-side).
struct Param {
data: Vec<f32>,
rows: usize,
cols: usize,
m: Vec<f32>,
v: Vec<f32>,
}
impl Param {
fn new(rows: usize, cols: usize, scale: f32, seed: u64) -> Self {
let n = rows * cols;
Self {
data: lcg_init(n, scale, seed),
rows,
cols,
m: vec![0.0; n],
v: vec![0.0; n],
}
}
fn shape(&self) -> [usize; 2] {
[self.rows, self.cols]
}
fn leaf(
&self,
dev: &AutodiffDevice<CpuBackend>,
) -> (<Ad as Backend>::TensorPrimitive<2>, usize) {
let t = Ad::require_grad(Ad::from_data(&self.data, self.shape(), dev));
let id = t.id().0;
(t, id)
}
fn adam(&mut self, grad: &[f32], lr: f32, t: u32) {
const B1: f32 = 0.9;
const B2: f32 = 0.999;
const EPS: f32 = 1e-8;
let bc1 = 1.0 - B1.powi(t as i32);
let bc2 = 1.0 - B2.powi(t as i32);
for ((w, g), (m, v)) in self
.data
.iter_mut()
.zip(grad.iter())
.zip(self.m.iter_mut().zip(self.v.iter_mut()))
{
*m = B1 * *m + (1.0 - B1) * g;
*v = B2 * *v + (1.0 - B2) * g * g;
*w -= lr * (*m / bc1) / ((*v / bc2).sqrt() + EPS);
}
}
}
fn grad_of(storage: &rtx_autograd::autodiff::GradientStorage<CpuBackend>, id: usize) -> Vec<f32> {
match storage
.get(TensorId(id))
.unwrap_or_else(|| panic!("no gradient for leaf {id}"))
{
GradTensor::D2(t) => t.to_vec(),
other => panic!("expected D2 gradient, got {other:?}"),
}
}
/// A GRU-style gated recurrent memory updater + predict-the-future readout.
pub struct GatedMemoryUpdater {
cfg: ClonedUpdaterConfig,
w_z: Param, // [d_mem + d_in, d_mem] update gate
w_c: Param, // [d_mem + d_in, d_mem] candidate
w_read: Param, // [d_mem, d_in]
step: u32,
dev: AutodiffDevice<CpuBackend>,
}
impl GatedMemoryUpdater {
/// Build with deterministic, seeded weights. `cfg.d_hidden` is unused by the
/// gated cell (gates project `[M ‖ x]` straight to `d_mem`); it is kept in
/// the shared config so this is a drop-in for `ClonedMemoryUpdater`.
#[must_use]
pub fn new(cfg: ClonedUpdaterConfig) -> Self {
let s = |i: u64| cfg.seed.wrapping_mul(0x100_0000_01b3).wrapping_add(i);
let fan = cfg.d_mem + cfg.d_in;
let sc = 1.0 / (fan as f32).sqrt();
Self {
w_z: Param::new(fan, cfg.d_mem, sc, s(1)),
w_c: Param::new(fan, cfg.d_mem, sc, s(2)),
w_read: Param::new(cfg.d_mem, cfg.d_in, 1.0 / (cfg.d_mem as f32).sqrt(), s(3)),
step: 0,
dev: AutodiffDevice::<CpuBackend>::default(),
cfg,
}
}
/// Memory size `|M|`.
#[must_use]
pub const fn d_mem(&self) -> usize {
self.cfg.d_mem
}
/// Off-tape gated step `g(prev_mem, x) → new_mem` (`[d_mem]`).
///
/// # Panics
/// Panics on shape mismatch.
#[must_use]
pub fn step(&self, prev_mem: &[f32], x: &[f32]) -> Vec<f32> {
let dm = self.cfg.d_mem;
let din = self.cfg.d_in;
assert_eq!(prev_mem.len(), dm);
assert_eq!(x.len(), din);
let cat_at = |i: usize| if i < dm { prev_mem[i] } else { x[i - dm] };
let fan = dm + din;
let mut out = vec![0.0f32; dm];
for j in 0..dm {
let mut zlin = 0.0f32;
let mut clin = 0.0f32;
for i in 0..fan {
let ci = cat_at(i);
zlin += ci * self.w_z.data[i * dm + j];
clin += ci * self.w_c.data[i * dm + j];
}
let z = sigmoid(zlin);
let c = clin.tanh();
out[j] = (1.0 - z) * prev_mem[j] + z * c;
}
out
}
/// Off-tape readout: predict the next latent from a memory (`[d_in]`).
///
/// # Panics
/// Panics if `mem.len() != d_mem`.
#[must_use]
pub fn predict(&self, mem: &[f32]) -> Vec<f32> {
let dm = self.cfg.d_mem;
let din = self.cfg.d_in;
assert_eq!(mem.len(), dm);
let mut out = vec![0.0f32; din];
for (j, oj) in out.iter_mut().enumerate() {
let mut acc = 0.0f32;
for (i, &mi) in mem.iter().enumerate() {
acc += mi * self.w_read.data[i * din + j];
}
*oj = acc;
}
out
}
/// Build the gated update graph on the tape, returning the `new_mem`
/// ([1, d_mem]) node. Shared by both training entry points.
fn graph(
&self,
prev_mem: &[f32],
x: &[f32],
w_z: &<Ad as Backend>::TensorPrimitive<2>,
w_c: &<Ad as Backend>::TensorPrimitive<2>,
) -> <Ad as Backend>::TensorPrimitive<2> {
let dm = self.cfg.d_mem;
let din = self.cfg.d_in;
let mut cat = Vec::with_capacity(dm + din);
cat.extend_from_slice(prev_mem);
cat.extend_from_slice(x);
let cat_t = Ad::from_data(&cat, [1, dm + din], &self.dev);
let prev_t = Ad::from_data(prev_mem, [1, dm], &self.dev);
let z = Ad::sigmoid(Ad::matmul(cat_t.clone(), w_z.clone())); // [1, d_mem]
let c = Ad::tanh(Ad::matmul(cat_t, w_c.clone()));
let one = Ad::from_data(&vec![1.0f32; dm], [1, dm], &self.dev);
let keep = Ad::sub(one, z.clone());
// M_t = (1 - z) ⊙ prev + z ⊙ c
Ad::add(Ad::mul(keep, prev_t), Ad::mul(z, c))
}
/// One behavioral-cloning step: train the gate + candidate to map
/// `(prev_mem, x) → target_mem` and the readout to predict `target_next`.
/// Returns the total loss before the update.
///
/// # Panics
/// Panics on shape mismatch.
pub fn train_step(
&mut self,
prev_mem: &[f32],
x: &[f32],
target_mem: &[f32],
target_next: &[f32],
) -> f32 {
let dm = self.cfg.d_mem;
let din = self.cfg.d_in;
assert_eq!(prev_mem.len(), dm);
assert_eq!(x.len(), din);
assert_eq!(target_mem.len(), dm);
assert_eq!(target_next.len(), din);
let (w_z, z_id) = self.w_z.leaf(&self.dev);
let (w_c, c_id) = self.w_c.leaf(&self.dev);
let (w_read, read_id) = self.w_read.leaf(&self.dev);
let new_mem = self.graph(prev_mem, x, &w_z, &w_c);
let pred = Ad::matmul(new_mem.clone(), w_read);
let tgt_mem_t = Ad::from_data(target_mem, [1, dm], &self.dev);
let tgt_next_t = Ad::from_data(target_next, [1, din], &self.dev);
let mem_err = Ad::sub(new_mem, tgt_mem_t);
let read_err = Ad::sub(pred, tgt_next_t);
let loss = Ad::add(
Ad::sum(Ad::mul(mem_err.clone(), mem_err)),
Ad::sum(Ad::mul(read_err.clone(), read_err)),
);
let loss_val = Ad::to_data(&loss)[0];
let storage = backward(&loss);
if !loss_val.is_finite() {
return loss_val;
}
self.step += 1;
let t = self.step;
const LR: f32 = 0.01;
self.w_z.adam(&clip(grad_of(&storage, z_id)), LR, t);
self.w_c.adam(&clip(grad_of(&storage, c_id)), LR, t);
self.w_read.adam(&clip(grad_of(&storage, read_id)), LR, t);
loss_val
}
/// DAgger memory-only correction at learning rate `lr`: train just the gate +
/// candidate to map `(prev_mem, x) → target_mem`, leaving the readout intact.
///
/// # Panics
/// Panics on shape mismatch.
pub fn train_step_memory(
&mut self,
prev_mem: &[f32],
x: &[f32],
target_mem: &[f32],
lr: f32,
) -> f32 {
let dm = self.cfg.d_mem;
assert_eq!(prev_mem.len(), dm);
assert_eq!(x.len(), self.cfg.d_in);
assert_eq!(target_mem.len(), dm);
let (w_z, z_id) = self.w_z.leaf(&self.dev);
let (w_c, c_id) = self.w_c.leaf(&self.dev);
let new_mem = self.graph(prev_mem, x, &w_z, &w_c);
let tgt_mem_t = Ad::from_data(target_mem, [1, dm], &self.dev);
let mem_err = Ad::sub(new_mem, tgt_mem_t);
let loss = Ad::sum(Ad::mul(mem_err.clone(), mem_err));
let loss_val = Ad::to_data(&loss)[0];
let storage = backward(&loss);
if !loss_val.is_finite() {
return loss_val;
}
self.step += 1;
let t = self.step;
self.w_z.adam(&clip(grad_of(&storage, z_id)), lr, t);
self.w_c.adam(&clip(grad_of(&storage, c_id)), lr, t);
loss_val
}
}
/// Run the tape backward from a scalar loss.
fn backward(
loss: &<Ad as Backend>::TensorPrimitive<1>,
) -> rtx_autograd::autodiff::GradientStorage<CpuBackend> {
backward_impl(
loss,
Some(GradTensor::from_d1(CpuBackend::ones(
[1],
&<CpuBackend as Backend>::Device::default(),
))),
)
.expect("backward")
}
/// Per-element gradient clip for stability on extreme rollout states.
fn clip(mut g: Vec<f32>) -> Vec<f32> {
const CLIP: f32 = 1.0;
for v in &mut g {
*v = v.clamp(-CLIP, CLIP);
}
g
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gated_updater_learns_a_transition() {
let cfg = ClonedUpdaterConfig::new(4, 6, 16, 1);
let mut g = GatedMemoryUpdater::new(cfg);
let prev = lcg_init(6, 0.5, 10);
let x = lcg_init(4, 1.0, 11);
let target_mem = lcg_init(6, 0.4, 12);
let target_next = lcg_init(4, 0.4, 13);
let first = g.train_step(&prev, &x, &target_mem, &target_next);
let mut last = first;
for _ in 0..400 {
last = g.train_step(&prev, &x, &target_mem, &target_next);
assert!(last.is_finite());
}
assert!(
last < first * 0.5,
"gated updater did not learn: {first:.4} -> {last:.4}"
);
}
#[test]
fn free_rollout_is_bounded_without_clamp() {
// The convex update is a non-expansion: |M_t| ≤ max(|M_0|, 1) forever.
let cfg = ClonedUpdaterConfig::new(4, 6, 8, 2);
let g = GatedMemoryUpdater::new(cfg);
let mut mem = vec![0.3f32; 6];
for t in 0..500 {
let x = lcg_init(4, 1.0, t as u64);
mem = g.step(&mem, &x);
assert!(
mem.iter().all(|v| v.abs() <= 1.0 + 1e-4),
"memory left [-1,1] at step {t}"
);
}
}
}
@@ -66,6 +66,10 @@ pub mod set_encoder_teacher;
// trajectory, no BPTT — see `cloned_memory_updater`). // trajectory, no BPTT — see `cloned_memory_updater`).
pub mod cloned_memory_updater; pub mod cloned_memory_updater;
// SMT gated (GRU-style) memory updater — switch-tracking, bounded recurrence
// for non-stationary streams (see `gated_memory_updater`).
pub mod gated_memory_updater;
// Ring attention for long context // Ring attention for long context
pub mod ring_attention; pub mod ring_attention;
// pub mod mamba_integration; // pub mod mamba_integration;