Scheduler
Scheduling is the engine’s job. You declare a regime — a small set of semantic choices — and the engine derives the execution order, the concurrency plan, and how and when effects become visible. This page covers the regime and its axes, how machines are partitioned for concurrency, synchronous update, and the cascade queue and time wheel that deliver messages.
The regime — one choice per axis
Section titled “The regime — one choice per axis”A regime is one choice on each of four declared axes, plus a fifth axis — concurrency — that the engine derives and never lets you set. The modeller picks the semantics; the engine works out the mechanics.
The declared axes
Section titled “The declared axes”Clock — how simulation time advances
Section titled “Clock — how simulation time advances”The turn IS the simulation clock — there is no second counter. The clock axis
decides how advanceTurn moves it forward each pass, and a --clock CLI flag can
override the declared axis for a single run.
| Value | Meaning | Status |
|---|---|---|
tick | Advance by a fixed step (+1) every pass. | active (default) |
event | Jump to the earliest due work — the next entity wake (the wake queue) or the next delayed envelope (the time wheel) — skipping the empty turns between. Bounded to +1 by pending activations and undelivered cascade work. Requires every state to resolve a dwell (rule D7: declared, forever, or defaulted to passive_on_entry). | selectable |
Even under tick, message delay still works: a delayed emit_message is placed
on the time wheel and drained into the cascade queue on the turn it comes due. A
state’s dwell: (fixed / uniform / normal / exponential / forever) is sampled
whenever the entity enters the state or completes a step in it, arming
NextWake = turn + D.
Work — what each agent does per advance
Section titled “Work — what each agent does per advance”| Value | Meaning | Status |
|---|---|---|
turn | Every active agent makes one event selection + execution this turn. | active (default) |
frame | Process entities whose NextWake ≤ turn — multi-rate batching where each entity controls its own step rate via set_next_wake or a state dwell:. | selectable |
event | Process entities whose NextWake ≤ turn. Entities control their own eligibility via dwell: / set_next_wake; those with NextWake > turn are skipped for event selection. Message delivery via the cascade is unconditional. | selectable |
Update — when writes become visible
Section titled “Update — when writes become visible”| Value | Meaning | Status |
|---|---|---|
async | Mutations are visible immediately to later agents in the same turn. | active (default) |
sync | Reads return frozen pre-advance state; writes accumulate and commit atomically at the advance boundary. Increments apply as deltas, so simultaneous writes to one cell all land. Compatible with all work modes. | selectable |
See Synchronous update below for the snapshot/commit mechanics.
Order — iteration order within a type
Section titled “Order — iteration order within a type”| Value | Meaning | Status |
|---|---|---|
random | Shuffle each machine’s active-row snapshot every turn (avoids insertion-order artifacts). | active (default) |
fixed | Process in insertion order — the escape hatch for order-sensitive, order-stable setups. | selectable |
Concurrency — engine-derived, never declared
Section titled “Concurrency — engine-derived, never declared”Concurrency is not a YAML field. At load time the planner builds an
ExecutionPlan: it unions machines connected by spawn, message, or reference edges
into Partitions, and flags singletons with no edges as Independent.
selectExecutor then chooses a turnExecutor:
| Value | When chosen |
|---|---|
serial | The default: one goroutine steps each partition sequentially. |
parallel | When >1 independent machines exist, each runs on its own goroutine (disjoint archetypes); connected clusters stay serialized, and shared state is guarded by sim.mu. |
Declaring a regime
Section titled “Declaring a regime”Add an optional scheduling: block at the top of a config. Omit it and you get the
default regime.
scheduling: clock: tick # tick | event work: turn # turn | frame | event update: async # async | sync order: random # random | fixedconvertScheduling resolves the strings to enums (absent block →
DefaultRegime()), and validateRegime checks the combination for coherence. See
Config YAML → scheduling for the authoring view.
Coherence rules
Section titled “Coherence rules”Not every point in the grid is meaningful. validateRegime rejects incoherent or
unbuilt combinations with an actionable error rather than silently degrading:
| Rule | Why |
|---|---|
clock: event is incompatible with work: turn | Turn-based work processes every entity every advance — there are no empty ticks to skip, so an event-driven clock adds nothing. |
How the axes shape a turn
Section titled “How the axes shape a turn”Each axis acts as a switch at a specific stage of a turn. The full sequence is the eight-stage turn diagram on the Process Flow page; here is what each axis governs:
| Axis | Where it acts in the turn |
|---|---|
| Clock | The first stage — how advanceTurn moves the turn (the simulation clock) forward. |
| Update | Wraps the agent pass: sync adds a SnapshotAll before and a CommitAll after the cascade drains. |
| Concurrency | Forks the agent pass into serial vs parallel execution (derived, not declared). |
| Order | Shuffles each per-type snapshot, unless fixed. |
| Work | The per-entity step itself. Under turn, every active entity makes one selection + execution. Under frame, only entities whose NextWake ≤ turn are stepped — each entity controls its own step rate via set_next_wake or a state dwell:. Under event, the same wake filtering applies; a dwell re-arm or set_next_wake makes an entity eligible for stepping again. Message delivery via the cascade is unconditional. |
Derived order & partitions
Section titled “Derived order & partitions”At load, finalizeSchedule (planner.go) builds Config.Plan:
DetectCycles— a spawn cycle is fatal (unbounded creation); a message cycle is a warning (legitimate request/reply, guarded at runtime by the cascade guard).BuildExecutionPlan— union-find over spawn, message, and reference edges groups machines intoPartitions; singleton groups with no edges becomeIndependentand may run concurrently.
Synchronous update (simultaneity)
Section titled “Synchronous update (simultaneity)”In sync mode the store takes a SnapshotAll() before the agent pass and a
CommitAll() after the cascade drains. Reads return frozen pre-advance values
(fields, state, and references); writes accumulate in the live columns and become
visible atomically at commit. The snapshot/commit sandwich wraps whatever the work
mode does, so sync is compatible with all three work modes — turn, frame, and
event. AddInt/AddFloat apply deltas (not last-writer-wins), so simultaneous
increments to one cell all land — what makes order-independent models correct. The
async hot path is untouched: a single if snapshot != nil branch.
scenarios/PrisonersDilemma declares {tick, turn, sync, random} for a
commit-then-reveal round.
The cascade queue: drain to quiescence
Section titled “The cascade queue: drain to quiescence”emit_message does not recurse. It pushes an Envelope{Recipient, Depth, When, Msg}
onto the cascadeQueue (a reusable FIFO ring, allocated once per partition) when due
immediately, or onto the timeWheel (a min-heap keyed by When) when the action
carries a delay. At the end of every turn, drainCascade pops the ring until empty
— delivering messages, letting handlers enqueue more, processing those in the same
pass — until the system reaches quiescence. deliverEnvelope calls resolveGroup
to find the authoritative MessagesAccepted group, builds a condition mask, routes
through Select(), and executes the chosen handler’s actions in the recipient’s
context, logging message_delivered / message_dropped.
The clock & time wheel
Section titled “The clock & time wheel”advanceTurn advances the turn — the simulation clock: +1 under tick; under
event it jumps to the earliest due work — the minimum of the next entity wake (from
the wake queue) and the earliest When on the time wheel. At the start of each turn,
timeWheel.drainDue(turn, &cascade) moves now-due future envelopes into the cascade
queue. Each entity carries a NextWake value; under the tick clock the step walk
skips entities whose NextWake > turn — the hook for multi-rate (“different time
scales in one sim”) scheduling.
The wake-queue system
Section titled “The wake-queue system”Under clock: event each partition owns a wake queue: the structure that answers
“when is the next sleeping entity due, and who is due now?” so the clock can jump
straight to the next due work and an executed turn can step only the entities that
are due. It exists only under the event clock — under tick the field is nil and
that hot path is byte-for-byte unchanged.
Column is truth, queue is index
Section titled “Column is truth, queue is index”The per-entity NextWake column in the archetype store remains the single source of
truth. The queue is a disposable index over it: entries are (handle, when)
values, and every pop validates the entry against the live store. An entry is stale
when the entity is dropped or passive, when its live NextWake no longer equals the
entry (a re-arm superseded it), or when its time has already passed. Stale entries
are simply discarded — no implementation ever needs cancel or decrease-key, which is
precisely what makes the implementations interchangeable behind one small seam:
type wakeScheduler interface { Push(h EntityHandle, when int64) // an arm: NextWake was set to a future, non-forever turn NextDue(st EntityStore, now int64) int64 // earliest still-valid FUTURE wake, or -1 PopDue(st EntityStore, now int64, buf []wakeEntry) []wakeEntry // harvest the due batch Len() int}scheduling.wake_queue selects the implementation (default and v1: heap, a binary
min-heap in the same container/heap idiom as the time wheel; wheel — a
hierarchical timing wheel for very large populations — is reserved and rejected until
it lands). Because correctness never depends on the implementation (the contract:
never report a time later than the true earliest valid wake — undershooting only
costs +1 turns, and even an overshoot cannot lose a wake, since any later turn still
satisfies NextWake ≤ turn), comparing implementations is purely a benchmark
exercise.
Arming: the setWake choke point
Section titled “Arming: the setWake choke point”Every runtime NextWake write goes through one choke point, setWake, which writes
the column and pushes the queue entry — so every armed wake is queue-visible by
construction. Wakes are armed by a state’s dwell: (sampled on state entry and on
every step that ends still in the state) or an explicit set_next_wake action (which
wins over the dwell for that step, rule D5). The arming sites are exactly:
| Site | Arms |
|---|---|
End of Step (all three exit paths: stay, no-selection, event fired) | The (possibly new) state’s dwell, derived from the executed actions — never a store read-back, which the sync snapshot would defeat. |
deliverEnvelope, when a handler transitioned the recipient | The destination state’s dwell — the revival path for dwell: forever sleepers. |
| Pending activation | The activated row’s state dwell (first step comes D turns after activation). |
| Instant spawn | The spawn state’s dwell at creation, so the spawn is queue-visible without a jump bound. |
Forever-sleepers never enter the queue: only a handler transition can revive one, and
that revival re-arms through the same choke point. The D7 resolution (every state on a
stepped machine declares a dwell, declares passive_on_entry, or is defaulted to it
at load) is what makes the invariant total: under the event clock every live entity is
at all times queue-armed, forever-asleep, or passive. A step that somehow ends
unarmed (a validation hole) hits a loud runtime backstop — warn and arm turn+1 — so
an entity degrades to stepping every turn rather than silently hibernating.
The bounded jump and O(due) turns
Section titled “The bounded jump and O(due) turns”advanceTurn jumps to min(wakeQ.NextDue, futureQueue.peekWhen), bounded to +1
whenever something must run next turn but is invisible to both queues: pending
activations (they activate — and arm — at the next turn open) or undelivered cascade
work. A quiescent world falls back to +1. At turn open the queue’s due batch
(PopDue) becomes the turn’s worklist directly: the persistent worklist’s O(live)
compact/enrol/skip-walk is not maintained at all under the event clock, so an executed
turn costs O(due), not O(live) — the property BenchmarkEventClockScale guards
(flat cost per due entity across 10k→1M live sleepers). The walk re-checks each
member’s live NextWake before stepping, so a handler that re-arms a later due member
mid-turn still defers it, exactly as the tick-clock scan would. Under order: fixed
the event-clock step order is wake/pop order rather than activation order; a targeted
request on a sleeping entity reports the same typed wake-deferred outcome (HTTP 409) as
under tick.
Messaging & handlers
Section titled “Messaging & handlers”Messages connect entities without direct field reach-across:
emit_messagesends aMessage{NameIdx, Sender, Payload}to a reference slot (target: <ref>) or back to the messagesender. Payload values are positional;self.<field>entries are resolved from the sender at send time. Adelayschedules future delivery via the time wheel.messages_acceptedgroups handlers by message, each with adecisionstrategy.resolveGroupfinds the authoritative group (state-level first; if any state-level group matches the message, machine-level is never consulted). Handler dispatch mirrors event selection: build a condition mask over the group’s handlers, resolve a probability row (for stochastic groups), then callSelect(kind, mask, row). If no handler is selected, the message is dropped (log-only, no sender notification). Handlers run the same action set as events, in the recipient’s context, with access to the payload (value_from_payload) and sender (value_from_sender).update_referencedesugars at load into anemit_messageplus an auto-generated machine-level handler on the target that performs theupdate. So cross-entity mutation is always message-mediated — which is what lets the planner reason about coupling. See Config YAML → actions.