Design Rationale#
Two program modes: imperative and simulation#
- Decision:
EB supports two valid program shapes — imperative (no
pi, noobs_*, norun()) 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 ofpi,event, oragentsignals 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
fnblocks 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 typersc. 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 + intcompiles to an integer add anddbl * dblto 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 (seedocs/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 producesdbl), 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, andinit, 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 suspension —
timeoutorclaim. Anagentbody 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 afnmay not (AG-22). Theagentkeyword signals process-oriented intent; the suspension points are what make that intent real.
Suspension never below a call frame: agents without coroutines#
- Decision:
timeoutandclaimare legal only inside anagentbody orinitblock (AG-80, CLAIM-40) — never inside afnorpi, and therefore never anywhere down a call chain. They are legal withinif,match, andforinside 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, orfordoes 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
fnthat 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: anytimeoutorclaimmust 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 explicitrelease(res)or until the agent terminates (CLAIM-10, CLAIM-15, CLAIM-70).- Alternatives:
A structured block,
claim(res) block end, releasing automatically atend— the original decision, superseded 12 AUG 2026.- Rationale:
The block form was chosen to eliminate the forget-to-release bug class: with no
breakor early return,endis 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 asrscin parameter lists and type annotations.- Alternatives:
Use one word for both declaration and type, either
resourceorrsc.- Rationale:
Using one word for both would cause syntax highlighters to render it as a type everywhere it appears, including in
resource teller := 1where it reads as a declaration keyword. The abbreviationrsc(parallel tostr,int,dbl,bool) is consistent with EB’s naming convention for types and is unambiguously a type wherever it appears. The declaration keywordresourceis then highlighted as a keyword, and the typerscas a type.
timeout, claim, and init have no colon prefix#
- Decision:
Agent control constructs use plain identifiers —
timeout,claim,init— not the:namecolon-prefix form.- Alternatives:
Use
:timeout,:claim,:initto 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.timeoutandclaimare simulation control, so they follow the same no-colon pattern.initis a declaration block, likescenarioandevent, neither of which uses a colon.
init block for lazy agent spawning#
- Decision:
The
initblock executes as a coroutine at the start of each run. Callingtimeout(dt)insideinitgenuinely suspends it, allowing other agents to run before spawning continues.- Alternatives:
Treat
initas a pre-simulation setup phase that runs to completion before t=0, pre-loading all initial events into the queue.- Rationale:
A pre-simulation
initwould 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. Treatinginitas a coroutine means a loop likefor i = 1, N do spawn(i); timeout(1) endspawns 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 closingendkeyword. All other execution units (event,fn,agent) close withend.- Alternatives:
Require
endfor all execution units includingpi; makeendoptional inpi.- 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
endwould add noise to the common case (pi x := 42 end) with no benefit. Makingendoptional is worse than either: optional syntax creates reader uncertainty (“does thispihave anend?”), writer uncertainty, and parser complexity for no gain. The asymmetry is intentional — it signals the semantic difference:piproduces a value;event,fn, andagentexecute statements.
duration as maximum simulation time#
- Decision:
The
{duration, nruns}argument torun()is defined as the maximum simulation time per run, not a period count.- Alternatives:
Keep
durationas 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 thatrun(){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).
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 formevent name_{idx|lo..hi}. Group declarations are therefore restricted topi,state,resource, andagent; they do not apply toevent.- Alternatives:
Allow
eventin 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_3versusarrival_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_idis 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
eventalso 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
claima 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.