Architecture¶
A deep look at how the GenAIRR engine actually
produces records. This page explains the lowering pipeline,
the seven invariants the kernel guarantees, the novel
constrain-before-propose sampling strategy, the dual stream
(trace + events) per pass, and the two-layer integrity model.
For the canonical contributor-facing reference, see the audit
corpus at audit-docs/.
The mental model in one diagram¶
A simulation goes through four distinct stages. The user touches the first one; the engine handles the rest.
flowchart LR
A["DSL<br/>Experiment.on().recombine()<br/>.mutate()..."] --> B["Pass plan<br/>typed, ordered, signed"]
B --> C["Compiled executor<br/>schedule + contract set"]
C --> D["Persistent IR<br/>+ event log + trace"]
D --> E["AIRR record<br/>pure projection"]
classDef accent fill:#1f8a4c,stroke:#0e0e10,color:#ffffff,font-weight:600;
classDef info fill:#1d4ed8,stroke:#0e0e10,color:#ffffff,font-weight:600;
classDef trace fill:#6e3a8e,stroke:#0e0e10,color:#ffffff,font-weight:600;
classDef warn fill:#dc2626,stroke:#0e0e10,color:#ffffff,font-weight:600;
classDef neutral fill:#fafaf7,stroke:#0e0e10,color:#0e0e10;
class A accent;
class B,C,D neutral;
class E info;
The transitions matter as much as the boxes:
| Transition | What happens |
|---|---|
| DSL → Pass plan | Experiment.compile() lowers the chained method calls into a typed, ordered, signed pass plan. The plan is the engine's actual input; the Experiment object is just a builder. |
| Pass plan → Executor | Compile analyses the plan, resolves the contract set, schedules live-call refresh hooks, and stamps the plan signature. |
| Executor → IR | Each pass runs against the persistent IR. Every random draw lands at a typed address; every state change emits a typed event. The IR is the source of truth. |
| IR → AIRR record | A pure projection layer reads the final IR pool and produces the AIRR record. The projection has no state of its own. Re-running it on the same IR is byte-identical. |
The boundaries between stages are why GenAIRR can offer features other simulators can't: byte-stable replay, structured validation, post-hoc revision, audit-first development.
Who reads this section¶
- Contributors adding new passes, contracts, AIRR fields, or cartridge planes.
- Advanced users debugging an unexpected output or reviewing a published GenAIRR dataset against its source.
- Maintainers auditing one mechanism's drift across a release, or scoping a refactor's blast radius before touching kernel code.
If you're learning the simulator for the first time, the Getting Started and Core Concepts tracks are the right starting points. Come back here when something below those surprises you.
How the DSL becomes a simulation¶
The DSL is not the engine. It's a builder for a typed pass plan. The compilation step is where novel correctness guarantees are established.
flowchart TB
subgraph one ["1. You write the DSL"]
DSL["exp = Experiment.on('human_igh')<br/>.recombine()<br/>.mutate(rate=0.05)<br/>.productive_only()"]
end
subgraph two ["2. compile() lowers"]
Plan["Pass plan<br/>SampleAllelePass<br/>TrimPass<br/>AssembleSegmentPass<br/>GenerateNPPass<br/>S5FMutationPass"]
PSig[("plan signature<br/>sha256")]
CSet["Contract set<br/>AnchorPreserved(V)<br/>AnchorPreserved(J)<br/>ProductiveJunctionFrame<br/>NoStopCodonInJunction"]
LiveCall["Live-call refresh<br/>hook schedule"]
end
subgraph three ["3. Per-record run"]
Exec["Executor walks the plan"]
IR[("Persistent IR<br/>nucleotide pool<br/>regions<br/>assignments")]
Tr[("Trace<br/>address,value")]
Ev[("Event log<br/>typed events")]
end
subgraph four ["4. Per-record project"]
Proj["AIRR projector"]
Rec["AIRR record"]
end
DSL --> Plan
Plan --> PSig
Plan --> CSet
Plan --> LiveCall
Plan --> Exec
Exec --> IR
Exec --> Tr
Exec --> Ev
IR --> Proj
Proj --> Rec
A few facts to call out:
- The pass plan is signed before the first record runs. That signature gets folded into every trace; replay is gated on it. Two runs of the same DSL produce the same plan signature, no matter the machine.
- The contract set is resolved at compile time, not per draw.
productive_only()doesn't fire at every draw point and check things; four predicates get registered, and the relevant sampling sites consult them via a typedadmits_typedtrait. This is constrain before propose, covered in detail below. - The IR is persistent. Each pass writes to it through an event-emitting builder, never directly. The event stream exists because validators and the live-call refresh hook need to know what changed.
- The AIRR record is a projection. It's derived from the final IR pool by a pure function. The record has no state of its own; the IR does.
The seven engine invariants¶
The kernel's correctness rests on seven layered invariants. Each
has a canonical Rust type and a contract test that proves it. The
canonical write-up is at
audit-docs/engine_architecture.md;
the short version follows.
1. Contracts constrain support before proposals¶
This is the most distinctive design choice. Most simulators build a sequence and check it afterwards; if broken, they discard and resample. That's slow, statistically biased toward weakly-constrained edges of the distribution, and offers no way to reason about which draws are admissible.
GenAIRR inverts the order:
flowchart LR
subgraph Trad ["Traditional: propose-and-retry"]
T1["Draw from<br/>full support"] --> T2{Valid?}
T2 -->|no| T1
T2 -->|yes| T3[Accept]
end
subgraph Genairr ["GenAIRR: constrain-before-propose"]
G1["Filter support<br/>by contracts"] --> G2["Draw from<br/>admissible subset"] --> G3["Always valid"]
end
classDef accent fill:#1f8a4c,stroke:#0e0e10,color:#ffffff,font-weight:600;
classDef info fill:#1d4ed8,stroke:#0e0e10,color:#ffffff,font-weight:600;
classDef trace fill:#6e3a8e,stroke:#0e0e10,color:#ffffff,font-weight:600;
classDef warn fill:#dc2626,stroke:#0e0e10,color:#ffffff,font-weight:600;
classDef neutral fill:#fafaf7,stroke:#0e0e10,color:#0e0e10;
class T2 warn;
class G1 accent;
At every contract-aware sampling site, the candidate distribution
is filtered by the active contract set's admits_typed predicate
before any draw happens. A draw that returns a value is admissible
by construction. There is no rejection loop.
When the admissible set is empty (the constraint can't be satisfied at this site), behaviour depends on the mode:
- Permissive (default): fall back to an explicit sentinel,
such as
0for a trim length,Nfor a base,-1for indel position. The record still gets produced; the sentinel marks "the pass committed nothing meaningful here." - Strict (
run_records(..., strict=True)): raiseStrictSamplingError(pass_name, address, reason).
2. Trace = choices/proposals¶
A trace records what was proposed or sampled at each addressed
decision point. Every sampling site has a stable typed
ChoiceAddress; every value drawn there is a typed ChoiceValue.
Two traces are value-equal iff every recorded (address, value)
pair matches in order. That's the substrate for replay.
The on-disk TraceFile is schema-versioned, refdata-content-hashed,
and address-schema-versioned. See
Trace, replay, reproducibility for the
user-facing surface.
3. Replay = validated proposal consumption¶
flowchart LR
Tr[(Trace file)] --> Cur[TraceCursor]
Cur -->|next address| Adm["admissibility chain:<br/>refdata lookup<br/>support membership<br/>contract acceptance<br/>feasibility check"]
Adm -->|pass| Apply[Apply value]
Adm -->|fail| Err[PassError::Replay]
Apply --> Out[Outcome]
classDef accent fill:#1f8a4c,stroke:#0e0e10,color:#ffffff,font-weight:600;
classDef info fill:#1d4ed8,stroke:#0e0e10,color:#ffffff,font-weight:600;
classDef trace fill:#6e3a8e,stroke:#0e0e10,color:#ffffff,font-weight:600;
classDef warn fill:#dc2626,stroke:#0e0e10,color:#ffffff,font-weight:600;
classDef neutral fill:#fafaf7,stroke:#0e0e10,color:#0e0e10;
class Adm neutral;
class Err warn;
Replay consumes recorded values through a TraceCursor at each
sampling site. Every replayed value passes the same
admissibility chain a fresh draw would: refdata lookup,
distribution-support membership, contract acceptance,
feasibility acceptance. The trace supplies proposals; the engine
decides whether they apply.
That's why a trace produced against one cartridge can fail to replay against another: the proposal might no longer be in the support.
4. Simulation events = consequences¶
SimulationEvent is the typed runtime stream describing what
actually happened to the IR. Variants include BasePushed,
BaseChanged, IndelInserted, IndelDeleted, AssignmentChanged,
TrimChanged, RegionAdded, RegionReplaced,
MutationCountChanged, ReverseComplementFlagRecorded.
Every event is emitted by the SimulationBuilder at a mutation
site. The compiled executor captures the per-pass event stream
into the outcome.
The distinction matters:
| Stream | Question it answers |
|---|---|
| Trace | What did the engine propose? |
| Events | What did the engine do? |
Both are needed because contracts can reject proposals, distributions can fall back to sentinels, and replay needs to know what was asked for, while validators need to know what actually changed.
5. Compile effects = scheduling facts¶
PassCompileEffect is the static, declarative category of
state change a pass produces. It's read at compile time by the
schedule analyser for ordering and dependency reasoning. It is
never consulted at runtime by derived-state refresh.
The static-vs-runtime split is asymmetric on purpose: declarations are intent; events are what happened.
6. Live-call refresh follows events¶
After every pass, the V/D/J live-call cache may need to be recomputed if the IR changed in a way that affects allele scores. The refresh hook reads the pass's event stream, not the declared compile effects, to decide whether to refresh and what to refresh:
flowchart LR
Pass[Pass runs] --> Events["events:<br/>BaseChanged<br/>TrimChanged"]
Events --> Plan["LiveCallRefreshPlan<br/>.from_events()"]
Plan --> Apply["refresh affected<br/>segments only"]
Apply --> Cache[(Live-call cache)]
This is critical: a pass that declares an effect but emits no event triggers no refresh; a pass that emits an event without declaring the effect still triggers a refresh. The runtime cache follows what changed, not what was intended.
Two divergence tests pin this invariant. See
audit-docs/engine_architecture.md
§1.6 for the test names.
7. Built-in mutating passes route through SimulationBuilder¶
The only sanctioned mutation path for production pass code is the
event-emitting SimulationBuilder API. Calling Simulation::with_*
directly bypasses event emission, leaving the live-call cache
stale.
Two CI lockdowns make this unrepresentable: production pass code
cannot reach the low-level builder mutators or the persistent
with_* mutators. Test code is free; production code is fenced.
Three streams per pass¶
Every pass produces three independent outputs. Understanding why each exists makes the rest of the architecture click:
flowchart LR
Pass[Pass executes] --> T[(Trace<br/>address, value)]
Pass --> E[(Event log<br/>typed events)]
Pass --> S[(Simulation IR<br/>updated state)]
T -.->|powers replay| TR[Replay]
E -.->|powers validation| V[Validator]
E -.->|powers cache refresh| LC[Live-call hook]
S -.->|powers projection| AR[AIRR record]
classDef accent fill:#1f8a4c,stroke:#0e0e10,color:#ffffff,font-weight:600;
classDef info fill:#1d4ed8,stroke:#0e0e10,color:#ffffff,font-weight:600;
classDef trace fill:#6e3a8e,stroke:#0e0e10,color:#ffffff,font-weight:600;
classDef warn fill:#dc2626,stroke:#0e0e10,color:#ffffff,font-weight:600;
classDef neutral fill:#fafaf7,stroke:#0e0e10,color:#0e0e10;
class T trace;
class E info;
class S accent;
This dual-stream model (trace as proposals, events as consequences) is what lets GenAIRR offer features other simulators can't:
- The trace alone is enough to replay a run, without needing the original RNG seed in scope.
- The event log alone is enough to validate a record. The validator re-derives every counter from events and asserts equality with the projected record.
- The events drive the live-call refresh hook, so the cached V/D/J calls stay in sync with the IR even when intermediate passes mutate things.
Plan signature: the replay safety gate¶
When you replay a trace, three checks fire before any choice is consumed:
flowchart LR
Trace[(Trace file)] --> G1{plan signature<br/>matches?}
G1 -->|no| F1[ValueError:<br/>plan_signature_mismatch]
G1 -->|yes| G2{refdata signature<br/>matches?}
G2 -->|no| F2[ValueError:<br/>refdata_signature_mismatch]
G2 -->|yes| G3{refdata content<br/>hash matches?}
G3 -->|no| F3[ValueError:<br/>refdata_content_hash_mismatch]
G3 -->|yes| Replay[Consume trace]
classDef accent fill:#1f8a4c,stroke:#0e0e10,color:#ffffff,font-weight:600;
classDef info fill:#1d4ed8,stroke:#0e0e10,color:#ffffff,font-weight:600;
classDef trace fill:#6e3a8e,stroke:#0e0e10,color:#ffffff,font-weight:600;
classDef warn fill:#dc2626,stroke:#0e0e10,color:#ffffff,font-weight:600;
classDef neutral fill:#fafaf7,stroke:#0e0e10,color:#0e0e10;
class F1,F2,F3 warn;
class Replay accent;
pass_plan_signaturehashes the resolved pass plan: every pass, every kwarg that affects sampling, every cartridge-driven compile parameter. Any DSL change flips this signature.refdata_signaturehashes the cartridge identity: which cartridge produced the trace.refdata_content_hashhashes the cartridge content: even if the cartridge name is the same, if a plane changed bytes, this differs.
The three together mean: a trace replays iff the experiment, the cartridge identity, and the cartridge content all match. Tighter than re-using a seed; verifiable post hoc.
See Trace, replay, reproducibility for the user surface.
The two-layer integrity model¶
GenAIRR's release-readiness gate runs two independent layers:
flowchart TB
subgraph Output ["Run-time output"]
Sim[Simulation IR] --> Proj[Projector]
Proj --> Rec[AIRR record]
end
subgraph Layer1 ["Layer 1: AIRR validator"]
Rec --> V1["Re-derive every field<br/>from Outcome events"]
V1 --> Cmp1{Match?}
Cmp1 -->|yes| Ok1["Pass"]
Cmp1 -->|no| Bad1["ValidationReport<br/>structured issue"]
end
subgraph Layer2 ["Layer 2: Cache parity"]
Sim --> V2["Recompute SegmentLiveCall<br/>from scratch"]
V2 --> Cmp2{Equals cached?}
Cmp2 -->|yes| Ok2["Pass"]
Cmp2 -->|no| Bad2["ParityFailure"]
end
classDef accent fill:#1f8a4c,stroke:#0e0e10,color:#ffffff,font-weight:600;
classDef info fill:#1d4ed8,stroke:#0e0e10,color:#ffffff,font-weight:600;
classDef trace fill:#6e3a8e,stroke:#0e0e10,color:#ffffff,font-weight:600;
classDef warn fill:#dc2626,stroke:#0e0e10,color:#ffffff,font-weight:600;
classDef neutral fill:#fafaf7,stroke:#0e0e10,color:#0e0e10;
class Ok1,Ok2 accent;
class Bad1,Bad2 warn;
| Layer | Question | API |
|---|---|---|
| AIRR validator | Does the projected AIRR record agree with an independent re-derivation from the Outcome? |
result.validate_records(refdata) → ValidationReport |
| Cache parity | Does the cached SegmentLiveCall on the final Simulation equal a from-scratch recompute? |
outcome.check_live_call_cache_parity(refdata) → list[dict] |
The two layers catch different classes of bug:
- The AIRR validator catches projection / counter drift. A bug in how the record is derived from the outcome.
- The cache parity check catches live-call refresh-hook bugs that the AIRR validator can't see, because they don't surface on the record (they corrupt the cache, which downstream code reads).
Both run in release-tier CI. See the validation matrix at
audit-docs/validation_matrix.md
for the row-by-row guarantee → audit doc → test mapping.
The audit-first workflow¶
GenAIRR's release process is unusual: a mechanism gets specified, validated against the existing engine, and pinned by contract tests before implementation begins.
flowchart LR
A[Audit doc] --> B["Contract pins<br/>tests fail on master"]
B --> C["Implementation<br/>slice 1"]
C --> D["Implementation<br/>slice 2"]
D --> E["Release<br/>audit becomes design"]
classDef accent fill:#1f8a4c,stroke:#0e0e10,color:#ffffff,font-weight:600;
classDef info fill:#1d4ed8,stroke:#0e0e10,color:#ffffff,font-weight:600;
classDef trace fill:#6e3a8e,stroke:#0e0e10,color:#ffffff,font-weight:600;
classDef warn fill:#dc2626,stroke:#0e0e10,color:#ffffff,font-weight:600;
classDef neutral fill:#fafaf7,stroke:#0e0e10,color:#0e0e10;
class A info;
class B warn;
class C,D neutral;
class E accent;
- Audit doc. Open
audit-docs/<topic>_audit.md. Define the biology, name the v1 boundary (what's in / what's deferred), enumerate the invariants the implementation must preserve. - Contract pins. Add
tests/test_<topic>_contract.pywith the three required test patterns: deterministic-by-trace, plan-signature-stable, event-ledger-complete. The pins fail on master because the implementation isn't there yet; that's the point. - Implementation slices. Land the mechanism in small, independently-reviewable slices. Each slice flips one set of contract pins from red to green.
- Release consolidation. Rename
audit-docs/<topic>_audit.mdtoaudit-docs/<topic>_design.md, updateaudit-docs/validation_matrix.mdwith the new row, stamp the audit as resolved.
Why this discipline matters for biological mechanisms: most simulators treat invariants as informal ("the recombination should produce a valid junction"). GenAIRR makes them executable. The validator re-derives the junction; the contract pins enforce counter-partition equalities; the plan signature catches any kwarg that would silently shift output. Audit-first keeps the invariant set explicit so a new mechanism can't quietly break an old one.
Where the code lives¶
| Mechanism area | Rust kernel | Python wrapper |
|---|---|---|
| Recombination | engine_rs/src/passes/sample_allele.rs, trim.rs, generate_np.rs, assemble_segment.rs, p_addition.rs, junction.rs |
Experiment.recombine, restrict_alleles, allele-usage estimator |
| Mutation (SHM) | engine_rs/src/passes/mutate/{s5f,uniform,segment_rates,v_subregion_rates}.rs |
Experiment.mutate, segment_rates, v_subregion_rates |
| Constraints | engine_rs/src/contract/{anchor_preserved,junction_stop_state,no_stop_codon_in_junction,productive_junction_frame}.rs |
Experiment.productive_only, restrict_alleles |
| Recombination editing | engine_rs/src/passes/{invert_d,receptor_revision}.rs |
Experiment.invert_d, receptor_revision |
| Corruption + library | engine_rs/src/passes/corrupt/{pcr,quality,indel,ncorrupt,end_loss,contaminant,rev_comp}.rs |
Experiment.pcr_amplify, polymerase_indels, sequencing_errors, ambiguous_base_calls, end_loss_*prime |
| Read layout | engine_rs/src/passes/paired_end.rs, airr_record/segment_projection.rs |
Experiment.paired_end, random_strand_orientation |
| Projection (AIRR) | engine_rs/src/airr_record/builder.rs, record.rs, validate.rs |
SimulationResult, exporters |
| Reference cartridge | engine_rs/src/refdata.rs, engine_rs/src/refdata/validation.rs |
DataConfig, ReferenceCartridgeBuilder, cfg.cartridge_manifest() |
| Trace + replay | engine_rs/src/trace.rs, engine_rs/src/replay.rs, engine_rs/src/trace_file.rs |
compiled.simulator.trace_file_from / replay_from_trace_file / rerun_from_trace_file |
| Validation | engine_rs/src/contract/*, engine_rs/src/airr_record/validate.rs |
result.validate_records, validate_families, validate_families_with_parents |
Anti-patterns¶
A few patterns the CI lockdowns specifically prevent:
- Direct
sim.with_*in production passes. Bypasses event emission; live-call cache goes stale. CI guard:no_pass_calls_persistent_with_star_mutators_in_production_code. - Replay force-apply. Pushing a recorded value through without re-validating its admissibility chain. The cursor's positional contract means trailing records are themselves a structured error.
- Contract-violating permissive fallback. A pass that drops to a non-sentinel value when the contract rejects all candidates. The only sanctioned outputs from a permissive draw are: a contract-admissible value, or the documented sentinel.
- Declaring a compile effect without emitting matching events. Schedule analyser sees the effect; refresh hook sees no event; derived state goes stale.
Deep links¶
The audit corpus at audit-docs/ is the contributor-facing
source of truth. The anchor documents:
| Document | Purpose |
|---|---|
audit-docs/engine_architecture.md |
The seven invariants in their canonical form, with Rust file pointers and CI lockdown names. |
audit-docs/adding_a_pass.md |
Copy-pasteable pass template, the three required test patterns, the live-call refresh hook contract. |
audit-docs/validation_matrix.md |
Every guarantee mapped to its audit doc, test file, and Rust kernel invariant. |
audit-docs/plan_signature_completeness_audit.md |
Every parameterised DSL surface and every cartridge-driven compile parameter mapped against plan-signature participation. |
The full audit corpus is browsable in the repository under
audit-docs/. About 40 audit and design docs covering every
mechanism.
Before you add a new mechanism¶
The checklist new contributors run before opening a slice PR:
- [ ] Define the biology and provenance. What does this model
in vivo? What counters and provenance fields does it
surface on the AIRR record? Write it in
audit-docs/<topic>_audit.md§1 and §2 before writing code. - [ ] Trace and replay choices. Every sampling site needs a
stable typed
ChoiceAddressand a deterministic projection fromChoiceValue. Write the address schema in the audit before lowering it. - [ ] Event consequences. Decide which event types the pass emits. They must be sufficient to reconstruct every counter the projection will read.
- [ ] Validators. Add the contract pin that re-derives the mechanism's counters from events and asserts equality with the projected record.
- [ ] Cartridge ownership. If the mechanism takes per-cartridge
parameters, decide which
reference_modelsplane owns them, whether they participate in the plan signature, and whether they fold intorefdata_content_hash. - [ ] Docs. New user-facing mechanism gets a user-tone guide
under
site_docs/guides/. Reference the audit doc only at the bottom under "Deep architecture notes." - [ ] Release-tier test. The two-layer integrity model (AIRR validator plus live-call cache parity) must pass before release.
The audit-docs/adding_a_pass.md companion turns this checklist
into copy-paste templates.