candding

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
FileWhat it decides
config.jsonmodel_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.jsonthe tokenizer, replayed as published, not as the Python runtime rebuilds it
tokenizer_config.jsonthe padding side, an added end-of-sequence token, and the chat template an instruct reranker needs
modules.jsonthe sentence-transformers pipeline: whether and in which order Pooling, Dense and Normalize run
1_Pooling/config.jsonthe pooling mode, through the CLS, mean and last-token flags
sentence_bert_config.jsonmax_seq_length, the descriptor's default length; its do_lower_case applies in sentence-transformers, not in candding
config_sentence_transformers.jsonthe query and passage prompts, which become the descriptor's templates; the model card can add an instruction format

2. Add the family

  1. Write candding/src/models/<family>/config.rs, a serde view of config.json with #[serde(default)] on every optional field and hidden_act typed as the shared Activation.
  2. Build the architecture from the shared layers in embeddings.rs, attention.rs, layer.rs and model.rs, and call layers::attention::scaled_dot_product for attention, so the backend choice stays in one place.
  3. Implement Encoder in model.rs: forward takes an EncoderInput and returns (batch, seq, hidden), beside hidden_size, max_position_embeddings and weights_dtype, read back from the token embedding table.
  4. Add the model_type arm to models::load_encoder, so an unregistered repository of that architecture loads without a registry entry.
  5. Try the bare tensor names first, then the checkpoint prefix (bert., roberta., model.) with vb.contains_tensor.
  6. Add the Family variant and the entries in candding/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.
  7. Run just fetch <id> and just golden <id>, add a golden_dense! line per model, and pass the suite on the CPU before trying an accelerator. A new id also needs just hub-patterns-fixture.
  8. Write website/content/docs/models/<family>.mdx with its family and phase frontmatter and its pitfalls, and run just docs-models.

3. Check the pitfalls

Each of these produces plausible vectors that are wrong.

  • gelu in a config is the erf form; gelu_new and gelu_pytorch_tanh are the tanh approximation.
  • LayerNorm epsilon differs by family, 1e-12 for BERT, 1e-5 for RoBERTa and ModernBERT, 1e-6 for 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 - 1 from 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 by 1 / 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_scaling and 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's ntk rescales 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_attention of 128 becomes local_attention / 2 + 1.
  • A model_type can name two architectures, as bert does 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 from auto_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.json order; a step the model card prescribes and modules.json omits, such as nomic-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_count on 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-check

The 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.

On this page