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.
Adding a model to candding is a checklist with a pass or a fail at the end of it, not a design exercise. The pipeline around the encoder already exists, so a port supplies one thing: a module that turns a padded batch into per-token hidden states, plus a descriptor that says how those states become a vector. The BERT family page is the worked example of the result, and everything below is what it took to get there.
Read the repository first
Start with the file list, which costs nothing and tells you what the repository actually publishes:
hf download <org>/<name> --include "*.json" --include "1_Pooling/*" --dry-runSeven files then decide the whole port.
| 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 are the architecture |
tokenizer.json | the tokenizer, replayed exactly as published; it is the authority, not the Python runtime's rebuilt version |
tokenizer_config.json | padding side, an added end-of-sequence token, and the chat template instruct rerankers need |
modules.json | the sentence-transformers pipeline: the presence and order of Pooling, Dense and Normalize |
1_Pooling/config.json | which pooling mode is on, through the CLS, mean and last-token flags |
sentence_bert_config.json | max_seq_length becomes the descriptor's default length; its do_lower_case is honoured by sentence-transformers and not by candding, so note the divergence on the family page when it applies |
config_sentence_transformers.json | the query and passage prompts that become the descriptor templates; the model card may add an instruction format on top |
New family checklist
- Write
candding/src/models/<family>/config.rs: a serde view ofconfig.jsonwith#[serde(default)]for every optional field andhidden_acttyped as the sharedActivationenum. - Build the architecture from the shared layers in
embeddings.rs,attention.rs,layer.rsandmodel.rs. Never copy attention math into a family; calllayers::attention::scaled_dot_productso the backend choice stays in one place. - Implement
Encoderinmodel.rs:forwardtakes anEncoderInputand returns(batch, seq, hidden), alongsidehidden_sizeandmax_position_embeddings. - Add the
model_typearm tomodels::load_encoder, which is what makes an unregistered repository of that architecture load without a registry entry. - Handle the weight prefix: try the bare tensor names first and fall back to the checkpoint prefix (
bert.,roberta.,model.) withvb.contains_tensor. - Add the
Familyvariant and the entries incandding/src/registry/<family>.rs, with pooling, templates, default length, license and phase. - Generate the references and wire up the tests:
just fetch <id>,just golden <id>, then agolden_dense!line per model, and run the suite on CPU before touching an accelerator. - Write the family page under
website/content/docs/models/<family>.mdxwith itsfamilyandphasefrontmatter and its pitfalls section, and runjust docs-modelsso the catalog, the site table and the README table all come from the registry again.
Pitfalls
These are the mistakes that produce plausible but wrong vectors, in the order they tend to bite.
- GELU: a config that says
gelumeans the erf-based form;gelu_newandgelu_pytorch_tanhmean the tanh approximation. Both look fine and only one matches. - LayerNorm epsilon differs by family,
1e-12for BERT,1e-5for RoBERTa and ModernBERT,1e-6for the RMSNorm models. Read it from the config, never hardcode it. - RoBERTa, XLM-RoBERTa and MPNet start position ids at
padding_idx + 1, computed from the attention mask rather than from a plain range. - BERT-style models add
token_type_embeddings[0]to every token; skipping the lookup shifts every hidden state. - With right padding and last-token pooling, take index
length - 1from the attention mask; the last column of the padded batch is not the last real token. - The additive mask uses the dtype minimum rather than
-inf, and it is added after the scores are scaled. - The attention scale is
1 / sqrt(head_dim), applied to the query-key product before the mask. - ALiBi, as JinaBERT uses it, has per-head slopes and adds its bias in every layer, with a maximum length of
8192. - RoPE needs
rope_theta,rope_scalingand any local and global theta pair from the config, applied to the query and key after the head split, in F32. - Dense modules run after pooling and before normalization, in
modules.jsonorder, and their activation is usuallyTanhorIdentity. - Matryoshka truncation comes after the Dense modules and before normalization, and the vector is re-normalized after truncating.
- Templates are part of the model, not of the call site: a missing
query:prefix for an e5 model costs several retrieval points while still producing perfectly unit vectors. - Tokenizer truncation counts special tokens, so the limit you set is the total length including them.
- Decoder-based embedders such as Qwen3 rely on the tokenizer's post-processor to append the end-of-sequence token; verify it with
token_counton a short string before blaming the model. - Mean pooling divides by the mask sum clamped at
1e-9, in the dtype of the hidden states.
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: cosine and max-abs against the reference for the raw, query and passage paths, plus the batching, norm, determinism, truncation and STS checks the testing page describes. Parity then confirms that the accelerator agrees with CPU on the same inputs, and just docs-check confirms that the site, the catalog data and the README table match the registry the port just changed. A model that fails parity on a backend ships with that backend marked as unverified in the catalog rather than with a skipped test.