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