Skip to main content

ferrox_models/
bert_encoder.rs

1//! BERT: the encoder graph, transcribed from llama.cpp
2//! `src/models/bert.cpp` (`llama_model_bert::graph::graph`).
3//!
4//! Loading lives next door in [`crate::bert_gguf_loader`]; pooling in
5//! [`crate::pooling`]; the reason this is not an
6//! [`crate::engine::Engine`] in [`crate::encoder`].
7//!
8//! # The graph, and the five places it is not a decoder
9//!
10//! ```text
11//! h[i] = tok_embd[t[i]] + type_embd[seg[i]] + pos_embd[i] (1) (2)
12//! h    = LayerNorm(h, token_embd_norm)                        (3)
13//! for each layer:
14//!     q,k,v = Wq h + bq,  Wk h + bk,  Wv h + bv
15//!     a     = softmax(q·kᵀ / √head_dim) v                  (4)
16//!     x     = LayerNorm(Wo a + bo + h,  attn_output_norm)      (3)
17//!     f     = W_down · GELU(W_up x + b_up) + b_down        (5)
18//!     h     = LayerNorm(f + x, layer_output_norm)              (3)
19//! result = h                                              (6)
20//! ```
21//!
22//! 1. **Learned position embeddings, added.** Not RoPE. `pos_embd` is a
23//!    real `[n_ctx_train, n_embd]` table and position `i` is a row
24//!    lookup. A learned table cannot be extrapolated, which is why
25//!    [`crate::encoder::EncodeError::TooLong`] is an error and not a
26//!    warning.
27//! 2. **A token-type embedding, per position.** A single-sequence
28//!    embedding pass is all "Sentence A" and uses row 0, which is what
29//!    upstream hardcodes — `ggml_view_1d(ctx0, model.type_embd, n_embd,
30//!    0)`, with the comment that token types are hardcoded to zero
31//!    because `llama_batch` carries no segment ids. A cross-encoder
32//!    PAIR is not that case: HuggingFace's `tokenizer(query, document)`
33//!    emits `0…0 1…1` and `BertModel` adds row 1 to every position
34//!    after the first `[SEP]`. Ferrox adds the row the caller names,
35//!    which is row 0 for every embedding request and 0/1 for a rerank
36//!    pair. Matching upstream here instead was measured, on
37//!    `cross-encoder/ms-marco-MiniLM-L6-v2` against a NumPy transcription
38//!    of `BertForSequenceClassification`, to put the RELEVANT document
39//!    LAST in three of four rankings — see
40//!    `tests/rerank_cross_encoder_ordering.rs`.
41//! 3. **LayerNorm, not RMSNorm, at three sites per layer plus one on
42//!    the input.** Mean-subtracting, and every one of them carries a
43//!    `bias` tensor as well as a `weight`. Substituting RMSNorm here
44//!    loads fine and produces a plausible-looking vector that is wrong.
45//! 4. **No causal mask.** Row 0 attends to the last token. This is the
46//!    single property that makes the whole model an encoder, and
47//!    `attention_is_bidirectional_not_causal` below is the test that
48//!    would go red if a mask ever appeared.
49//! 5. **A plain GELU MLP, not a gated one.** Two matrices, not three,
50//!    and both carry biases. `LLM_FFN_GELU, LLM_FFN_SEQ` upstream.
51//! 6. **No output head and no logits.** The hidden states *are* the
52//!    result (`res->t_embd`); this checkpoint has no `output.weight` at
53//!    all.
54//!
55//! # What this module does not do
56//!
57//! Only `arch == "bert"`, and only its dense, non-RoPE, separate-QKV
58//! shape. `nomic-bert` (RoPE + gated FFN), `jina-bert-v2` (GEGLU + a
59//! second attention norm), `nomic-bert-moe` (expert layers) and
60//! `modern-bert` all share `bert.cpp` upstream and are all refused by
61//! name in the loader instead of being run through this graph.
62
63use ferrox_core::matmul::{gelu, layer_norm};
64use ferrox_core::weight_matrix::WeightMatrix;
65
66use crate::encoder::{EncodeError, PairSequence, TextEncoder};
67use crate::pooling::PoolingType;
68
69/// `bert.*` metadata, after the loader has checked it.
70#[derive(Debug, Clone)]
71pub struct BertHparams {
72    pub arch: String,
73    pub n_layer: usize,
74    pub n_embd: usize,
75    pub n_ff: usize,
76    pub n_head: usize,
77    pub n_head_kv: usize,
78    /// Height of the learned position table.
79    pub n_ctx_train: usize,
80    pub n_token_types: usize,
81    pub layer_norm_eps: f32,
82    pub pooling: PoolingType,
83    /// `[CLS]` / `[SEP]`, from `tokenizer.ggml.bos_token_id` and
84    /// `tokenizer.ggml.seperator_token_id` (upstream's spelling of the
85    /// key, typo included). See [`BertEncoder::wrap_special`].
86    pub cls_id: u32,
87    pub sep_id: u32,
88}
89
90impl BertHparams {
91    pub fn head_dim(&self) -> usize {
92        self.n_embd / self.n_head
93    }
94}
95
96/// One transformer block's weights. Biases that llama.cpp marks
97/// `TENSOR_NOT_REQUIRED` are `Option`, so a checkpoint without them is
98/// run without them rather than with a silently fabricated zero vector.
99pub struct BertLayer {
100    pub wq: WeightMatrix,
101    pub bq: Option<Vec<f32>>,
102    pub wk: WeightMatrix,
103    pub bk: Option<Vec<f32>>,
104    pub wv: WeightMatrix,
105    pub bv: Option<Vec<f32>>,
106    pub wo: WeightMatrix,
107    pub bo: Option<Vec<f32>>,
108    /// `attn_output_norm`, applied after the attention residual.
109    pub attn_out_norm_w: Vec<f32>,
110    pub attn_out_norm_b: Vec<f32>,
111    pub ffn_up: WeightMatrix,
112    pub ffn_up_b: Option<Vec<f32>>,
113    pub ffn_down: WeightMatrix,
114    pub ffn_down_b: Option<Vec<f32>>,
115    /// `layer_output_norm`, applied after the FFN residual.
116    pub layer_out_norm_w: Vec<f32>,
117    pub layer_out_norm_b: Vec<f32>,
118}
119
120pub struct BertEncoder {
121    pub hp: BertHparams,
122    pub tok_embd: WeightMatrix,
123    /// `token_types.weight`, **every** row: `[n_token_types, n_embd]`.
124    /// Row 0 is "Sentence A" and row 1 "Sentence B". `None` when the
125    /// checkpoint carries no table at all, which upstream allows
126    /// (`TENSOR_NOT_REQUIRED`) and which means no segment embedding is
127    /// added anywhere. Loading only row 0 — what this held before — is
128    /// what made a rerank pair score both halves as Sentence A.
129    pub type_embd: Option<Vec<Vec<f32>>>,
130    pub pos_embd: WeightMatrix,
131    pub tok_norm_w: Vec<f32>,
132    pub tok_norm_b: Vec<f32>,
133    pub layers: Vec<BertLayer>,
134}
135
136/// Adds `bias` to every `width`-wide row of `rows`, when there is one.
137fn add_bias_rows(rows: &mut [f32], width: usize, bias: Option<&Vec<f32>>) {
138    let Some(b) = bias else { return };
139    debug_assert_eq!(b.len(), width);
140    for row in rows.chunks_exact_mut(width) {
141        for (x, bv) in row.iter_mut().zip(b.iter()) {
142            *x += bv;
143        }
144    }
145}
146
147/// LayerNorm applied independently to each `width`-wide row, in place.
148fn layer_norm_rows(rows: &mut [f32], width: usize, weight: &[f32], bias: &[f32], eps: f32) {
149    for row in rows.chunks_exact_mut(width) {
150        let normed = layer_norm(row, weight, bias, eps);
151        row.copy_from_slice(&normed);
152    }
153}
154
155/// In-place softmax over one score row, max-shifted.
156fn softmax_row(scores: &mut [f32]) {
157    let max = scores.iter().copied().fold(f32::NEG_INFINITY, f32::max);
158    let mut sum = 0.0f32;
159    for s in scores.iter_mut() {
160        *s = (*s - max).exp();
161        sum += *s;
162    }
163    let inv = 1.0 / sum;
164    for s in scores.iter_mut() {
165        *s *= inv;
166    }
167}
168
169/// Full bidirectional multi-head attention over `n` positions.
170///
171/// `q` is `[n][n_head * head_dim]`; `k` and `v` are
172/// `[n][n_head_kv * head_dim]`. **Every query row attends to every key
173/// row** — there is no mask argument here on purpose, so a causal mask
174/// cannot be added by accident.
175fn bidirectional_attention(
176    q: &[f32],
177    k: &[f32],
178    v: &[f32],
179    n: usize,
180    n_head: usize,
181    n_head_kv: usize,
182    head_dim: usize,
183) -> Vec<f32> {
184    let q_width = n_head * head_dim;
185    let kv_width = n_head_kv * head_dim;
186    let heads_per_kv = n_head / n_head_kv;
187    let scale = 1.0 / (head_dim as f32).sqrt();
188    let mut out = vec![0.0f32; n * q_width];
189    let mut scores = vec![0.0f32; n];
190    for h in 0..n_head {
191        let kv_h = h / heads_per_kv;
192        let q_off = h * head_dim;
193        let kv_off = kv_h * head_dim;
194        for i in 0..n {
195            let qi = &q[i * q_width + q_off..i * q_width + q_off + head_dim];
196            for (j, s) in scores.iter_mut().enumerate() {
197                let kj = &k[j * kv_width + kv_off..j * kv_width + kv_off + head_dim];
198                *s = qi.iter().zip(kj).map(|(a, b)| a * b).sum::<f32>() * scale;
199            }
200            softmax_row(&mut scores);
201            let dst = &mut out[i * q_width + q_off..i * q_width + q_off + head_dim];
202            for (j, &p) in scores.iter().enumerate() {
203                let vj = &v[j * kv_width + kv_off..j * kv_width + kv_off + head_dim];
204                for (o, &vv) in dst.iter_mut().zip(vj) {
205                    *o += p * vv;
206                }
207            }
208        }
209    }
210    out
211}
212
213impl BertEncoder {
214    pub fn vocab_size(&self) -> usize {
215        self.tok_embd.rows()
216    }
217}
218
219impl TextEncoder for BertEncoder {
220    fn n_embd(&self) -> usize {
221        self.hp.n_embd
222    }
223
224    fn n_ctx_train(&self) -> usize {
225        self.hp.n_ctx_train
226    }
227
228    fn pooling_type(&self) -> PoolingType {
229        self.hp.pooling
230    }
231
232    /// `[CLS] … [SEP]`, which is what llama.cpp's WPM branch adds when
233    /// `add_special` is set: it pushes `special_bos_id` before the
234    /// pieces and `special_sep_id` after, unconditionally — the
235    /// `add_bos`/`add_eos` flags are not consulted on that path
236    /// (`llama-vocab.cpp`, `case LLAMA_VOCAB_TYPE_WPM`).
237    fn wrap_special(&self, pieces: &[u32]) -> Vec<u32> {
238        let mut out = Vec::with_capacity(pieces.len() + 2);
239        out.push(self.hp.cls_id);
240        out.extend_from_slice(pieces);
241        out.push(self.hp.sep_id);
242        out
243    }
244
245    /// The height of `token_types.weight`, or 1 when the checkpoint
246    /// carries no table (nothing is added at any position, which is
247    /// what a one-row table would do anyway).
248    fn n_segments(&self) -> usize {
249        self.type_embd.as_ref().map(Vec::len).unwrap_or(1)
250    }
251
252    /// `[CLS] a [SEP] b [SEP]` with segments `0…0 1…1` — what
253    /// HuggingFace's `tokenizer(query, document)` builds for a BERT
254    /// cross-encoder, which is the input these checkpoints were
255    /// trained on.
256    ///
257    /// The boundary is defined once, here, and both vectors are cut on
258    /// it: the first `[SEP]` closes segment 0 (HF counts it as part of
259    /// the first half) and everything after it is segment 1. Returning
260    /// the ids alone and letting the graph assume a segment is how the
261    /// document half came to be scored as "Sentence A".
262    fn wrap_special_pair(&self, a: &[u32], b: &[u32]) -> Option<PairSequence> {
263        let mut tokens = Vec::with_capacity(a.len() + b.len() + 3);
264        tokens.push(self.hp.cls_id);
265        tokens.extend_from_slice(a);
266        tokens.push(self.hp.sep_id);
267        let first_half = tokens.len();
268        tokens.extend_from_slice(b);
269        tokens.push(self.hp.sep_id);
270        let mut segments = vec![0u32; tokens.len()];
271        for s in segments[first_half..].iter_mut() {
272            *s = 1;
273        }
274        Some(PairSequence { tokens, segments })
275    }
276
277    fn encode_on_worker(
278        &self,
279        tokens: &[u32],
280        segments: Option<&[u32]>,
281    ) -> Result<Vec<f32>, EncodeError> {
282        let n = tokens.len();
283        if n == 0 {
284            return Err(EncodeError::EmptySequence);
285        }
286        if let Some(seg) = segments {
287            if seg.len() != n {
288                return Err(EncodeError::RaggedSegments {
289                    tokens: n,
290                    segments: seg.len(),
291                });
292            }
293        }
294        if n > self.hp.n_ctx_train {
295            return Err(EncodeError::TooLong {
296                got: n,
297                max: self.hp.n_ctx_train,
298                arch: self.hp.arch.clone(),
299            });
300        }
301        let d = self.hp.n_embd;
302        let vocab_size = self.vocab_size();
303
304        // (1)(2) token + type + position, then the input LayerNorm.
305        let mut h = vec![0.0f32; n * d];
306        for (i, &t) in tokens.iter().enumerate() {
307            if t as usize >= vocab_size {
308                return Err(EncodeError::TokenOutOfRange { id: t, vocab_size });
309            }
310            let tok = self.tok_embd.dequant_row(t as usize);
311            let pos = self.pos_embd.dequant_row(i);
312            let row = &mut h[i * d..(i + 1) * d];
313            for (j, slot) in row.iter_mut().enumerate() {
314                *slot = tok[j] + pos[j];
315            }
316            if let Some(table) = &self.type_embd {
317                let seg = segments.map(|s| s[i]).unwrap_or(0);
318                let ty = table
319                    .get(seg as usize)
320                    .ok_or(EncodeError::SegmentOutOfRange {
321                        id: seg,
322                        pos: i,
323                        n_segments: table.len(),
324                    })?;
325                for (slot, tv) in row.iter_mut().zip(ty.iter()) {
326                    *slot += tv;
327                }
328            }
329        }
330        layer_norm_rows(
331            &mut h,
332            d,
333            &self.tok_norm_w,
334            &self.tok_norm_b,
335            self.hp.layer_norm_eps,
336        );
337
338        let head_dim = self.hp.head_dim();
339        for layer in &self.layers {
340            let mut q = layer.wq.apply_batch(&h, n);
341            let mut k = layer.wk.apply_batch(&h, n);
342            let mut v = layer.wv.apply_batch(&h, n);
343            add_bias_rows(&mut q, self.hp.n_head * head_dim, layer.bq.as_ref());
344            add_bias_rows(&mut k, self.hp.n_head_kv * head_dim, layer.bk.as_ref());
345            add_bias_rows(&mut v, self.hp.n_head_kv * head_dim, layer.bv.as_ref());
346
347            let attn =
348                bidirectional_attention(&q, &k, &v, n, self.hp.n_head, self.hp.n_head_kv, head_dim);
349
350            let mut x = layer.wo.apply_batch(&attn, n);
351            add_bias_rows(&mut x, d, layer.bo.as_ref());
352            // Residual over the *layer input*, then attn_output_norm.
353            for (xv, hv) in x.iter_mut().zip(h.iter()) {
354                *xv += hv;
355            }
356            layer_norm_rows(
357                &mut x,
358                d,
359                &layer.attn_out_norm_w,
360                &layer.attn_out_norm_b,
361                self.hp.layer_norm_eps,
362            );
363
364            // (5) plain GELU MLP; the FFN residual is over `x`, i.e.
365            // over the post-norm value, not over the layer input.
366            let mut up = layer.ffn_up.apply_batch(&x, n);
367            add_bias_rows(&mut up, self.hp.n_ff, layer.ffn_up_b.as_ref());
368            for a in up.iter_mut() {
369                *a = gelu(*a);
370            }
371            let mut down = layer.ffn_down.apply_batch(&up, n);
372            add_bias_rows(&mut down, d, layer.ffn_down_b.as_ref());
373            for (dv, xv) in down.iter_mut().zip(x.iter()) {
374                *dv += xv;
375            }
376            layer_norm_rows(
377                &mut down,
378                d,
379                &layer.layer_out_norm_w,
380                &layer.layer_out_norm_b,
381                self.hp.layer_norm_eps,
382            );
383            h = down;
384        }
385        Ok(h)
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use ferrox_core::tensor::Tensor;
393
394    /// Deterministic pseudo-random weights: a small LCG, so the fixture
395    /// is reproducible without pulling in a dependency.
396    struct Lcg(u64);
397    impl Lcg {
398        fn next_f32(&mut self) -> f32 {
399            self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1);
400            ((self.0 >> 33) as f32 / (1u64 << 31) as f32) - 0.5
401        }
402        fn vec(&mut self, n: usize) -> Vec<f32> {
403            (0..n).map(|_| self.next_f32()).collect()
404        }
405        fn matrix(&mut self, rows: usize, cols: usize) -> WeightMatrix {
406            WeightMatrix::F32(Tensor::new(self.vec(rows * cols), vec![rows, cols]))
407        }
408    }
409
410    const D: usize = 8;
411    const FF: usize = 16;
412    const HEADS: usize = 2;
413    const VOCAB: usize = 20;
414    const CTX: usize = 12;
415    const EPS: f32 = 1e-12;
416
417    fn fixture(n_layer: usize) -> BertEncoder {
418        let mut r = Lcg(0x5EED);
419        let tok_embd = r.matrix(VOCAB, D);
420        let pos_embd = r.matrix(CTX, D);
421        // Two rows, like every real BERT: "Sentence A" and "Sentence B".
422        let type_embd = Some(vec![r.vec(D), r.vec(D)]);
423        let tok_norm_w = r.vec(D);
424        let tok_norm_b = r.vec(D);
425        let layers = (0..n_layer)
426            .map(|_| BertLayer {
427                wq: r.matrix(D, D),
428                bq: Some(r.vec(D)),
429                wk: r.matrix(D, D),
430                bk: Some(r.vec(D)),
431                wv: r.matrix(D, D),
432                bv: Some(r.vec(D)),
433                wo: r.matrix(D, D),
434                bo: Some(r.vec(D)),
435                attn_out_norm_w: r.vec(D),
436                attn_out_norm_b: r.vec(D),
437                ffn_up: r.matrix(FF, D),
438                ffn_up_b: Some(r.vec(FF)),
439                ffn_down: r.matrix(D, FF),
440                ffn_down_b: Some(r.vec(D)),
441                layer_out_norm_w: r.vec(D),
442                layer_out_norm_b: r.vec(D),
443            })
444            .collect();
445        BertEncoder {
446            hp: BertHparams {
447                arch: "bert".into(),
448                n_layer,
449                n_embd: D,
450                n_ff: FF,
451                n_head: HEADS,
452                n_head_kv: HEADS,
453                n_ctx_train: CTX,
454                n_token_types: 2,
455                layer_norm_eps: EPS,
456                pooling: PoolingType::Cls,
457                cls_id: 1,
458                sep_id: 2,
459            },
460            tok_embd,
461            type_embd,
462            pos_embd,
463            tok_norm_w,
464            tok_norm_b,
465            layers,
466        }
467    }
468
469    /// An f64 transcription of the graph in the module docs, written
470    /// the slowest possible way: no `apply_batch`, no shared buffers,
471    /// one scalar loop per matrix element. It exists to disagree with
472    /// [`BertEncoder::encode`] if the fast path transposes a matrix,
473    /// drops a bias, norms the wrong residual, reuses a buffer it
474    /// should not, or reads the wrong row of the token-type table.
475    fn reference_forward(m: &BertEncoder, tokens: &[u32], segments: &[u32]) -> Vec<f64> {
476        let d = m.hp.n_embd;
477        let n = tokens.len();
478        let hd = m.hp.head_dim();
479
480        let dense = |w: &WeightMatrix| -> Vec<Vec<f64>> {
481            (0..w.rows())
482                .map(|r| w.dequant_row(r).iter().map(|&v| v as f64).collect())
483                .collect()
484        };
485        let matvec = |w: &Vec<Vec<f64>>, x: &[f64]| -> Vec<f64> {
486            w.iter()
487                .map(|row| row.iter().zip(x).map(|(a, b)| a * b).sum())
488                .collect()
489        };
490        let ln = |x: &[f64], wt: &[f32], b: &[f32]| -> Vec<f64> {
491            let mean = x.iter().sum::<f64>() / x.len() as f64;
492            let var = x.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / x.len() as f64;
493            let inv = 1.0 / (var + m.hp.layer_norm_eps as f64).sqrt();
494            x.iter()
495                .zip(wt)
496                .zip(b)
497                .map(|((v, w), bb)| (v - mean) * inv * (*w as f64) + (*bb as f64))
498                .collect()
499        };
500
501        let mut h: Vec<Vec<f64>> = tokens
502            .iter()
503            .enumerate()
504            .map(|(i, &t)| {
505                let tok = m.tok_embd.dequant_row(t as usize);
506                let pos = m.pos_embd.dequant_row(i);
507                let ty = m
508                    .type_embd
509                    .as_ref()
510                    .map(|t| t[segments[i] as usize].clone())
511                    .unwrap_or_else(|| vec![0.0; d]);
512                let row: Vec<f64> = (0..d)
513                    .map(|j| tok[j] as f64 + pos[j] as f64 + ty[j] as f64)
514                    .collect();
515                ln(&row, &m.tok_norm_w, &m.tok_norm_b)
516            })
517            .collect();
518
519        for layer in &m.layers {
520            let (wq, wk, wv, wo) = (
521                dense(&layer.wq),
522                dense(&layer.wk),
523                dense(&layer.wv),
524                dense(&layer.wo),
525            );
526            let (wu, wd) = (dense(&layer.ffn_up), dense(&layer.ffn_down));
527            let bias = |v: &mut Vec<f64>, b: &Option<Vec<f32>>| {
528                if let Some(b) = b {
529                    for (x, bb) in v.iter_mut().zip(b) {
530                        *x += *bb as f64;
531                    }
532                }
533            };
534            let mut q = Vec::new();
535            let mut k = Vec::new();
536            let mut v = Vec::new();
537            for row in &h {
538                let mut a = matvec(&wq, row);
539                bias(&mut a, &layer.bq);
540                q.push(a);
541                let mut a = matvec(&wk, row);
542                bias(&mut a, &layer.bk);
543                k.push(a);
544                let mut a = matvec(&wv, row);
545                bias(&mut a, &layer.bv);
546                v.push(a);
547            }
548            let mut attn = vec![vec![0.0f64; d]; n];
549            for head in 0..m.hp.n_head {
550                let off = head * hd;
551                for i in 0..n {
552                    let raw: Vec<f64> = (0..n)
553                        .map(|j| {
554                            (0..hd).map(|c| q[i][off + c] * k[j][off + c]).sum::<f64>()
555                                / (hd as f64).sqrt()
556                        })
557                        .collect();
558                    let mx = raw.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
559                    let ex: Vec<f64> = raw.iter().map(|s| (s - mx).exp()).collect();
560                    let sum: f64 = ex.iter().sum();
561                    for j in 0..n {
562                        let p = ex[j] / sum;
563                        for c in 0..hd {
564                            attn[i][off + c] += p * v[j][off + c];
565                        }
566                    }
567                }
568            }
569            let mut next = Vec::new();
570            for i in 0..n {
571                let mut o = matvec(&wo, &attn[i]);
572                bias(&mut o, &layer.bo);
573                for (x, hv) in o.iter_mut().zip(&h[i]) {
574                    *x += hv;
575                }
576                let x = ln(&o, &layer.attn_out_norm_w, &layer.attn_out_norm_b);
577                let mut up = matvec(&wu, &x);
578                bias(&mut up, &layer.ffn_up_b);
579                let act: Vec<f64> = up
580                    .iter()
581                    .map(|&u| {
582                        const K: f64 = 0.797_884_560_802_865_4;
583                        const C: f64 = 0.044_715;
584                        0.5 * u * (1.0 + (K * (u + C * u * u * u)).tanh())
585                    })
586                    .collect();
587                let mut down = matvec(&wd, &act);
588                bias(&mut down, &layer.ffn_down_b);
589                for (dv, xv) in down.iter_mut().zip(&x) {
590                    *dv += xv;
591                }
592                next.push(ln(&down, &layer.layer_out_norm_w, &layer.layer_out_norm_b));
593            }
594            h = next;
595        }
596        h.into_iter().flatten().collect()
597    }
598
599    #[test]
600    fn matches_an_independent_f64_transcription_of_the_graph() {
601        let m = fixture(3);
602        let tokens = [1u32, 7, 13, 4, 9, 2];
603        let got = m.encode_tokens(&tokens).unwrap();
604        let want = reference_forward(&m, &tokens, &[0; 6]);
605        assert_eq!(got.len(), want.len());
606        for (i, (g, w)) in got.iter().zip(&want).enumerate() {
607            assert!(
608                (*g as f64 - w).abs() < 2e-4,
609                "element {i}: {g} vs reference {w}"
610            );
611        }
612    }
613
614    /// The same transcription, driven with a real `0 0 0 1 1 1` split.
615    /// The point is not that segments *do something* — it is that the
616    /// fast path reads the SAME row the reference does at every
617    /// position, so an off-by-one on the boundary or a table indexed
618    /// with the token id would show up here.
619    #[test]
620    fn the_segment_id_selects_the_token_type_row_at_every_position() {
621        let m = fixture(3);
622        let tokens = [1u32, 7, 13, 4, 9, 2];
623        let segments = [0u32, 0, 0, 1, 1, 1];
624        let got = m.encode(&tokens, Some(&segments)).unwrap();
625        let want = reference_forward(&m, &tokens, &segments);
626        for (i, (g, w)) in got.iter().zip(&want).enumerate() {
627            assert!(
628                (*g as f64 - w).abs() < 2e-4,
629                "element {i}: {g} vs reference {w}"
630            );
631        }
632        // And it is genuinely a different graph from the all-zeros one,
633        // which is the whole of issue #44: scoring the second half as
634        // "Sentence A" is not a rounding difference.
635        let all_zero = m.encode_tokens(&tokens).unwrap();
636        let moved: f32 = all_zero
637            .iter()
638            .zip(&got)
639            .map(|(x, y)| (x - y).abs())
640            .sum::<f32>();
641        assert!(moved > 1e-3, "segment 1 changed nothing ({moved})");
642    }
643
644    /// A segment id with no row, and a segment list that is not one per
645    /// token, are refusals rather than a panic or a silently wrong row.
646    #[test]
647    fn a_segment_id_off_the_table_and_a_ragged_segment_list_are_refused() {
648        let m = fixture(1);
649        assert!(matches!(
650            m.encode(&[1, 7, 2], Some(&[0, 2, 0])),
651            Err(EncodeError::SegmentOutOfRange { id: 2, pos: 1, .. })
652        ));
653        assert!(matches!(
654            m.encode(&[1, 7, 2], Some(&[0, 0])),
655            Err(EncodeError::RaggedSegments {
656                tokens: 3,
657                segments: 2
658            })
659        ));
660    }
661
662    /// The property that makes this an encoder. Row 0's output must
663    /// change when the *last* token changes; under a causal mask it
664    /// could not, because position 0 would attend only to itself.
665    #[test]
666    fn attention_is_bidirectional_not_causal() {
667        let m = fixture(2);
668        let a = m.encode_tokens(&[5u32, 6, 7, 8]).unwrap();
669        let b = m.encode_tokens(&[5u32, 6, 7, 19]).unwrap();
670        let moved: f32 = a[..D].iter().zip(&b[..D]).map(|(x, y)| (x - y).abs()).sum();
671        assert!(
672            moved > 1e-3,
673            "row 0 barely moved ({moved}) when the last token changed — \
674             attention is behaving causally"
675        );
676    }
677
678    /// Position is a learned table lookup, so the same token at a
679    /// different index must land somewhere else.
680    #[test]
681    fn position_embeddings_make_the_same_token_differ_by_index() {
682        let m = fixture(1);
683        let out = m.encode_tokens(&[11u32, 11]).unwrap();
684        let delta: f32 = out[..D]
685            .iter()
686            .zip(&out[D..2 * D])
687            .map(|(x, y)| (x - y).abs())
688            .sum();
689        assert!(
690            delta > 1e-3,
691            "identical tokens gave identical rows: {delta}"
692        );
693    }
694
695    /// The graph ends on a LayerNorm: with unit weight and zero bias
696    /// each output row is mean-zero and unit-variance. An RMSNorm in
697    /// that slot would leave the mean wherever it was.
698    #[test]
699    fn the_last_op_is_a_mean_subtracting_layer_norm() {
700        let mut m = fixture(2);
701        let last = m.layers.last_mut().unwrap();
702        last.layer_out_norm_w = vec![1.0; D];
703        last.layer_out_norm_b = vec![0.0; D];
704        let out = m.encode_tokens(&[3u32, 4, 5]).unwrap();
705        for row in out.as_chunks::<D>().0 {
706            let mean: f32 = row.iter().sum::<f32>() / D as f32;
707            let var: f32 = row.iter().map(|v| (v - mean).powi(2)).sum::<f32>() / D as f32;
708            assert!(mean.abs() < 1e-4, "row mean {mean} is not zero");
709            assert!((var - 1.0).abs() < 1e-3, "row variance {var} is not one");
710        }
711    }
712
713    #[test]
714    fn refuses_an_empty_sequence_and_one_past_the_position_table() {
715        let m = fixture(1);
716        assert!(matches!(
717            m.encode_tokens(&[]),
718            Err(EncodeError::EmptySequence)
719        ));
720        let long: Vec<u32> = (0..CTX as u32 + 1).map(|i| i % VOCAB as u32).collect();
721        let err = m.encode_tokens(&long).unwrap_err();
722        assert!(
723            matches!(err, EncodeError::TooLong { got, max, .. } if got == CTX + 1 && max == CTX)
724        );
725        assert!(matches!(
726            m.encode_tokens(&[VOCAB as u32]),
727            Err(EncodeError::TokenOutOfRange { .. })
728        ));
729    }
730
731    #[test]
732    fn wrap_special_brackets_the_pieces_with_cls_and_sep() {
733        let m = fixture(1);
734        assert_eq!(m.wrap_special(&[7, 8]), vec![1, 7, 8, 2]);
735        assert_eq!(m.wrap_special(&[]), vec![1, 2]);
736    }
737
738    /// The cross-encoder input is `[CLS] a [SEP] b [SEP]` with segments
739    /// `0 0 0 0 1 1` — the boundary between the two halves is the whole
740    /// reason a reranker scores differently from an embedding model.
741    /// Concatenating without it, dropping the trailing `[SEP]`, or
742    /// leaving every segment at 0, produces a perfectly plausible
743    /// ranking that is not the model's, so both vectors are asserted
744    /// exactly rather than by length.
745    ///
746    /// The first `[SEP]` belongs to segment 0, which is what
747    /// HuggingFace's `tokenizer(query, document)` emits: an off-by-one
748    /// there is a one-position difference that no shape check catches.
749    #[test]
750    fn the_pair_form_separates_the_two_halves_and_labels_each_one() {
751        let m = fixture(1);
752        let pair = m.wrap_special_pair(&[7, 8], &[9]).unwrap();
753        assert_eq!(pair.tokens, vec![1, 7, 8, 2, 9, 2]);
754        assert_eq!(pair.segments, vec![0, 0, 0, 0, 1, 1]);
755        // An empty half is still a half: the boundary stays.
756        let empty = m.wrap_special_pair(&[], &[]).unwrap();
757        assert_eq!(empty.tokens, vec![1, 2, 2]);
758        assert_eq!(empty.segments, vec![0, 0, 1]);
759        // And it is NOT the single-sequence form of the two texts run
760        // together, which is what a defaulted implementation would give.
761        assert_ne!(pair.tokens, m.wrap_special(&[7, 8, 9]));
762    }
763
764    /// A checkpoint with no "Sentence B" row cannot express a pair, and
765    /// [`crate::EmbeddingModel`] refuses one at load. This is the value
766    /// that refusal reads.
767    #[test]
768    fn n_segments_is_the_height_of_the_token_type_table() {
769        let mut m = fixture(1);
770        assert_eq!(m.n_segments(), 2);
771        m.type_embd = Some(vec![vec![0.0; D]]);
772        assert_eq!(m.n_segments(), 1);
773        m.type_embd = None;
774        assert_eq!(m.n_segments(), 1);
775    }
776
777    /// `embed_tokens` must return the CLS row of the hidden states this
778    /// checkpoint's `pooling_type` names, not the mean and not the last.
779    #[test]
780    fn embed_tokens_pools_the_way_the_hparams_say() {
781        let m = fixture(2);
782        let tokens = [1u32, 9, 4, 2];
783        let hidden = m.encode_tokens(&tokens).unwrap();
784        assert_eq!(m.embed_tokens(&tokens).unwrap(), hidden[..D].to_vec());
785    }
786}