Skip to main content

steeldb/
hrm.rs

1//! **HRM two-timescale reasoning core** — a faithful candle port of the reference
2//! `python/splade/hrm_core.py` + `spo_tagger.py::HRMTagger`.
3//!
4//! This is the architecture the reference actually uses, and it is not a stack of transformer layers:
5//!
6//! * the backbone is BERT's **embeddings only** (word + position + type) — no encoder. With a 128-dim,
7//!   30522-token vocabulary that is ~3.9M parameters, which is where the "4M model" comes from;
8//! * on top sits [`HrmCore`], two coupled recurrent timescales — a **fast** `L` stream that iterates
9//!   `T_inner` times against the injected input, and a **slow** `H` stream that *steps back* once per
10//!   cycle to integrate what `L` produced. Depth comes from `N_cycles × T_inner` refinement passes
11//!   rather than from more weights;
12//! * gradients flow only through the **final** L+H step (the reference wraps the earlier cycles in
13//!   `no_grad` and detaches the carry). That one-step gradient approximation is what makes recurrent
14//!   depth affordable, and it is reproduced here with `detach()`.
15//!
16//! Heads are **independent** linear maps off the shared refined state: BIO span typing, the epistemic
17//! reading, and (via [`HrmTagger::hidden`]) span pooling for the biaffine relation head. Adding a head
18//! costs one matrix, not another encoder.
19//!
20//! Faithfulness notes: `Block` is pre-norm `RMSNorm → MHA → RMSNorm → SwiGLU`, matching the reference.
21//! Dropout is omitted — the reference uses 0.1 on the residual branches, which regularises a long
22//! training run but changes no shapes or values at eval; it is the one deliberate deviation.
23
24use candle_core::{DType, Device, Module, Result, Tensor, D};
25use candle_nn::{ops::softmax, Linear, VarBuilder};
26
27/// `x * rsqrt(mean(x²) + eps) * w` — the reference's RMSNorm (no mean subtraction, learned gain).
28pub struct RmsNorm {
29    w: Tensor,
30    eps: f64,
31}
32
33impl RmsNorm {
34    pub fn new(h: usize, vb: VarBuilder) -> Result<Self> {
35        Ok(RmsNorm { w: vb.get(h, "w")?, eps: 1e-6 })
36    }
37    pub fn forward(&self, x: &Tensor) -> Result<Tensor> {
38        let ms = x.sqr()?.mean_keepdim(D::Minus1)?;
39        let scaled = x.broadcast_div(&(ms + self.eps)?.sqrt()?)?;
40        scaled.broadcast_mul(&self.w)
41    }
42}
43
44/// Multi-head self-attention with an additive key-padding mask.
45struct SelfAttention {
46    q: Linear,
47    k: Linear,
48    v: Linear,
49    o: Linear,
50    heads: usize,
51    head_dim: usize,
52}
53
54impl SelfAttention {
55    fn new(h: usize, heads: usize, vb: VarBuilder) -> Result<Self> {
56        Ok(SelfAttention {
57            q: candle_nn::linear(h, h, vb.pp("q"))?,
58            k: candle_nn::linear(h, h, vb.pp("k"))?,
59            v: candle_nn::linear(h, h, vb.pp("v"))?,
60            o: candle_nn::linear(h, h, vb.pp("o"))?,
61            heads,
62            head_dim: h / heads,
63        })
64    }
65
66    /// `x [B,T,h]`, `mask [B,T]` (1 = real token) → `[B,T,h]`.
67    fn forward(&self, x: &Tensor, mask: Option<&Tensor>) -> Result<Tensor> {
68        let (b, t, h) = x.dims3()?;
69        let split = |p: &Linear, x: &Tensor| -> Result<Tensor> {
70            p.forward(x)?.reshape((b, t, self.heads, self.head_dim))?.transpose(1, 2)?.contiguous()
71        };
72        let q = split(&self.q, x)?;
73        let k = split(&self.k, x)?;
74        let v = split(&self.v, x)?;
75        let scale = 1.0 / (self.head_dim as f64).sqrt();
76        let mut att = (q.matmul(&k.transpose(2, 3)?.contiguous()?)? * scale)?; // [B,heads,T,T]
77        if let Some(m) = mask {
78            // Additive mask: 0 for real tokens, a large negative for padding, broadcast over heads and
79            // query rows. A *finite* penalty is deliberate — scaling the inverted mask by -inf computes
80            // `0 * -inf` at every real position, which is NaN and poisons the whole attention matrix.
81            const MASK_PENALTY: f64 = -1e9;
82            let neg = m
83                .to_dtype(DType::F32)?
84                .affine(-1.0, 1.0)? // real 1 → 0, pad 0 → 1
85                .affine(MASK_PENALTY, 0.0)? // real → 0, pad → -1e9
86                .reshape((b, 1, 1, t))?;
87            att = att.broadcast_add(&neg)?;
88        }
89        let att = softmax(&att, D::Minus1)?;
90        let out = att.matmul(&v)?.transpose(1, 2)?.reshape((b, t, h))?;
91        self.o.forward(&out)
92    }
93}
94
95/// Pre-norm block: `RMSNorm → MHA → residual → RMSNorm → SwiGLU → residual`.
96struct Block {
97    n1: RmsNorm,
98    attn: SelfAttention,
99    n2: RmsNorm,
100    w_in: Linear,
101    w_out: Linear,
102}
103
104impl Block {
105    fn new(h: usize, heads: usize, mult: usize, vb: VarBuilder) -> Result<Self> {
106        Ok(Block {
107            n1: RmsNorm::new(h, vb.pp("n1"))?,
108            attn: SelfAttention::new(h, heads, vb.pp("attn"))?,
109            n2: RmsNorm::new(h, vb.pp("n2"))?,
110            // w_in emits 2*mult*h so it can be chunked into the SwiGLU gate pair
111            w_in: candle_nn::linear(h, 2 * mult * h, vb.pp("w_in"))?,
112            w_out: candle_nn::linear(mult * h, h, vb.pp("w_out"))?,
113        })
114    }
115
116    fn forward(&self, x: &Tensor, mask: Option<&Tensor>) -> Result<Tensor> {
117        let a = self.attn.forward(&self.n1.forward(x)?, mask)?;
118        let x = (x + a)?;
119        let g = self.n2.forward(&x)?;
120        let uv = self.w_in.forward(&g)?;
121        let half = uv.dim(D::Minus1)? / 2;
122        let u = uv.narrow(D::Minus1, 0, half)?;
123        let v = uv.narrow(D::Minus1, half, half)?;
124        let gated = (u.silu()? * v)?;
125        x + self.w_out.forward(&gated)?
126    }
127}
128
129/// The two-timescale core. `L` iterates fast against the injected input; `H` steps back once per cycle to
130/// integrate `L`'s state. Output is `zH + reps`, a residual refinement of the embeddings.
131pub struct HrmCore {
132    inj: Linear,
133    l_blocks: Vec<Block>,
134    h_blocks: Vec<Block>,
135    z_l0: Tensor,
136    z_h0: Tensor,
137    pub t_inner: usize,
138    pub n_cycles: usize,
139}
140
141impl HrmCore {
142    pub fn new(h: usize, layers_l: usize, layers_h: usize, t_inner: usize, n_cycles: usize, heads: usize, vb: VarBuilder) -> Result<Self> {
143        let l_blocks = (0..layers_l).map(|i| Block::new(h, heads, 2, vb.pp(format!("L{i}")))).collect::<Result<Vec<_>>>()?;
144        let h_blocks = (0..layers_h).map(|i| Block::new(h, heads, 2, vb.pp(format!("H{i}")))).collect::<Result<Vec<_>>>()?;
145        Ok(HrmCore {
146            inj: candle_nn::linear(h, h, vb.pp("inj"))?,
147            l_blocks,
148            h_blocks,
149            z_l0: vb.get(h, "z_l0")?,
150            z_h0: vb.get(h, "z_h0")?,
151            t_inner,
152            n_cycles,
153        })
154    }
155
156    fn l_step(&self, z_l: &Tensor, z_h: &Tensor, x: &Tensor, mask: Option<&Tensor>) -> Result<Tensor> {
157        let mut y = ((z_l + z_h)? + x)?;
158        for b in &self.l_blocks {
159            y = b.forward(&y, mask)?;
160        }
161        Ok(y)
162    }
163
164    fn h_step(&self, z_h: &Tensor, z_l: &Tensor, mask: Option<&Tensor>) -> Result<Tensor> {
165        let mut y = (z_h + z_l)?;
166        for b in &self.h_blocks {
167            y = b.forward(&y, mask)?;
168        }
169        Ok(y)
170    }
171
172    /// Refine `reps [B,T,h]` → `[B,T,h]`. The `N_cycles` of refinement run detached (the reference's
173    /// `no_grad`), and only the final L+H step carries gradient — the one-step gradient approximation that
174    /// makes recurrent depth trainable at this size.
175    pub fn forward(&self, reps: &Tensor, mask: Option<&Tensor>, grad_last_step: bool) -> Result<Tensor> {
176        let (b, t, _h) = reps.dims3()?;
177        let x = self.inj.forward(reps)?;
178        let mut z_l = self.z_l0.reshape((1, 1, ()))?.broadcast_as((b, t, self.z_l0.dim(0)?))?.contiguous()?;
179        let mut z_h = self.z_h0.reshape((1, 1, ()))?.broadcast_as((b, t, self.z_h0.dim(0)?))?.contiguous()?;
180
181        let cycles = if grad_last_step { self.n_cycles } else { self.n_cycles };
182        for _ in 0..cycles {
183            for _ in 0..self.t_inner {
184                z_l = self.l_step(&z_l, &z_h, &x, mask)?;
185                if grad_last_step {
186                    z_l = z_l.detach();
187                }
188            }
189            z_h = self.h_step(&z_h, &z_l, mask)?;
190            if grad_last_step {
191                z_h = z_h.detach();
192            }
193        }
194        // final step (gradient flows here)
195        let z_l = self.l_step(&z_l, &z_h, &x, mask)?;
196        let z_h = self.h_step(&z_h, &z_l, mask)?;
197        z_h + reps
198    }
199}
200
201
202// ── embeddings-only backbone + independent heads ────────────────────────────────────────────────
203
204/// BERT's embedding table on its own (word + position + token-type, then LayerNorm) — the reference uses
205/// `BertModel.from_pretrained(base).embeddings` and discards the encoder entirely. candle keeps its
206/// `BertEmbeddings` private, so this is implemented directly, which also lets it load the checkpoint's
207/// `bert.embeddings.*` tensors verbatim.
208pub struct BertEmbeddingsOnly {
209    word: candle_nn::Embedding,
210    position: candle_nn::Embedding,
211    token_type: candle_nn::Embedding,
212    norm: candle_nn::LayerNorm,
213    pub hidden: usize,
214}
215
216impl BertEmbeddingsOnly {
217    pub fn load(vb: VarBuilder, vocab: usize, max_pos: usize, type_vocab: usize, hidden: usize) -> Result<Self> {
218        Ok(BertEmbeddingsOnly {
219            word: candle_nn::embedding(vocab, hidden, vb.pp("word_embeddings"))?,
220            position: candle_nn::embedding(max_pos, hidden, vb.pp("position_embeddings"))?,
221            token_type: candle_nn::embedding(type_vocab, hidden, vb.pp("token_type_embeddings"))?,
222            norm: candle_nn::layer_norm(hidden, 1e-12, vb.pp("LayerNorm"))?,
223            hidden,
224        })
225    }
226
227    /// `ids [B,T]` → `[B,T,h]`.
228    pub fn forward(&self, ids: &Tensor) -> Result<Tensor> {
229        let (b, t) = ids.dims2()?;
230        let w = self.word.forward(ids)?;
231        let pos_ids = Tensor::arange(0u32, t as u32, ids.device())?.reshape((1, t))?.broadcast_as((b, t))?.contiguous()?;
232        let p = self.position.forward(&pos_ids)?;
233        let tt = self.token_type.forward(&ids.zeros_like()?)?;
234        self.norm.forward(&((w + p)? + tt)?)
235    }
236}
237
238/// The reference tagger: embeddings → HRM refinement → **independent** linear heads. Each head is one
239/// matrix over the shared refined state, so adding a dimension of the Vocabulary Space costs a matrix
240/// rather than another encoder.
241pub struct HrmTagger {
242    emb: BertEmbeddingsOnly,
243    core: HrmCore,
244    head_a: Linear,
245    head_b: Linear,
246    hidden: usize,
247}
248
249/// Shape/behaviour knobs, defaulting to the reference's `cycles=2, t_inner=2, layers=2, heads=4`.
250#[derive(Debug, Clone, Copy)]
251pub struct HrmConfig {
252    pub vocab: usize,
253    pub max_pos: usize,
254    pub type_vocab: usize,
255    pub hidden: usize,
256    pub layers: usize,
257    pub heads: usize,
258    pub t_inner: usize,
259    pub n_cycles: usize,
260}
261
262impl HrmConfig {
263    /// Defaults matching the reference `HRMTagger` on a bert-tiny embedding table.
264    pub fn bert_tiny(vocab: usize, hidden: usize, max_pos: usize, type_vocab: usize) -> Self {
265        HrmConfig { vocab, max_pos, type_vocab, hidden, layers: 2, heads: 4, t_inner: 2, n_cycles: 2 }
266    }
267}
268
269impl HrmTagger {
270    pub fn new(vb: VarBuilder, cfg: &HrmConfig, n_a: usize, n_b: usize) -> Result<Self> {
271        let emb = BertEmbeddingsOnly::load(vb.pp("bert").pp("embeddings"), cfg.vocab, cfg.max_pos, cfg.type_vocab, cfg.hidden)?;
272        let core = HrmCore::new(cfg.hidden, cfg.layers, cfg.layers, cfg.t_inner, cfg.n_cycles, cfg.heads, vb.pp("core"))?;
273        Ok(HrmTagger {
274            emb,
275            core,
276            head_a: candle_nn::linear(cfg.hidden, n_a, vb.pp("head_a"))?,
277            head_b: candle_nn::linear(cfg.hidden, n_b, vb.pp("head_b"))?,
278            hidden: cfg.hidden,
279        })
280    }
281
282    pub fn hidden_size(&self) -> usize {
283        self.hidden
284    }
285
286    /// Refined per-token state `[B,T,h]` — what the independent heads read, and what Head C pools spans from.
287    pub fn hidden(&self, ids: &Tensor, attn: &Tensor, grad_last_step: bool) -> Result<Tensor> {
288        let reps = self.emb.forward(ids)?;
289        self.core.forward(&reps, Some(attn), grad_last_step)
290    }
291
292    /// `(logits_a [B,T,n_a], logits_b [B,T,n_b])`.
293    pub fn forward(&self, ids: &Tensor, attn: &Tensor, grad_last_step: bool) -> Result<(Tensor, Tensor)> {
294        let y = self.hidden(ids, attn, grad_last_step)?;
295        Ok((self.head_a.forward(&y)?, self.head_b.forward(&y)?))
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302    use candle_nn::VarMap;
303
304    fn core(h: usize, cycles: usize, t_inner: usize) -> (HrmCore, Device) {
305        let device = Device::Cpu;
306        let varmap = VarMap::new();
307        let vb = VarBuilder::from_varmap(&varmap, DType::F32, &device);
308        let c = HrmCore::new(h, 1, 1, t_inner, cycles, 4, vb).unwrap();
309        (c, device)
310    }
311
312    /// Parameter budget: embeddings dominate and the HRM core is small — the point of the architecture.
313    #[test]
314    fn independent_heads_and_a_small_core() {
315        let device = Device::Cpu;
316        let varmap = VarMap::new();
317        let vb = VarBuilder::from_varmap(&varmap, DType::F32, &device);
318        let cfg = HrmConfig::bert_tiny(30522, 128, 512, 2);
319        let t = HrmTagger::new(vb, &cfg, 19, 4).unwrap();
320        let total: usize = varmap.all_vars().iter().map(|v| v.as_tensor().elem_count()).sum();
321        let emb_params = 30522 * 128 + 512 * 128 + 2 * 128 + 2 * 128;
322        eprintln!("total params {total} (embeddings {emb_params}, core+heads {})", total - emb_params);
323        assert!(total < 5_000_000, "must stay a ~4M model, got {total}");
324        assert!(emb_params * 10 / 8 > total - emb_params, "embeddings should dominate the budget");
325
326        let ids = Tensor::from_vec(vec![101u32, 2054, 2003, 102], (1, 4), &device).unwrap();
327        let attn = Tensor::from_vec(vec![1u32, 1, 1, 1], (1, 4), &device).unwrap();
328        let (a, b) = t.forward(&ids, &attn, false).unwrap();
329        assert_eq!(a.dims(), &[1, 4, 19]);
330        assert_eq!(b.dims(), &[1, 4, 4]);
331        // heads are independent: they read the same state but produce different widths and values
332        let hidden = t.hidden(&ids, &attn, false).unwrap();
333        assert_eq!(hidden.dims(), &[1, 4, 128]);
334        let av: Vec<f32> = a.flatten_all().unwrap().to_vec1().unwrap();
335        assert!(av.iter().all(|v| v.is_finite()));
336    }
337
338    #[test]
339    fn rmsnorm_matches_the_reference_formula() {
340        let device = Device::Cpu;
341        let varmap = VarMap::new();
342        let vb = VarBuilder::from_varmap(&varmap, DType::F32, &device);
343        let n = RmsNorm::new(4, vb.pp("n")).unwrap();
344        // freshly-initialised `w` is ones in the reference; candle's default init differs, so compare the
345        // normalisation itself: a scaled input must normalise to the same direction.
346        let x = Tensor::from_vec(vec![1f32, 2., 3., 4.], (1, 1, 4), &device).unwrap();
347        let y1 = n.forward(&x).unwrap().flatten_all().unwrap().to_vec1::<f32>().unwrap();
348        let y2 = n.forward(&(x.affine(10.0, 0.0).unwrap())).unwrap().flatten_all().unwrap().to_vec1::<f32>().unwrap();
349        for (a, b) in y1.iter().zip(y2.iter()) {
350            assert!((a - b).abs() < 1e-4, "RMSNorm must be scale-invariant: {y1:?} vs {y2:?}");
351        }
352    }
353
354    #[test]
355    fn core_preserves_shape_and_is_residual() {
356        let (c, device) = core(8, 2, 2);
357        let reps = Tensor::rand(0f32, 1f32, (2, 5, 8), &device).unwrap();
358        let mask = Tensor::from_vec(vec![1u32, 1, 1, 0, 0, 1, 1, 1, 1, 0], (2, 5), &device).unwrap();
359        let out = c.forward(&reps, Some(&mask), false).unwrap();
360        assert_eq!(out.dims(), &[2, 5, 8]);
361        // output is reps + refinement, so it must differ from reps but stay finite
362        let d: Vec<f32> = (out - &reps).unwrap().flatten_all().unwrap().to_vec1().unwrap();
363        assert!(d.iter().all(|v| v.is_finite()), "refinement must be finite (padding mask must not produce NaN)");
364        assert!(d.iter().any(|v| v.abs() > 1e-6), "core must actually change the representation");
365    }
366
367    #[test]
368    fn more_cycles_change_the_refinement() {
369        // depth here comes from recurrence, so cycle count must matter
370        let device = Device::Cpu;
371        let varmap = VarMap::new();
372        let vb = VarBuilder::from_varmap(&varmap, DType::F32, &device);
373        let shallow = HrmCore::new(8, 1, 1, 1, 1, 4, vb.pp("c")).unwrap();
374        let deep = HrmCore::new(8, 1, 1, 2, 3, 4, vb.pp("c")).unwrap(); // same weights, more passes
375        let reps = Tensor::rand(0f32, 1f32, (1, 4, 8), &device).unwrap();
376        let a: Vec<f32> = shallow.forward(&reps, None, false).unwrap().flatten_all().unwrap().to_vec1().unwrap();
377        let b: Vec<f32> = deep.forward(&reps, None, false).unwrap().flatten_all().unwrap().to_vec1().unwrap();
378        assert!(a.iter().zip(b.iter()).any(|(x, y)| (x - y).abs() > 1e-5), "cycle count must affect the output");
379    }
380
381    #[test]
382    fn padding_mask_blocks_attention_to_pad_positions() {
383        let (c, device) = core(8, 1, 1);
384        // two batches: identical real prefix, different padding content
385        let mut a = vec![0f32; 3 * 8];
386        for (i, v) in a.iter_mut().enumerate() {
387            *v = (i % 7) as f32 * 0.1;
388        }
389        let reps_a = Tensor::from_vec(a.clone(), (1, 3, 8), &device).unwrap();
390        // same first two positions, wildly different third (which is masked out)
391        let mut b = a.clone();
392        for v in b[16..24].iter_mut() {
393            *v = 99.0;
394        }
395        let reps_b = Tensor::from_vec(b, (1, 3, 8), &device).unwrap();
396        let mask = Tensor::from_vec(vec![1u32, 1, 0], (1, 3), &device).unwrap();
397        let oa = c.forward(&reps_a, Some(&mask), false).unwrap().narrow(1, 0, 2).unwrap();
398        let ob = c.forward(&reps_b, Some(&mask), false).unwrap().narrow(1, 0, 2).unwrap();
399        let va: Vec<f32> = oa.flatten_all().unwrap().to_vec1().unwrap();
400        let vb2: Vec<f32> = ob.flatten_all().unwrap().to_vec1().unwrap();
401        for (x, y) in va.iter().zip(vb2.iter()) {
402            assert!((x - y).abs() < 1e-3, "masked positions must not influence real ones: {va:?} vs {vb2:?}");
403        }
404    }
405}