Recording and replay¶
Only leg positions and mobilities change during a run, and only at events. State is constant in between, so it can be sampled exactly at any time, with no interpolation.
Two kinds of record¶
- Snapshots of every LEF on a time grid. Observations at defined times.
- Event deltas: one row per leg changed by each event. Lossless and compact.
The examples below come from one short run: 100 sites, CTCFs at 30 (capturing from the right) and 70 (capturing from the left), and three LEFs.
Snapshots. A snapshot is the world's LEF table, with left and right legs
side by side since the loop is the observable, plus a time column. Here are
two time grid points, one time unit apart:
| time | lef_id | left_pos | right_pos | left_mobility | right_mobility |
|---|---|---|---|---|---|
| 2.0 | 0 | 14 | 16 | Moving | Moving |
| 2.0 | 1 | 50 | 56 | Moving | Moving |
| 2.0 | 2 | 25 | 29 | Moving | Moving |
| 3.0 | 0 | 13 | 17 | Moving | Moving |
| 3.0 | 1 | 46 | 56 | Moving | Moving |
| 3.0 | 2 | 62 | 62 | Moving | Moving |
Event deltas. Each row gives a leg's new state and the action that set it. These are the events between those same two grid points (times rounded):
| time | leg_id | lef_id | pos | mobility | action |
|---|---|---|---|---|---|
| 2.172 | 1 | 0 | 17 | Moving | Step |
| 2.422 | 2 | 1 | 49 | Moving | Step |
| 2.493 | 4 | 2 | 62 | Moving | Evict |
| 2.493 | 5 | 2 | 62 | Moving | Evict |
| 2.493 | 2 | 1 | 48 | Moving | Step |
| 2.565 | 0 | 0 | 13 | Moving | Step |
| 2.791 | 2 | 1 | 47 | Moving | Step |
| 2.838 | 2 | 1 | 46 | Moving | Step |
A step changes one leg. A respawn or an eviction places both legs of the LEF at its new site, so it writes two rows with the same time. At 2.493, LEF 2's right leg, at 29, stepped toward the CTCF at 30 from its evict side, and the LEF reloaded at site 62 with both legs on that one site.
A time grid point records the state after every event at or before it. Recording doesn't modify the random state, so a simulation run with the same seed and initial state fires the same events regardless of how they are recorded. Similarly, a run that is paused and resumed will record the same events as an uninterrupted one.
LEFs and legs are identified by dense ids: lef_id runs over 0..n_lefs, and
leg_id = 2 * lef_id + side, with side 0 for the left leg and 1 for the
right.
Bounded recording and streaming¶
A recorder can have a bounded capacity. When it fills, the run returns before
firing another event, so nothing is lost: it can hand off the chunk to the
caller and continue where it left off. In Python and TypeScript,
sim.stream(...) wraps this as a lazy stream that runs only as it's read, so a
run of any length will consume bounded memory.
Keyframes¶
Keyframes are optional snapshots included in the event delta log as a set of rows, one per leg in the system.
We prepend batches of event deltas with a full keyframe, such that every batch
opens with one Keyframe pseudo-action row per leg, thus encoding the full
world state. This way, each batch can be replayed on its own, allowing for
seeking across a stream or collection of batches, or for playing from a stream
already partway through. Rows carry absolute values, so replaying is an
idempotent overwrite, and dropping the keyframe rows leaves exactly the plain
event log.
Here is the start of a keyframed batch from the same run, recorded from t = 2.
The six Keyframe rows restate the snapshot at 2.0 one leg at a time, and the
event deltas follow as before:
| time | leg_id | lef_id | pos | mobility | action |
|---|---|---|---|---|---|
| 2.0 | 0 | 0 | 14 | Moving | Keyframe |
| 2.0 | 1 | 0 | 16 | Moving | Keyframe |
| 2.0 | 2 | 1 | 50 | Moving | Keyframe |
| 2.0 | 3 | 1 | 56 | Moving | Keyframe |
| 2.0 | 4 | 2 | 25 | Moving | Keyframe |
| 2.0 | 5 | 2 | 29 | Moving | Keyframe |
| 2.172 | 1 | 0 | 17 | Moving | Step |
| 2.422 | 2 | 1 | 49 | Moving | Step |
| 2.493 | 4 | 2 | 62 | Moving | Evict |
| … |
Replaying¶
A replayer rebuilds the state at any recorded time from keyframed batches, with no simulation:
advance_to(t)moves forward through the data received so far.seek(t)jumps anywhere, by restoring the nearest keyframe at or beforet.snapshots(dt)decodes the log to a time grid: the same table a recorder on that grid would have produced.world()gives the state as a world to start a new run from. Such a run continues the original faithfully in distribution, though not event for event, which would need the original random state.
Replayers exist in all three languages. The TypeScript one is plain TypeScript, with no WebAssembly, so a page can replay streamed data without loading the simulator.
Persistence¶
Simulations → Parquet¶
The Python binding writes keyframed event logs to Parquet, one row group per
keyframed batch, compressed with zstd. The file's metadata stores where the run
came from: lefsim.model, the model it started from, and lefsim.run, with the
seed, start time and recorded span.
A replayer can open the model metadata from the file's footer, and then scans only the row groups it needs. In the browser, it does so over HTTP with range requests, so a recording of any size can be scrubbed without downloading it.
Models → JSON¶
Models (as in the lefsim.model serialization above) also save and load as a
standalone versioned JSON document, described by a
JSON Schema, the same in all three languages:
{
"format": "lefsim.model",
"version": 2,
"lattice": {
"n_sites": 100,
"ctcfs": [{ "site": 30, "captures_from": "right", "strength": 0.8 }]
},
"lefs": [
{
"left_pos": 10,
"right_pos": 12,
"left_mobility": "moving",
"right_mobility": "moving"
}
],
"rates": {
"step": 1.0,
"respawn": 0.001,
"respawn_blocked": 0.01,
"respawn_captured": 0.001
},
"spawn": { "rule": "uniform_non_ctcf" }
}
Moreover, when a model is loaded in lefsim it is validated, which covers what a schema cannot express: CTCF sites and leg positions inside the lattice, legs in order, no two LEFs on one site, captured legs on a CTCF that captures from their side, enough free sites for the spawn rule, etc. As a result, a valid model JSON decodes to a valid simulation state, and a simulation snapshot at any time can be serialized as a valid model JSON.
The schema
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://abdenlab.github.io/lefsim/schemas/model.v2.json",
"title": "lefsim model",
"description": "A lefsim model, version 2: a lattice with CTCFs, the LEFs on it, their rates and a spawn rule. Loading also runs the checks a schema can't express, such as CTCF sites and leg positions lying inside the lattice.",
"type": "object",
"properties": {
"format": { "const": "lefsim.model" },
"version": { "const": 2 },
"lattice": { "$ref": "#/$defs/lattice" },
"lefs": {
"description": "One entry per LEF; LEF i is entry i.",
"type": "array",
"items": { "$ref": "#/$defs/lef" }
},
"rates": { "$ref": "#/$defs/rates" },
"spawn": { "$ref": "#/$defs/spawn" }
},
"required": ["format", "version", "lattice", "lefs", "rates", "spawn"],
"additionalProperties": false,
"$defs": {
"position": {
"description": "A site index, from 0 to n_sites - 1.",
"type": "integer",
"minimum": 0,
"maximum": 4294967295
},
"mobility": {
"description": "A leg's intrinsic mobility. Whether it's blocked is derived, not stored.",
"enum": ["moving", "captured"]
},
"lattice": {
"type": "object",
"properties": {
"n_sites": { "type": "integer", "minimum": 1, "maximum": 4294967295 },
"ctcfs": {
"description": "At most one CTCF per site. Defaults to none.",
"type": "array",
"items": { "$ref": "#/$defs/ctcf" }
}
},
"required": ["n_sites"],
"additionalProperties": false
},
"ctcf": {
"type": "object",
"properties": {
"site": { "$ref": "#/$defs/position" },
"captures_from": {
"description": "The CTCF's capture side. Legs arriving from the other side may be evicted.",
"enum": ["left", "right"]
},
"strength": {
"description": "The probability p of capture from the capture side, or eviction from the other side.",
"type": "number",
"minimum": 0,
"maximum": 1
}
},
"required": ["site", "captures_from", "strength"],
"additionalProperties": false
},
"lef": {
"description": "left_pos <= right_pos. A LEF's two legs may share a site; two LEFs may not. A captured leg sits on a CTCF that captures from the side it arrived from: right for a left leg, left for a right leg.",
"type": "object",
"properties": {
"left_pos": { "$ref": "#/$defs/position" },
"right_pos": { "$ref": "#/$defs/position" },
"left_mobility": { "$ref": "#/$defs/mobility", "default": "moving" },
"right_mobility": { "$ref": "#/$defs/mobility", "default": "moving" }
},
"required": ["left_pos", "right_pos"],
"additionalProperties": false
},
"rate": {
"description": "Events per unit time, positive and finite.",
"type": "number",
"exclusiveMinimum": 0
},
"rates": {
"type": "object",
"properties": {
"step": { "$ref": "#/$defs/rate", "description": "A leg's step rate." },
"respawn": { "$ref": "#/$defs/rate", "description": "The basal unloading rate." },
"respawn_blocked": { "$ref": "#/$defs/rate", "description": "The unloading rate while either leg is blocked." },
"respawn_captured": { "$ref": "#/$defs/rate", "description": "The unloading rate while either leg is captured; it takes precedence over respawn_blocked." }
},
"required": ["step", "respawn", "respawn_blocked", "respawn_captured"],
"additionalProperties": false
},
"spawn": {
"description": "Where an unloaded LEF binds again.",
"type": "object",
"properties": {
"rule": {
"description": "uniform_non_ctcf: a uniformly random site that isn't a CTCF and isn't occupied, with both legs on it. It needs at least 2n - 1 non-CTCF sites for n LEFs.",
"enum": ["uniform_non_ctcf"]
}
},
"required": ["rule"]
}
}
}