RFC-0048: Discrete-event + hybrid core
Status: Proposed (step 3 of RFC-0047). No frozen-contract change is made by this document; it specifies the shared discrete-event / hybrid surface and marks the landed slices. Each slice below lands as its own change with a named fixture + conformance case, per RFC-0047. Slices A and B are landed, along with the C1 priority-queueing + statistics and C2 resource increments (recorded in their sections); Slice D is Proposed.
Motivation
RFC-0047 sequences PWE toward a general simulation language. Steps 1–2 (typed values RFC-0043, typed arrays RFC-0044) are Done. Step 3 is the shared need of four product classes PWE cannot address today:
- DES / agent-based (AnyLogic, Simio, Arena): event calendar, queues, resources, entities, statistics.
- Control / hybrid (Simulink, PLECS): continuous states + discrete mode switches triggered by zero crossings, with event-driven reinitialization.
- Power electronics / switching (Simulink, PLECS): switch events whose timing must be detected, not stepped past.
- Chemical kinetics (COPASI): already has
gillespieSSA, but needs the same event calendar/queue vocabulary for time-varying inputs.
PWE today has emit/last_event (ordered events), at/periodic/schedule
(a dynamic event queue on the step grid), and watch (a per-entity sign-change
flag that requires two user state slots as memory and flag). It has no
runtime-owned zero-crossing operator, no queue/resource library, and no
event-calendar surface. This RFC fixes the shape of all three and lands the
smallest one first.
Design
Deterministic event ordering (non-negotiable)
Events are ordered by the pair (time, seq), where seq is a monotonically
increasing insertion counter owned by the execution context. Time ties are
therefore broken by insertion order, never by float comparison of time
alone. The queue previously relied on a stable insert under equal times
(partition_point(|e| e.time <= time)); the calendar slice (B) promotes seq
to an explicit field on each entry and inserts at
partition_point(|e| (e.time, e.seq) <= (time, seq)), so cross-region and
snapshot order is explicit rather than incidental. step_cross compares
next_seq too, so a backend that assigned sequence numbers differently is
caught even before the queue diverges.
Slice A — zero-crossing detection (this change)
Two runtime-owned operators detect an exact sign event on any expression,
using per-call-site history (the RFC-0043 HistRead/HistWrite/HistHas
mechanism already used by deriv), so no user state slot is consumed:
cross(e): fires 1.0 on the (sub)step whereechanges sign (strictly,prev ≠ 0 ∧ e ≠ 0 ∧ sign(prev) ≠ sign(e)); 0.0 otherwise. Fire-once, transition edge.rise(e)/fall(e): fire on a strict upward / downward transition.last_cross(e): the simulation time of the most recent crossing of this call site (0.0 before any crossing).
Semantics are defined on the substep grid: under rk4 the operator sees
every integration substage, so it cannot step over a crossing the way a
per-step watch can. The operators are pure and deterministic, so both backends
produce identical wires; step_cross compares writes, events, queue, field
overlays, and the hist/cross_time maps, so a history-only divergence is
caught too.
History-site scheme. A function's history sites are a per-function
HistRead count, keyed into the runtime's hist/cross_time maps. Two
correctness fixes landed with this slice (both were filed while reviewing the
WIP):
- #94 — function namespace (fixed): the builder (
PhysicsProgram:: build_with_guards) rewrites each function's site constants to(function_id << 32) | site, so two different systems that usederiv/crosscan no longer alias each other's previous value. Regression:lang::tests::history_sites_are_namespaced_per_function. - #95 — nesting order (fixed):
lower_zero_crossingnow lowers its argument before taking its site, matchingderiv, socross(deriv(x))no longer shares the nested operator's site. Regression:lang::tests::nested_history_operators_do_not_share_a_site.
Sites remain distinct within a function and code-gen order is deterministic, so
cross-backend equivalence holds; step_cross additionally compares the
hist/cross_time maps (and, with Slice B, next_seq).
Slice B — event calendar as a first-class value
An explicit calendar value (ordered (time, seq, kind, payload) entries) with
deterministic next/pop/at_time reads, so process-flow models can consume
events rather than only probe last_event.
Landed (this slice): ScheduledEvent and EmittedEvent carry an explicit
monotonic seq (assigned by ExecEnv.next_seq), so ordering is (time, seq)
everywhere — a tie in time is broken by insertion order, never by a float
comparison, and the order is stable across regions and snapshots. The language
surface is a read/pop view over the pending calendar, lowered to interpreter-
oracle opcodes:
| Builtin | Opcode | Meaning |
|---|---|---|
event_count() |
EventCount |
pending entries |
next_event_time() |
NextEventTime |
earliest time, f64::MAX when empty |
next_event_kind() |
NextEventKind |
earliest kind, 0 when empty |
next_event_payload() |
NextEventPayload |
earliest payload, 0 when empty |
pop_event() |
PopEvent |
remove + return the earliest payload |
events_seen(k) |
EventSeenCount |
events delivered this step with kind k |
pop_event mutates the pending calendar; the rest are pure reads. All are
deterministic and cross-backend checked. (Time-advance — an explicit
wait_until/advance-to-next-event host contract — remains future work; this
slice deliberately adds only reads/pops so the host step grid is unchanged.)
Slice C — queue / resource / statistics library
queue, resource, seize, release, wait_time, utilization primitives
lowered to a domain IR over the calendar, with their own deterministic
statistics accumulation. This is the DES-domain library; it depends on Slice B.
Landed (C1 — priority queueing). The calendar gained a queue discipline:
ScheduledEvent carries a priority, the queue is ordered by
(time, priority, seq) (earlier time, then lower priority, then insertion),
and the new schedule_at(gate, delay, kind, payload, priority) sets it while
next_event_priority() reads the head's priority. The plain schedule(...)
keeps priority 0, so its order is exactly Slice B's (time, seq) — this is a
strict generalisation. FIFO (priority 0), LIFO/EDF (priority −t) and
priority/SJF (priority = job length) are all expressible.
| Builtin | Opcode | Meaning |
|---|---|---|
schedule_at(g, d, k, p, prio) |
ScheduleEventAt = 246 |
schedule with priority prio |
next_event_priority() |
NextEventPriority = 247 |
head priority, 0 when empty |
Landed (C1 — statistics library). std/des.pwe provides the deterministic
DES statistics a process-flow model needs — utilization, availability,
throughput, wait_time, queue_length, mean, variance, ewma,
littles_law — all as pure funcs over caller-held state (no hidden state, no
RNG), and all trap-free (safe_ratio clamps its denominator, since if is
eager). It is self-contained so a funcs module can use it.
Landed (C2 — resources). A resource r { capacity = n } world declaration
plus four builtins give capacity-gated seize/release, the DES "server"
primitive the statistics library consumes:
| Builtin | Opcode | Meaning |
|---|---|---|
seize(r, cap) |
SeizeResource = 248 |
non-blocking grab; 1.0 on success, 0.0 when full |
release(r) |
ReleaseResource = 249 |
free one unit; returns the remaining busy count |
resource_busy(r) |
ResourceBusy = 250 |
current holders (0 when unknown) |
resource_capacity(r) |
ResourceCapacity = 251 |
declared capacity (0 when unknown) |
Resources live in the execution context (ExecEnv.resources, keyed by a
deterministic FNV-1a of the resource name), exactly like the event calendar:
SeizeResource/ReleaseResource mutate it, the reads are pure, step_cross
compares the whole table, and the JIT/AOT never compile these opcodes — so the
interpreter ≡ JIT guarantee holds. The first seize fixes the capacity
(the declaration value); a later call cannot widen it, which keeps a resource's
semantics independent of the order in which rules happen to run. Busy is
saturating at 0 on release. Because a resource is execution-context state, it is
deliberately not mirrored into a State component; the capacity in the
declaration is the only user-visible constant, and it is fixed at compile time.
Slice D — hybrid coupling
Event-driven reinitialization for continuous states (when cross(e) { … }),
algebraic-loop detection, and the block-diagram IR that Simulink-class models
need. Depends on Slice A.
New opcodes (Slice C2)
SeizeResource = 248— operand 0 = capacity, resource id inconstant, F64: 1.0 when acquired, 0.0 when full.ReleaseResource = 249— no operands, resource id inconstant, F64: remaining busy count after freeing one unit (saturating at 0).ResourceBusy = 250— no operands, F64: current holders.ResourceCapacity = 251— no operands, F64: declared capacity.
All are interpreter-oracle; the codec round-trip test covers them.
New opcodes (Slice C1)
ScheduleEventAt = 246— operands[gate, delay, kind, payload, priority], no result: enqueue with an explicit priority.NextEventPriority = 247— no operands, F64: the head's priority,0empty.
Both are interpreter-oracle; the codec round-trip test covers them.
New opcodes (Slice B)
Added via the declare_opcodes! table (single source), pure, interpreter-oracle,
JIT/AOT must remain byte-identical. PopEvent is the only stateful one (it
removes the earliest pending entry); the rest read:
EventCount = 240— no operands, F64: number of pending entries.NextEventTime = 241— no operands, F64: earliest time,f64::MAXwhen empty.NextEventKind = 242— no operands, F64: earliest kind,0when empty.NextEventPayload = 243— no operands, F64: earliest payload (bit pattern).PopEvent = 244— no operands, F64: remove + return the earliest payload.EventSeenCount = 245— operand 0 = kind, F64: delivered-this-step count.
The empty-calendar sentinels are finite (f64::MAX for time, 0 for kind and
payload) so a stored next_event_time() cannot trip the detail-88 non-finite
check.
New opcodes (Slice A)
Added via the declare_opcodes! table (single source), pure (no effect bits),
interpreter-oracle, JIT/AOT must remain byte-identical:
CrossDown = 236— operands[prev, cur, has], result F64: 1.0 on a strict sign changehas ≠ 0 ∧ prev ≠ 0 ∧ cur ≠ 0 ∧ sign(prev) ≠ sign(cur), else 0.0.RiseEdge = 237— operands[prev, cur, has], F64: 1.0 onhas ≠ 0 ∧ prev ≤ 0 ∧ cur > 0.FallEdge = 238— operands[prev, cur, has], F64: 1.0 onhas ≠ 0 ∧ prev ≥ 0 ∧ cur < 0.LastCross = 239— no operands, F64: the simulation time of the most recent crossing at this site (0.0 before any).
has is the history-present flag, so the first (sub)step never fires a
crossing. The site id is the count of HistReads emitted so far in the
function: deriv uses the same counter and each of these operators emits
exactly one HistRead plus one HistWrite, so ids are unique within a single
function and do not collide with deriv there. The previous value reuses
ExecEnv.hist; the crossing timestamp uses a parallel ExecEnv.cross_time map.
last_cross(e) lowers to the same edge detection (which timestamps the site)
followed by LastCross, so it reports when the transition happened. See the
history-site scheme above: the function id is folded into the key by the
builder, and the site is taken after the argument is lowered.
Validation
- Conformance:
"RFC-0048 zero-crossing detection (cross/rise/fall/last_cross, cross-backend)"— a<src>entity whose signal crosses while a<w>entity detectscross/rise/falland timestamps it vialast_cross; stepped cross-backend (interpreter ≡ JIT) for the new opcodes. lang::tests::rfc_0048_zero_crossing_operators—cross/rise/fallfire on the transition step,last_crossrecords the time, and both backends agree.lang::tests::rfc_0048_zero_crossing_is_strict_and_fires_once— strictness (an exact zero touch is not a crossing) and fire-once.lang::tests::rfc_0048_zero_crossing_gates_when_for_reinit— a zero-crossing expression composes withwhen = rise(e)for event-driven reinitialization.eir::tests::eir_zero_crossing_operators_fire_and_timestamp— opcode-level edge semantics and timestamps; the codec round-trip test covers every new opcode (the RFC-0021 drift guard).lang::tests::history_sites_are_namespaced_per_function(#94) andlang::tests::nested_history_operators_do_not_share_a_site(#95).lang::tests::builtin_arity_mismatch_is_a_diagnostic_not_a_panic— a wrong-arity builtin call (including the new operators) reports detail 59 rather than panicking in lowering.- Slice B:
"RFC-0048 event calendar (event_count/next_event_*/pop_event/ events_seen, cross-backend)"— a producer schedules two future events and a consumer reads/pops the pending calendar; stepped cross-backend. lang::tests::rfc_0048_calendar_reads_and_pop_the_pending_queue,lang::tests::rfc_0048_calendar_equal_time_ties_use_insertion_order,lang::tests::rfc_0048_events_seen_counts_delivered_events_by_kind,lang::tests::rfc_0048_empty_calendar_reads_are_finite_sentinels;eir::tests::eir_calendar_reads_and_pop_in_time_seq_order,eir::tests::eir_calendar_equal_time_ties_use_insertion_order.- Slice C1:
"RFC-0048 priority calendar (schedule_at / next_event_priority, cross-backend)"and"RFC-0048 DES statistics library (std/des utilization, cross-backend)";lang::tests::rfc_0048_priority_calendar_orders_by_time_priority_seq,lang::tests::rfc_0048_priority_ties_use_insertion_order,eir::tests::eir_calendar_priority_orders_within_a_time. - Slice C2:
"RFC-0048 resources (seize/release/resource_busy, cross-backend)";lang::tests::rfc_0048_resource_seize_release_respects_capacity,lang::tests::rfc_0048_resource_capacity_is_fixed_and_release_saturates,eir::tests::eir_resource_seize_release_respects_capacity.
Non-goals
- No visual block editor, no GUI.
- No new world state for Slice A: zero crossings are derived signals.
- No weakening of determinism; no float-only time ordering (ties use
seq).
Alternatives
- Extend
watch: rejected — it consumes user state slots, sees only the step grid (misses substep crossings), and cannot report when the crossing happened. - Queue/resource first: rejected as Slice A — it needs new runtime
components, scene layout, and a serialization surface, so it is the largest
and riskiest slice, while Slice A reuses the existing
derivhistory precedent end to end.