A reference cartridge is the sealed, typed description of the biological universe a simulation runs against. Every record GenAIRR produces is attributable to one cartridge — its identity, its rules, and its catalogue all participate in the simulation's content hash and trace metadata.
This document describes the cartridge model, the planes that make it
up, and how the user-facing authoring surface
(DataConfig, Experiment) plugs into the engine-native
RefDataConfig.
In the previous generation of GenAIRR, "reference data" meant a
loosely-typed DataConfig (a Python pickle) plus some implicit
biology — anchor expectations were inferred from allele names,
"is this a heavy chain?" was deduced from metadata.chain_type,
and the engine carried hardcoded knowledge about which catalogue
shapes were biologically sensible.
The cartridge model replaces that with four explicit planes plus one orthogonal concept:
| Plane | Question | Lives on |
|---|---|---|
| identity | What cartridge is this? | RefDataConfig.identity |
| catalogue | Which alleles exist? | RefDataConfig.v_pool / d_pool / j_pool / c_pool |
| rules | How should the engine interpret alleles? | RefDataConfig.rules / DataConfig.reference_rules |
| empirical models | What are the default sampling distributions? | DataConfig.reference_models (Python only in v1) |
Curation is orthogonal: given a cartridge, which subset of the catalogue participates in simulation? See Curation policy below.
refdata.identity()
# {
# "species": "Human",
# "locus": "IGH",
# "reference_set": "OGRDB V8",
# "name": "Heavy Chain",
# "source": "DataConfig",
# }Every field is Optional[str]. Synthetic test cartridges may carry
none of it; bundled Experiment.on("human_igh") data has all five
fields populated by the loader.
Identity participates in the cartridge content hash. Two cartridges with identical catalogues + rules but different declared identity hash differently — trace files attribute outputs to the cartridge, not just to the catalogue.
The validator also cross-checks identity.locus against
chain_type: IGH / TRB / TRD must be VDJ; IGK / IGL / TRA / TRG
must be VJ. A mismatch raises LocusChainTypeMismatch (Fatal —
never opt-outable).
The set of V / D / J / C alleles available to the simulator. Each
allele carries name, gene, seq, segment, and an optional
anchor position (V Cys / J W-or-F codon offset). Population is
exactly what cfg.add_v_allele(...) etc. have always done — the
catalogue concept is unchanged from earlier GenAIRR versions.
V-region substructure annotations. V alleles can additionally
carry an Allele.subregions mapping from IMGT region label to
(start, end) ungapped nucleotide intervals — the five canonical
labels FWR1 / CDR1 / FWR2 / CDR2 / FWR3. When a V allele
has a populated gapped_seq (the IMGT-aligned sequence with .
characters at gap positions), the bridge derives these intervals
automatically via the compute_v_region_boundaries helper. A user
can override the dict explicitly; the bridge validates the
overrides at load time (canonical labels, no duplicates,
start < end, in-bounds, no overlap) and rejects malformed input
with a clear error. Subregions cross the bridge as
Vec<VSubregion> on the Rust Allele, fold into the cartridge
content hash, and surface coverage statistics in the manifest's
models.shm.v_subregion_support block. Today they are
inspectable + hashed only — they do NOT yet drive SHM targeting
(no v_subregion_rates) or appear in per-region AIRR counters
(no n_cdr1_mutations etc.). Those are separate future slices.
See v_region_substructure_audit.md.
The interpretation layer. Tells the engine how anchor codons, sequence alphabets, and severity classifications should be read.
from GenAIRR import ReferenceRulesSpec, AnchorRuleSpec
rules = ReferenceRulesSpec(
allowed_bases=["A", "C", "G", "T", "N"],
v_anchor=AnchorRuleSpec(expected_aa=["C"], required=True),
j_anchor=AnchorRuleSpec(expected_aa=["W"], required=True),
)Severity strings are "fatal" or "curatable" — the same vocabulary
the validator's issue dicts use.
When a ReferenceRulesSpec is attached to a DataConfig, the
loader ships it verbatim into RefDataConfig.rules and the
engine's bundled-locus inference (IGH → W, IGK/IGL/TR* → F) is
overridden.
The defaults plane — distributions the engine samples from when the user doesn't override at recombine time.
from GenAIRR import ReferenceEmpiricalModels, EmpiricalDistributionSpec
models = ReferenceEmpiricalModels(
np_lengths={
"NP1": EmpiricalDistributionSpec([(0, 1.0), (3, 4.0), (6, 2.0)]),
},
trims={
"V_3": EmpiricalDistributionSpec([(0, 5.0), (1, 3.0), (2, 1.0)]),
"J_5": EmpiricalDistributionSpec([(0, 5.0), (1, 2.0)]),
},
)In v1 this plane lives only on the Python side. The Rust engine consumes already-lowered pass distributions; the Python loader preferred order is:
cfg.reference_models[...](typed, validated).cfg.NP_lengths/cfg.trim_dicts(legacy nested-dict extraction, marginalised across genes).- Uniform
[(0, 1.0), ..., (6, 1.0)]placeholder (raw-RefDataConfigfallback).
Distributions are validated shape-only Python-side: non-negative int values, finite positive weights, known keys, no D-trim on a VJ cartridge.
DataConfig (Python) |
RefDataConfig (Rust) |
|
|---|---|---|
| Identity | metadata.species, metadata.chain_type, metadata.reference_set, name |
identity.species, identity.locus, identity.reference_set, identity.name, identity.source |
| Catalogue | v_alleles, d_alleles, j_alleles (nested {gene: [Allele]}) |
v_pool, d_pool, j_pool, c_pool (flat AllelePool) |
| Rules | reference_rules: Optional[ReferenceRulesSpec] |
rules: ReferenceRules |
| Empirical models | reference_models: Optional[ReferenceEmpiricalModels] |
(not yet — engine consumes lowered pass distributions) |
| Validation | shape-only on the specs | full validate() / validate_with_mode() |
| Curation | (none — see Rust side) | curated("functional_anchors_only") |
The bridge is dataconfig_to_refdata(cfg): alleles + identity +
rules cross the PyO3 boundary; empirical models stay Python-side
and feed Experiment.recombine's default extraction.
Curation is not validation and not catalogue authoring. It's the answer to: given a catalogue and a set of rules, which subset of alleles do we let the engine sample from?
Two policies in v1:
"raw"— identity (no filtering)."functional_anchors_only"— drop V/J alleles that fail the activeAnchorRule: missing anchor, anchor out of bounds, or anchor codon AA outsideexpected_amino_acids. D and C pools pass through unchanged.
# Strict compile passes after curation; raw catalogue would fail.
ga.Experiment.on("mouse_igh") \
.curate_refdata("functional_anchors_only") \
.recombine() \
.compile()Curation never silences structural corruption (duplicate names,
invalid bytes, locus/chain mismatch) — those continue to surface
from the validator on the curated cartridge. If curation empties a
required pool, compile fails with EmptyRequiredPool.
The curated cartridge's identity.source is tagged
|curated:functional_anchors_only so trace files distinguish raw
from curated artefacts and content hashes diverge.
Two ways to handle pseudogene-bearing catalogues:
curate_refdata("functional_anchors_only")removes the non-canonical alleles. Cartridge is clean; strict validation passes by construction. This is the professional model — the cartridge identifies which alleles participate.allow_curatable_refdata()keeps the catalogue as-is and relaxes the validator at compile time. Strict validation passes Curatable issues (pseudogene-shape anchor anomalies) but still rejects Fatal ones (empty pools, duplicates, invalid bytes, locus/chain mismatch).
Recommended progression: start strict; if you want to exclude
pseudogenes, use curate_refdata; if you want to sample from
them explicitly, use allow_curatable_refdata.
A DataConfig can reach the simulator from three sources, each
with a distinct provenance signal. Pick the path that matches
how you arrived at the cartridge:
| Path | Use case | build_report |
schema_sha256 |
|---|---|---|---|
Bundled cartridge (ga.HUMAN_IGH_OGRDB, …) — 106 ship with the wheel via lazy __getattr__ |
Production users with a supported species/locus combo | None (predate the builder; legacy provenance) |
populated at wheel-build time |
Manual DataConfig(...) — direct dataclass construction + field-by-field population (see Minimal custom cartridge below) |
Tests, lightweight programmatic creation, edge-case authoring | None (no audit-trail layer) |
NOT populated by default; call cfg.schema_sha256 = cfg.compute_checksum() before verify_integrity() |
ReferenceCartridgeBuilder — staged FASTA-driven authoring with a build report |
New cartridges from raw FASTA where you want a reproducibility/audit record | populated with per-stage entries + manifest snapshot + checksum | populated automatically by build() |
ReferenceCartridgeBuilder is the recommended path when you
need provenance about how the cartridge was produced —
which FASTA inputs went in, which stages ran, which alleles
were rejected at parse time and why, what the manifest
looked like at the moment build() finalised. The build
report rides through pickle, so a teammate loading your
cartridge can replay the audit trail without re-running the
builder.
import GenAIRR as ga
builder = (
ga.ReferenceCartridgeBuilder
.from_fasta(
v_fasta="v.fa",
d_fasta="d.fa",
j_fasta="j.fa",
chain_type="BCR_HEAVY",
)
.infer_identity(
species="human",
locus="IGH",
reference_set="custom",
name="my_igh",
)
.infer_v_subregions()
)
cfg = builder.build()
report = builder.report()cfg.build_report is a CartridgeBuildReport dataclass; its
.to_dict() method returns a JSON-clean dict so CI
artifacts / audit dashboards can capture the full provenance.
v1 statistical estimators (estimate_allele_usage,
estimate_trim_distributions, estimate_np_length_distributions,
estimate_np_base_model, estimate_p_nucleotide_lengths,
estimate_shm_rates) are intentionally deferred to a
follow-up slice; the v1 builder covers FASTA → identity →
V-subregions → rules/models attachment → build(). See
docs/reference_cartridge_authoring_audit.md
for the full design.
- Bundled cartridge already covers your case — just
ga.HUMAN_IGH_OGRDB; the build report staysNonebecause bundled cartridges predate the builder. - One-off test fixture — manual
DataConfig(name="…")with a hand-stitched catalogue is faster to author and doesn't need an audit trail. - You need parameters the builder doesn't ship yet —
statistical estimators are deferred; attach
ReferenceEmpiricalModelsdirectly via.with_models(...)if you've estimated rates / NP / P distributions outside GenAIRR.
Author a DataConfig with explicit rules + models and run it
through Experiment:
from datetime import date
import GenAIRR as ga
from GenAIRR import (
AnchorRuleSpec,
ConfigInfo,
DataConfig,
EmpiricalDistributionSpec,
ReferenceEmpiricalModels,
ReferenceRulesSpec,
Species,
ChainType,
)
from GenAIRR.alleles.allele import VAllele, DAllele, JAllele
# (1) Catalogue — V/D/J alleles
v1 = VAllele("MyV1*01", "TGT...", length=300, anchor_override=288)
d1 = DAllele("MyD1*01", "GGGCCC...", length=15)
j1 = JAllele("MyJ1*01", "TGG...", length=60, anchor_override=10)
# (2) Identity
metadata = ConfigInfo(
species=Species.HUMAN,
chain_type=ChainType.BCR_HEAVY,
reference_set="custom",
last_updated=date(2026, 1, 1),
has_d=True,
)
# (3) Rules — defaults are fine for canonical biology
rules = ReferenceRulesSpec(
v_anchor=AnchorRuleSpec(expected_aa=["C"]),
j_anchor=AnchorRuleSpec(expected_aa=["W"]),
)
# (4) Empirical models — defaults the engine samples from
models = ReferenceEmpiricalModels(
np_lengths={
"NP1": EmpiricalDistributionSpec([(0, 1.0), (3, 4.0), (6, 2.0)]),
"NP2": EmpiricalDistributionSpec([(0, 1.0), (2, 3.0), (4, 2.0)]),
},
)
cfg = DataConfig(
name="my_custom_igh",
metadata=metadata,
v_alleles={"MyV1": [v1]},
d_alleles={"MyD1": [d1]},
j_alleles={"MyJ1": [j1]},
gene_use_dict={"V": {}, "D": {}, "J": {}},
trim_dicts={"V_3": {}, "D_5": {}, "D_3": {}, "J_5": {}},
reference_rules=rules,
reference_models=models,
)
result = ga.Experiment.on(cfg).recombine().run_records(n=100, seed=42)A custom J anchor expectation (Y instead of W), an extended
alphabet that admits R (purine ambiguity code), and a tailored
NP-length distribution:
from GenAIRR import (
AnchorRuleSpec,
EmpiricalDistributionSpec,
ReferenceEmpiricalModels,
ReferenceRulesSpec,
)
rules = ReferenceRulesSpec(
allowed_bases=["A", "C", "G", "T", "N", "R"],
v_anchor=AnchorRuleSpec(expected_aa=["C"]),
j_anchor=AnchorRuleSpec(expected_aa=["Y"]), # non-standard locus
)
models = ReferenceEmpiricalModels(
np_lengths={
"NP1": EmpiricalDistributionSpec(
[(0, 0.1), (5, 0.4), (10, 0.3), (15, 0.2)]
),
},
)
cfg.reference_rules = rules
cfg.reference_models = modelsThe cartridge now declares: my J alleles' anchor codons translate to tyrosine, my catalogue may contain purine-ambiguity bases, my NP1 lengths are long-tailed. No engine-side code needs to change — the rules and models are data, not behaviour.
Three layers, in order of when they fire:
-
Validation (
RefDataConfig.validate()/validate_with_mode(mode)) describes the catalogue against the cartridge's rules. Returns a list of issue dicts (each tagged withseverity:"fatal"or"curatable"). -
Curation (
RefDataConfig.curated(policy)) selects which alleles participate. Re-runs validation on the curated cartridge. Fatal structural issues are NOT fixed by curation — they still surface. -
Compile (
Experiment.compile()) runs validation under either strict mode (default) orAllowCuratablemode (after.allow_curatable_refdata()). Fatal issues always reject; Curatable issues reject only under strict mode.
# Direct authoring:
issues = cfg.validate()
cfg.validate_strict() # raises on any issue
cfg.validate_with_mode(mode="allow_curatable") # raises only on Fatal
curated = cfg.curated("functional_anchors_only")
# Through Experiment:
ga.Experiment.on(cfg).curate_refdata("functional_anchors_only").compile()
ga.Experiment.on(cfg).allow_curatable_refdata().compile()If you're authoring a new cartridge, the workflow is usually:
- Build the catalogue + identity + rules + models.
- Call
cfg.validate()(orExperiment.on(cfg).compile()) to see what the validator says. - If it complains about pseudogene-shape anchors and you want them
excluded:
.curate_refdata("functional_anchors_only"). - If you want them included anyway:
.allow_curatable_refdata(). - If it complains about something Fatal (duplicate names, invalid bytes, locus/chain mismatch): fix the cartridge itself; neither curation nor opt-in helps.
Before the cartridge model, biology lived implicitly in:
- engine code (locus-prefix inference from allele names),
- DataConfig keys (loose nested dicts), and
- "well, the user just knows" tribal knowledge.
After:
- biology lives on the cartridge as data — identity, rules, empirical models;
- the engine code that consumes the cartridge is generic and data-driven;
- trace files attribute outputs to a precise cartridge identity, so reproducibility crosses machines and code versions;
- non-standard species (custom alphabet, non-W/F J anchor, empirical distributions you measured) are first-class authoring scenarios, not "hack the engine" workarounds.