Semantic Rules#

This document is normative. Where it and spec/eb.ebnf disagree with any older specification, this document wins. The executable suite in tests/suite pins the behavior described here.

Summary of Rules

Agents          │ AG-i    │ Coroutines, spawn, local scope, timeout         │
Resource Claims │ CLAIM-i │ Acquire, block, explicit release, wait queue    │
Context Values  │ CTX-i   │ :p, :t, :r, read-only                           │
Declaration/    │ DECL-i  │ := vs =, redeclaration                          │
Assignment      │         │                                                 │
Determinism     │ DET-i   │ Overall simulation determinism                  │
Events          │ EV-i    │ Atomic execution, scheduling, local scope       │
Execution Model │ EXEC-i  │ Program sections, run(), independence, reset    │
Functions       │ FN-i    │ Zero-time, no side effects, local scope         │
Group Decls     │ GRP-i   │ Expansion, index scope, compile-time vs runtime │
Guards          │ GUARD-i │ Expressions, statements, default arm,           │
                │         │ type consistency                                │
History Access  │ HIST-i  │ [-k] notation, zero defaults, explicit init     │
If Statements   │ IF-i    │ if/else, bool condition, sugar for match        │
Init Block      │ INIT-i  │ Per-run entry point, singleton                  │
Intrinsics      │ INTR-i  │ Naming, RNG, stats, math, I/O, boxplot, huge,   │
                │         │ NHPP                                            │
Periodic Items  │ PI-i    │ Per-period eval, declaration order, immutability│
Import/Export   │ MOD-i   │ RESERVED — not implemented in this version      │
Model           │ MODEL-i │ Model overview                                  │
Observations    │ OBS-i   │ obs_p/i/r/s, aggregation                        │
Resources       │ RC-i    │ Capacity, availability, rsc type, reset         │
Scenarios       │ SCEN-i  │ Assignment-only, inheritance, application       │
Scheduling/     │ SCHED-i │ sched, stop_r, stop_s, halt                     │
Control         │         │                                                 │
Scoping         │ SCOPE-i │ Modules, execution units, no shadowing          │
State Variables │ STATE-i │ Persistence, event-only mutation                │
Timing          │ TIME-i  │ Periods vs events, hybrid ordering, FIFO,       │
                │         │ IEEE 754                                        │
Types           │ TYPE-i  │ Primitives, promotion, division, operators, rsc │
Zero Values     │ ZERO-i  │ Default initialization                          │

Rule prefixes are historical in two cases: PI-i rules were LI-i when periodic items were called lineitems (li), and EV-i rules were EH-i when events were called event handlers (eh). The prefixes were renamed with the keywords; AG-i and RC-i already matched agent and resource.

Agents#

AG-10: An agent (agent) defines a named process template. Invoking

an agent by name with arguments spawns a new independent execution instance scheduled to begin at the current simulation time.

AG-15: The parameter list may be omitted for a parameterless agent:

agent worker is equivalent to agent worker().

AG-20: Agents execute as interleaved processes with coroutine-like

semantics: an agent runs until it reaches a suspension point (timeout or a blocking claim), at which point it suspends and other scheduled events may execute. The suspension mechanism is an implementation choice, not part of the language.

AG-22: An agent body that contains neither timeout nor claim never

suspends: once dequeued, it runs to completion in zero simulation time. It is not equivalent to a function call: its execution is deferred until the spawning context completes its current atomic step or reaches a suspension point (see AG-30), and unlike a fn it may mutate state variables. Spawning such an agent is a legitimate way to defer a zero-time action into the current time’s FIFO queue.

AG-25: Agents may be spawned from the init block, from events, and

from other agents. Spawning an agent from a fn or pi body is a compile-time error (consistent with the no-side-effect rule for functions, FN-30, and the purity of periodic items, PI-40).

AG-30: Spawning an agent schedules it at the current simulation time and

returns immediately; the spawning context does not wait for the spawned agent to complete. Multiple instances of the same agent may be active simultaneously.

AG-40: Variables declared inside an agent are local and ephemeral. They

are discarded when the agent terminates.

AG-50: Agents may mutate state variables, call functions (fn), spawn

other agents, and use timeout and claim. The same mutation rules as events apply: only state variables may be mutated, not module-level variables.

AG-55: An agent body may not read the value of a periodic item. Bridge

through a state variable or an observation instead.

AG-60: Agents do not return values. An agent invocation is a statement,

not an expression.

AG-70: An agent that reaches the end of its body terminates silently. No

event is fired on termination. Units of any resource still held by the agent are reclaimed at termination, and a resource scan follows (CLAIM-70).

AG-80: timeout(dt) suspends the current agent for dt time units,

rescheduling it to resume at current_time + dt. dt must be non-negative; a negative value is a compile-time error when constant, a run-time error otherwise. timeout is valid only inside an agent body or init block; using it in an event, fn, or pi is a compile-time error.

Resource Claims#

CLAIM-10: claim(res) acquires one unit of resource res. It is a

statement, not a block: the unit is held until an explicit release(res), or until the holding agent terminates (AG-70).

CLAIM-15: release(res) returns one unit of res held by the current

agent. Releasing a resource the agent does not hold is a runtime error — one lookup that catches double release, release without claim, and release of another agent’s unit alike.

CLAIM-17: The rationale for separating the two is that resource lifetimes

cross. Hand-over-hand acquisition — hold A, acquire B, release A — is common in real models, and a scope stack cannot express it. The cost is that holding is no longer bounded by syntax; see CLAIM-70.

CLAIM-20: If a unit of res is available when claim is reached, the

agent acquires it immediately and continues without suspending.

CLAIM-30: If no unit is available, the agent suspends and is placed in a

wait queue for res. Waiting agents are resumed in FIFO order as units become available. A reduction in capacity does not disturb the queue (RC-70).

CLAIM-40: claim and release are valid only inside an agent body

or init block; using either in an event, fn, or pi is a compile-time error.

CLAIM-50: An agent may hold units of several resources at once, acquired and

released in any order. Deadlock from circular waiting is possible and is the programmer’s responsibility to avoid.

CLAIM-60: A claim is a suspension point and therefore cuts an agent into

segments; a release is not and does not (see AG-20). A release does make units available, so the segment containing it has a resource scan appended — the same treatment a capacity raise receives (RC-60).

CLAIM-70: An agent that terminates while still holding units returns them at

termination (AG-70). Without that rule an agent leaking a unit would remove capacity permanently and block every waiter in silence.

Context Values#

CTX-10: :p is the current period (int, 1-indexed).

CTX-15: :i is a synonym for :p.

CTX-20: :t is the current event time (dbl).

CTX-30: :r is the current run number (int, 1-indexed).

CTX-40: Context values are read-only. Assignment to a context value is a

compile-time error.

Declaration and Assignment#

DECL-10: := declares a new variable. = assigns to an existing

variable. These are distinct operations.

DECL-20: A variable must be declared exactly once. Redeclaration is a

compile-time error.

DECL-30: Assignment to an undeclared variable is a compile-time error.

DECL-40: := must not be used after a variable’s initial declaration.

name :type = e declares with an explicit type; name :type forward-declares, completed by a later =.

Determinism#

DET-10: Given identical module code, identical inputs, identical scenario,

and identical random seed, simulation execution is deterministic. All observable results are reproducible.

DET-20: Within a run, event handling and periodic evaluation are

single-threaded and strictly sequential. No intra-run parallelism is permitted.

DET-30: Runs may be executed in parallel without affecting determinism.

Each simulation instance has a simulation seed (simseed). The value of simseed is either explicitly set by the user via :randseed() or generated by the runtime at simulation start.

For run index r (1-indexed, in program order of the run() statement), the run-specific seed is:

runseed(r) = (simseed + r * 2654435761) mod 2^64

The PRNG is xoshiro256**. Its state at the start of run r is obtained by seeding splitmix64 with runseed(r) and taking four successive splitmix64 outputs as the initial xoshiro256** state.

This derivation is a pure, random-access function of (simseed, r): a run’s starting state does not depend on execution order, scheduling, or how many random numbers other runs consumed. Every conforming implementation — VM, native, or GPU — must use exactly this generator and derivation, so that identical seeds yield bit-identical streams on every backend (DET-10).

Events#

EV-10: An event (event) is an entry point into the simulation kernel.

It executes atomically when dequeued from the event queue.

EV-15: The parameter list may be omitted for a parameterless event:

event tick is equivalent to event tick().

EV-20: Only event-declared units may be scheduled with sched().

Passing a fn to sched() is a compile-time error. Arguments may be passed at schedule time: sched(dt, handler(a, b)). They are evaluated immediately and stored in the event record. When the handler fires, they are bound to its declared parameters in order, as if called with those values.

EV-25: Direct invocation of an event by name is a compile-time error. An

event enters the simulation only through sched().

EV-30: An event may mutate state variables, call helper functions

(fn), declare and modify local variables, execute statements, schedule further events, spawn agents (see AG-25), and change resource capacity (RC-60).

EV-35: An event body may not read the value of a periodic item. Bridge

through a state variable or an observation instead.

EV-40: Variables declared inside an event are local and ephemeral. They

are discarded when the handler returns.

Execution Model#

EXEC-05: A program has two sections: model declarations, then the

contextual keyword exec, then execution items (scenarios and statements) in program order. Statements — including run(), sched() registrations, and :print calls — are valid only after exec. A file without exec is a pure model. exec is contextual: it marks the section only as a bare word at top level, and remains usable as an identifier elsewhere.

EXEC-08: Model-section declarations may appear in any order. Module

binding initializers are evaluated in dependency order; a cycle among them is a compile-time error.

EXEC-10: run(scenario, label) {duration, nruns} initiates a simulation.

scenario selects parameter values; label identifies output. duration is the maximum simulation time per run (or :huge for a run with no time horizon; see EXEC-15); nruns is the number of independent runs. For pi-only programs, duration equals the number of periods (each period advances time by 1). For programs with events or agents, the run ends when simulation time would exceed duration or the event queue is empty, whichever comes first.

EXEC-15: duration may be :huge (positive infinity, INTR-75). Such a

run has no time horizon: it ends when a stop_r fires (SCHED-20), halt executes (SCHED-40), or the event queue is empty, whichever comes first.

EXEC-16: A no-horizon pi model cannot end via queue exhaustion (see

TIME-45), so it must be able to stop: if the duration expression is the compile-time constant :huge and the model contains pi declarations but no stop_r or halt, it is a compile-time error. When the duration is not compile-time constant, termination is the programmer’s responsibility.

EXEC-20: scenario and label are optional. If scenario is

omitted, module-level default values are used. A label may only be supplied together with a scenario.

EXEC-30: Each run is independent. All pi values, state variables, local

variables, and resource availability are reset to their initial values at the start of each run. Any agent instances active at run end are discarded. The PRNG is re-seeded at the start of each run with a run-specific seed derived deterministically from the simulation seed and the run index (see DET-30).

EXEC-32: Module-level values that are set for a scenario are kept for the

duration of the run.

EXEC-35: :randseed(seed) is valid only as an exec statement, at most

once per program; a second occurrence is a compile-time error. It sets the simulation seed used by subsequent run() statements, so two identical run() statements after one :randseed produce identical results (DET-30, common random numbers).

EXEC-36: An exec-level sched(dt, handler(args)) registers an initial

event: it is applied at the start of every run of every subsequent run() statement, in program order, after init (INIT-20).

EXEC-40: Within a run, periodic items evaluate once per period in

declaration order (see PI-10). Observations are recorded at the boundaries defined by their type (see OBS rules).

EXEC-45: After a run() completes, exec statements may read a pi’s or

state variable’s final value directly (:print(x)), in addition to the observation-based intrinsics of INTR-40. Assignment at exec level reaches exec locals and module globals only.

Functions#

FN-10: A function (fn) is a synchronous, zero-time computation. It

does not advance simulation time.

FN-20: A function may have zero or more parameters; any parameters it has

must be explicitly typed.

FN-25: The return type is mandatory: functions exist to provide

calculation, and must return a value of the declared type. return is valid only in a fn body and requires an expression.

FN-30: A function may not mutate state variables. A function may not

call sched(), stop_r(), stop_s(), or halt().

FN-32: A function body may not read state variables. Functions see their

parameters and module values only; pass state as an argument.

FN-35: A function body may not reference a periodic item by name. To use

a pi’s value inside a function, the caller must pass it as an argument and the function must declare a corresponding typed parameter.

FN-40: Functions may be called from periodic items, events, agents, and

other functions.

FN-50: Variables declared inside a function are local and ephemeral.

Group Declarations#

GRP-10: A group declaration defines N entities with independent state and a

single shared body. The body is compiled once as parameterized bytecode in which the index variable is a bound parameter. The compiler allocates one state record per entity (history slots and current value); it does not emit N copies of the body. At evaluation time the shared body executes once per entity, with the index bound to that entity’s value. Memory cost is proportional to N × (state per entity), not N × (body size), making large N (millions or more) practical.

GRP-20: The range bounds must be compile-time constant integer

expressions. The lower bound must be less than or equal to the upper bound. A range with equal bounds (1..1) expands to a single declaration.

GRP-30: The index variable is in scope throughout the declaration body,

including any history-init clauses. It is not visible outside the declaration.

GRP-40: Within the body, any reference to the declared group name that

includes an index in braces (e.g., voter_{id}) is a group member reference. The braced index is a full int expression (e.g., source_{i%N+1}[-1]); when it can be resolved at compile time the compiler substitutes the concrete expanded name. The {...} wrapper is required; bare voter_id is a separate identifier and is not a group member reference.

GRP-50: When the index cannot be resolved at compile time the compiler

generates a runtime dispatch over the expanded names with a bounds check. An index outside the declared range at runtime halts the run with an out-of-range error.

GRP-60: Group declarations may be applied to pi, state,

resource, and agent. Group declarations are not permitted for event or fn.

GRP-70: Expanded names must not overlap with each other or with any

ordinary declaration in the same scope. A group declaration whose range would produce a name already declared is a compile-time error.

GRP-80: A multi-dimensional group declaration (two or more index specs,

e.g., node_{r|1..R}_{c|1..C}) expands to the Cartesian product of all index ranges. All index variables are simultaneously in scope within the body. Expansion is lexicographic: innermost (rightmost) index increments first.

Guards#

GUARD-10: A guard expression evaluates to a value. Guard arms are evaluated

top-to-bottom; the first arm whose condition is true determines the value.

GUARD-20: A guard statement (match ... end) executes the statements of

the first arm whose condition is true.

GUARD-30: A guard expression must include a terminating arm with condition

true or _ (wildcard). Omitting the default arm is a compile-time error. This is required because a guard expression must produce a value for every possible input.

GUARD-35: A guard statement (match ... end) does not require a default

arm. If no arm’s condition is true, execution continues after end with no effect.

GUARD-40: Guard-expression arms may mix int and dbl; all arms then

widen to dbl. Any other type mix across arms is a compile-time error.

GUARD-50: Since there is no notion of an expression that does assignment,

guard expressions are side-effect free.

History Access#

HIST-10: name[-k] refers to the value of periodic item name from

k evaluations ago, where k is a positive integer literal. name[-1] is the value from the immediately preceding evaluation. For a pi with period p > 1, [-k] counts evaluations, not ticks (PI-15).

HIST-20: History access is available for periodic items only. Applying

[-k] to a non-pi variable is a compile-time error.

HIST-30: When a historical reference reaches before the first evaluation

(e.g., x[-1] at period 1), the value is the zero value for the pi’s type (see ZERO-10), unless an explicit history initialization has been provided.

HIST-40: Explicit history initialization uses the form

[offset] = expression at the start of a pi body. Offsets are 0 or negative integer literals.

HIST-50: Within a pi body, a bare reference to the pi’s own name (without

[-k]) implicitly refers to its value from the immediately preceding evaluation (equivalent to name[-1]). To access two evaluations ago, name[-2] is required explicitly. A bare reference to a different pi always refers to that pi’s value for the current period (subject to declaration order; see HIST-60). Within a pi body, any k=0 reference into the pi’s own group reads the prior evaluation — deterministic regardless of member order. In a trailing item (PI-25) a bare self-reference reads the value just committed.

HIST-60: A pi may reference its own prior values or the prior values of

other pis, subject to declaration order: a pi may only reference pis declared before it for the current period’s value, but may reference any pi’s prior period values.

HIST-70: Historical references refer to prior-period values and therefore

do not impose declaration-order constraints.

If Statements#

IF-10: if cond then S end executes the statements in S if and only

if cond evaluates to true. If cond is false, execution continues after end with no effect.

IF-20: if cond then S1 else S2 end executes S1 if cond is

true, otherwise executes S2.

IF-30: The condition expression must be of type bool. A non-bool

condition is a compile-time error.

IF-40: if is syntactic sugar for match:

  • if cond then S endmatch | cond -> S end

  • if cond then S1 else S2 endmatch | cond -> S1 | _ -> S2 end

There is no elif; use match for multi-branch conditionals.

Init Block#

INIT-10: The init block is a singleton declaration; at most one init

block may appear per program.

INIT-20: The init block executes once per run, at time t=0, before any

other events. It is the primary entry point for spawning the initial population of agents.

INIT-30: The init block has the same capabilities as an agent body: it

may spawn agents, call functions, use timeout and claim, declare local variables, mutate state variables, and change resource capacity (RC-60).

INIT-40: State variables and resources are reset to their declared default

values before init executes (see EXEC-30).

Intrinsics#

INTR-10: Intrinsic functions use the :name prefix and are provided by

the runtime. They are not user-definable. The complete set is listed in eb.ebnf.

INTR-20: A user may declare a variable or function with the same name as an

intrinsic (without the colon prefix) without conflict.

INTR-30: Random number intrinsics (:randu, :randn, :rande,

:randp, :randb, :randi) draw from their respective distributions. :randseed(seed) sets the PRNG seed for reproducibility (EXEC-35).

INTR-31: RNG intrinsics are valid in pi, event, agent, fn,

and init bodies only. MODEL-20’s imperative intrinsic subset has no RNG.

INTR-32: The RNG algorithms are normative, so that every backend produces

identical streams (DET-10):

u01        (next_u64() >> 11) * 2^-53
:randi(lo, hi)  inclusive; lo + next_u64() mod (hi-lo+1)
:randn     Box–Muller, cosine branch, exactly two uniform draws
:rande(m)  -m * ln(u)
:randp     Knuth's method
INTR-40: Statistical intrinsics (:mean, :median, :quantile,

:probability, :min, :max, :count, :sum, :var) operate on observation data. They are meaningful in obs_r and obs_s declarations, where they aggregate over the observations collected at finer granularities. After a run() completes, they may also be applied to observation variables in ordinary statements, where they aggregate over the observation data of the most recently completed simulation.

INTR-41: Statistical details are normative: :quantile uses linear

interpolation (R type 7); :var is the n−1 sample variance; :mean always returns dbl; :sum returns the element type; :probability(x) takes a bool observation series and returns the fraction of true values.

INTR-42: Series extraction intrinsics :first, :last, and :nth

return a single element of an observation series. :nth(i, x) returns the i-th value of observation variable x; indices are 1-based, matching :p and :r (CTX-10, CTX-30). :first(x) is equivalent to :nth(1, x); :last(x) is equivalent to :nth(:count(x), x). The result type is the element type of the series. An index outside 1..:count(x) is a runtime error. Context rules follow INTR-40.

INTR-50: Math intrinsics (:abs, :log, :exp, :sqrt) operate

on numeric values. They accept int or dbl and return dbl.

INTR-55: Some intrinsics can be either statistical or vararg (:max(...),

:min(...)). In the vararg form they operate on a variable number of numeric arguments. They accept int or dbl. If the arguments mix types the result is dbl; otherwise the result takes the type of the arguments.

INTR-60: :print writes its arguments to stdout. :printf writes

formatted output to stdout using a subset of C-style format strings.

INTR-62: :print formats values according to their type:

int  : no decimal point       (42, not 42.0)
dbl  : always includes decimal (5.0, not 5)
str  : without quotes
bool : true or false
INTR-64: :print of a computed observation variable (one declared with

obs_x name := expr) prints its series. Let N be the number of values. When N <= 100 all values are printed, comma-space separated, enclosed in square brackets, each element formatted per INTR-62:

[1, 2, 3, 4, 5]

When N > 100, the first four and last four values are printed with an ellipsis between them, followed by the count:

[1, 2, 3, 4, ..., 98, 99, 100, 101] (101 values)

A single-value series prints as the bare value. :print of a declared symbol (pi, state, global) always prints that symbol’s current or final scalar value, even when the symbol is observed; use :dump to export its series. This format is normative and identical across implementations (DET-10). :printf has no format specifier for series; passing an observation series to :printf is a compile-time error.

INTR-66: :dump(x) writes every value of observation series x to

stdout with no truncation, one value per line, each formatted per INTR-62. It is the intended way to export a full series — even millions of values — for external analysis. Context rules follow INTR-40.

INTR-70: :boxplot returns a string that is an ASCII box-plot summary of

observation data.

INTR-75: :huge returns positive infinity (IEEE 754 +∞) as a dbl.

It is the canonical sentinel for “no further arrivals” when used with :nhpp, and for any other computation that needs to represent an unbounded value. EB literal syntax does not support infinity directly, so :huge is the standard way to name it.

INTR-80: :nhpp(start0, rate0, start1, rate1, ...) returns the next

interarrival time (dbl) for a Non-Homogeneous Poisson Process given a piecewise-constant rate schedule.

INTR-81: The schedule is expressed as a flat list of (start, rate)

pairs, all dbl, in ascending order of start time. Each pair defines a segment: rate rate applies from start until the next segment’s start (or forever, for the last segment). At least one pair is required. An odd number of arguments is a compile-time error.

INTR-82: :nhpp reads the current event time (:t) internally. It

draws one unit of exponential arrival potential and walks the schedule segments forward from :t until that potential is consumed, returning the elapsed time to the arrival point. Segments that ended before :t are skipped. Segments within which :t falls are entered at :t, not at their start.

INTR-83: A segment with rate = 0 contributes no arrival potential.

Potential is not consumed while traversing it. A zero-rate segment therefore acts as a gap during which no arrivals can occur.

INTR-84: If the schedule is exhausted before arrival potential is consumed,

:nhpp returns positive infinity — the value of :huge (see INTR-75). The caller is responsible for treating this as “no further arrivals” — typically by not scheduling the next event.

INTR-85: :nhpp is meaningful only when event time is active (i.e.,

called from within an event, or from a fn called by an event). Calling it during pi evaluation — where :t reflects period boundaries, not event time — produces results that are technically defined but unlikely to be meaningful.

Periodic Items#

PI-10: A periodic item (pi) is evaluated once per period, in

declaration order.

PI-15: pi(p) name fires every p ticks; pi(p, d) first fires at

t = d. Defaults are p = 1 and d = p. In this version p and d must be compile-time integer constants, with p >= 1 and d >= 0.

OPEN ISSUE — non-integer periods

Sub-tick periods (pi(0.5) value) are rejected in this version. With integer periods every pi rides one integer-tick period driver, which pins down three things that dbl periods leave open: same-instant ordering across period classes (PI-10/HIST-60 rely on declaration order, but whether two firing times coincide at all becomes a floating-point question); the relationship to the period counter, since :p, obs_p recording, and duration counting are defined at integer times (TIME-15, OBS-15); and drift-free firing times under TIME-50, which requires times to come from multiplication (d + k*p), never accumulation. The kernel is ready — each (period, offset) class can be its own self-rescheduling activation — what is missing is the semantic specification.

PI-20: A pi body consists of optional history initializations, optional

local declarations and assignments, and a mandatory terminating expression. The value of that expression becomes the pi’s value for the current evaluation.

PI-25: Keyword statements and calls may follow the terminating expression

(stop_r, halt, if, match, for, call statements — not bindings). These trailing items belong to the pi and run after the value is committed, which gives the idiomatic per-period stop check:

pi x :int =
  x + 1
  stop_r(x >= 3, "done")
PI-30: Local variables declared inside a pi body are ephemeral — they

exist only for the current evaluation.

PI-40: A pi may not be assigned to. Its value is derived, not stored. It

is a compile-time error to use = on a pi name outside its own body.

PI-50: In a pi model, state is implicit: it is the sequence of values

produced by all pis over all periods. Once produced, a pi’s value for a period is immutable.

Import and Export#

RESERVED

import, export, and as are reserved words rejected by this version of the compiler. The MOD-i rules below record the intended semantics and are not normative until modules ship.

MOD-10: export makes a module-level symbol visible to other modules.

Exportable symbols include variables, state variables, periodic items, and functions.

MOD-20: import module_name makes all exported symbols of

module_name accessible via qualified names (module_name.symbol).

MOD-30: import module_name as alias provides an alternative qualifier.

MOD-40: Imported non-state symbols are read-only. Assignment to an

imported non-state variable is a compile-time error.

MOD-50: Imported state variables may be mutated, but only from within an

event or agent in the importing module (see STATE-40).

MOD-60: Mutation of imported state variables occurs in the same atomic

step as local state mutation, that is, immediately in the event.

MOD-70: Importing a pi provides access to its current and historical

values. The importing module may not redefine the pi’s computation.

MOD-80: agent, resource, and init declarations are

module-private and may not be exported. Cross-module interaction with a module’s agent processes happens through its exported state variables.

Model#

MODEL-10: EB programs are either imperative programs or simulations.

MODEL-20: EB imperative programs have varDecl, fnDecl, statements, and a

subset of intrinsics (output, math, and those statistical intrinsics that have vararg forms). Execution consists of executing the statements from top to bottom, once. A purely imperative program is bindings, exec, and statements.

MODEL-30: EB simulation programs have one or more pi, event, and/or

agent declarations, one or more observations, and one or more run(). Execution consists of an ordered sequence of runs. Within each run, execution proceeds by alternating between period evaluation, event handling, and agent execution according to the Timing rules.

MODEL-35: Agent-based simulation programs use agent and resource

declarations for process-oriented modeling. Agents execute as coroutines and interact through shared resources. An init block fires at the start of each run to spawn the initial population of agents.

Observations#

OBS-10: obs_p (synonym: obs_i) records the value of a variable at

the end of each period. Over a run, this produces P (number of periods) values. At the start of a new run all previous values are discarded and that memory reused to record P values for this run. At the end of the simulation only the values from the last run are available. At no point are P × R values (periods × runs) produced.

OBS-15: Periods exist in every simulation, including models with no

periodic items: the period counter advances at each integer time step up to duration (see TIME-15), so obs_p records at each period boundary regardless of model style. In a no-horizon run (EXEC-15), the period counter advances at each integer time step reached before the run ends; the number of obs_p values is therefore not known in advance.

OBS-20: obs_r records a value at the end of each run. It produces R

values.

OBS-30: obs_s records a value at the end of the simulation. It

produces one value.

OBS-40: An observation declaration may either name existing variables or

declare a new computed variable:

obs_p x, y            # observe existing variables
obs_r avg := :mean(x) # declare and observe a computed value
OBS-50: Statistical intrinsics in obs_r and obs_s declarations

aggregate over the observation data collected at finer granularities (obs_r aggregates over period observations; obs_s aggregates over run observations).

OBS-60: Aggregation for obs_r occurs at the end of the run, before

obs_p data are discarded.

Resources#

RC-10: A resource (resource) is a named synchronization primitive with

an integer capacity. resource teller := 1 is the declaration and the initial capacity; the capacity may change during a run through :capacity (RC-60).

RC-20: A resource tracks the number of currently held units. Available

units at any point equal capacity held. Initially all units are available.

RC-30: A capacity — declared or set — must be a non-negative integer.

A negative capacity is a compile-time error when constant, a run-time error otherwise. Zero is legal: the resource is offline, it drains, and it admits nobody until a later raise.

RC-40: A resource variable has type rsc. It may be passed as an

argument to agents and functions that declare a parameter of type rsc. Resources are passed by reference: all callers sharing the same rsc value contend for the same pool of units.

RC-50: Resources are reset to fully available at the start of each run,

and the declaration’s capacity expression is re-evaluated then.

RC-60: :capacity(r, n) sets the capacity of resource r to n.

The value is absolute, not a delta: after :capacity(teller, 2) the resource has 2 units regardless of its previous capacity. :capacity(r) is the matching read form, returning the current capacity as an int; relative schedules are written :capacity(t, :capacity(t) + 1). r is a resource name, a group member reference, or an rsc parameter; n must be int.

:capacity is valid in pi, event, agent, and init bodies — unlike claim it never suspends, so it is not restricted to agents. It is not valid in a fn body (no side effects) or at exec level (resources exist only for the duration of a run()).

A capacity raise increases availability, so any segment that may execute a :capacity set has a resource scan appended. Because n is a general expression the compiler cannot in general tell a raise from a reduction, so the rule is syntactic and conservative: presence of a set form in the segment, not its direction. A pure reduction therefore costs one no-op scan.

RC-70: Drain, never preempt. Units already held are never revoked.

Lowering capacity below the number held is legal and leaves the resource over-subscribed (held > capacity); that is a normal transient state, not an error. New claims keep failing the held < capacity test until enough releases land. Waiters are untouched by a reduction: they stay queued in FIFO order and are granted by the scan that follows a later raise.

Note

When resource observables are added, utilization under a time-varying capacity is a time-weighted ratio against a varying denominator — not held/capacity averaged. Do not define a resource observable that assumes a constant denominator.

Scenarios#

SCEN-05: Scenario application happens once per run, before period 1.

SCEN-10: A scenario block contains only assignments (=) to variables

already declared at module level. Declarations inside a scenario are not permitted.

SCEN-20: All scenarios implicitly inherit from the module-level default

values. A scenario overrides only the variables it explicitly assigns.

SCEN-30: When run() is invoked with a scenario, the scenario’s

assignments are applied before execution begins. Module-level variables not overridden retain their default values.

SCEN-40: Module defaults are restored and the scenario applied before the

run() duration and nruns expressions are evaluated — once per run statement, and again before each run (EXEC-30, EXEC-32). Accordingly, if a scenario assigns a value to a variable referenced in {duration, nruns}, the assigned value determines the number of periods and runs.

SCEN-50: A scenario may not assign a variable used in a group range: the

range was fixed at compile time (GRP-20).

Scheduling and Control#

SCHED-10: sched(delta_t, handler) places handler on the event queue

at time current_time + delta_t. delta_t must be non-negative.

SCHED-12: sched(delta_t, handler(args...)) evaluates each argument

expression at schedule time and stores the values in the event record. When the event fires the values are bound to the handler’s declared parameters in positional order. The number of supplied arguments must equal the number of declared parameters; a mismatch is a compile-time error.

SCHED-15: If delta_t < 0, it is a compile-time error when constant;

otherwise a run-time error.

SCHED-20: stop_r(condition, message) ends the current run when

condition is true. It is imperative: the condition is evaluated when the statement executes; nothing is registered for later re-evaluation. When the condition is false, execution continues with no effect. stop_r is valid only inside pi, event, agent, and init bodies; stop_r at module level is a compile-time error. (A stop_r in a pi trailing item re-executes each period as part of normal evaluation — the idiomatic per-period stop check, PI-25.)

SCHED-22: Timing of the stop. When a true stop_r executes inside a pi

body, the run ends at the end of the current period: remaining pis evaluate and obs_p records before the run ends. Inside an event, agent, or init block, the run ends when the enclosing handler or agent segment completes its atomic step; events remaining in the event queue (including same-time FIFO entries) are discarded.

SCHED-24: A run ended by stop_r is a completed run: message is

recorded, end-of-run processing occurs (obs_r values are recorded per OBS-60), and if additional runs remain, execution continues with the next run.

SCHED-30: stop_s(condition, message) registers a check evaluated at the

end of each run, after obs_r aggregation (OBS-60), so the condition may reference observation data collected so far. When condition is true, the simulation ends and message is recorded; no further runs execute. stop_s is valid only at model level; using it inside a pi, event, agent, fn, or init body is a compile-time error.

SCHED-40: halt(message) immediately terminates the current run and the

simulation. message is written to stderr.

Scoping#

SCOPE-10: Each source file defines a module. Declarations at module

level are visible throughout the module and private unless explicitly exported.

SCOPE-20: pi, event, agent, fn, and the init block each

introduce an execution unit with its own lexical scope.

SCOPE-30: Variables declared inside an execution unit are local to that unit.

They exist only for the duration of the unit’s execution.

SCOPE-40: Shadowing is prohibited. It is a compile-time error to declare a

variable whose name matches any name in an enclosing scope, including module-level variables, state variables, functions, periodic items, and imported symbols.

SCOPE-50: for loop variables are local to the loop body and follow the

same no-shadowing rule.

State Variables#

STATE-10: A state variable is declared with the state keyword and is

persistent across event firings within a run. The effect discipline stays declared, not inferred.

STATE-20: State variables may only be mutated (assigned with =) inside

events, agents, or the init block. Assignment to a state variable outside an event, agent, or init block is a compile-time error.

STATE-30: State variables form the Markov state of an event-driven or

agent-based model (that is, a model with event or agent declarations). They do not exist in pure pi models (models with neither event nor agent declarations) — see PI-50.

STATE-40: State variables are the only variables whose values may be mutated

by an importing module, and only from within an event or agent in that module. (Reserved with the MOD-i rules.)

Timing#

TIME-10: EB supports two notions of time: periods (integer, discrete,

advancing by 1) and event time (double, continuous, advancing according to sched()). Both are unit-less.

TIME-15: Period p corresponds to simulation time t = p (periods are

1-indexed; period boundaries fall on integer times). Time t = 0 precedes period 1; the init block executes at t = 0 (INIT-20), before period 1 evaluation and before any events.

TIME-20: In hybrid models, period-based execution precedes event handling

when a period boundary and an event share the same numeric time.

TIME-30: Events are ordered by (scheduled_time, insertion_order). When

multiple events share a scheduled time, they are dequeued in FIFO order.

TIME-40: All pi periods are considered scheduled at the start of a run. As

a consequence of FIFO ordering, pis for a given period execute before any events scheduled at the same time.

TIME-45: Period boundaries are not events: pending period boundaries do not

count as queue contents for the queue-empty test (EXEC-10, EXEC-15). In a no-horizon run, pi periods are scheduled indefinitely — pis continue to evaluate each period until a stop_r or halt ends the run. In a model with no pis, period boundaries alone do not keep the run alive.

TIME-50: There is no epsilon or tolerance for time comparisons. Equality

between period values and event times is exact under IEEE 754.

TIME-60: Events scheduled with delta_t = 0 go into the FIFO for the

current period. Since scheduling is FIFO, such events are handled after any other pis or events already scheduled.

TIME-70: The scheduler pulls the next item from the FIFO only after the

completion of a pi evaluation or an event.

Types#

TYPE-10: EB has four primitive types: int, dbl, str, bool.

TYPE-15: rsc is an opaque reference type for resources (see RC-40). It

is valid only as the declared type of a fn or agent parameter. Values of type rsc are created only by resource declarations; rsc may not appear in variable declarations, forward declarations, return types, or pi type annotations.

TYPE-20: Every variable has a type fixed at declaration time. Types are

either inferred from the initializer (with :=), stated explicitly (with :type =), or given by an explicit forward declaration (:type).

TYPE-30: int is implicitly promoted to dbl in any binary arithmetic

operation where the other operand is dbl. The result type of such an operation is dbl.

TYPE-40: dbl is never implicitly narrowed to int. Assigning a

dbl expression to an int variable is a compile-time error.

TYPE-50: / (division) ALWAYS produces dbl, regardless of operand

types. If either operand is int, it is promoted to dbl before the division.

TYPE-60: // (integer division) produces int when both operands are

int. When either operand is dbl, both are promoted to dbl and the result is floor(a / b) as dbl, where floor() is IEEE 754 floor (round toward −∞).

TYPE-65: // and % are floor division and a floor-consistent

remainder: a % b == a - (a // b) * b. int // 0 and int % 0 are runtime errors; the dbl variants follow IEEE 754.

TYPE-70: Arithmetic operators +, -, *, ^ on two int

operands produce int. If either operand is dbl, the int operand is promoted and the result is dbl.

TYPE-75: A negative int exponent to ^ is a runtime error; use

dbl operands.

TYPE-80: Unary - preserves the type of its operand.

TYPE-85: int arithmetic wraps on overflow (two’s complement, 64-bit).

TYPE-90: Comparison operators (==, !=, <, <=, >,

>=) produce bool. Operands follow the same promotion rules as arithmetic: mixed int/dbl promotes the int.

TYPE-100: Logical operators and, or, not operate on bool and

produce bool.

TYPE-105: and and or short-circuit. The right operand is not

evaluated — and draws no random numbers — when the left operand decides the result.

Zero Values#

ZERO-10: Every type has a zero value used for implicit initialization:

int  : 0
dbl  : 0.0
str  : ""
bool : false
ZERO-20: An uninitialized pi history slot contains the zero value for the

pi’s type (see HIST-30).