This document codifies the invariants and contributor-facing
conventions of the Rust simulation kernel under
engine_rs/. It exists so the load-bearing
architectural decisions live somewhere greppable, not just in
tests and conversation history.
The code enforces most of these rules — CI lockdowns, policy checks, divergence tests — but the doc teaches the right way before CI yells.
Audience. Contributors adding a new pass, a new contract, a new derived-state consumer, or any code that mutates the
SimulationIR.
The engine's correctness rests on seven layered invariants. Each has a canonical type and a test that proves it.
Sampling is constrain-before-propose, not propose-and-retry. Every contract-aware sampler narrows its distribution's support through a filter pass before drawing a candidate. A draw that returns a value is always admissible by construction.
- Trait:
Contract::admits_typed - Set:
ContractSet - Typical site:
sample_filtered_result(rng, dist, |candidate| contracts.admits_typed(...).is_ok())
A permissive mode failure surfaces as the pass's explicit
sentinel (e.g. b'N' for ambiguous-base sampling, no-op skip
for indel position sampling). A strict mode failure surfaces as
PassError::ConstraintSampling.
The trace records what was proposed or sampled at each addressed decision point. It is the persisted artifact that makes runs reproducible across rebuilds.
- Type:
Trace,ChoiceValue - Address vocabulary:
ChoiceAddress - File format:
TraceFile— schema-versioned, refdata-content-hashed, address-schema-versioned
Two traces are value-equal iff every recorded (address, ChoiceValue) pair matches in order.
Trace-injected replay (Option B) consumes the recorded values
through a TraceCursor at each
sampling site. The cursor's strict-positional contract means
the cursor must be drained when the plan completes — trailing
records are a structured PassError::Replay.
Critical rule — every replayed value must pass 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. See SampleAllelePass::validate_replay_candidate
for the canonical example.
strict=True on replay does not re-raise permissive sentinels.
Sentinel values (indel site = -1 NoOp, NP length 0, NP base
N, trim 0) are themselves valid trace records — they represent
"the pass committed nothing" rather than "the pass committed
something the contract rejects." Replay consumes them verbatim and
they reproduce the original outcome. A user who wants the
strict-fresh-sampling behaviour for a recorded seed should call
simulator.run(seed=<trace.seed>, strict=True), not
replay_from_trace_file(tf, strict=True). See
docs/productive_failure_mode_audit.md
§5 and §6.2 for the failure matrix and the test pin.
SimulationEvent is the
typed runtime stream describing what actually happened to
the IR. Variants today:
| Variant | Carried payload |
|---|---|
BasePushed |
handle, base, segment, germline_pos, flags |
BaseChanged |
handle, old_base, new_base, segment, germline_pos |
IndelInserted / IndelDeleted |
at, base/removed_base, segment, flags |
AssignmentChanged |
segment, old, new |
TrimChanged |
segment, end, old, new |
RegionAdded / RegionReplaced |
region or (old, new) |
MutationCountChanged |
old, new, delta |
ReverseComplementFlagRecorded |
applied |
BaseDeleted |
at, removed_base (reserved) |
Events are emitted by SimulationBuilder
at every mutation site and fanned out to attached
SimulationEventSink
implementors. The compiled executor captures the per-pass event
stream into EventRecord.simulation_events.
PassCompileEffect is the
static, declarative category of state change a pass produces.
It's read at compile time by the schedule analyzer for ordering
and dependency reasoning. It is never consulted at runtime
by derived-state refresh.
A transitional pub type PassEffect = PassCompileEffect alias
keeps the old spelling compiling; new code should use the
canonical name. The static-vs-runtime split is asymmetric on
purpose: declarations are intent; events are what happened.
The post-pass V/D/J live-call refresh
(LiveCallRefreshHook)
reads the pass's simulation_events stream — not the
compile_effects declarations — and runs the steps produced by
LiveCallRefreshPlan::from_events.
A pass that declares an effect but emits no event triggers no
refresh; a pass that emits an event without declaring the
matching effect still triggers refresh.
Two divergence tests pin this: refresh_follows_events_not_effects_effect_without_event_skips_refresh
and ..._event_without_effect_triggers_refresh.
The only sanctioned mutation path for production pass code is the
event-emitting SimulationBuilder
API:
| Pass intent | Builder method |
|---|---|
| Assign allele | builder.assign_allele(segment, instance) |
| Update trim | builder.update_trim(segment, end, value) |
| Add region | builder.add_region(region) |
| Replace region | builder.replace_region(replacement) |
| Bump mutation count | builder.set_mutation_count(new_count) |
| Record rev-comp flag | builder.record_reverse_complement_flag(applied) |
| Substitute / insert / delete a base | route through MutationTransaction |
Calling Simulation::with_* directly from production pass code
bypasses event emission, so the
LiveCallRefreshHook
sees nothing and the live-call sidecar goes stale.
Two CI lockdowns make this unrepresentable:
no_pass_calls_low_level_builder_mutators_directly— guards the low-levelchange_base/insert_indel/delete_indelbuilder methods.no_pass_calls_persistent_with_star_mutators_in_production_code— guards the persistentwith_allele_assigned/with_trim/with_region_added/with_region_replaced_for_segment/with_mutation_countmutators. Test code inside#[cfg(test)]regions and undertests/subtrees stays free.
A new pass that mutates biology must touch six surfaces. The template below is the canonical order; each step has an existing analog you can copy from.
Practical companion.
docs/adding_a_pass.mdis the copy-pasteable version of this section: minimal pass template, three required test patterns with helpers frompasses::test_support, and a per-mechanism crib sheet of which existing pass to copy from. Read it when you're about to write code; come back here for the why.
If the pass samples anything, every sampling site needs a
ChoiceAddress variant in
engine_rs/src/address.rs.
fn declared_choice_patterns(&self) -> Vec<ChoiceAddressPattern> {
vec![ChoiceAddressPattern::MyNewPassCount, ...]
}The address vocabulary is schema-versioned via
ADDRESS_SCHEMA_VERSION (see address.rs);
adding a variant is additive. Pin the spelling via the
frozen_address_spellings_for_choice_address_schema_v1 test.
fn effects(&self) -> Vec<PassCompileEffect> {
vec![PassCompileEffect::EditBases] // or AssembleSegment(seg), etc.
}This is static. The scheduler reads it to order this pass correctly relative to its dependencies. It is not read at runtime for derived-state refresh.
Mutate via the builder, not Simulation::with_*. For
substitutions and indels, route through
MutationTransaction.
For non-pool consequences (assignment, trim, region, mutation
count, rev-comp), call the typed builder methods listed in
§1.7.
If you build your own SimulationBuilder instance (e.g. to
isolate a region-add step) and the caller passed
PassContext.event_log_sink, forward the captured events:
let mut builder = SimulationBuilder::from_simulation(sim);
if ctx.event_log_sink.is_some() {
builder.attach_event_log_observer();
}
builder.add_region(region);
if let Some(sink) = ctx.event_log_sink.as_deref_mut() {
sink.extend(builder.seal_event_log_observer());
}
let sim = builder.seal();When the pass is in replay mode (ctx.replay_cursor.is_some()),
the cursor supplies the value. Run the same admissibility
chain a fresh draw would before applying. The canonical
template is SampleAllelePass::validate_replay_candidate:
- Look up resource (refdata, etc.).
- Check the value lies in the natural distribution's support.
- Run
contracts.admits_typed(...). - Run
feasibility.admits(...).
Mismatches surface as the structured PassError variant that
best names what failed (MissingAllele,
InvalidDistributionOutput { reason: ... }, ContractViolation).
If the pass should respect the productive bundle (or any future
contract bundle), wire it into the
sample_filtered_result
pattern its category uses. Mutation/PCR/quality passes go through
MutationTransaction::substitute_position_constrained;
indel passes go through MutationTransaction::insert_base /
delete_base_admitting.
Three layers of test coverage:
- Pass-level event-log test — exercise the pass through
PassRuntime::execute_with_refdata(or a directPassContextinvocation), captureEventRecord.simulation_events, assert the expected event variants fire. Seeoutcome_event_records_carry_assignment_trim_and_region_eventsfor the template. - Event-emission policy entry — if the pass declares any
PassCompileEffect, add it tobuild_coverage_audit_plansobuiltin_passes_emit_events_consistent_with_declared_compile_effectspins the declared/emitted relationship. - Biological coverage — if the pass mutates anything that
could break the productive contract, add a stack to
tests/test_productive_stress_matrix.pyexercising it underproductive_onlyand as a negative control.
The patterns below are caught by tests today. The doc lists them so a new contributor sees the rule before tripping the CI.
❌
sim.with_allele_assigned(...)/sim.with_trim(...)/sim.with_region_added(...)/sim.with_mutation_count(...)/sim.with_base_changed(...)/sim.with_indel_inserted(...)
The runtime LiveCallRefreshHook
follows events. A direct with_* call leaves the event stream
silent and the V/D/J live-call sidecar goes stale.
Caught by:
no_pass_calls_persistent_with_star_mutators_in_production_code
plus the existing low-level lockdown.
Fix: route through SimulationBuilder or MutationTransaction.
❌ A replay path that applies the recorded value without running the admissibility chain.
Replay does not mean "trust the trace." A recorded value can
have been invalidated by a refdata swap, an address-schema
revision, or a contract change since the trace was written.
Force-applying silently corrupts the IR; validation surfaces a
structured PassError::Replay or PassError::MissingAllele
that the caller can diagnose.
Caught by:
replay_rejects_id_missing_from_refdata
and friends.
Fix: mirror SampleAllelePass::validate_replay_candidate.
❌ Permissive mode falling back to an unconstrained draw.
When the contract narrows the support to empty, the canonical
permissive fallback is the pass's explicit sentinel — b'N'
for ambiguous-base, no-op skip for trim, etc. Falling back to
the unconstrained natural distribution defeats the purpose of
declaring the contract.
The one documented exception is SampleAllelePass::sample_allele
(an empty allele pool can't sentinel-skip without panicking the
next assembly pass). That exception is spelled out in the
method-level doc and tested via the productive-only NP1 frame
filter.
❌ A new derived-state consumer that reads
effectsinstead ofevents.
PassCompileEffect is the intent a pass declared at compile
time. SimulationEvent is the consequence stream the pass
actually emitted. A consumer reading effects sees declarations
the pass may not have honored; a consumer reading events sees
the truth.
Caught by:
refresh_follows_events_not_effects_effect_without_event_skips_refresh
plus the policy-conformance test.
Fix: consume events: &[SimulationEvent] in your hook's
apply. Use a LiveCallRefreshPlan-style
event-to-step translator if the consumer logic is non-trivial.
❌
fn effects(&self) -> vec![PassCompileEffect::EditBases]plus mutation via a path that doesn't fireBaseChanged.
The runtime refresh trusts events. Declaring an effect without
emitting the matching event variant silently skips refresh —
the same anti-pattern as direct sim.with_*, just phrased
differently.
Caught by:
builtin_passes_emit_events_consistent_with_declared_compile_effects
and the per-pass policy unit tests in event::tests.
Fix: either route through the event-emitting builder, or drop the effect declaration if the pass legitimately doesn't produce that consequence.
Quick navigation for new contributors:
| Concept | Canonical home |
|---|---|
| Contract trait + set | engine_rs/src/contract/ |
| Choice address vocabulary | engine_rs/src/address.rs |
| Persisted trace | engine_rs/src/trace.rs, trace_file.rs |
| Replay cursor | engine_rs/src/replay.rs |
| Persistent IR | engine_rs/src/ir/ |
| Event-emitting builder | engine_rs/src/ir/builder.rs |
| Simulation event channel | engine_rs/src/ir/sim_event.rs |
| Compile effects + Pass trait | engine_rs/src/pass/ |
| Mutation transaction | engine_rs/src/passes/mutation_transaction/ |
| Live-call refresh + plan | engine_rs/src/live_call/refresh_hook.rs, refresh_plan.rs |
| Event record / outcome ledger | engine_rs/src/event.rs |
| Compiled execution loop | engine_rs/src/compiled/execute.rs |
| Production passes | engine_rs/src/passes/ |
| AIRR record projection | engine_rs/src/airr_record/ |
| Productive-contract stress matrix | tests/test_productive_stress_matrix.py |
- Pass. A scheduled unit of simulation work. Implements
Pass. Declares requirements, effects, and choice-address patterns; runs inside the compiled executor's transactional loop. - Plan. Ordered list of passes via
PassPlanor the higher-levelExperimentDSL on the Python side. - Contract. A typed admissibility predicate over a candidate choice. Constraint-aware samplers narrow their distribution's support via contracts before drawing.
- Effect. Renamed
PassCompileEffect. Static declaration of category of state change. Not a runtime fact. - Event.
SimulationEvent. Typed runtime consequence emitted by the builder at each mutation site. - Outcome.
Outcome. The per-run result: revisions, trace, pass names, and the per-passEventRecordledger. - Live call. The V/D/J allele-call sidecar maintained on
each
Simulation, refreshed by theLiveCallRefreshHookafter every pass that emits relevant events. - AIRR record. The flat record dict produced at the Python
boundary by
outcome_to_airr_record— the user-visible projection of anOutcome.