Skip to content

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.

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 five scheduling axes Four declared axes — Clock, Work, Update, Order — each with default, selectable, gated and reserved values, plus a derived Concurrency axis. A regime = one choice on each axis Clock when time advances tick event Work what each agent does turn frame event Update when writes are seen async sync Order iteration within a type random fixed Concurrency derived, not declared serial parallel ← read from the dependency graph by the planner default selectable engine-derived
The default regime {tick, turn, async, random} is highlighted. All values on the four declared axes are selectable; only clock: event + work: turn is rejected (see Coherence rules).

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.

ValueMeaningStatus
tickAdvance by a fixed step (+1) every pass.active (default)
eventJump 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.

ValueMeaningStatus
turnEvery active agent makes one event selection + execution this turn.active (default)
frameProcess entities whose NextWake ≤ turn — multi-rate batching where each entity controls its own step rate via set_next_wake or a state dwell:.selectable
eventProcess 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
ValueMeaningStatus
asyncMutations are visible immediately to later agents in the same turn.active (default)
syncReads 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.

ValueMeaningStatus
randomShuffle each machine’s active-row snapshot every turn (avoids insertion-order artifacts).active (default)
fixedProcess 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:

ValueWhen chosen
serialThe default: one goroutine steps each partition sequentially.
parallelWhen >1 independent machines exist, each runs on its own goroutine (disjoint archetypes); connected clusters stay serialized, and shared state is guarded by sim.mu.

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

convertScheduling resolves the strings to enums (absent block → DefaultRegime()), and validateRegime checks the combination for coherence. See Config YAML → scheduling for the authoring view.

Not every point in the grid is meaningful. validateRegime rejects incoherent or unbuilt combinations with an actionable error rather than silently degrading:

RuleWhy
clock: event is incompatible with work: turnTurn-based work processes every entity every advance — there are no empty ticks to skip, so an event-driven clock adds nothing.

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:

AxisWhere it acts in the turn
ClockThe first stage — how advanceTurn moves the turn (the simulation clock) forward.
UpdateWraps the agent pass: sync adds a SnapshotAll before and a CommitAll after the cascade drains.
ConcurrencyForks the agent pass into serial vs parallel execution (derived, not declared).
OrderShuffles each per-type snapshot, unless fixed.
WorkThe 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.

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 into Partitions; singleton groups with no edges become Independent and may run concurrently.

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.

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.

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.

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.

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.

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:

SiteArms
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 recipientThe destination state’s dwell — the revival path for dwell: forever sleepers.
Pending activationThe activated row’s state dwell (first step comes D turns after activation).
Instant spawnThe 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.

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.

Messages connect entities without direct field reach-across:

  • emit_message sends a Message{NameIdx, Sender, Payload} to a reference slot (target: <ref>) or back to the message sender. Payload values are positional; self.<field> entries are resolved from the sender at send time. A delay schedules future delivery via the time wheel.
  • messages_accepted groups handlers by message, each with a decision strategy. resolveGroup finds 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 call Select(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_reference desugars at load into an emit_message plus an auto-generated machine-level handler on the target that performs the update. So cross-entity mutation is always message-mediated — which is what lets the planner reason about coupling. See Config YAML → actions.