Skip to content

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.

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:

ConcernWhere it happensWhat it does
Stampingthe runtime hot loopwrites the current turn number into the field when its trigger fires
Conversionexportmaps the stored turn to a wall-clock instant via the timing: block

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.

Converting a stored turn number into an exported timestamp A stored turn number 3 is combined with the timing block (start 2018-01-01, 24h per turn) to compute the instant 2018-01-04, which exports as a native Arrow Timestamp — a Parquet TIMESTAMP column or a CSV RFC3339 string. A stored sentinel of -1 exports as null or an empty cell. stored field ordered_at = 3 an integer turn number timing: start 2018-01-01 turn_duration 24h EventTimestamp start + turn × duration = 2018-01-04T00:00:00Z Parquet TIMESTAMP (UTC) native logical type CSV 2018-01-04T00:00:00Z RFC 3339 string turn = −1 → null / empty cell unset rows bypass the map

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 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.

TriggerFires whenHot-loop siteSemantics
on: createthe entity activates (becomes live)the activate phasestamped once with the activation turn
on: enter, state: Xthe entity transitions into Xthe transition action, mid-stepfirst 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:

Where timestamp fields are stamped within one turn A turn has three phases: Activate, Step, and Cascade. on:create stamps fire in the Activate phase as pending entities become live; on:enter stamps fire inside the transition action during the Step phase. Both write a turn number directly into the entity and are independent of the event log. One turn (turn = T) 1 · Activate pending → live 2 · Step select event, run actions 3 · Cascade drain messages (no timestamp work) on: create field ← T every activation route on: enter X if unset: field ← T inside transition action entity row (columnar store) ordered_at:int   shipped_at:int independent of logging stamps even with eventLog off

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.

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.

One order's lifecycle mapped to timestamp fields A timeline of turns 1 to 5. The order is created in placed on turn 1 (ordered_at), enters shipped on turn 3 (shipped_at), and enters completed on turn 5 (completed_at). returned_at is never stamped. Each turn maps to a date from 2018-01-01, so the exported row shows 2018-01-02, 2018-01-04, 2018-01-06 and an empty returned_at. turn 1 2 3 4 5 placed shipped completed on: create ordered_at = 1 enter shipped shipped_at = 3 enter completed completed_at = 5 exported order.csv row  (start_time 2018-01-01, 24h/turn) ordered_at=2018-01-02  |  shipped_at=2018-01-04  |  completed_at=2018-01-06  |  returned_at=<empty>

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.

KeyMeaning
namethe field (and exported column) name — choose the full name you want in the output, e.g. ordered_at
on: createstamp once when the entity activates
on: enter + state: Xstamp on first transition into state X
  • Name fields for the output. The exported column uses the field name verbatim — there is no automatic _at suffix, so write ordered_at, not ordered.
  • 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