Porting a model
How a new architecture becomes a candding family, from the repository files to read to the checklist, the pitfalls and the verification run.
A port adds one module that turns a padded batch into per-token hidden states, plus a descriptor that says how those states become a vector; the pipeline around the encoder already exists. The BERT family page shows the result.
1. Read the repository
List what the repository publishes before writing code:
hf download <org>/<name> --include "*.json" --include "1_Pooling/*" --dry-run| File | What it decides |
|---|---|
config.json | model_type picks the family; hidden_act, layer_norm_eps, max_position_embeddings, type_vocab_size, pad_token_id, position_embedding_type and any rotary fields shape the architecture |
tokenizer.json | the tokenizer, replayed as published, not as the Python runtime rebuilds it |
tokenizer_config.json | the padding side, an added end-of-sequence token, and the chat template an instruct reranker needs |
modules.json | the sentence-transformers pipeline: whether and in which order Pooling, Dense and Normalize run |
1_Pooling/config.json | the pooling mode, through the CLS, mean and last-token flags |
sentence_bert_config.json | max_seq_length, the descriptor's default length; its do_lower_case applies in sentence-transformers, not in candding |
config_sentence_transformers.json | the query and passage prompts, which become the descriptor's templates; the model card can add an instruction format |
2. Add the family
- Write
candding/src/models/<family>/config.rs, a serde view ofconfig.jsonwith#[serde(default)]on every optional field andhidden_acttyped as the sharedActivation. - Build the architecture from the shared layers in
embeddings.rs,attention.rs,layer.rsandmodel.rs, and calllayers::attention::scaled_dot_productfor attention, so the backend choice stays in one place. - Implement
Encoderinmodel.rs:forwardtakes anEncoderInputand returns(batch, seq, hidden), besidehidden_size,max_position_embeddingsandweights_dtype, read back from the token embedding table. - Add the
model_typearm tomodels::load_encoder, so an unregistered repository of that architecture loads without a registry entry. - Try the bare tensor names first, then the checkpoint prefix (
bert.,roberta.,model.) withvb.contains_tensor. - Add the
Familyvariant and the entries incandding/src/registry/<family>.rs: pooling, templates, default length, licence, phase, default dtype and per-dtype statuses, which start pending and change in the commit that lands the golden run. - Run
just fetch <id>andjust golden <id>, add agolden_dense!line per model, and pass the suite on the CPU before trying an accelerator. A new id also needsjust hub-patterns-fixture. - Write
website/content/docs/models/<family>.mdxwith itsfamilyandphasefrontmatter and its pitfalls, and runjust docs-models.
3. Check the pitfalls
Each of these produces plausible vectors that are wrong.
geluin a config is the erf form;gelu_newandgelu_pytorch_tanhare the tanh approximation.- LayerNorm epsilon differs by family,
1e-12for BERT,1e-5for RoBERTa and ModernBERT,1e-6for the RMSNorm models: read it from the config. - RoBERTa, XLM-RoBERTa and MPNet number positions from
padding_idx + 1, computed from the token ids. - BERT-style models add
token_type_embeddings[0]to every token. - Last-token pooling takes index
length - 1from the attention mask, not the batch's last column. - The additive mask uses the dtype minimum, not
-inf, and is added after the scores are scaled by1 / sqrt(head_dim). - A positional bias travels in the additive mask, as MPNet's does; a bucketed bias is bidirectional, and its bucket count comes from the reference's defaults, not the config.
- ALiBi, as JinaBERT uses it, has per-head slopes and adds its bias in every layer.
- RoPE reads
rope_theta,rope_scalingand any local and global pair from the config, and rotates query and key after the head split, in F32; the base has no safe default. - A rescaling's name can hide when it applies: GTE
new'sntkrescales at construction. Where the model card's arithmetic disagrees with the reference's code, implement the code. - A sliding window can be inclusive behind an exclusive constructor: ModernBERT's
local_attentionof 128 becomeslocal_attention / 2 + 1. - A
model_typecan name two architectures, asbertdoes for jina-embeddings-v2, and a registry entry with the wrong family fails no test; a config key can also be unused by its own family and decisive for another. - A block's shape can be visible only in the safetensors header: probe it with
contains_tensor, never fromauto_map. - Per-head query and key norms (Qwen3, Gemma3) apply over the head size, after the projection and before the rotation.
- Grouped-query attention tiles the key and value heads on the matmul path; Metal's kernel takes them untiled, and the parity test must agree.
- Dense modules run after pooling and before normalization, in
modules.jsonorder; a step the model card prescribes andmodules.jsonomits, such asnomic-embed-text-v1.5's LayerNorm, goes into the descriptor. - Matryoshka truncation comes after the Dense modules and before normalization, and the vector is re-normalized.
- Templates are part of the model, and the sentence-transformers prompt file is their authority: Qwen3-Embedding's has no space after
Query:. - Tokenizer truncation counts special tokens, and a decoder such as Qwen3 relies on the post-processor for its end-of-sequence token; check
token_counton a short string.
4. Verify
just fetch <id>
just golden <id>
cargo test -p candding --features model-tests --test golden_<family> <module>
cargo test -p candding --features metal,model-tests --test parity
just docs-checkThe golden run is the gate, with the checks testing lists; parity confirms the accelerator agrees with the CPU, and just docs-check that the site matches the changed registry. A model that fails parity on a backend ships with that backend unverified in the catalog, not with a skipped test.