SMT D319 (rustytorch): learned-[MEM]-query (content-addressable) teacher pool
Performance Benchmarks / Run Benchmarks (pull_request) Has been cancelled
CI / Format Check (pull_request) Has been cancelled
CI / Clippy Check (pull_request) Has been cancelled
CI / Build (macos-latest) (pull_request) Has been cancelled
CI / Build (ubuntu-latest) (pull_request) Has been cancelled
CI / Build CPU-Only (Explicit) (pull_request) Has been cancelled
Documentation / Build API Documentation (pull_request) Has been cancelled
Documentation / Build User Guide (pull_request) Has been cancelled
CI / Test (macos-latest) (pull_request) Has been cancelled
CI / Test (ubuntu-latest) (pull_request) Has been cancelled
CI / CI Success (pull_request) Has been cancelled

Adds an opt-in learned-query attention pool to SetEncoderTeacher
(SetEncoderConfig::with_learned_pool): pool = softmax(q_mem·hᵀ)·h instead of the
fixed mean/decay pool — Isola's transformer-teacher [MEM] query. New q_mem param
(threaded through graph/train_step/run/named_params; only updated in learned-pool
mode). Default off → existing teachers byte-unchanged.

Finding (test teacher_content_addresses_selective_retrieval): on a selective-
retrieval task (signal in one marked token among distractors) BOTH the mean-pool
and learned-query teachers recover the marked token to low MSE (~0.0006 / ~0.002)
— because the self-attention layer already routes the marked token's signal to
every position before the pool. So the mean-pool was NOT the recall bottleneck
(correcting the D318 hypothesis): the teacher can content-address; the real
recall bottleneck is the recurrent *cell* that imitates it. The learned-query
pool is shipped as an equally-capable, faithful-to-the-talk alternative.

All 5 teacher unit tests pass; clippy(-D)/fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-06-17 09:31:18 -07:00
co-authored by Claude Opus 4.8
parent 117a1b6e28
commit 0e3ff0a1b9
@@ -71,6 +71,14 @@ pub struct SetEncoderConfig {
/// *sharper* (concentrates on the most recent tokens → fast switch reaction, /// *sharper* (concentrates on the most recent tokens → fast switch reaction,
/// less denoising); larger = wider/smoother window. Default 0.85. /// less denoising); larger = wider/smoother window. Default 0.85.
pub recency_decay: f32, pub recency_decay: f32,
/// **Content-addressable pooling.** When `false` (default) the window is
/// pooled by fixed weights (uniform mean, or recency decay). When `true` a
/// **learned `[MEM]` query** attends over the tokens
/// (`pool = softmax(q_mem·hᵀ)·h`), so the encoder *learns which tokens to
/// read* — the capability a mean/decay pool fundamentally lacks (selective
/// retrieval, not just averaging). This is Isola's transformer teacher;
/// `false` is the simplified aggregator. Overrides the fixed pool when set.
pub learned_pool: bool,
} }
impl SetEncoderConfig { impl SetEncoderConfig {
@@ -84,9 +92,17 @@ impl SetEncoderConfig {
seed, seed,
recency: false, recency: false,
recency_decay: 0.85, recency_decay: 0.85,
learned_pool: false,
} }
} }
/// Enable/disable the learned-`[MEM]`-query (content-addressable) pool.
#[must_use]
pub const fn with_learned_pool(mut self, learned_pool: bool) -> Self {
self.learned_pool = learned_pool;
self
}
/// Enable/disable recency (timestamp-embedded, decay-pooled) mode. /// Enable/disable recency (timestamp-embedded, decay-pooled) mode.
#[must_use] #[must_use]
pub const fn with_recency(mut self, recency: bool) -> Self { pub const fn with_recency(mut self, recency: bool) -> Self {
@@ -224,6 +240,7 @@ pub struct SetEncoderTeacher {
wv: Param, // [d_model, d_model] wv: Param, // [d_model, d_model]
w_mem: Param, // [d_model, d_mem] w_mem: Param, // [d_model, d_mem]
w_dec: Param, // [d_mem, d_in] w_dec: Param, // [d_mem, d_in]
q_mem: Param, // [1, d_model] learned [MEM] pooling query (content-addressable)
/// Global Adam step counter (1-based after the first update). /// Global Adam step counter (1-based after the first update).
step: u32, step: u32,
dev: AutodiffDevice<CpuBackend>, dev: AutodiffDevice<CpuBackend>,
@@ -253,6 +270,7 @@ impl SetEncoderTeacher {
wv: Param::new(cfg.d_model, cfg.d_model, sc(cfg.d_model), s(4)), wv: Param::new(cfg.d_model, cfg.d_model, sc(cfg.d_model), s(4)),
w_mem: Param::new(cfg.d_model, cfg.d_mem, sc(cfg.d_model), s(5)), w_mem: Param::new(cfg.d_model, cfg.d_mem, sc(cfg.d_model), s(5)),
w_dec: Param::new(cfg.d_mem, cfg.d_in, sc(cfg.d_mem), s(6)), w_dec: Param::new(cfg.d_mem, cfg.d_in, sc(cfg.d_mem), s(6)),
q_mem: Param::new(1, cfg.d_model, sc(cfg.d_model), s(7)),
step: 0, step: 0,
dev: AutodiffDevice::<CpuBackend>::default(), dev: AutodiffDevice::<CpuBackend>::default(),
cfg, cfg,
@@ -301,8 +319,11 @@ impl SetEncoderTeacher {
let (v_w, v_id) = self.wv.leaf(&self.dev); let (v_w, v_id) = self.wv.leaf(&self.dev);
let (mem_w, mem_id) = self.w_mem.leaf(&self.dev); let (mem_w, mem_id) = self.w_mem.leaf(&self.dev);
let (dec_w, dec_id) = self.w_dec.leaf(&self.dev); let (dec_w, dec_id) = self.w_dec.leaf(&self.dev);
let (qm, qm_id) = self.q_mem.leaf(&self.dev);
let loss = self.forward_graph(window, l, target, &we, &q_w, &k_w, &v_w, &mem_w, &dec_w); let loss = self.forward_graph(
window, l, target, &we, &q_w, &k_w, &v_w, &mem_w, &dec_w, &qm,
);
let loss_val = Ad::to_data(&loss)[0]; let loss_val = Ad::to_data(&loss)[0];
let storage = backward_impl( let storage = backward_impl(
@@ -324,6 +345,10 @@ impl SetEncoderTeacher {
self.wv.adam(&grad_of(&storage, v_id), LR, t); self.wv.adam(&grad_of(&storage, v_id), LR, t);
self.w_mem.adam(&grad_of(&storage, mem_id), LR, t); self.w_mem.adam(&grad_of(&storage, mem_id), LR, t);
self.w_dec.adam(&grad_of(&storage, dec_id), LR, t); self.w_dec.adam(&grad_of(&storage, dec_id), LR, t);
// `q_mem` only participates (and receives a gradient) in learned-pool mode.
if self.cfg.learned_pool {
self.q_mem.adam(&grad_of(&storage, qm_id), LR, t);
}
loss_val loss_val
} }
@@ -340,7 +365,9 @@ impl SetEncoderTeacher {
let v_w = Ad::from_data(&self.wv.data, self.wv.shape(), &self.dev); let v_w = Ad::from_data(&self.wv.data, self.wv.shape(), &self.dev);
let mem_w = Ad::from_data(&self.w_mem.data, self.w_mem.shape(), &self.dev); let mem_w = Ad::from_data(&self.w_mem.data, self.w_mem.shape(), &self.dev);
let dec_w = Ad::from_data(&self.w_dec.data, self.w_dec.shape(), &self.dev); let dec_w = Ad::from_data(&self.w_dec.data, self.w_dec.shape(), &self.dev);
let (memory, prediction) = self.graph(window, l, &we, &q_w, &k_w, &v_w, &mem_w, &dec_w); let qm = Ad::from_data(&self.q_mem.data, self.q_mem.shape(), &self.dev);
let (memory, prediction) =
self.graph(window, l, &we, &q_w, &k_w, &v_w, &mem_w, &dec_w, &qm);
Forward { Forward {
memory: Ad::to_data(&memory), memory: Ad::to_data(&memory),
prediction: Ad::to_data(&prediction), prediction: Ad::to_data(&prediction),
@@ -361,9 +388,10 @@ impl SetEncoderTeacher {
v_w: &<Ad as Backend>::TensorPrimitive<2>, v_w: &<Ad as Backend>::TensorPrimitive<2>,
mem_w: &<Ad as Backend>::TensorPrimitive<2>, mem_w: &<Ad as Backend>::TensorPrimitive<2>,
dec_w: &<Ad as Backend>::TensorPrimitive<2>, dec_w: &<Ad as Backend>::TensorPrimitive<2>,
qm: &<Ad as Backend>::TensorPrimitive<2>,
) -> <Ad as Backend>::TensorPrimitive<1> { ) -> <Ad as Backend>::TensorPrimitive<1> {
let din = self.cfg.d_in; let din = self.cfg.d_in;
let (_memory, pred) = self.graph(window, l, we, q_w, k_w, v_w, mem_w, dec_w); let (_memory, pred) = self.graph(window, l, we, q_w, k_w, v_w, mem_w, dec_w, qm);
let target_t = Ad::from_data(target, [1, din], &self.dev); let target_t = Ad::from_data(target, [1, din], &self.dev);
let err = Ad::sub(pred, target_t); let err = Ad::sub(pred, target_t);
Ad::sum(Ad::mul(err.clone(), err)) Ad::sum(Ad::mul(err.clone(), err))
@@ -384,6 +412,7 @@ impl SetEncoderTeacher {
v_w: &<Ad as Backend>::TensorPrimitive<2>, v_w: &<Ad as Backend>::TensorPrimitive<2>,
mem_w: &<Ad as Backend>::TensorPrimitive<2>, mem_w: &<Ad as Backend>::TensorPrimitive<2>,
dec_w: &<Ad as Backend>::TensorPrimitive<2>, dec_w: &<Ad as Backend>::TensorPrimitive<2>,
qm: &<Ad as Backend>::TensorPrimitive<2>,
) -> ( ) -> (
<Ad as Backend>::TensorPrimitive<2>, <Ad as Backend>::TensorPrimitive<2>,
<Ad as Backend>::TensorPrimitive<2>, <Ad as Backend>::TensorPrimitive<2>,
@@ -405,11 +434,18 @@ 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]
// Pool over the L tokens → [1, d_model]. Stationary mode uses a uniform // Pool the L tokens → [1, d_model]. Learned-query mode lets a learned
// mean (order-invariant); recency mode uses an exponential-decay weight // `[MEM]` query attend over the tokens (content-addressable: the encoder
// (recent tokens dominate) so the statistic tracks non-stationary state. // *reads* the relevant tokens); otherwise a fixed weight (uniform mean,
// or recency-decay) just *averages* them.
let pool = if self.cfg.learned_pool {
let pool_scores = Ad::matmul(qm.clone(), Ad::transpose(h.clone())); // [1, l]
let pool_attn = Ad::softmax(pool_scores, 1);
Ad::matmul(pool_attn, h) // [1, d_model]
} else {
let pool_row = pool_weights(l, self.cfg.recency, self.cfg.recency_decay); let pool_row = pool_weights(l, self.cfg.recency, self.cfg.recency_decay);
let pool = Ad::matmul(Ad::from_data(&pool_row, [1, l], &self.dev), h); 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)
@@ -427,6 +463,7 @@ impl SetEncoderTeacher {
("wv", self.wv.data.clone(), self.wv.shape()), ("wv", self.wv.data.clone(), self.wv.shape()),
("w_mem", self.w_mem.data.clone(), self.w_mem.shape()), ("w_mem", self.w_mem.data.clone(), self.w_mem.shape()),
("w_dec", self.w_dec.data.clone(), self.w_dec.shape()), ("w_dec", self.w_dec.data.clone(), self.w_dec.shape()),
("q_mem", self.q_mem.data.clone(), self.q_mem.shape()),
] ]
} }
@@ -447,6 +484,7 @@ impl SetEncoderTeacher {
("wv", &mut self.wv), ("wv", &mut self.wv),
("w_mem", &mut self.w_mem), ("w_mem", &mut self.w_mem),
("w_dec", &mut self.w_dec), ("w_dec", &mut self.w_dec),
("q_mem", &mut self.q_mem),
] { ] {
let want = target.data.len(); let want = target.data.len();
let got = get(name).ok_or_else(|| format!("missing param `{name}`"))?; let got = get(name).ok_or_else(|| format!("missing param `{name}`"))?;
@@ -496,6 +534,91 @@ mod tests {
(x, target) (x, target)
} }
/// D319 — selective-retrieval task: a window of `L` tokens where exactly one
/// "marked" token (dim 0 high) carries the signal in dims 1.. and all others
/// are distractor noise (dim 0 low). The target is the marked token. A mean
/// pool dilutes the signal by `L`; a learned `[MEM]` query can *attend to the
/// marker* and read it out. Returns `(window [l*d_in], target [d_in])`.
fn selective_sample(l: usize, d_in: usize, seed: u64) -> (Vec<f32>, Vec<f32>) {
let marked = (seed as usize) % l;
let sig = lcg_init(d_in, 0.7, seed.wrapping_mul(31).wrapping_add(1));
let noise = lcg_init(l * d_in, 0.7, seed.wrapping_mul(97).wrapping_add(3));
let mut win = vec![0.0f32; l * d_in];
let mut target = vec![0.0f32; d_in];
for t in 0..l {
if t == marked {
win[t * d_in] = 1.0; // marker flag
for d in 1..d_in {
win[t * d_in + d] = sig[d];
}
} else {
win[t * d_in] = -1.0; // unmarked
for d in 1..d_in {
win[t * d_in + d] = noise[t * d_in + d];
}
}
}
target[0] = 1.0;
for d in 1..d_in {
target[d] = sig[d];
}
(win, target)
}
/// D319 — the teacher **already content-addresses**: on a selective-retrieval
/// task (signal in one marked token among distractors) *both* pooling modes
/// recover the marked token to low error — because the **self-attention layer
/// routes the marked token's signal to every position before the pool**, so a
/// mean pool suffices. This refutes the D318 hypothesis that the mean-pool was
/// the recall bottleneck: it isn't — the teacher can select. (The learned
/// `[MEM]`-query pool, exercised here, is an equally-capable available
/// alternative; the genuine recall bottleneck is the recurrent *cell* that
/// imitates the teacher, not the teacher.)
#[test]
fn teacher_content_addresses_selective_retrieval() {
const L: usize = 8;
const DIN: usize = 4;
let train: Vec<_> = (0..20u64)
.map(|i| selective_sample(L, DIN, 1000 + i))
.collect();
let test: Vec<_> = (0..8u64)
.map(|i| selective_sample(L, DIN, 9000 + i))
.collect();
let mut mean = SetEncoderTeacher::new(SetEncoderConfig::new(DIN, 16, 8, 7));
let mut learned =
SetEncoderTeacher::new(SetEncoderConfig::new(DIN, 16, 8, 7).with_learned_pool(true));
for _ in 0..250 {
for (w, t) in &train {
mean.train_step(w, L, t);
learned.train_step(w, L, t);
}
}
let eval = |teacher: &SetEncoderTeacher| -> f32 {
let mut acc = 0.0f32;
for (w, t) in &test {
let p = teacher.predict(w, L);
acc += p.iter().zip(t).map(|(a, b)| (a - b).powi(2)).sum::<f32>() / DIN as f32;
}
acc / test.len() as f32
};
let mean_mse = eval(&mean);
let learned_mse = eval(&learned);
eprintln!(
"D319 selective-retrieval MSE mean-pool={mean_mse:.5} learned-query={learned_mse:.5}"
);
// Both content-address well (target magnitude ~0.7; a pure averager that
// couldn't select would land near ~0.3 MSE). The self-attention does the
// selecting; the learned-query pool is a working alternative, not a fix.
assert!(
mean_mse < 0.02 && learned_mse < 0.02,
"teacher should content-address selective retrieval with either pool: \
mean {mean_mse:.5}, learned {learned_mse:.5}"
);
}
#[test] #[test]
fn teacher_trains_and_loss_decreases() { fn teacher_trains_and_loss_decreases() {
let cfg = SetEncoderConfig::new(4, 8, 6, 42); let cfg = SetEncoderConfig::new(4, 8, 6, 42);