Skip to main content

frink_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]`. Frink 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 frink_core::matmul::{gelu, layer_norm};
64use frink_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    /// NEOX RoPE on Q and K instead of a learned position table:
83    /// `Some(theta)` for the architectures `bert.cpp:126-133` rotates
84    /// (`nomic-bert`, `nomic-bert-moe`, `jina-bert-v3`), `None` for
85    /// `bert` itself, which adds `position_embd` at `:90` instead.
86    ///
87    /// One field for both facts on purpose: a file cannot have a
88    /// position table AND a rotation here, because upstream decides
89    /// both from the architecture and never reads the table for a
90    /// rotating one -- measured, libllama's load log never names
91    /// `position_embd` for `nomic-bert`.
92    pub rope_theta: Option<f32>,
93    /// How many of each head's channels rotate
94    /// (`{arch}.rope.dimension_count`), `head_dim` when the key is
95    /// absent. Only read when [`Self::rope_theta`] is `Some`.
96    pub rope_dim: usize,
97    /// The FFN this architecture runs (`bert.cpp:179-201`).
98    pub ffn: BertFfn,
99    /// Where this architecture's norms sit and what they are.
100    pub topology: BertTopology,
101    /// `true` for the NORM (interleaved-pair) rotation, `false` for
102    /// NEOX (split-half). `llama_model_rope_type` answers NORM for
103    /// `neo-bert` alone among the encoders here.
104    pub rope_interleaved: bool,
105    /// ALiBi slopes, one per head, for the architecture whose graph
106    /// carries a positional bias instead of a table or a rotation.
107    ///
108    /// `jina-bert-v2.cpp:5` sets `f_max_alibi_bias = 8.0f` as a
109    /// LITERAL and `bert.cpp:78-80` builds no `inp_pos` for it at all,
110    /// so this is the only place position enters that model. The bias
111    /// is SYMMETRIC here -- `llama-graph.cpp:442` fills the mask with
112    /// `-|p0 - p1|` for a non-causal model, where the decoder's is
113    /// `p_key - p_query` -- which is why the encoder computes its own
114    /// rather than calling the decoder's row helper.
115    pub alibi_slopes: Option<Vec<f32>>,
116    pub pooling: PoolingType,
117    /// `[CLS]` / `[SEP]`, from `tokenizer.ggml.bos_token_id` and
118    /// `tokenizer.ggml.seperator_token_id` (upstream's spelling of the
119    /// key, typo included). See [`BertEncoder::wrap_special`].
120    pub cls_id: u32,
121    pub sep_id: u32,
122}
123
124impl BertHparams {
125    pub fn head_dim(&self) -> usize {
126        self.n_embd / self.n_head
127    }
128}
129
130/// One transformer block's weights. Biases that llama.cpp marks
131/// `TENSOR_NOT_REQUIRED` are `Option`, so a checkpoint without them is
132/// run without them rather than with a silently fabricated zero vector.
133/// Where an encoder layer's norms sit, and which function they are.
134///
135/// `bert.cpp`'s graph is POST-norm with LayerNorm: attention, residual,
136/// norm, FFN, residual, norm. `neo-bert.cpp:59-118` and
137/// `eurobert.cpp:55-114` are the other shape -- RMSNorm BEFORE each
138/// block and a bare residual after it, with one final norm at the end
139/// -- and they are one topology with four table columns between them,
140/// not two graphs.
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub enum BertTopology {
143    /// `bert.cpp`: LayerNorm with biases, after each residual.
144    PostNormLayerNorm,
145    /// `neo-bert` / `eurobert`: RMSNorm with a weight and no bias,
146    /// before each block, and one final norm.
147    PreNormRms,
148}
149
150/// The two FFN shapes `bert.cpp` builds on this graph for the
151/// architectures frink serves.
152///
153/// `bert` and `nomic-bert-moe`'s dense layers take the ungated GELU
154/// with both biases (`:179-187`); `nomic-bert` takes the gated SiLU
155/// with none (`:195-201`, the final `else`). The variant is the
156/// architecture's, read once at load, so a layer body cannot ask
157/// "is there a gate tensor" and answer differently on two files.
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159pub enum BertFfn {
160    /// `down(gelu(up(x)))`, biases where the file has them.
161    GeluSeq,
162    /// `down(silu(gate(x)) * up(x))`, no biases.
163    SwigluPar,
164    /// `down(gelu(gate(x)) * up(x))`: `jina-bert-v2` with a separate
165    /// `ffn_gate` (`bert.cpp:188-194` picks `LLM_FFN_GELU` under
166    /// `LLM_FFN_PAR`, which `build_ffn` turns into `ggml_geglu_split`).
167    GegluPar,
168    /// The same function with the gate FUSED into `ffn_up`: the matrix
169    /// is `2 * n_ff` rows and `ggml_geglu` splits it, the FIRST half
170    /// being the gate (`bert.cpp:189`, `up_contains_gate`).
171    GegluFusedUp,
172    /// SwiGLU with the gate fused into `ffn_up` the same way:
173    /// `neo-bert.cpp:35,110-115` creates a `2 * n_ff`-wide matrix and
174    /// passes `LLM_FFN_SWIGLU` under `LLM_FFN_SEQ`, which is
175    /// `ggml_swiglu` over the doubled row.
176    SwigluFusedUp,
177}
178
179/// One layer's Q/K LayerNorm pair, weights and biases.
180#[derive(Debug, Clone)]
181pub struct QkLayerNorm {
182    pub q_w: Vec<f32>,
183    pub q_b: Vec<f32>,
184    pub k_w: Vec<f32>,
185    pub k_b: Vec<f32>,
186}
187
188pub struct BertLayer {
189    pub wq: WeightMatrix,
190    pub bq: Option<Vec<f32>>,
191    pub wk: WeightMatrix,
192    pub bk: Option<Vec<f32>>,
193    pub wv: WeightMatrix,
194    pub bv: Option<Vec<f32>>,
195    pub wo: WeightMatrix,
196    pub bo: Option<Vec<f32>>,
197    /// A LayerNorm over the WHOLE Q / K projection, with biases, when
198    /// the file carries one (`bert.cpp:109-123`; `jina-bert-v2.cpp:
199    /// 30-35` creates the pair optional). Not per head: the reshape at
200    /// `:110` is `n_embd_head * n_head` wide, so one norm covers every
201    /// head's channels together.
202    pub qk_norm: Option<QkLayerNorm>,
203    /// `attn_norm` / `ffn_norm`: the PRE-norm weights, `Some` exactly
204    /// for [`BertTopology::PreNormRms`].
205    pub pre_attn_norm: Option<Vec<f32>>,
206    pub pre_ffn_norm: Option<Vec<f32>>,
207    /// `attn_output_norm`, applied after the attention residual.
208    /// `Some` exactly for [`BertTopology::PostNormLayerNorm`].
209    pub attn_out_norm_w: Option<Vec<f32>>,
210    pub attn_out_norm_b: Option<Vec<f32>>,
211    /// `attn_norm_2`, `jina-bert-v2`'s second attention norm
212    /// (`bert.cpp:156-159`): the LAYER INPUT is re-added and normed a
213    /// second time before the FFN reads it.
214    pub attn_norm_2: Option<(Vec<f32>, Vec<f32>)>,
215    pub ffn_up: WeightMatrix,
216    pub ffn_up_b: Option<Vec<f32>>,
217    /// `ffn_gate`, present exactly for [`BertFfn::SwigluPar`].
218    pub ffn_gate: Option<WeightMatrix>,
219    pub ffn_down: WeightMatrix,
220    pub ffn_down_b: Option<Vec<f32>>,
221    /// `layer_output_norm`, applied after the FFN residual. `Some`
222    /// exactly for [`BertTopology::PostNormLayerNorm`].
223    pub layer_out_norm_w: Option<Vec<f32>>,
224    pub layer_out_norm_b: Option<Vec<f32>>,
225}
226
227pub struct BertEncoder {
228    pub hp: BertHparams,
229    pub tok_embd: WeightMatrix,
230    /// `token_types.weight`, **every** row: `[n_token_types, n_embd]`.
231    /// Row 0 is "Sentence A" and row 1 "Sentence B". `None` when the
232    /// checkpoint carries no table at all, which upstream allows
233    /// (`TENSOR_NOT_REQUIRED`) and which means no segment embedding is
234    /// added anywhere. Loading only row 0 — what this held before — is
235    /// what made a rerank pair score both halves as Sentence A.
236    pub type_embd: Option<Vec<Vec<f32>>>,
237    /// The learned position table, `None` for a rotating architecture
238    /// (see [`BertHparams::rope_theta`]).
239    pub pos_embd: Option<WeightMatrix>,
240    /// The embedding LayerNorm, `Some` exactly for
241    /// [`BertTopology::PostNormLayerNorm`]; a pre-norm encoder feeds
242    /// the raw embeddings into layer 0.
243    pub tok_norm_w: Option<Vec<f32>>,
244    pub tok_norm_b: Option<Vec<f32>>,
245    /// The final RMSNorm weight, `Some` exactly for
246    /// [`BertTopology::PreNormRms`] (`enc.output_norm` for
247    /// `neo-bert`, `output_norm` for `eurobert`).
248    pub final_norm: Option<Vec<f32>>,
249    pub layers: Vec<BertLayer>,
250}
251
252/// Adds `bias` to every `width`-wide row of `rows`, when there is one.
253fn add_bias_rows(rows: &mut [f32], width: usize, bias: Option<&Vec<f32>>) {
254    let Some(b) = bias else { return };
255    debug_assert_eq!(b.len(), width);
256    for row in rows.chunks_exact_mut(width) {
257        for (x, bv) in row.iter_mut().zip(b.iter()) {
258            *x += bv;
259        }
260    }
261}
262
263/// RMSNorm applied independently to each `width`-wide row, returning a
264/// new buffer: the pre-norm shape needs the normed value AND the
265/// unnormed residual, so this one does not work in place.
266fn rms_norm_rows(rows: &[f32], width: usize, weight: &[f32], eps: f32) -> Vec<f32> {
267    debug_assert_eq!(weight.len(), width);
268    rows.chunks_exact(width)
269        .flat_map(|row| frink_core::matmul::rms_norm(row, weight, eps))
270        .collect()
271}
272
273/// LayerNorm applied independently to each `width`-wide row, in place.
274fn layer_norm_rows(rows: &mut [f32], width: usize, weight: &[f32], bias: &[f32], eps: f32) {
275    for row in rows.chunks_exact_mut(width) {
276        let normed = layer_norm(row, weight, bias, eps);
277        row.copy_from_slice(&normed);
278    }
279}
280
281/// In-place softmax over one score row, max-shifted.
282fn softmax_row(scores: &mut [f32]) {
283    let max = scores.iter().copied().fold(f32::NEG_INFINITY, f32::max);
284    let mut sum = 0.0f32;
285    for s in scores.iter_mut() {
286        *s = (*s - max).exp();
287        sum += *s;
288    }
289    let inv = 1.0 / sum;
290    for s in scores.iter_mut() {
291        *s *= inv;
292    }
293}
294
295/// Full bidirectional multi-head attention over `n` positions.
296///
297/// `q` is `[n][n_head * head_dim]`; `k` and `v` are
298/// `[n][n_head_kv * head_dim]`. **Every query row attends to every key
299/// row** — there is no mask argument here on purpose, so a causal mask
300/// cannot be added by accident.
301#[allow(clippy::too_many_arguments)]
302fn bidirectional_attention(
303    q: &[f32],
304    k: &[f32],
305    v: &[f32],
306    n: usize,
307    n_head: usize,
308    n_head_kv: usize,
309    head_dim: usize,
310    // One slope per head, or `None`. The bias is `-slope * |i - j|`:
311    // SYMMETRIC, because `llama-graph.cpp:442` fills a non-causal
312    // model's mask with `-|p0 - p1|` and `ggml_soft_max_ext`
313    // multiplies it by the head's slope.
314    alibi_slopes: Option<&[f32]>,
315) -> Vec<f32> {
316    let q_width = n_head * head_dim;
317    let kv_width = n_head_kv * head_dim;
318    let heads_per_kv = n_head / n_head_kv;
319    let scale = 1.0 / (head_dim as f32).sqrt();
320    let mut out = vec![0.0f32; n * q_width];
321    let mut scores = vec![0.0f32; n];
322    for h in 0..n_head {
323        let kv_h = h / heads_per_kv;
324        let q_off = h * head_dim;
325        let kv_off = kv_h * head_dim;
326        let slope = alibi_slopes.map(|s| s[h]);
327        for i in 0..n {
328            let qi = &q[i * q_width + q_off..i * q_width + q_off + head_dim];
329            for (j, s) in scores.iter_mut().enumerate() {
330                let kj = &k[j * kv_width + kv_off..j * kv_width + kv_off + head_dim];
331                *s = qi.iter().zip(kj).map(|(a, b)| a * b).sum::<f32>() * scale;
332                if let Some(slope) = slope {
333                    *s += slope * -((i as f32 - j as f32).abs());
334                }
335            }
336            softmax_row(&mut scores);
337            let dst = &mut out[i * q_width + q_off..i * q_width + q_off + head_dim];
338            for (j, &p) in scores.iter().enumerate() {
339                let vj = &v[j * kv_width + kv_off..j * kv_width + kv_off + head_dim];
340                for (o, &vv) in dst.iter_mut().zip(vj) {
341                    *o += p * vv;
342                }
343            }
344        }
345    }
346    out
347}
348
349impl BertEncoder {
350    pub fn vocab_size(&self) -> usize {
351        self.tok_embd.rows()
352    }
353}
354
355impl TextEncoder for BertEncoder {
356    fn bert_hparams(&self) -> Option<&BertHparams> {
357        Some(&self.hp)
358    }
359
360    fn n_embd(&self) -> usize {
361        self.hp.n_embd
362    }
363
364    fn n_ctx_train(&self) -> usize {
365        self.hp.n_ctx_train
366    }
367
368    fn pooling_type(&self) -> PoolingType {
369        self.hp.pooling
370    }
371
372    /// `[CLS] … [SEP]`, which is what llama.cpp's WPM branch adds when
373    /// `add_special` is set: it pushes `special_bos_id` before the
374    /// pieces and `special_sep_id` after, unconditionally — the
375    /// `add_bos`/`add_eos` flags are not consulted on that path
376    /// (`llama-vocab.cpp`, `case LLAMA_VOCAB_TYPE_WPM`).
377    fn wrap_special(&self, pieces: &[u32]) -> Vec<u32> {
378        let mut out = Vec::with_capacity(pieces.len() + 2);
379        out.push(self.hp.cls_id);
380        out.extend_from_slice(pieces);
381        out.push(self.hp.sep_id);
382        out
383    }
384
385    /// The height of `token_types.weight`, or 1 when the checkpoint
386    /// carries no table (nothing is added at any position, which is
387    /// what a one-row table would do anyway).
388    fn n_segments(&self) -> usize {
389        self.type_embd.as_ref().map(Vec::len).unwrap_or(1)
390    }
391
392    /// `[CLS] a [SEP] b [SEP]` with segments `0…0 1…1` — what
393    /// HuggingFace's `tokenizer(query, document)` builds for a BERT
394    /// cross-encoder, which is the input these checkpoints were
395    /// trained on.
396    ///
397    /// The boundary is defined once, here, and both vectors are cut on
398    /// it: the first `[SEP]` closes segment 0 (HF counts it as part of
399    /// the first half) and everything after it is segment 1. Returning
400    /// the ids alone and letting the graph assume a segment is how the
401    /// document half came to be scored as "Sentence A".
402    fn wrap_special_pair(&self, a: &[u32], b: &[u32]) -> Option<PairSequence> {
403        let mut tokens = Vec::with_capacity(a.len() + b.len() + 3);
404        tokens.push(self.hp.cls_id);
405        tokens.extend_from_slice(a);
406        tokens.push(self.hp.sep_id);
407        let first_half = tokens.len();
408        tokens.extend_from_slice(b);
409        tokens.push(self.hp.sep_id);
410        let mut segments = vec![0u32; tokens.len()];
411        for s in segments[first_half..].iter_mut() {
412            *s = 1;
413        }
414        Some(PairSequence { tokens, segments })
415    }
416
417    fn encode_on_worker(
418        &self,
419        tokens: &[u32],
420        segments: Option<&[u32]>,
421    ) -> Result<Vec<f32>, EncodeError> {
422        let n = tokens.len();
423        if n == 0 {
424            return Err(EncodeError::EmptySequence);
425        }
426        if let Some(seg) = segments {
427            if seg.len() != n {
428                return Err(EncodeError::RaggedSegments {
429                    tokens: n,
430                    segments: seg.len(),
431                });
432            }
433        }
434        if n > self.hp.n_ctx_train {
435            return Err(EncodeError::TooLong {
436                got: n,
437                max: self.hp.n_ctx_train,
438                arch: self.hp.arch.clone(),
439            });
440        }
441        let d = self.hp.n_embd;
442        let vocab_size = self.vocab_size();
443
444        // (1)(2) token + type + position, then the input LayerNorm.
445        let mut h = vec![0.0f32; n * d];
446        for (i, &t) in tokens.iter().enumerate() {
447            if t as usize >= vocab_size {
448                return Err(EncodeError::TokenOutOfRange { id: t, vocab_size });
449            }
450            let tok = self.tok_embd.dequant_row(t as usize);
451            let row = &mut h[i * d..(i + 1) * d];
452            match &self.pos_embd {
453                Some(table) => {
454                    let pos = table.dequant_row(i);
455                    for (j, slot) in row.iter_mut().enumerate() {
456                        *slot = tok[j] + pos[j];
457                    }
458                }
459                // A rotating architecture adds no table here;
460                // `bert.cpp:90` is gated on `arch == LLM_ARCH_BERT`.
461                None => row.copy_from_slice(&tok),
462            }
463            if let Some(table) = &self.type_embd {
464                let seg = segments.map(|s| s[i]).unwrap_or(0);
465                let ty = table
466                    .get(seg as usize)
467                    .ok_or(EncodeError::SegmentOutOfRange {
468                        id: seg,
469                        pos: i,
470                        n_segments: table.len(),
471                    })?;
472                for (slot, tv) in row.iter_mut().zip(ty.iter()) {
473                    *slot += tv;
474                }
475            }
476        }
477        if let (Some(w), Some(b)) = (&self.tok_norm_w, &self.tok_norm_b) {
478            layer_norm_rows(&mut h, d, w, b, self.hp.layer_norm_eps);
479        }
480
481        let head_dim = self.hp.head_dim();
482        for layer in &self.layers {
483            // A pre-norm layer normalises what the block reads and
484            // leaves the residual alone; a post-norm one reads the
485            // residual directly and norms after each add.
486            // `neo-bert.cpp:62-65` against `bert.cpp:103`.
487            let block_in = match &layer.pre_attn_norm {
488                None => h.clone(),
489                Some(w) => rms_norm_rows(&h, d, w, self.hp.layer_norm_eps),
490            };
491            let mut q = layer.wq.apply_batch(&block_in, n);
492            let mut k = layer.wk.apply_batch(&block_in, n);
493            let mut v = layer.wv.apply_batch(&block_in, n);
494            add_bias_rows(&mut q, self.hp.n_head * head_dim, layer.bq.as_ref());
495            add_bias_rows(&mut k, self.hp.n_head_kv * head_dim, layer.bk.as_ref());
496            add_bias_rows(&mut v, self.hp.n_head_kv * head_dim, layer.bv.as_ref());
497
498            if let Some(qk) = &layer.qk_norm {
499                // `bert.cpp:109-123`: a LayerNorm over the whole
500                // projection, BEFORE the rotation below, which is the
501                // order the graph builds them in.
502                layer_norm_rows(
503                    &mut q,
504                    self.hp.n_head * head_dim,
505                    &qk.q_w,
506                    &qk.q_b,
507                    self.hp.layer_norm_eps,
508                );
509                layer_norm_rows(
510                    &mut k,
511                    self.hp.n_head_kv * head_dim,
512                    &qk.k_w,
513                    &qk.k_b,
514                    self.hp.layer_norm_eps,
515                );
516            }
517
518            if let Some(theta) = self.hp.rope_theta {
519                // `bert.cpp:126-133`, NEOX (`llama_model_rope_type`),
520                // over the first `rope_dim` channels of every head and
521                // at the row's own position -- the same rotation the
522                // decoder path applies, on both Q and K.
523                let rot = self.hp.rope_dim.min(head_dim);
524                // NORM (interleaved pairs) for `neo-bert`, NEOX
525                // (split-half) for the others: `llama_model_rope_type`
526                // is the table and `BertHparams::rope_interleaved`
527                // carries its answer.
528                let rotate: fn(&mut [f32], usize, f32) = if self.hp.rope_interleaved {
529                    frink_core::attention::apply_rope_interleaved
530                } else {
531                    frink_core::attention::apply_rope
532                };
533                for (pos, row) in q.chunks_exact_mut(self.hp.n_head * head_dim).enumerate() {
534                    for head in row.chunks_exact_mut(head_dim) {
535                        rotate(&mut head[..rot], pos, theta);
536                    }
537                }
538                for (pos, row) in k.chunks_exact_mut(self.hp.n_head_kv * head_dim).enumerate() {
539                    for head in row.chunks_exact_mut(head_dim) {
540                        rotate(&mut head[..rot], pos, theta);
541                    }
542                }
543            }
544
545            let attn = bidirectional_attention(
546                &q,
547                &k,
548                &v,
549                n,
550                self.hp.n_head,
551                self.hp.n_head_kv,
552                head_dim,
553                self.hp.alibi_slopes.as_deref(),
554            );
555
556            let mut x = layer.wo.apply_batch(&attn, n);
557            add_bias_rows(&mut x, d, layer.bo.as_ref());
558            // Residual over the *layer input*; the post-norm shape
559            // then norms it, the pre-norm shape does not.
560            for (xv, hv) in x.iter_mut().zip(h.iter()) {
561                *xv += hv;
562            }
563            if let (Some(w), Some(b)) = (&layer.attn_out_norm_w, &layer.attn_out_norm_b) {
564                layer_norm_rows(&mut x, d, w, b, self.hp.layer_norm_eps);
565            }
566
567            // `bert.cpp:156-159`: the layer INPUT is re-added and
568            // normed a second time. Only `jina-bert-v2` carries the
569            // tensor, and only on the layers that have it.
570            if let Some((w, b)) = &layer.attn_norm_2 {
571                for (xv, hv) in x.iter_mut().zip(h.iter()) {
572                    *xv += hv;
573                }
574                layer_norm_rows(&mut x, d, w, b, self.hp.layer_norm_eps);
575            }
576
577            // (5) the architecture's MLP; the FFN residual is over
578            // `x`, i.e. over the post-norm value, not over the layer
579            // input.
580            let ffn_in = match &layer.pre_ffn_norm {
581                None => x.clone(),
582                Some(w) => rms_norm_rows(&x, d, w, self.hp.layer_norm_eps),
583            };
584            let mut up = layer.ffn_up.apply_batch(&ffn_in, n);
585            add_bias_rows(&mut up, self.hp.n_ff, layer.ffn_up_b.as_ref());
586            match (self.hp.ffn, &layer.ffn_gate) {
587                (BertFfn::GeluSeq, _) => {
588                    for a in up.iter_mut() {
589                        *a = gelu(*a);
590                    }
591                }
592                (BertFfn::SwigluPar, Some(gate)) => {
593                    let g = gate.apply_batch(&ffn_in, n);
594                    for (a, gv) in up.iter_mut().zip(g.iter()) {
595                        *a *= frink_core::matmul::silu(*gv);
596                    }
597                }
598                (BertFfn::GegluPar, Some(gate)) => {
599                    let g = gate.apply_batch(&ffn_in, n);
600                    for (a, gv) in up.iter_mut().zip(g.iter()) {
601                        *a *= gelu(*gv);
602                    }
603                }
604                (BertFfn::SwigluFusedUp, _) => {
605                    // `ggml_swiglu` over a `2 * n_ff`-wide row: the
606                    // first half is the gate.
607                    let wide = self.hp.n_ff * 2;
608                    debug_assert_eq!(up.len(), n * wide);
609                    let mut folded = vec![0.0f32; n * self.hp.n_ff];
610                    for (row, out) in up
611                        .chunks_exact(wide)
612                        .zip(folded.chunks_exact_mut(self.hp.n_ff))
613                    {
614                        let (gate, rest) = row.split_at(self.hp.n_ff);
615                        for ((o, g), u) in out.iter_mut().zip(gate).zip(rest) {
616                            *o = frink_core::matmul::silu(*g) * u;
617                        }
618                    }
619                    up = folded;
620                }
621                (BertFfn::GegluFusedUp, _) => {
622                    // `ggml_geglu` over a `2 * n_ff`-wide row: the
623                    // first half is the gate and the second the up, per
624                    // row, and the result is `n_ff` wide.
625                    let wide = self.hp.n_ff * 2;
626                    debug_assert_eq!(up.len(), n * wide);
627                    let mut folded = vec![0.0f32; n * self.hp.n_ff];
628                    for (row, out) in up
629                        .chunks_exact(wide)
630                        .zip(folded.chunks_exact_mut(self.hp.n_ff))
631                    {
632                        let (gate, rest) = row.split_at(self.hp.n_ff);
633                        for ((o, g), u) in out.iter_mut().zip(gate).zip(rest) {
634                            *o = gelu(*g) * u;
635                        }
636                    }
637                    up = folded;
638                }
639                // Unreachable through the loader, which builds the
640                // pair together; spelled so a third FFN has to answer
641                // here rather than silently running GELU.
642                (BertFfn::SwigluPar | BertFfn::GegluPar, None) => {
643                    return Err(EncodeError::MissingGate { layer: 0 });
644                }
645            }
646            let mut down = layer.ffn_down.apply_batch(&up, n);
647            add_bias_rows(&mut down, d, layer.ffn_down_b.as_ref());
648            for (dv, xv) in down.iter_mut().zip(x.iter()) {
649                *dv += xv;
650            }
651            if let (Some(w), Some(b)) = (&layer.layer_out_norm_w, &layer.layer_out_norm_b) {
652                layer_norm_rows(&mut down, d, w, b, self.hp.layer_norm_eps);
653            }
654            h = down;
655        }
656        // One final norm for the pre-norm shape, which has normed
657        // nothing since the last block read its input
658        // (`neo-bert.cpp:122-125`, `eurobert.cpp:116-119`).
659        if let Some(w) = &self.final_norm {
660            h = rms_norm_rows(&h, d, w, self.hp.layer_norm_eps);
661        }
662        Ok(h)
663    }
664}
665
666#[cfg(test)]
667mod tests {
668    use super::*;
669    use frink_core::tensor::Tensor;
670
671    /// Deterministic pseudo-random weights: a small LCG, so the fixture
672    /// is reproducible without pulling in a dependency.
673    struct Lcg(u64);
674    impl Lcg {
675        fn next_f32(&mut self) -> f32 {
676            self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1);
677            ((self.0 >> 33) as f32 / (1u64 << 31) as f32) - 0.5
678        }
679        fn vec(&mut self, n: usize) -> Vec<f32> {
680            (0..n).map(|_| self.next_f32()).collect()
681        }
682        fn matrix(&mut self, rows: usize, cols: usize) -> WeightMatrix {
683            WeightMatrix::F32(Tensor::new(self.vec(rows * cols), vec![rows, cols]))
684        }
685    }
686
687    const D: usize = 8;
688    const FF: usize = 16;
689    const HEADS: usize = 2;
690    const VOCAB: usize = 20;
691    const CTX: usize = 12;
692    const EPS: f32 = 1e-12;
693
694    fn fixture(n_layer: usize) -> BertEncoder {
695        let mut r = Lcg(0x5EED);
696        let tok_embd = r.matrix(VOCAB, D);
697        let pos_embd = r.matrix(CTX, D);
698        // Two rows, like every real BERT: "Sentence A" and "Sentence B".
699        let type_embd = Some(vec![r.vec(D), r.vec(D)]);
700        let tok_norm_w = r.vec(D);
701        let tok_norm_b = r.vec(D);
702        let layers = (0..n_layer)
703            .map(|_| BertLayer {
704                ffn_gate: None,
705                qk_norm: None,
706                attn_norm_2: None,
707                pre_attn_norm: None,
708                pre_ffn_norm: None,
709                wq: r.matrix(D, D),
710                bq: Some(r.vec(D)),
711                wk: r.matrix(D, D),
712                bk: Some(r.vec(D)),
713                wv: r.matrix(D, D),
714                bv: Some(r.vec(D)),
715                wo: r.matrix(D, D),
716                bo: Some(r.vec(D)),
717                attn_out_norm_w: Some(r.vec(D)),
718                attn_out_norm_b: Some(r.vec(D)),
719                ffn_up: r.matrix(FF, D),
720                ffn_up_b: Some(r.vec(FF)),
721                ffn_down: r.matrix(D, FF),
722                ffn_down_b: Some(r.vec(D)),
723                layer_out_norm_w: Some(r.vec(D)),
724                layer_out_norm_b: Some(r.vec(D)),
725            })
726            .collect();
727        BertEncoder {
728            hp: BertHparams {
729                topology: BertTopology::PostNormLayerNorm,
730                rope_interleaved: false,
731                alibi_slopes: None,
732                rope_theta: None,
733                rope_dim: 0,
734                ffn: BertFfn::GeluSeq,
735                arch: "bert".into(),
736                n_layer,
737                n_embd: D,
738                n_ff: FF,
739                n_head: HEADS,
740                n_head_kv: HEADS,
741                n_ctx_train: CTX,
742                n_token_types: 2,
743                layer_norm_eps: EPS,
744                pooling: PoolingType::Cls,
745                cls_id: 1,
746                sep_id: 2,
747            },
748            tok_embd,
749            type_embd,
750            pos_embd: Some(pos_embd),
751            tok_norm_w: Some(tok_norm_w),
752            tok_norm_b: Some(tok_norm_b),
753            final_norm: None,
754            layers,
755        }
756    }
757
758    /// An f64 transcription of the graph in the module docs, written
759    /// the slowest possible way: no `apply_batch`, no shared buffers,
760    /// one scalar loop per matrix element. It exists to disagree with
761    /// [`BertEncoder::encode`] if the fast path transposes a matrix,
762    /// drops a bias, norms the wrong residual, reuses a buffer it
763    /// should not, or reads the wrong row of the token-type table.
764    fn reference_forward(m: &BertEncoder, tokens: &[u32], segments: &[u32]) -> Vec<f64> {
765        let d = m.hp.n_embd;
766        let n = tokens.len();
767        let hd = m.hp.head_dim();
768
769        let dense = |w: &WeightMatrix| -> Vec<Vec<f64>> {
770            (0..w.rows())
771                .map(|r| w.dequant_row(r).iter().map(|&v| v as f64).collect())
772                .collect()
773        };
774        let matvec = |w: &Vec<Vec<f64>>, x: &[f64]| -> Vec<f64> {
775            w.iter()
776                .map(|row| row.iter().zip(x).map(|(a, b)| a * b).sum())
777                .collect()
778        };
779        let ln = |x: &[f64], wt: &[f32], b: &[f32]| -> Vec<f64> {
780            let mean = x.iter().sum::<f64>() / x.len() as f64;
781            let var = x.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / x.len() as f64;
782            let inv = 1.0 / (var + m.hp.layer_norm_eps as f64).sqrt();
783            x.iter()
784                .zip(wt)
785                .zip(b)
786                .map(|((v, w), bb)| (v - mean) * inv * (*w as f64) + (*bb as f64))
787                .collect()
788        };
789
790        let mut h: Vec<Vec<f64>> = tokens
791            .iter()
792            .enumerate()
793            .map(|(i, &t)| {
794                let tok = m.tok_embd.dequant_row(t as usize);
795                let pos = m
796                    .pos_embd
797                    .as_ref()
798                    .expect("the reference model has a table")
799                    .dequant_row(i);
800                let ty = m
801                    .type_embd
802                    .as_ref()
803                    .map(|t| t[segments[i] as usize].clone())
804                    .unwrap_or_else(|| vec![0.0; d]);
805                let row: Vec<f64> = (0..d)
806                    .map(|j| tok[j] as f64 + pos[j] as f64 + ty[j] as f64)
807                    .collect();
808                // The naive reference covers the POST-norm topology,
809                // which is the one it was written for; a pre-norm
810                // model has no embedding norm at all.
811                let (tw, tb) = (
812                    m.tok_norm_w.as_ref().expect("post-norm reference"),
813                    m.tok_norm_b.as_ref().expect("post-norm reference"),
814                );
815                ln(&row, tw, tb)
816            })
817            .collect();
818
819        for layer in &m.layers {
820            let (wq, wk, wv, wo) = (
821                dense(&layer.wq),
822                dense(&layer.wk),
823                dense(&layer.wv),
824                dense(&layer.wo),
825            );
826            let (wu, wd) = (dense(&layer.ffn_up), dense(&layer.ffn_down));
827            let bias = |v: &mut Vec<f64>, b: &Option<Vec<f32>>| {
828                if let Some(b) = b {
829                    for (x, bb) in v.iter_mut().zip(b) {
830                        *x += *bb as f64;
831                    }
832                }
833            };
834            let mut q = Vec::new();
835            let mut k = Vec::new();
836            let mut v = Vec::new();
837            for row in &h {
838                let mut a = matvec(&wq, row);
839                bias(&mut a, &layer.bq);
840                q.push(a);
841                let mut a = matvec(&wk, row);
842                bias(&mut a, &layer.bk);
843                k.push(a);
844                let mut a = matvec(&wv, row);
845                bias(&mut a, &layer.bv);
846                v.push(a);
847            }
848            let mut attn = vec![vec![0.0f64; d]; n];
849            for head in 0..m.hp.n_head {
850                let off = head * hd;
851                for i in 0..n {
852                    let raw: Vec<f64> = (0..n)
853                        .map(|j| {
854                            (0..hd).map(|c| q[i][off + c] * k[j][off + c]).sum::<f64>()
855                                / (hd as f64).sqrt()
856                        })
857                        .collect();
858                    let mx = raw.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
859                    let ex: Vec<f64> = raw.iter().map(|s| (s - mx).exp()).collect();
860                    let sum: f64 = ex.iter().sum();
861                    for j in 0..n {
862                        let p = ex[j] / sum;
863                        for c in 0..hd {
864                            attn[i][off + c] += p * v[j][off + c];
865                        }
866                    }
867                }
868            }
869            let mut next = Vec::new();
870            for i in 0..n {
871                let mut o = matvec(&wo, &attn[i]);
872                bias(&mut o, &layer.bo);
873                for (x, hv) in o.iter_mut().zip(&h[i]) {
874                    *x += hv;
875                }
876                let x = ln(
877                    &o,
878                    layer.attn_out_norm_w.as_ref().expect("post-norm reference"),
879                    layer.attn_out_norm_b.as_ref().expect("post-norm reference"),
880                );
881                let mut up = matvec(&wu, &x);
882                bias(&mut up, &layer.ffn_up_b);
883                let act: Vec<f64> = up
884                    .iter()
885                    .map(|&u| {
886                        const K: f64 = 0.797_884_560_802_865_4;
887                        const C: f64 = 0.044_715;
888                        0.5 * u * (1.0 + (K * (u + C * u * u * u)).tanh())
889                    })
890                    .collect();
891                let mut down = matvec(&wd, &act);
892                bias(&mut down, &layer.ffn_down_b);
893                for (dv, xv) in down.iter_mut().zip(&x) {
894                    *dv += xv;
895                }
896                next.push(ln(
897                    &down,
898                    layer
899                        .layer_out_norm_w
900                        .as_ref()
901                        .expect("post-norm reference"),
902                    layer
903                        .layer_out_norm_b
904                        .as_ref()
905                        .expect("post-norm reference"),
906                ));
907            }
908            h = next;
909        }
910        h.into_iter().flatten().collect()
911    }
912
913    #[test]
914    fn matches_an_independent_f64_transcription_of_the_graph() {
915        let m = fixture(3);
916        let tokens = [1u32, 7, 13, 4, 9, 2];
917        let got = m.encode_tokens(&tokens).unwrap();
918        let want = reference_forward(&m, &tokens, &[0; 6]);
919        assert_eq!(got.len(), want.len());
920        for (i, (g, w)) in got.iter().zip(&want).enumerate() {
921            assert!(
922                (*g as f64 - w).abs() < 2e-4,
923                "element {i}: {g} vs reference {w}"
924            );
925        }
926    }
927
928    /// The same transcription, driven with a real `0 0 0 1 1 1` split.
929    /// The point is not that segments *do something* — it is that the
930    /// fast path reads the SAME row the reference does at every
931    /// position, so an off-by-one on the boundary or a table indexed
932    /// with the token id would show up here.
933    #[test]
934    fn the_segment_id_selects_the_token_type_row_at_every_position() {
935        let m = fixture(3);
936        let tokens = [1u32, 7, 13, 4, 9, 2];
937        let segments = [0u32, 0, 0, 1, 1, 1];
938        let got = m.encode(&tokens, Some(&segments)).unwrap();
939        let want = reference_forward(&m, &tokens, &segments);
940        for (i, (g, w)) in got.iter().zip(&want).enumerate() {
941            assert!(
942                (*g as f64 - w).abs() < 2e-4,
943                "element {i}: {g} vs reference {w}"
944            );
945        }
946        // And it is genuinely a different graph from the all-zeros one,
947        // which is the whole of issue #44: scoring the second half as
948        // "Sentence A" is not a rounding difference.
949        let all_zero = m.encode_tokens(&tokens).unwrap();
950        let moved: f32 = all_zero
951            .iter()
952            .zip(&got)
953            .map(|(x, y)| (x - y).abs())
954            .sum::<f32>();
955        assert!(moved > 1e-3, "segment 1 changed nothing ({moved})");
956    }
957
958    /// A segment id with no row, and a segment list that is not one per
959    /// token, are refusals rather than a panic or a silently wrong row.
960    #[test]
961    fn a_segment_id_off_the_table_and_a_ragged_segment_list_are_refused() {
962        let m = fixture(1);
963        assert!(matches!(
964            m.encode(&[1, 7, 2], Some(&[0, 2, 0])),
965            Err(EncodeError::SegmentOutOfRange { id: 2, pos: 1, .. })
966        ));
967        assert!(matches!(
968            m.encode(&[1, 7, 2], Some(&[0, 0])),
969            Err(EncodeError::RaggedSegments {
970                tokens: 3,
971                segments: 2
972            })
973        ));
974    }
975
976    /// The property that makes this an encoder. Row 0's output must
977    /// change when the *last* token changes; under a causal mask it
978    /// could not, because position 0 would attend only to itself.
979    #[test]
980    fn attention_is_bidirectional_not_causal() {
981        let m = fixture(2);
982        let a = m.encode_tokens(&[5u32, 6, 7, 8]).unwrap();
983        let b = m.encode_tokens(&[5u32, 6, 7, 19]).unwrap();
984        let moved: f32 = a[..D].iter().zip(&b[..D]).map(|(x, y)| (x - y).abs()).sum();
985        assert!(
986            moved > 1e-3,
987            "row 0 barely moved ({moved}) when the last token changed — \
988             attention is behaving causally"
989        );
990    }
991
992    /// Position is a learned table lookup, so the same token at a
993    /// different index must land somewhere else.
994    #[test]
995    fn position_embeddings_make_the_same_token_differ_by_index() {
996        let m = fixture(1);
997        let out = m.encode_tokens(&[11u32, 11]).unwrap();
998        let delta: f32 = out[..D]
999            .iter()
1000            .zip(&out[D..2 * D])
1001            .map(|(x, y)| (x - y).abs())
1002            .sum();
1003        assert!(
1004            delta > 1e-3,
1005            "identical tokens gave identical rows: {delta}"
1006        );
1007    }
1008
1009    /// The graph ends on a LayerNorm: with unit weight and zero bias
1010    /// each output row is mean-zero and unit-variance. An RMSNorm in
1011    /// that slot would leave the mean wherever it was.
1012    #[test]
1013    fn the_last_op_is_a_mean_subtracting_layer_norm() {
1014        let mut m = fixture(2);
1015        let last = m.layers.last_mut().unwrap();
1016        last.layer_out_norm_w = Some(vec![1.0; D]);
1017        last.layer_out_norm_b = Some(vec![0.0; D]);
1018        let out = m.encode_tokens(&[3u32, 4, 5]).unwrap();
1019        for row in out.as_chunks::<D>().0 {
1020            let mean: f32 = row.iter().sum::<f32>() / D as f32;
1021            let var: f32 = row.iter().map(|v| (v - mean).powi(2)).sum::<f32>() / D as f32;
1022            assert!(mean.abs() < 1e-4, "row mean {mean} is not zero");
1023            assert!((var - 1.0).abs() < 1e-3, "row variance {var} is not one");
1024        }
1025    }
1026
1027    #[test]
1028    fn refuses_an_empty_sequence_and_one_past_the_position_table() {
1029        let m = fixture(1);
1030        assert!(matches!(
1031            m.encode_tokens(&[]),
1032            Err(EncodeError::EmptySequence)
1033        ));
1034        let long: Vec<u32> = (0..CTX as u32 + 1).map(|i| i % VOCAB as u32).collect();
1035        let err = m.encode_tokens(&long).unwrap_err();
1036        assert!(
1037            matches!(err, EncodeError::TooLong { got, max, .. } if got == CTX + 1 && max == CTX)
1038        );
1039        assert!(matches!(
1040            m.encode_tokens(&[VOCAB as u32]),
1041            Err(EncodeError::TokenOutOfRange { .. })
1042        ));
1043    }
1044
1045    #[test]
1046    fn wrap_special_brackets_the_pieces_with_cls_and_sep() {
1047        let m = fixture(1);
1048        assert_eq!(m.wrap_special(&[7, 8]), vec![1, 7, 8, 2]);
1049        assert_eq!(m.wrap_special(&[]), vec![1, 2]);
1050    }
1051
1052    /// The cross-encoder input is `[CLS] a [SEP] b [SEP]` with segments
1053    /// `0 0 0 0 1 1` — the boundary between the two halves is the whole
1054    /// reason a reranker scores differently from an embedding model.
1055    /// Concatenating without it, dropping the trailing `[SEP]`, or
1056    /// leaving every segment at 0, produces a perfectly plausible
1057    /// ranking that is not the model's, so both vectors are asserted
1058    /// exactly rather than by length.
1059    ///
1060    /// The first `[SEP]` belongs to segment 0, which is what
1061    /// HuggingFace's `tokenizer(query, document)` emits: an off-by-one
1062    /// there is a one-position difference that no shape check catches.
1063    #[test]
1064    fn the_pair_form_separates_the_two_halves_and_labels_each_one() {
1065        let m = fixture(1);
1066        let pair = m.wrap_special_pair(&[7, 8], &[9]).unwrap();
1067        assert_eq!(pair.tokens, vec![1, 7, 8, 2, 9, 2]);
1068        assert_eq!(pair.segments, vec![0, 0, 0, 0, 1, 1]);
1069        // An empty half is still a half: the boundary stays.
1070        let empty = m.wrap_special_pair(&[], &[]).unwrap();
1071        assert_eq!(empty.tokens, vec![1, 2, 2]);
1072        assert_eq!(empty.segments, vec![0, 0, 1]);
1073        // And it is NOT the single-sequence form of the two texts run
1074        // together, which is what a defaulted implementation would give.
1075        assert_ne!(pair.tokens, m.wrap_special(&[7, 8, 9]));
1076    }
1077
1078    /// A checkpoint with no "Sentence B" row cannot express a pair, and
1079    /// [`crate::EmbeddingModel`] refuses one at load. This is the value
1080    /// that refusal reads.
1081    #[test]
1082    fn n_segments_is_the_height_of_the_token_type_table() {
1083        let mut m = fixture(1);
1084        assert_eq!(m.n_segments(), 2);
1085        m.type_embd = Some(vec![vec![0.0; D]]);
1086        assert_eq!(m.n_segments(), 1);
1087        m.type_embd = None;
1088        assert_eq!(m.n_segments(), 1);
1089    }
1090
1091    /// `embed_tokens` must return the CLS row of the hidden states this
1092    /// checkpoint's `pooling_type` names, not the mean and not the last.
1093    #[test]
1094    fn embed_tokens_pools_the_way_the_hparams_say() {
1095        let m = fixture(2);
1096        let tokens = [1u32, 9, 4, 2];
1097        let hidden = m.encode_tokens(&tokens).unwrap();
1098        assert_eq!(m.embed_tokens(&tokens).unwrap(), hidden[..D].to_vec());
1099    }
1100}