Representing Time in Mock Machines
A simulation advances in discrete turns. A timestamp field lets an entity
record the turn at which something happened to it — when it was created, when it
first reached a state — and have that turn exported as a real wall-clock date.
The turn number is stamped in the simulation’s hot loop; the conversion to a date
happens only on export, driven by the scenario’s timing: block. This article
covers both halves and walks through a worked example from the JaffleShop
scenario.
What a timestamp field is
Section titled “What a timestamp field is”Timestamp fields are a field category alongside taxonomy, counters,
dimensions and the rest. You declare them under a machine’s fields:, each with
a trigger that says when it should be stamped:
Order: fields: timestamp: - { name: ordered_at, on: create } # stamped once, at creation - { name: shipped_at, on: enter, state: shipped } # stamped on first entry to `shipped` - { name: completed_at, on: enter, state: completed }Internally each one is just an integer that holds a turn number (with -1
meaning “not yet stamped”). You never see that raw turn in the output: on export
it is rendered as a wall-clock value under the field’s own name — ordered_at,
shipped_at, completed_at. There are two moving parts, and they live in two
different places:
| Concern | Where it happens | What it does |
|---|---|---|
| Stamping | the runtime hot loop | writes the current turn number into the field when its trigger fires |
| Conversion | export | maps the stored turn to a wall-clock instant via the timing: block |
Turn numbers → timestamps on export
Section titled “Turn numbers → timestamps on export”A scenario’s timing: block defines two things: how much wall-clock time one turn
represents, and the instant turn 0 sits at.
timing: turn_duration: 24h # one turn == one day start_time: 2018-01-01T00:00:00Z # turn 0 == this instant (optional; defaults to 2024-01-01)The conversion is a single affine map — instant = start_time + turn × turn_duration
— applied per cell at export time. A turn of -1 (never stamped) becomes a
null cell.
The column is emitted as a native Arrow Timestamp (seconds, UTC). That
matters most for Parquet: the writer maps it straight to a real TIMESTAMP
logical type, so downstream tools (DuckDB, pandas, a warehouse) read a true
temporal column, not an integer or a string they have to parse. The CSV sink
formats the same array as RFC 3339.
The same timing: block also enriches the JSONL event log on export: every event
row gets a wall-clock timestamp field from its turn. Timestamp fields and
event-log enrichment are two faces of the one mapping — the runtime records turns,
export applies the calendar.
Stamping in the hot loop
Section titled “Stamping in the hot loop”Stamping is part of the simulation, not the export. It runs inside the per-turn loop and has no dependency on logging — which is essential, because the event log is switched off during large dataset generation. Two triggers fire at two well-defined points in a turn.
| Trigger | Fires when | Hot-loop site | Semantics |
|---|---|---|---|
on: create | the entity activates (becomes live) | the activate phase | stamped once with the activation turn |
on: enter, state: X | the entity transitions into X | the transition action, mid-step | first entry wins (only stamps if still unset) |
A turn runs three phases. Activate promotes pending entities (seeds and last turn’s spawns) to live; Step walks each live entity, selects an event, and executes its actions; Cascade drains any messages those actions emitted. The two stamping hooks sit in the first two phases:
on: create covers every way an entity comes to life — seeds, deferred spawns,
instant spawns, and passive entities — because the hook sits at the shared
activation point, not at any one spawn site. on: enter is
first-entry-wins: the write only happens if the field is still unset, so a
state re-entered later keeps the date of its first visit. A state that is never
reached leaves its field at -1, which exports as an empty cell.
Worked example: a Jaffle Shop order
Section titled “Worked example: a Jaffle Shop order”The JaffleShop scenario reconstructs dbt’s classic customers → orders →
payments dataset. An order’s status is its state machine
(placed → shipped → completed, with an optional return tail), so its lifecycle
milestones are a perfect fit for timestamp fields. One turn is one day.
Order: init_state: placed fields: timestamp: - { name: ordered_at, on: create } # the order date - { name: shipped_at, on: enter, state: shipped } - { name: completed_at, on: enter, state: completed } - { name: returned_at, on: enter, state: returned } # empty unless returned states: placed: { events: [ { name: ship, actions: [ { type: transition, state: shipped } ] } ], ... } shipped: { events: [ { name: complete, actions: [ { type: transition, state: completed } ] } ], ... } completed: { events: [ { name: request_return, actions: [ { type: transition, state: return_pending } ] } ], ... }Follow one order through a run that starts on 2018-01-01. It is created on turn 1,
sits in placed for a day, ships on turn 3, and completes on turn 5. It is never
returned. Each milestone stamps the turn it was reached; export turns those into
dates.
Customers and payments use the same mechanism: Customer.joined_at and
Payment.paid_at are both on: create. Because payments are passive entities,
this also demonstrates that on: create reaches entities the turn loop never
steps — the stamp happens at activation, which every entity goes through.
Reference & authoring checklist
Section titled “Reference & authoring checklist”| Key | Meaning |
|---|---|
name | the field (and exported column) name — choose the full name you want in the output, e.g. ordered_at |
on: create | stamp once when the entity activates |
on: enter + state: X | stamp on first transition into state X |
- Name fields for the output. The exported column uses the field name verbatim
— there is no automatic
_atsuffix, so writeordered_at, notordered. - Every milestone is optional. A state that is never reached exports an empty cell, which is meaningful data (it did not happen).
- Timing is optional. Omit
timing:to get one-day-per-turn from 2024-01-01; declare it to set your own epoch and granularity. - Don’t seed them. Timestamp fields are stamped by the runtime; a value in a seed CSV column of the same name is ignored on import.
- They survive resume. The stored turn is preserved column-for-column across a checkpoint, so a resumed run keeps its real dates.
See also: Many-to-One Relationships · Config YAML Reference · Data Model