Design Rationale#

Two program modes: imperative and simulation#

Decision:

EB supports two valid program shapes — imperative (no pi, no obs_*, no run()) and simulation (requires all three).

Alternatives:

Require run() in every EB program, even for simple computations.

Rationale:

Many useful tasks — parameter calculations, function libraries, unit checks, exploratory computation — do not need a simulation loop. Requiring run() everywhere would make EB needlessly hostile to these uses. The two modes are structurally disjoint: the presence of pi, event, or agent signals simulation intent, and the compiler enforces that the rest of the simulation machinery (obs_*, run()) is present.

Intrinsics hide simulation algorithms so modelers focus on the model#

Decision:

EB provides high-level simulation intrinsics (e.g., :nhpp) that encapsulate well-known stochastic algorithms.

Alternatives:

Require users to implement these algorithms themselves in fn blocks using lower-level primitives.

Rationale:

EB strives to reduce the need for users to write algorithms. Rather, they focus on the model. For example, instead of having to write your own approach to a Non-Homogeneous Poisson Process, you can use :nhpp(schedule). The algorithm is correct, tested, and invisible — the modeler declares what the arrival process is, not how to sample from it.

Determinism: FIFO ordering, IEEE 754 exact comparison, no epsilon#

Decision:

Events sharing the same scheduled time are dequeued in FIFO (insertion) order. Time comparisons are exact under IEEE 754 with no tolerance or epsilon.

Alternatives:

Priority queues with tie-breaking by event type; approximate time comparison with a small epsilon.

Rationale:

FIFO ordering makes simulation traces reproducible and predictable — the order in which events fire at the same time is determined entirely by the order they were scheduled, not by implementation details of a priority queue. Exact IEEE 754 comparison follows from the same principle: given identical inputs and seed, results must be bit-for-bit identical across runs and implementations.

Per-run PRNG reseed via a keyed-hash derivation (DET-30, EXEC-30)#

Decision:

The PRNG is re-seeded at the start of every run with runseed(r) = (simseed + r * 2654435761) mod 2^64, hashed through splitmix64 to produce the initial xoshiro256** state. The exact formula and generator are pinned by DET-30.

Alternatives:

One continuous stream across all runs (no per-run reseed); the xoshiro256** jump function, advancing the master state by 2^128 draws per run.

Rationale:

The requirement came out of the GPU experiments (aa30-gpu0, aa31-gpu1): to execute runs in parallel — one GPU thread per run — each run’s starting PRNG state must be computable independently, without knowing how many draws any other run consumed. A single continuous stream makes run r’s results depend on the draw counts of runs 1..r-1, which forbids parallel execution and means an early-terminated run (stop_r) perturbs every run after it.

The keyed-hash derivation is chosen over the jump function, which the GPU proofs-of-concept originally used, for two reasons. First, it is random-access: any thread computes its own starting state directly from (simseed, r), whereas reaching run r by jumping requires r sequential jump applications on the host. Second, it generalizes: keying can extend to finer stream ownership — per (simseed, sampler, r) — which the jump’s fixed 2^128 stride cannot express. The trade-off is that non-overlap of the per-run streams is probabilistic (splitmix64 scrambling) rather than guaranteed by construction; with 2^64 seed space and typical run counts, overlap probability is negligible.

Pinning the exact constant, hash, and generator makes the per-run streams part of the language contract: identical seeds produce bit-identical results on every backend, so a model debugged on the VM replays exactly on native or GPU (DET-10).

Static scalar types, no aggregates#

Decision:

EB is statically typed with four scalar primitives (int, dbl, str, bool) plus the opaque resource type rsc. Every variable’s type is fixed at declaration (TYPE-20) and inferred by default. There are no aggregate types — no records, structs, or arrays.

Alternatives:

Dynamic typing (a single number type with runtime tags); a richer static type system (records, unions, generics).

Rationale:

Types in EB pay in exactly two currencies — representation and numeric correctness — and in neither does EB need more than scalars.

Representation. Because the compiler knows the machine representation of every value, int + int compiles to an integer add and dbl * dbl to a floating-point multiply, with no runtime tag checks and no boxing. Fixed-size values are what make the memory model work: one fixed-size state record per group entity (GRP-10), agent locals at fixed byte offsets (see docs/source/rewrite.rst), and history and observation buffers as flat native arrays. Dynamic tagged values would roughly double memory and add a branch per access — and GPU compilation (--target gpu) would be infeasible rather than merely slower.

Numeric correctness. A numeric type bug in a Monte Carlo model does not crash; it produces a plausible wrong distribution — the worst failure mode, because randomized output cannot be verified by inspection. TYPE-40 (no silent narrowing), TYPE-50 (/ always produces dbl), and TYPE-60 (integer division is the explicit //) turn this class of silent corruption into compile-time errors. Static types also decide at compile time whether each operation is int or IEEE 754 double, forcing every backend — VM or native — to compute identical results; this underwrites DET-10.

The benefits richer type systems provide are architectural — refactoring safety, API contracts across large codebases — and do not apply to EB. EB models scale by data, not by code: a three-million-entity model is a handful of group and agent declarations backed by millions of state records (GRP-10), not millions of lines with deep abstraction layers. With no aggregate shapes to describe and no large API surface to protect, the type system is minimal and stays so. Most of the value comes from the type rules, not annotations: inference (:=) does the work, and explicit types are needed only at abstraction boundaries (function parameters, FN-20; forward declarations).

Agents as processes alongside event handlers#

Decision:

EB adds a process-based simulation style via agent (agents), resource (resources), claim, timeout, and init, coexisting with the existing event-handler (event/sched) style.

Alternatives:

Extend only the event-handler model with indexed state arrays and ID-threading through handler arguments; or replace event handlers with agents entirely.

Rationale:

Process-based models let modelers write sequential logic — “arrive, wait for server, be served, leave” — rather than inverting control into separate handlers for each state transition. This is more readable for entity-flow models (queuing networks, agent-based models) where the narrative of a single entity’s lifecycle is the natural unit of thought. The event-handler style remains appropriate for models naturally expressed as state transitions (reliability, Markov chains). Both styles compile to the same underlying event queue and can coexist in one program.

What makes an agent genuinely an agent is suspensiontimeout or claim. An agent body that contains neither never suspends: it completes in zero simulation time when dequeued. Even then it is not a function call — its execution is deferred through the event queue, and it may mutate state variables, which a fn may not (AG-22). The agent keyword signals process-oriented intent; the suspension points are what make that intent real.

Suspension never below a call frame: agents without coroutines#

Decision:

timeout and claim are legal only inside an agent body or init block (AG-80, CLAIM-40) — never inside a fn or pi, and therefore never anywhere down a call chain. They are legal within if, match, and for inside an agent body.

Alternatives:

Allow suspension anywhere in an agent’s dynamic extent, as general coroutines do, so that helper functions could encapsulate waiting patterns.

Rationale:

The property that buys the implementation everything is that an agent never suspends with call frames beneath it. Its saved execution context is then nothing more than which instruction runs next plus its local variables — no captured call stack, no coroutine machinery, no stack switching. This is what lets EB agents compile to flat native code and scale to large populations at the cost of one small heap record each.

Note that nesting inside if, match, or for does not threaten this. Those are control flow within a single body, not call frames, so a suspension inside a loop is an ordinary back edge in the agent’s segment graph — an agent is a cyclic graph of segments cut at suspension points, not a straight-line sequence. An earlier version of this document stated the decision as “top level only” and rested the no-coroutines argument on it; that was stronger than necessary, and stronger than the compiler enforces.

The restriction that does the work preserves the purity rules: a fn that could suspend would no longer be a pure computation (FN-30), and every call site would need to be suspension-aware. The cost is that helpers cannot hide waiting: any timeout or claim must be written in the agent body where it occurs. That is arguably a readability win — an agent’s complete waiting behavior is visible in one place, the body itself.

claim and release are separate statements#

Decision:

Resource acquisition is claim(res), and the unit is held until an explicit release(res) or until the agent terminates (CLAIM-10, CLAIM-15, CLAIM-70).

Alternatives:

A structured block, claim(res) block end, releasing automatically at end — the original decision, superseded 12 AUG 2026.

Rationale:

The block form was chosen to eliminate the forget-to-release bug class: with no break or early return, end is the only exit path, so auto-release is unconditional and resource lifetime is lexically obvious. That reasoning is sound and the guarantee was real.

It was given up because it cannot express crossing lifetimes. Hand-over-hand acquisition — hold A, acquire B, release A — is routine in queueing and manufacturing models, and lexical nesting can only express lifetimes that nest. A scope stack has no way to represent two overlapping holds where neither contains the other. Modelers hitting this had no recourse within the language.

The bug class does come back. Two things bound it: releasing a unit the agent does not hold is a runtime error rather than silent corruption (CLAIM-15), and an agent that terminates holding units returns them (CLAIM-70), so a leak costs capacity for the agent’s remaining lifetime rather than for the rest of the run. Neither is as strong as the lexical guarantee, and that is the price of expressiveness here.

resource as declaration keyword; rsc as the type name#

Decision:

Resources are declared with the keyword resource (resource name := N) but typed as rsc in parameter lists and type annotations.

Alternatives:

Use one word for both declaration and type, either resource or rsc.

Rationale:

Using one word for both would cause syntax highlighters to render it as a type everywhere it appears, including in resource teller := 1 where it reads as a declaration keyword. The abbreviation rsc (parallel to str, int, dbl, bool) is consistent with EB’s naming convention for types and is unambiguously a type wherever it appears. The declaration keyword resource is then highlighted as a keyword, and the type rsc as a type.

timeout, claim, and init have no colon prefix#

Decision:

Agent control constructs use plain identifiers — timeout, claim, init — not the :name colon-prefix form.

Alternatives:

Use :timeout, :claim, :init to signal that they are runtime-provided.

Rationale:

The colon prefix in EB marks value-producing runtime intrinsics (:rande, :print, :nhpp). Simulation control statements — sched, halt, stop_r, stop_s — are also runtime-provided but carry no colon, because they affect execution flow rather than producing values. timeout and claim are simulation control, so they follow the same no-colon pattern. init is a declaration block, like scenario and event, neither of which uses a colon.

init block for lazy agent spawning#

Decision:

The init block executes as a coroutine at the start of each run. Calling timeout(dt) inside init genuinely suspends it, allowing other agents to run before spawning continues.

Alternatives:

Treat init as a pre-simulation setup phase that runs to completion before t=0, pre-loading all initial events into the queue.

Rationale:

A pre-simulation init would force all N initial agents to be enqueued before any of them fire — acceptable for small N, but a memory and initialisation cost proportional to N for large populations. Treating init as a coroutine means a loop like for i = 1, N do spawn(i); timeout(1) end spawns one agent, suspends, lets that agent begin running, then resumes to spawn the next. At any moment only a small number of events are live in the queue, regardless of N.

pi has no end; the asymmetry is intentional#

Decision:

Periodic item bodies (pi) have no closing end keyword. All other execution units (event, fn, agent) close with end.

Alternatives:

Require end for all execution units including pi; make end optional in pi.

Rationale:

A periodic item body is expression-valued: the final expression IS the value and therefore the natural terminator. There is no open-ended statement sequence to close explicitly. Requiring end would add noise to the common case (pi x := 42 end) with no benefit. Making end optional is worse than either: optional syntax creates reader uncertainty (“does this pi have an end?”), writer uncertainty, and parser complexity for no gain. The asymmetry is intentional — it signals the semantic difference: pi produces a value; event, fn, and agent execute statements.

duration as maximum simulation time#

Decision:

The {duration, nruns} argument to run() is defined as the maximum simulation time per run, not a period count.

Alternatives:

Keep duration as a strict period count meaningful only for periodic item programs; require separate syntax for time-bounded event and agent programs.

Rationale:

Periodic item programs advance time by exactly 1 per period, so “number of periods” and “maximum simulation time” are equivalent for them — no existing programs are affected. The generalised definition makes a single run() syntax work uniformly across all program modes (PI, EV, AG, and hybrids), and matches the intuition that run(){100, 10} means “simulate 100 time units, 10 times.”

Functions receive periodic item values as parameters, not by name (FN-35)#

Decision:

A function body may not reference a periodic item by name. A periodic item’s value may only enter a function through an explicitly typed parameter, passed by the caller.

Alternatives:

Allow function bodies to reference any in-scope periodic item by name, as they can reference ordinary variables.

Rationale:

A function that references periodic items by name has hidden dependencies on specific module-level declarations. Those dependencies are invisible at the call site — the reader must trace into the function body to know what simulation state it touches, and the compiler must do the same to verify declaration-order constraints (HIST-60). Requiring values to be passed as arguments makes every periodic item dependency explicit at the call site, keeps functions module-agnostic and reusable across programs, and preserves the clean separation between periodic items (model declarations) and functions (pure computations on values).

Group declarations: shared body, N independent state records#

Decision:

A group declaration (pi name_{idx|lo..hi}) compiles the body once as shared parameterized bytecode and allocates one independent state record per entity. The compiler does not emit N copies of the body.

Alternatives:

Compile-time textual expansion to N separate declarations (N body copies); a runtime array or list type; a preprocessor macro.

Rationale:

Many simulation models require a large number of structurally identical entities — voters in a voter model, cells in a spatial grid, servers in a pool. Writing each declaration explicitly does not scale and invites copy-paste errors.

Naïve compile-time expansion (N body copies) has the same problem in a different form: if the body is long, memory usage grows as N × body-size. The shared-body design keeps memory proportional to N × (state per entity) — just the history slots and current value — which makes N in the millions or billions practical where expansion would be prohibitive.

A runtime array or list type would impose uniform element types and access syntax that conflicts with EB’s design: each entity in a group is a first-class declaration with its own history, its own observations, and its own guard expression. Hiding entities inside an array loses those capabilities.

The {idx|lo..hi} notation echoes the {duration,nruns} braces of run() — braces consistently signal arguments that are resolved before simulation begins, whether that is a run configuration or a group expansion range. The {...} wrapper is also required at every reference site inside the body, making substitution points visually distinct from ordinary identifiers and eliminating the ambiguity that would arise if bare voter_id could mean either a literal name or “the voter at index id.”

sched arguments instead of event group declarations#

Decision:

Event handlers receive identity through arguments passed at sched() time — sched(dt, handler(id)) — rather than through group declarations of the form event name_{idx|lo..hi}. Group declarations are therefore restricted to pi, state, resource, and agent; they do not apply to event.

Alternatives:

Allow event in group declarations, producing N separately named handlers (arrival_1, arrival_2, …) with shared source code; or provide both mechanisms side by side.

Rationale:

Groups exist to give N entities independent persistent state — separate history arrays, separate resource pools, separate per-period values. That is the capability that would be prohibitively verbose to hand-write.

Event handlers have no persistent state (EV-40: all locals are ephemeral). A group of event declarations would therefore yield N identically-behaved callables with no independent storage to justify the expansion. The only thing such a group would add is routing identity — the ability to schedule arrival_3 versus arrival_7. Passing an argument achieves the same routing without any of the group machinery:

event arrival(entity_id: int)
  ...
end

sched(dt, arrival(3))
sched(dt, arrival(7))

The argument is evaluated at sched() call time and stored in the event record. When the event fires, entity_id is bound in the handler’s local scope, indistinguishable from a normal parameter. This is the same mechanism agents already use at spawn time (agent_name(args)), so it requires no new concepts from the modeler.

Keeping groups out of event also keeps the group grammar unambiguous: group identity ({idx|lo..hi}) is always a compile-time quantity derived from a range, while event argument values are runtime values. Mixing the two would require distinguishing compile-time-fixed group members from dynamically-supplied arguments, complicating both the language and the compiler with no clear benefit.

FIFO wait queue for resource claims#

Decision:

Agents waiting to claim a resource are queued in FIFO order and resumed in that order as units become available.

Alternatives:

Random selection among waiting agents; priority-based selection.

Rationale:

FIFO is consistent with EB’s existing event-ordering principle (TIME-30), which dequeues simultaneous events in insertion order. Applying the same discipline to resource wait queues keeps the model predictable and reproducible. The agent that has waited longest is always served next, which also prevents starvation under sustained load.