Merge pull request 'SMT D312: timestamp-embedded (recency) teacher mode' (#7) from d312-timestamp-teacher into main
CI / Build (macos-latest) (push) Has been cancelled
CI / Build (ubuntu-latest) (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 CPU-Only (Explicit) (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 #7.
This commit is contained in:
2026-06-17 01:35:00 +00:00
@@ -59,10 +59,18 @@ pub struct SetEncoderConfig {
/// Deterministic init seed. Two teachers built with the same `(config, /// Deterministic init seed. Two teachers built with the same `(config,
/// seed)` are bit-identical, so training is reproducible. /// seed)` are bit-identical, so training is reproducible.
pub seed: u64, pub seed: u64,
/// **Recency / timestamp mode.** When `false` (default) the encoder mean-
/// pools the whole window — an order-invariant, *stationary* sufficient
/// statistic. When `true` it adds sinusoidal **timestamp embeddings** to the
/// token embeddings (so attention can reason about position) and pools with
/// an **exponential-decay (recent-weighted)** reduction instead of a uniform
/// mean — giving a *recent-window* statistic that tracks non-stationary /
/// regime-switching signals (the talk's timestamp-embedded teacher).
pub recency: bool,
} }
impl SetEncoderConfig { impl SetEncoderConfig {
/// Standard config for a `d_in`-wide latent stream. /// Standard (stationary, mean-pool) config for a `d_in`-wide latent stream.
#[must_use] #[must_use]
pub const fn new(d_in: usize, d_model: usize, d_mem: usize, seed: u64) -> Self { pub const fn new(d_in: usize, d_model: usize, d_mem: usize, seed: u64) -> Self {
Self { Self {
@@ -70,8 +78,16 @@ impl SetEncoderConfig {
d_model, d_model,
d_mem, d_mem,
seed, seed,
recency: false,
} }
} }
/// Enable/disable recency (timestamp-embedded, decay-pooled) mode.
#[must_use]
pub const fn with_recency(mut self, recency: bool) -> Self {
self.recency = recency;
self
}
} }
/// One trainable parameter matrix held on the host as a flat row-major `Vec`, /// One trainable parameter matrix held on the host as a flat row-major `Vec`,
@@ -137,6 +153,38 @@ impl Param {
} }
} }
/// Pooling weights over `l` positions: a uniform mean (stationary), or an
/// exponential-decay weight `ρ^(l-1-t)` normalized to sum 1 (recency mode), so
/// the most recent token has weight ∝ 1 and older tokens decay — a recent-window
/// statistic that tracks non-stationary signals.
fn pool_weights(l: usize, recency: bool) -> Vec<f32> {
if !recency {
return vec![1.0f32 / l as f32; l];
}
const RHO: f32 = 0.85;
let mut w: Vec<f32> = (0..l).map(|t| RHO.powi((l - 1 - t) as i32)).collect();
let sum: f32 = w.iter().sum();
for x in &mut w {
*x /= sum;
}
w
}
/// Standard sinusoidal positional encoding, row-major `[l, d_model]`. Even dims
/// use `sin`, odd dims `cos`, with the usual geometric wavelength schedule.
fn sinusoidal_pos(l: usize, d_model: usize) -> Vec<f32> {
let mut out = vec![0.0f32; l * d_model];
for t in 0..l {
for i in 0..d_model {
let pair = (i / 2) as f32;
let freq = 1.0f32 / 10_000_f32.powf(2.0 * pair / d_model as f32);
let angle = t as f32 * freq;
out[t * d_model + i] = if i % 2 == 0 { angle.sin() } else { angle.cos() };
}
}
out
}
/// Deterministic init in `[-scale, scale)` via a SplitMix64-style LCG — avoids /// Deterministic init in `[-scale, scale)` via a SplitMix64-style LCG — avoids
/// an all-zeros start (which gives zero gradients through a linear layer). /// an all-zeros start (which gives zero gradients through a linear layer).
fn lcg_init(n: usize, scale: f32, seed: u64) -> Vec<f32> { fn lcg_init(n: usize, scale: f32, seed: u64) -> Vec<f32> {
@@ -327,8 +375,14 @@ impl SetEncoderTeacher {
<Ad as Backend>::TensorPrimitive<2>, <Ad as Backend>::TensorPrimitive<2>,
) { ) {
let din = self.cfg.d_in; let din = self.cfg.d_in;
let dm = self.cfg.d_model;
let x = Ad::from_data(window, [l, din], &self.dev); let x = Ad::from_data(window, [l, din], &self.dev);
let e = Ad::matmul(x, we.clone()); // [l, d_model] let mut e = Ad::matmul(x, we.clone()); // [l, d_model]
// Recency mode: add sinusoidal timestamp embeddings so attention can
// reason about position (the talk's reparameterization).
if self.cfg.recency {
e = Ad::add(e, Ad::from_data(&sinusoidal_pos(l, dm), [l, dm], &self.dev));
}
// e fans out to q/k/v and the residual (four uses). // e fans out to q/k/v and the residual (four uses).
let q = Ad::matmul(e.clone(), q_w.clone()); let q = Ad::matmul(e.clone(), q_w.clone());
let k = Ad::matmul(e.clone(), k_w.clone()); let k = Ad::matmul(e.clone(), k_w.clone());
@@ -337,9 +391,11 @@ impl SetEncoderTeacher {
let attn = Ad::softmax(scores, 1); let attn = Ad::softmax(scores, 1);
let ctx = Ad::matmul(attn, v); // [l, d_model] let ctx = Ad::matmul(attn, v); // [l, d_model]
let h = Ad::add(e, ctx); // residual [l, d_model] let h = Ad::add(e, ctx); // residual [l, d_model]
// Mean-pool over the L tokens → [1, d_model] (a set-symmetric reduction). // Pool over the L tokens → [1, d_model]. Stationary mode uses a uniform
let mean_row = vec![1.0f32 / l as f32; l]; // mean (order-invariant); recency mode uses an exponential-decay weight
let pool = Ad::matmul(Ad::from_data(&mean_row, [1, l], &self.dev), h); // (recent tokens dominate) so the statistic tracks non-stationary state.
let pool_row = pool_weights(l, self.cfg.recency);
let pool = Ad::matmul(Ad::from_data(&pool_row, [1, l], &self.dev), h);
let memory = Ad::matmul(pool, mem_w.clone()); // [1, d_mem] let memory = Ad::matmul(pool, mem_w.clone()); // [1, d_mem]
let prediction = Ad::matmul(memory.clone(), dec_w.clone()); // [1, d_in] let prediction = Ad::matmul(memory.clone(), dec_w.clone()); // [1, d_in]
(memory, prediction) (memory, prediction)
@@ -444,6 +500,25 @@ mod tests {
); );
} }
#[test]
fn recency_mode_trains_and_loss_decreases() {
// Timestamp-embedded + decay-pooled teacher still trains end-to-end.
let cfg = SetEncoderConfig::new(4, 8, 6, 42).with_recency(true);
let mut teacher = SetEncoderTeacher::new(cfg);
assert!(teacher.config().recency);
let (x, target) = window_and_mean_target(6, 4, 7);
let first = teacher.train_step(&x, 6, &target);
let mut last = first;
for _ in 0..400 {
last = teacher.train_step(&x, 6, &target);
assert!(last.is_finite(), "non-finite loss");
}
assert!(
last < first * 0.2,
"recency teacher did not learn: {first:.5} -> {last:.5}"
);
}
#[test] #[test]
fn encode_is_deterministic_and_fixed_size() { fn encode_is_deterministic_and_fixed_size() {
let cfg = SetEncoderConfig::new(4, 8, 6, 1); let cfg = SetEncoderConfig::new(4, 8, 6, 1);