Testing
How candding proves its outputs: golden references generated in Python, eleven tests per dense model, fixed and per-checkpoint tolerances, and device parity.
An embedding can be wrong and still look right: finite, unit length and plausibly ranked. Every catalog model is therefore compared with stored outputs of its reference implementation, at fixed tolerances.
What "verified" means
A catalog cell reads verified when that device's check below passed in that dtype, pending when a run is planned, untested when no run has reached the registry, and unsupported when the builder refuses the combination.
| Device | Check |
|---|---|
| CPU | the golden suite, within the tolerances |
| Metal | just parity for a dense entry whose F32 weights fit twice in memory, all but Qwen3-Embedding-4B and -8B: a cosine of at least 0.999 between the CPU's and Metal's vector for each fixture input; the golden suite on Metal for every other entry |
| CUDA | the golden suites in F32 and F16 on an NVIDIA T4, from the cuda workflow's full sweep of every entry but the non-commercial ones; a cell the sweep could not run reads untested, and one it measured failing reads unsupported, so the builder refuses it, as the table below lists; every BF16 cell reads untested, since candle builds no BF16 kernels below sm_80 and the T4 is sm_75 |
| Entries | CUDA cell | Reads | Why |
|---|---|---|---|
| Qwen/Qwen3-Embedding-4B and -8B, Qwen/Qwen3-Reranker-4B and -8B | F32 | untested | 16 GB of F32 weights or more, over the sweep's 7 GB ceiling for the T4's 16 GB |
| Qwen/Qwen3-Embedding-8B, Qwen/Qwen3-Reranker-8B | F16 | untested | out of memory on the T4: the weights load, and the first forward pass does not fit beside them |
| jinaai/jina-embeddings-v2-base-de, jinaai/jina-embeddings-v2-base-zh | F16 | unsupported | NaN vectors on the T4, as on the CPU |
| Qwen/Qwen3-Reranker-0.6B | F16 | unsupported | scores[0][4] misses its ceiling, at 1.06 times the allowed difference |
| google/embeddinggemma-300m | F32 | untested | its repository is gated, and no full sweep has run with the HF_TOKEN secret that fetches it |
| naver/splade-code-06B, jinaai/jina-colbert-v2, jinaai/jina-reranker-v2-base-multilingual | F32 and F16 | untested | non-commercial, so no workflow fetches them |
A dtype refused on every device, such as EmbeddingGemma's F16, reads unsupported on CUDA too. The unit tests, cargo test --workspace, run offline on synthetic checkpoints of all four pipelines; the golden and parity tests need the model-tests feature and the weights in the cache.
Golden references
scripts/gen_reference.py writes the dense references with sentence-transformers, in F32 on the CPU, forcing F32 parameters where a repository declares torch_dtype: float16. It reads candding describe <id> --json, so templates and maximum length match candding's, and pins the tokenizer's normalizer and pre-tokenizer to the published tokenizer.json. Other outputs are checked against their own reference:
| Reference implementation | Outputs checked against it |
|---|---|
| FlagEmbedding | BAAI/bge-m3's sparse and multi-vector heads |
| fastembed | the BM25, BM42 and miniCOIL heads, and prithivida/Splade_PP_en_v1 beside sentence-transformers |
| colbert-ai | colbert-ir/colbertv2.0, and answerdotai/answerai-colbert-small-v1 beside sentence-transformers |
| the model card | nomic-ai/nomic-embed-text-v1.5 |
| the repository's own script | Alibaba-NLP/gte-multilingual-base |
the checkpoint's own model in transformers | the eight rerankers, under the rerank suite |
- Inputs. Nine, shared by every suite: an empty and a whitespace-only string, a multilingual line, a Rust snippet, a text longer than 512 tokens, and three texts of different lengths for a mixed batch.
- Fixtures. A dense model's directory under
candding/tests/fixtures/holdsreference.safetensors, araw,queryandpassagerow per input, andmeta.json, which records the weight commit, the descriptor, the library versions and the token counts. - Whitespace. The sentence-transformers release in
scripts/remote-code/strips each input and the one inscripts/does not; the generator removes the strip, so both projects' references match candding, which never strips. - Qwen3-Embedding-8B. Its reference is BF16 from Apple's MPS backend, since its F32 weights do not fit in the generating machine's memory.
Remote code
The jina-embeddings-v2, NomicBERT and GTE new repositories and two rerankers ship their architecture as remote code, so their references run in scripts/remote-code/, a second uv project that pins transformers 4. The generator runs remote code only when --trust-remote-code names the architecture at a pinned revision, and asserts that the loaded class is the repository's own, since a stock class of the same name initializes missing tensors randomly.
The dense tests
| Test | What it checks |
|---|---|
| raw, query, passage | embed, query_embed and passage_embed against the reference's rows, so a wrong template fails |
| single equals batched | each input alone against its row of a batched run, so padding cannot leak into a result |
| unit norm | every row of a normalizing model has length one |
| determinism | two runs are bit-identical |
| truncation | the long input stops at the maximum length or at the reference's token count, and the empty string's count matches the reference's |
| Matryoshka | a model without Matryoshka support refuses a dimension before tokenizing, and a truncated vector equals the re-normalized prefix of the full one |
| STS smoke | the Spearman correlation on STS-B pairs stays within a fixed slack of the reference's |
| snapshot head | the first eight values of the first vector on the CPU, as a readable diff |
Each extra dtype has a module of six: raw, query, passage, single equals batched, unit norm and STS. A dtype the catalog refuses on the device becomes a check of the builder's error, and every module that computes reads back weights_dtype, so a load that dropped the requested dtype cannot pass in F32.
Tolerances
| Check | F32 on CPU | F32 on Metal or CUDA | F16, any device | BF16, any device |
|---|---|---|---|---|
| cosine to the reference | ≥ 0.9999 | ≥ 0.999 | ≥ 0.998 | ≥ 0.995 |
| max abs difference after normalizing both | ≤ 1e-4 | ≤ 1e-2 | ≤ 5e-2 | ≤ 5e-2 |
| single versus batched | ≤ 1e-5 | ≤ 1e-3 | ≤ 5e-3 | ≤ 5e-3 |
| unit norm | 1e-5 | 1e-3 | 1e-3 | 1e-3 |
| STS-B Spearman | ≥ reference − 0.02 | ≥ reference − 0.05 | ≥ reference − 0.05 | ≥ reference − 0.05 |
A BF16 model is held to the BF16 column; every other half-precision run, an F16 model or an F32 model against a BF16 reference, to the F16 column. The BF16 floor is lower because sentence-transformers' own BF16 runs miss the F16 floor. Tolerances are never loosened to let a result pass.
Sparse and multi-vector suites
| Suite | Modules | Tests |
|---|---|---|
golden_sparse | splade, bge_m3, splade_code, minicoil and bm42, each with _f16 and _bf16, plus splade_code_batch_tolerance | raw, query and passage rows, determinism and token counts; the sparse page has the ceilings |
golden_multi_vector | one per checkpoint and dtype | raw, query and passage rows, determinism, token counts and the late-interaction scores; see multi-vector output |
Qdrant/bm25 has no golden module; a pinned unit-test suite checks it in the default run.
The rerank suite
golden_rerank has a module per checkpoint, such as ms_marco or qwen3_reranker_0_6b, with _f16 and _bf16 siblings. just golden-rerank <id> writes each fixture in F32 on the CPU from the descriptor in scripts/rerank/:
- a pair through
AutoModelForSequenceClassification, on ids from the rawtokenizersbackend, since a transformers tokenizer call turns a pair with an empty document into one segment; - a prompt through
AutoModelForCausalLMon the prompt rendered with the default instruction; - the windowed reranker through its own
rerankcall, its windows recorded id for id.
Each module scores all 81 (query, document) pairs of the nine inputs, which candding batches per query with padding while the reference scores them one at a time. The default-dtype module also checks token counts, determinism, one pair's ids and that sorting a row is a total order. Before any score is compared, the fixture's head, activation and input must equal the registry entry's.
| Dtype | Allowed absolute difference from the reference score | Order of each query's documents |
|---|---|---|
| F32 | 1e-4 | the reference's, except exact ties |
| F16, BF16 | twice PyTorch's own largest deviation at that dtype for the checkpoint, on the CPU and MPS, recorded in the fixture as half_precision_max_abs | not checked, since half precision ties or swaps close scores |
The 4B and 8B references load through an offloading route that just golden-rerank-route-check confirms bitwise equal to a plain load. The rerank page has each checkpoint's measured figures.
Running the suites
just test # unit tests, no weights
just fetch BAAI/bge-small-en-v1.5
just test-models # the golden suites on the CPU
just test-metal # the same on the accelerator
just parity # the CPU against the accelerator
cargo test -p candding --features model-tests --test golden_bert bge_small_en_v1_5
CANDDING_TEST_DEVICE=auto cargo test -p candding --features metal,model-tests --test golden_rerank ms_marco_f16:: -- --test-threads=1 --nocapture
just golden BAAI/bge-small-en-v1.5There is one golden_<family> binary per dense family, plus golden_qwen3_4b, golden_qwen3_8b, golden_sparse, golden_multi_vector and golden_rerank; a module name filters one model. just test-models and just test-metal run the sparse, multi-vector and rerank modules one per process, since a module keeps its model loaded; the recipes include the large Qwen3 runs. Regenerate a reference only when the descriptor changes, and commit the fixture with that change.
CI
The ci workflow runs the unit suite and these golden modules on every push to main and every pull request; the rest of the catalog runs locally.
| Test binary | Models or modules, each with its default, F16 and BF16 module |
|---|---|
golden_bert | BAAI/bge-small-en-v1.5, sentence-transformers/all-MiniLM-L6-v2, intfloat/multilingual-e5-small |
golden_mpnet, golden_nomic_bert, golden_modernbert, golden_gte_new | sentence-transformers/all-mpnet-base-v2, nomic-ai/nomic-embed-text-v1.5, Alibaba-NLP/gte-modernbert-base, Alibaba-NLP/gte-base-en-v1.5 |
golden_jina_bert | jinaai/jina-embeddings-v2-small-en and -base-en |
golden_sparse | splade, minicoil and bm42 |
golden_rerank | ms_marco and jina_reranker_v1_tiny |
golden_qwen3 | Qwen/Qwen3-Embedding-0.6B's raw comparison and token-count test, and its BF16 refusal module only |
- The
cudaworkflow runs the same modules on an NVIDIA T4, or, dispatched withsweep: full, the golden modules of every entry but the non-commercial ones, ascandding/tests/cuda_sweepderives them from the golden sources and the registry; it records BF16 as skipped belowsm_80and a module whose weights the T4 cannot hold as skipped, and contributing lists every workflow. - EmbeddingGemma stays out of
ci, since its repository is gated; the full sweep fetches it with theHF_TOKENsecret, which only its model-fetch step receives. naver/splade-code-06B,jinaai/jina-colbert-v2andjinaai/jina-reranker-v2-base-multilingualstay out for their non-commercial licences:workflow_license_exclusion.rsfails the default test run if a workflow names one outside a comment.