Skip to main content

frink_models/
bert_gguf_loader.rs

1//! BERT GGUF → [`BertEncoder`].
2//!
3//! Tensor names and requiredness follow llama.cpp
4//! `llama_model_bert::load_arch_tensors` (`src/models/bert.cpp`), and
5//! hparam keys follow its `load_arch_hparams` plus the shared
6//! `LLM_KV_*` set.
7//!
8//! # `bert.cpp` upstream is five architectures; this is one of them
9//!
10//! `nomic-bert`, `nomic-bert-moe`, `jina-bert-v2`, `jina-bert-v3` and
11//! `neo-bert` all build their graph from the same file, each switching
12//! on `model.arch` for RoPE, a gated FFN, expert layers or a second
13//! attention norm. [`crate::bert_encoder`] implements only the plain
14//! `bert` shape, so every one of those differences is checked for here
15//! and **refused by name**: a checkpoint that carries `ffn_gate` or
16//! `attn_q_norm` is not silently run without it.
17//!
18//! The last line of defence is
19//! [`crate::loader::assert_every_tensor_consumed`], which the load ends
20//! with: for `bge-small-en-v1.5-q8_0.gguf` this graph reads all 197
21//! tensors, so any weight a variant adds and this module has no home
22//! for stops the load instead of being ignored.
23
24use frink_gguf::{ShardedGguf, TensorSource};
25
26use crate::bert_encoder::{BertEncoder, BertFfn, BertHparams, BertLayer, BertTopology};
27use crate::loader::{
28    assert_every_tensor_consumed, load_f32_vec, load_f32_vec_optional, load_weight_matrix,
29    LoadError,
30};
31use crate::pooling::PoolingType;
32
33/// The architecture string this loader implements.
34pub const BERT_ARCH: &str = "bert";
35
36/// llama.cpp's `tokenizer_model == "bert"` defaults, applied when the
37/// GGUF carries no explicit id (`llama-vocab.cpp`).
38const DEFAULT_CLS_ID: u32 = 101;
39const DEFAULT_SEP_ID: u32 = 102;
40
41fn meta_u64(file: &impl TensorSource, key: &str) -> Result<u64, LoadError> {
42    file.metadata_u64(key)
43        .ok_or_else(|| LoadError::MissingHparam(key.to_string()))
44}
45
46fn refuse(what: &str) -> LoadError {
47    LoadError::UnsupportedFeature(BERT_ARCH.to_string(), what.to_string())
48}
49
50/// Refuses if `name` exists, naming the upstream variant that carries it.
51fn reject_tensor(file: &ShardedGguf, name: &str, why: &str) -> Result<(), LoadError> {
52    if file.find_tensor(name).is_some() {
53        return Err(refuse(&format!("checkpoint carries '{name}': {why}")));
54    }
55    Ok(())
56}
57
58/// The whole architecture policy of this loader, in one place so it can
59/// be tested without a GGUF: `bert` and nothing else.
60pub fn check_arch(arch: &str) -> Result<(), LoadError> {
61    if ENCODER_ARCHS.iter().any(|(a, _)| *a == arch) {
62        Ok(())
63    } else {
64        Err(LoadError::UnsupportedArchitecture(arch.to_string()))
65    }
66}
67
68/// The architectures this loader builds, with the FFN each one runs.
69///
70/// Both share `bert.cpp`'s graph; what differs is two lines of it,
71/// and both are read from the architecture because upstream reads
72/// them that way: the rotation at `:126-133` and the FFN at
73/// `:179-201`. A row here is a promise that every OTHER line of that
74/// graph is the same, which is why `nomic-bert-moe` is not in it (its
75/// `moe_every_n_layers` layers are a second FFN shape) and
76/// `jina-bert-v2` is not either (a second attention norm).
77/// One architecture's row on this loader: the FFN, where the norms
78/// sit, and which rotation (if any) the attention uses.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub struct EncoderSpec {
81    pub ffn: BertFfn,
82    pub topology: BertTopology,
83    /// `true` for the NORM (interleaved) rotation: `llama_model_rope_type`
84    /// answers that for `neo-bert` and NEOX for the others.
85    pub rope_interleaved: bool,
86    /// The tensor the FINAL norm is stored under, for the pre-norm
87    /// shape. `neo-bert.cpp:23` uses `enc.output_norm` and
88    /// `eurobert.cpp:16` plain `output_norm` -- one fact, two
89    /// spellings, which is the `attn_output_norm` case again.
90    pub final_norm_name: &'static str,
91}
92
93const POST: EncoderSpec = EncoderSpec {
94    ffn: BertFfn::GeluSeq,
95    topology: BertTopology::PostNormLayerNorm,
96    rope_interleaved: false,
97    final_norm_name: "",
98};
99
100pub const ENCODER_ARCHS: &[(&str, EncoderSpec)] = &[
101    ("bert", POST),
102    (
103        "nomic-bert",
104        EncoderSpec {
105            ffn: BertFfn::SwigluPar,
106            ..POST
107        },
108    ),
109    // `jina-bert-v3.cpp` reuses `llama_model_bert::graph` verbatim
110    // (`models.h:314-322`) and creates no position table and no
111    // QK-norm tensors, so it is `nomic-bert`'s rotation with `bert`'s
112    // ungated GELU FFN.
113    ("jina-bert-v3", POST),
114    // The one row whose position is neither a table nor a rotation:
115    // ALiBi at a literal 8.0 (`jina-bert-v2.cpp:5`). Its GEGLU has two
116    // spellings and the loader narrows this one per file.
117    (
118        "jina-bert-v2",
119        EncoderSpec {
120            ffn: BertFfn::GegluFusedUp,
121            ..POST
122        },
123    ),
124    // The PRE-NORM pair. `neo-bert.cpp:59-118` and
125    // `eurobert.cpp:55-114` are the same topology -- RMSNorm before
126    // each block, a bare residual after it, one final norm -- and
127    // differ in three columns: the QKV spelling (fused vs split, which
128    // the loader reads off the file), the FFN's (fused vs a separate
129    // gate), and the rotation.
130    (
131        "neo-bert",
132        EncoderSpec {
133            ffn: BertFfn::SwigluFusedUp,
134            topology: BertTopology::PreNormRms,
135            rope_interleaved: true,
136            final_norm_name: "enc.output_norm.weight",
137        },
138    ),
139    (
140        "eurobert",
141        EncoderSpec {
142            ffn: BertFfn::SwigluPar,
143            topology: BertTopology::PreNormRms,
144            rope_interleaved: false,
145            final_norm_name: "output_norm.weight",
146        },
147    ),
148];
149
150/// `jina-bert-v2.cpp:5` assigns this as a literal; no key carries it.
151const JINA_V2_ALIBI_MAX_BIAS: f32 = 8.0;
152
153/// A `WeightMatrix` copy for the three slices of a split fused QKV.
154///
155/// `WeightMatrix` is not `Clone` (a quantized one owns its bytes and a
156/// folded one an `Arc`), and the encoder needs three owned matrices out
157/// of one fused tensor, so this dequantizes into an owned F32 matrix.
158/// Only the fused path reaches it, and only at load.
159fn clone_matrix(m: &frink_core::WeightMatrix) -> frink_core::WeightMatrix {
160    let cols = m.cols();
161    let mut data = Vec::with_capacity(m.rows() * cols);
162    for r in 0..m.rows() {
163        data.extend_from_slice(&m.dequant_row(r));
164    }
165    frink_core::WeightMatrix::F32(frink_core::Tensor::new(data, vec![m.rows(), cols]))
166}
167
168/// Reads and checks `bert.*` hparams. Fails closed on anything the
169/// graph in [`crate::bert_encoder`] does not implement.
170pub fn read_bert_hparams(file: &impl TensorSource) -> Result<BertHparams, LoadError> {
171    let arch = file
172        .metadata_str("general.architecture")
173        .ok_or_else(|| LoadError::MissingHparam("general.architecture".into()))?
174        .to_string();
175    check_arch(&arch)?;
176    let p = |suffix: &str| format!("{arch}.{suffix}");
177
178    let spec = ENCODER_ARCHS
179        .iter()
180        .find(|(a, _)| *a == arch)
181        .map(|(_, s)| *s)
182        .expect("check_arch admitted this architecture");
183    let ffn = spec.ffn;
184    let n_layer = meta_u64(file, &p("block_count"))? as usize;
185    let n_embd = meta_u64(file, &p("embedding_length"))? as usize;
186    let n_ff = meta_u64(file, &p("feed_forward_length"))? as usize;
187    let n_head = meta_u64(file, &p("attention.head_count"))? as usize;
188    let n_head_kv = file
189        .metadata_u64(&p("attention.head_count_kv"))
190        .unwrap_or(n_head as u64) as usize;
191    let n_ctx_train = meta_u64(file, &p("context_length"))? as usize;
192
193    // `LLM_KV_ATTENTION_LAYERNORM_EPS` is read with `get_key(..., true)`
194    // upstream, i.e. required: there is no sane default for a norm this
195    // small (this checkpoint's is 1e-12, a thousand times tighter than
196    // any RMSNorm eps in the rest of this codebase).
197    // The two topologies read DIFFERENT keys, because they are
198    // different norm functions: `bert.cpp:4` reads
199    // `attention.layer_norm_epsilon` and `neo-bert.cpp:4` /
200    // `eurobert.cpp:4` read `attention.layer_norm_rms_epsilon`. Both
201    // are required by upstream, so neither gets a default here.
202    let eps_key = match spec.topology {
203        BertTopology::PostNormLayerNorm => p("attention.layer_norm_epsilon"),
204        BertTopology::PreNormRms => p("attention.layer_norm_rms_epsilon"),
205    };
206    let layer_norm_eps = match file.metadata_f32(&eps_key) {
207        Some(eps) => eps,
208        None => return Err(LoadError::MissingHparam(eps_key)),
209    };
210
211    // `n_token_types` is required by upstream's own loader, which
212    // throws "model needs to define token type count".
213    let n_token_types = meta_u64(file, "tokenizer.ggml.token_type_count")? as usize;
214    if n_token_types == 0 {
215        return Err(refuse("tokenizer.ggml.token_type_count is 0"));
216    }
217
218    // An encoder is bidirectional by construction. If a checkpoint ever
219    // says otherwise, this graph is the wrong one for it.
220    if file.metadata_bool(&p("attention.causal")).unwrap_or(false) {
221        return Err(refuse(
222            "bert.attention.causal is true, but this graph applies no mask — \
223             a causal BERT would need a decoder path",
224        ));
225    }
226
227    if n_head == 0 || n_head_kv == 0 || !n_head.is_multiple_of(n_head_kv) {
228        return Err(refuse(&format!(
229            "head_count {n_head} is not a multiple of head_count_kv {n_head_kv}"
230        )));
231    }
232    if !n_embd.is_multiple_of(n_head) {
233        return Err(refuse(&format!(
234            "embedding_length {n_embd} is not divisible by head_count {n_head}"
235        )));
236    }
237    if file.metadata_u64(&p("expert_count")).unwrap_or(0) != 0
238        || file.metadata_u64(&p("moe_every_n_layers")).unwrap_or(0) != 0
239    {
240        return Err(refuse(
241            "expert layers (nomic-bert-moe's moe_every_n_layers) are not implemented",
242        ));
243    }
244
245    // Upstream defaults `hparams.pooling_type` to NONE and reads the key
246    // as optional, so an absent key means "return every row", not
247    // "guess CLS".
248    let pooling = PoolingType::from_gguf(file, &arch)
249        .map_err(|e| refuse(&e.to_string()))?
250        .unwrap_or(PoolingType::None);
251
252    let cls_id = file
253        .metadata_u64("tokenizer.ggml.bos_token_id")
254        .unwrap_or(u64::from(DEFAULT_CLS_ID)) as u32;
255    let sep_id = file
256        .metadata_u64("tokenizer.ggml.seperator_token_id")
257        .unwrap_or(u64::from(DEFAULT_SEP_ID)) as u32;
258
259    // `bert.cpp:126-133` rotates for the architectures listed there and
260    // adds no position table for them (`:90` is gated on `bert`); the
261    // two facts are one field on `BertHparams`.
262    // `bert.cpp:189` decides jina-bert-v2's FFN spelling from the
263    // FILE, not from the architecture: `up_contains_gate` is true when
264    // there is no `ffn_gate` and `ffn_up` is wider than `n_ff`. The
265    // table above carries the fused spelling and this narrows it, so
266    // the two cannot disagree about a file that has the gate.
267    let ffn = if ffn == BertFfn::GegluFusedUp && file.find_tensor("blk.0.ffn_gate.weight").is_some()
268    {
269        BertFfn::GegluPar
270    } else {
271        ffn
272    };
273    // `bert.cpp:78-80` builds no positions at all for `jina-bert-v2`,
274    // `:126-133` rotates for the others, and `bert` itself takes the
275    // learned table. Three architectures, three answers, one place.
276    let rope_theta = (arch != BERT_ARCH && arch != "jina-bert-v2")
277        .then(|| file.metadata_f32(&p("rope.freq_base")).unwrap_or(10_000.0));
278    // The pre-norm rows read the RMS epsilon key, not the LayerNorm
279    // one (`neo-bert.cpp:4`, `eurobert.cpp:4`).
280    let _ = &spec;
281    let alibi_slopes = (arch == "jina-bert-v2")
282        .then(|| frink_core::alibi::slopes(n_head, JINA_V2_ALIBI_MAX_BIAS))
283        .flatten();
284    let head_dim = n_embd / n_head;
285    let rope_dim = file
286        .metadata_u64(&p("rope.dimension_count"))
287        .map(|v| v as usize)
288        .unwrap_or(head_dim);
289    if rope_theta.is_some() && (rope_dim == 0 || rope_dim > head_dim || !rope_dim.is_multiple_of(2))
290    {
291        return Err(refuse(&format!(
292            "rope.dimension_count is {rope_dim}, which is not an even width at or under \
293             the {head_dim}-wide head"
294        )));
295    }
296
297    Ok(BertHparams {
298        arch,
299        topology: spec.topology,
300        rope_interleaved: spec.rope_interleaved,
301        alibi_slopes,
302        rope_theta,
303        rope_dim,
304        ffn,
305        n_layer,
306        n_embd,
307        n_ff,
308        n_head,
309        n_head_kv,
310        n_ctx_train,
311        n_token_types,
312        layer_norm_eps,
313        pooling,
314        cls_id,
315        sep_id,
316    })
317}
318
319/// Loads a `bert` GGUF into a runnable encoder.
320pub fn load_bert_encoder_from_path(
321    path: impl AsRef<std::path::Path>,
322) -> Result<BertEncoder, LoadError> {
323    load_bert_encoder(&ShardedGguf::open(path.as_ref())?)
324}
325
326/// Same, from an already-open file — so a caller that also needs the
327/// tokenizer out of it ([`crate::embedding_model`]) mmaps it once.
328pub fn load_bert_encoder(file: &ShardedGguf) -> Result<BertEncoder, LoadError> {
329    let hp = read_bert_hparams(file)?;
330
331    let tok_embd = load_weight_matrix(file, "token_embd.weight")?;
332    // `bert.cpp:32` creates the table for every architecture on the
333    // graph, but `:90` reads it only for `bert` -- and libllama's own
334    // load log never names it for `nomic-bert`, measured. So a
335    // rotating file carries none and a rotating file that DOES carry
336    // one is refused rather than silently ignored.
337    // The learned table belongs to `bert` alone: the rotating rows
338    // carry none, and `jina-bert-v2` carries none either because its
339    // position is ALiBi (`bert.cpp:78-80` builds no `inp_pos` for it).
340    let pos_embd = match (hp.rope_theta, hp.alibi_slopes.is_some()) {
341        (None, false) => Some(load_weight_matrix(file, "position_embd.weight")?),
342        _ => {
343            if file.find_tensor("position_embd.weight").is_some() {
344                return Err(refuse(
345                    "this encoder's graph carries its position in the attention (RoPE at \
346                     bert.cpp:126-133, or ALiBi for jina-bert-v2) and never reads \
347                     position_embd.weight; llama.cpp refuses the file as carrying an \
348                     unread tensor and so does frink",
349                ));
350            }
351            None
352        }
353    };
354    if let Some(table) = &pos_embd {
355        if table.rows() != hp.n_ctx_train {
356            return Err(refuse(&format!(
357                "position_embd.weight has {} rows but {}.context_length says {} — the \
358                 learned position table and the advertised context disagree",
359                table.rows(),
360                hp.arch,
361                hp.n_ctx_train
362            )));
363        }
364    }
365    if pos_embd.as_ref().is_some_and(|t| t.cols() != hp.n_embd) || tok_embd.cols() != hp.n_embd {
366        return Err(refuse(&format!(
367            "embedding tables are {} / {} wide but embedding_length is {}",
368            tok_embd.cols(),
369            pos_embd.as_ref().map_or(hp.n_embd, |t| t.cols()),
370            hp.n_embd
371        )));
372    }
373
374    // The WHOLE table, not row 0. Upstream views `type_embd` at offset
375    // 0 and adds it everywhere, because `llama_batch` carries no
376    // segment ids; frink's encoder takes them as a parameter, so a
377    // cross-encoder pair can put its document half on row 1 the way the
378    // checkpoint was trained. Loading row 0 alone is what made
379    // `/v1/rerank` rank the relevant document last (see
380    // `bert_encoder`'s module docs). The tensor is
381    // `TENSOR_NOT_REQUIRED` upstream, so its absence is not an error —
382    // it means no segment embedding is added at all, and a reranker
383    // checkpoint in that state is refused by `EmbeddingModel`.
384    let type_embd = match file.find_tensor("token_types.weight") {
385        Some(_) => {
386            let table = load_weight_matrix(file, "token_types.weight")?;
387            if table.rows() != hp.n_token_types || table.cols() != hp.n_embd {
388                return Err(refuse(&format!(
389                    "token_types.weight is {}x{}, expected {}x{}",
390                    table.rows(),
391                    table.cols(),
392                    hp.n_token_types,
393                    hp.n_embd
394                )));
395            }
396            Some((0..table.rows()).map(|r| table.dequant_row(r)).collect())
397        }
398        None => None,
399    };
400
401    // The post-norm shape norms the embeddings before layer 0
402    // (`bert.cpp:96`); the pre-norm one feeds them in raw
403    // (`neo-bert.cpp:52`, `eurobert.cpp:48`) and norms once at the end
404    // instead.
405    let (tok_norm_w, tok_norm_b, final_norm) = match hp.topology {
406        BertTopology::PostNormLayerNorm => (
407            Some(load_f32_vec(file, "token_embd_norm.weight")?),
408            Some(load_f32_vec(file, "token_embd_norm.bias")?),
409            None,
410        ),
411        BertTopology::PreNormRms => {
412            let name = ENCODER_ARCHS
413                .iter()
414                .find(|(a, _)| *a == hp.arch)
415                .map(|(_, s)| s.final_norm_name)
416                .expect("a loaded architecture is in the table");
417            (None, None, Some(load_f32_vec(file, name)?))
418        }
419    };
420
421    let mut layers = Vec::with_capacity(hp.n_layer);
422    for l in 0..hp.n_layer {
423        let b = format!("blk.{l}");
424        // `neo-bert.cpp:29` stores one `n_embd + 2 * n_embd_gqa`-wide
425        // matrix where the other rows store three; every other
426        // architecture on this loader is refused for carrying it,
427        // because their graphs read the three.
428        let fused_qkv = match hp.topology {
429            BertTopology::PreNormRms => file.find_tensor(&format!("{b}.attn_qkv.weight")).is_some(),
430            BertTopology::PostNormLayerNorm => {
431                reject_tensor(
432                    file,
433                    &format!("{b}.attn_qkv.weight"),
434                    "a fused QKV projection; this graph reads separate attn_q/attn_k/attn_v",
435                )?;
436                false
437            }
438        };
439        let split = if fused_qkv {
440            let fused = load_weight_matrix(file, &format!("{b}.attn_qkv.weight"))?;
441            let q_rows = hp.n_head * hp.head_dim();
442            let kv_rows = hp.n_head_kv * hp.head_dim();
443            if fused.rows() != q_rows + 2 * kv_rows {
444                return Err(refuse(&format!(
445                    "blk.{l}.attn_qkv.weight has {} rows, expected {} (q {q_rows} + 2 x kv \
446                     {kv_rows})",
447                    fused.rows(),
448                    q_rows + 2 * kv_rows
449                )));
450            }
451            Some(crate::qkv_fused::split_fused_weight(
452                &fused,
453                crate::qkv_fused::FusedQkvRows::from_widths(q_rows, kv_rows),
454            )?)
455        } else {
456            None
457        };
458        if hp.arch != "jina-bert-v2" {
459            reject_tensor(
460                file,
461                &format!("{b}.attn_q_norm.weight"),
462                "per-projection QK normalization (jina-bert-v3 / neo-bert), not implemented",
463            )?;
464            reject_tensor(
465                file,
466                &format!("{b}.attn_k_norm.weight"),
467                "per-projection QK normalization (jina-bert-v3 / neo-bert), not implemented",
468            )?;
469            reject_tensor(
470                file,
471                &format!("{b}.attn_norm_2.weight"),
472                "jina-bert-v2's second attention norm, not implemented",
473            )?;
474        }
475        // The gate belongs to `BertFfn::SwigluPar` and to nothing
476        // else: a `bert` file that carries one is a file this graph
477        // would run as an ungated GELU while llama.cpp ran it gated,
478        // which is the silent-wrong shape the refusal exists for.
479        if hp.ffn == BertFfn::GeluSeq {
480            reject_tensor(
481                file,
482                &format!("{b}.ffn_gate.weight"),
483                "a gated FFN (jina-bert-v2 GEGLU); this architecture's graph runs a plain \
484                 GELU MLP (bert.cpp:179-187)",
485            )?;
486        }
487        reject_tensor(
488            file,
489            &format!("{b}.ffn_up_exps.weight"),
490            "MoE expert tensors (nomic-bert-moe), not implemented",
491        )?;
492
493        layers.push(BertLayer {
494            wq: match split.as_ref() {
495                Some((q, _, _)) => clone_matrix(q),
496                None => load_weight_matrix(file, &format!("{b}.attn_q.weight"))?,
497            },
498            bq: load_f32_vec_optional(file, &format!("{b}.attn_q.bias"))?,
499            wk: match split.as_ref() {
500                Some((_, k, _)) => clone_matrix(k),
501                None => load_weight_matrix(file, &format!("{b}.attn_k.weight"))?,
502            },
503            bk: load_f32_vec_optional(file, &format!("{b}.attn_k.bias"))?,
504            wv: match split.as_ref() {
505                Some((_, _, v)) => clone_matrix(v),
506                None => load_weight_matrix(file, &format!("{b}.attn_v.weight"))?,
507            },
508            bv: load_f32_vec_optional(file, &format!("{b}.attn_v.bias"))?,
509            wo: load_weight_matrix(file, &format!("{b}.attn_output.weight"))?,
510            bo: load_f32_vec_optional(file, &format!("{b}.attn_output.bias"))?,
511            // The two topologies read different norm slots, and the
512            // pair is exclusive by construction: a post-norm layer has
513            // `attn_output_norm` / `layer_output_norm` and no
514            // `attn_norm`, a pre-norm layer the other way round.
515            pre_attn_norm: match hp.topology {
516                BertTopology::PostNormLayerNorm => None,
517                BertTopology::PreNormRms => {
518                    Some(load_f32_vec(file, &format!("{b}.attn_norm.weight"))?)
519                }
520            },
521            pre_ffn_norm: match hp.topology {
522                BertTopology::PostNormLayerNorm => None,
523                BertTopology::PreNormRms => {
524                    Some(load_f32_vec(file, &format!("{b}.ffn_norm.weight"))?)
525                }
526            },
527            attn_out_norm_w: match hp.topology {
528                BertTopology::PostNormLayerNorm => {
529                    Some(load_f32_vec(file, &format!("{b}.attn_output_norm.weight"))?)
530                }
531                BertTopology::PreNormRms => None,
532            },
533            attn_out_norm_b: match hp.topology {
534                BertTopology::PostNormLayerNorm => {
535                    Some(load_f32_vec(file, &format!("{b}.attn_output_norm.bias"))?)
536                }
537                BertTopology::PreNormRms => None,
538            },
539            qk_norm: match load_f32_vec_optional(file, &format!("{b}.attn_q_norm.weight"))? {
540                None => None,
541                Some(q_w) => Some(crate::bert_encoder::QkLayerNorm {
542                    q_w,
543                    q_b: load_f32_vec(file, &format!("{b}.attn_q_norm.bias"))?,
544                    k_w: load_f32_vec(file, &format!("{b}.attn_k_norm.weight"))?,
545                    k_b: load_f32_vec(file, &format!("{b}.attn_k_norm.bias"))?,
546                }),
547            },
548            attn_norm_2: match load_f32_vec_optional(file, &format!("{b}.attn_norm_2.weight"))? {
549                None => None,
550                Some(w) => Some((w, load_f32_vec(file, &format!("{b}.attn_norm_2.bias"))?)),
551            },
552            ffn_up: load_weight_matrix(file, &format!("{b}.ffn_up.weight"))?,
553            ffn_up_b: load_f32_vec_optional(file, &format!("{b}.ffn_up.bias"))?,
554            ffn_gate: match hp.ffn {
555                BertFfn::GeluSeq | BertFfn::GegluFusedUp | BertFfn::SwigluFusedUp => None,
556                BertFfn::SwigluPar | BertFfn::GegluPar => {
557                    Some(load_weight_matrix(file, &format!("{b}.ffn_gate.weight"))?)
558                }
559            },
560            ffn_down: load_weight_matrix(file, &format!("{b}.ffn_down.weight"))?,
561            ffn_down_b: load_f32_vec_optional(file, &format!("{b}.ffn_down.bias"))?,
562            layer_out_norm_w: match hp.topology {
563                BertTopology::PostNormLayerNorm => Some(load_f32_vec(
564                    file,
565                    &format!("{b}.layer_output_norm.weight"),
566                )?),
567                BertTopology::PreNormRms => None,
568            },
569            layer_out_norm_b: match hp.topology {
570                BertTopology::PostNormLayerNorm => {
571                    Some(load_f32_vec(file, &format!("{b}.layer_output_norm.bias"))?)
572                }
573                BertTopology::PreNormRms => None,
574            },
575        });
576    }
577
578    let kv_dim = hp.n_head_kv * hp.head_dim();
579    for (l, layer) in layers.iter().enumerate() {
580        for (name, m, rows) in [
581            ("attn_q", &layer.wq, hp.n_embd),
582            ("attn_k", &layer.wk, kv_dim),
583            ("attn_v", &layer.wv, kv_dim),
584            ("attn_output", &layer.wo, hp.n_embd),
585            // Twice as wide for the fused GEGLU spelling, where the
586            // first half of every row is the gate (`bert.cpp:189`).
587            (
588                "ffn_up",
589                &layer.ffn_up,
590                match hp.ffn {
591                    BertFfn::GegluFusedUp | BertFfn::SwigluFusedUp => 2 * hp.n_ff,
592                    _ => hp.n_ff,
593                },
594            ),
595            ("ffn_down", &layer.ffn_down, hp.n_embd),
596        ] {
597            if m.rows() != rows {
598                return Err(refuse(&format!(
599                    "blk.{l}.{name}.weight has {} output rows, expected {rows}",
600                    m.rows()
601                )));
602            }
603        }
604    }
605
606    assert_every_tensor_consumed(file)?;
607
608    Ok(BertEncoder {
609        hp,
610        tok_embd,
611        type_embd,
612        pos_embd,
613        tok_norm_w,
614        tok_norm_b,
615        final_norm,
616        layers,
617    })
618}
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623
624    /// Only `bert`. The other eleven encoder rows in the catalog share
625    /// `bert.cpp` upstream, and each of them differs from this graph in
626    /// a way that would load clean and embed wrong.
627    #[test]
628    fn an_architecture_outside_the_table_is_refused_by_name() {
629        for arch in ["nomic-bert-moe", "modern-bert", "t5encoder", "llama"] {
630            assert!(
631                !ENCODER_ARCHS.iter().any(|(a, _)| *a == arch),
632                "`{arch}` is in the table; the refusal below would be wrong"
633            );
634            let err = check_arch(arch).unwrap_err();
635            assert!(
636                matches!(&err, LoadError::UnsupportedArchitecture(a) if a == arch),
637                "{arch} was not refused: {err}"
638            );
639        }
640        assert!(check_arch(BERT_ARCH).is_ok());
641    }
642}