Interactive emulator interface with typed observability hooks in emu/, shared simulation backend with command queue, CLI REPL, and web GUI with dataflow graph execution overlay. Six implementation phases.
···11+# OR1 Monitor — Emulator Interface & Visual Monitor UI
22+33+## Summary
44+55+The OR1 Monitor is an interactive simulation tool that wraps the existing assembler (`asm/`) and emulator (`emu/`) into a controllable, observable runtime. The core problem it solves is that the emulator today runs to completion and only exposes a post-hoc `output_log`; the monitor replaces that with tick-by-tick stepping, real-time structured events, and full state inspection at any point during execution. Two front-ends are built on top of a shared simulation backend: a thin CLI REPL for scripted or headless use, and a browser-based visual monitor that overlays execution state on the dataflow graph.
66+77+The approach has three layers. First, a small set of typed event callbacks is added to `emu/pe.py` and `emu/sm.py` — these fire at well-defined points (token received, match completed, ALU executed, cell written, and so on) and are zero-cost when unused, so all existing tests continue to pass without changes. Second, a `monitor/backend.py` simulation thread owns the SimPy environment and exposes a command queue: callers send commands (`LoadCmd`, `StepTickCmd`, `RunUntilCmd`, etc.) and receive `StepResult` messages containing both the semantic events that fired and a full state snapshot. Third, the REPL and web server are thin adapters over this shared backend — they differ only in how they present results to the user, not in any simulation logic.
88+99+## Definition of Done
1010+1111+1. **New `monitor/` package** that integrates the assembler and emulator into an interactive simulation tool
1212+2. **Observability hooks in `emu/`** — event callbacks or similar mechanism so external code can observe PE/SM state changes in real-time (not just post-execution)
1313+3. **Web GUI** showing the dataflow graph with execution state overlay (active nodes, token flow, SM cell states), with tick-level stepping controls and optional per-event granularity
1414+4. **Thin CLI REPL** for headless use — load dfasm programs, inject tokens, step, inspect state, clear/reset
1515+5. **Shared simulation backend** powering both interfaces
1616+1717+**Out of scope:** Modifying dfgraph, IDE-level debugging features.
1818+1919+## Acceptance Criteria
2020+2121+### or1-monitor.AC1: Monitor package loads and runs simulations
2222+- **or1-monitor.AC1.1 Success:** `LoadCmd` with valid dfasm source assembles, builds topology, injects seed tokens, and returns `GraphLoaded` with IR graph and initial snapshot
2323+- **or1-monitor.AC1.2 Success:** `LoadCmd` wires event callbacks into all PEs and SMs automatically
2424+- **or1-monitor.AC1.3 Failure:** `LoadCmd` with invalid dfasm source returns assembly errors without crashing the backend
2525+- **or1-monitor.AC1.4 Success:** `ResetCmd` tears down the current simulation and leaves the backend ready for a new `LoadCmd`
2626+- **or1-monitor.AC1.5 Success:** `ResetCmd` after a load can optionally reload the same program
2727+2828+### or1-monitor.AC2: Observability hooks fire structured events
2929+- **or1-monitor.AC2.1 Success:** PE fires `TokenReceived` when a token is dequeued from `input_store`
3030+- **or1-monitor.AC2.2 Success:** PE fires `Matched` when dyadic match completes with both operands
3131+- **or1-monitor.AC2.3 Success:** PE fires `Executed` after ALU execution with op, result, and bool_out
3232+- **or1-monitor.AC2.4 Success:** PE fires `Emitted` for each output token placed on route_table
3333+- **or1-monitor.AC2.5 Success:** PE fires `IRAMWritten` after processing an `IRAMWriteToken`
3434+- **or1-monitor.AC2.6 Success:** SM fires `TokenReceived` when a token is dequeued
3535+- **or1-monitor.AC2.7 Success:** SM fires `CellWritten` on any cell state change (includes old and new presence)
3636+- **or1-monitor.AC2.8 Success:** SM fires `DeferredRead` when a read blocks on a non-FULL cell
3737+- **or1-monitor.AC2.9 Success:** SM fires `DeferredSatisfied` when a subsequent write satisfies a deferred read
3838+- **or1-monitor.AC2.10 Success:** SM fires `ResultSent` when a result token is routed back to a PE
3939+- **or1-monitor.AC2.11 Regression:** All existing emulator tests pass unchanged when no callback is provided
4040+4141+### or1-monitor.AC3: Web GUI displays execution state
4242+- **or1-monitor.AC3.1 Success:** Graph renders in physical layout (grouped by PE) by default
4343+- **or1-monitor.AC3.2 Success:** User can switch between physical and logical layout
4444+- **or1-monitor.AC3.3 Success:** Active nodes highlight when their PE receives a token at that IRAM offset
4545+- **or1-monitor.AC3.4 Success:** Edges animate when a token traverses them
4646+- **or1-monitor.AC3.5 Success:** Event log panel shows SimEvents with simulation timestamps
4747+- **or1-monitor.AC3.6 Success:** Event log is filterable by component (pe:0, sm:1) and event type
4848+- **or1-monitor.AC3.7 Success:** State inspector shows full PE state (IRAM, matching store, gen counters, input queue, output log)
4949+- **or1-monitor.AC3.8 Success:** State inspector shows full SM state (cells with presence, deferred reads, T0 store)
5050+- **or1-monitor.AC3.9 Success:** Step tick button advances simulation to next time and updates all panels
5151+- **or1-monitor.AC3.10 Success:** Step event button advances exactly one SimPy event
5252+- **or1-monitor.AC3.11 Success:** Clicking a node shows its matching store state — occupied slots, stored data, which port is waiting for a partner
5353+- **or1-monitor.AC3.12 Success:** Nodes visually indicate matching state (e.g., half-matched node shows it has one operand waiting)
5454+5555+### or1-monitor.AC4: CLI REPL provides headless interaction
5656+- **or1-monitor.AC4.1 Success:** `load <path>` assembles and loads a dfasm program
5757+- **or1-monitor.AC4.2 Success:** `step` and `stepe` advance by tick and by event respectively
5858+- **or1-monitor.AC4.3 Success:** `run <until>` runs simulation to target time
5959+- **or1-monitor.AC4.4 Success:** `send <args>` injects a token via FIFO (wakes waiting processes)
6060+- **or1-monitor.AC4.5 Success:** `inject <args>` does direct injection (pre-sim setup)
6161+- **or1-monitor.AC4.6 Success:** `state`, `pe <id>`, `sm <id>` display readable state information
6262+- **or1-monitor.AC4.7 Success:** `log` shows recent events, `log filter` narrows by component
6363+- **or1-monitor.AC4.8 Failure:** Commands on an unloaded simulation return clear error messages
6464+- **or1-monitor.AC4.9 Success:** `reset` clears simulation state and `quit` exits cleanly
6565+6666+### or1-monitor.AC5: Shared backend powers both interfaces
6767+- **or1-monitor.AC5.1 Success:** REPL and web server use the same `SimulationBackend` instance (no code duplication)
6868+- **or1-monitor.AC5.2 Success:** `StepTickCmd` processes all events at the current simulation time before returning
6969+- **or1-monitor.AC5.3 Success:** `StepEventCmd` processes exactly one event
7070+- **or1-monitor.AC5.4 Success:** `RunUntilCmd` batches events per tick to avoid flooding
7171+- **or1-monitor.AC5.5 Success:** `StepResult` contains both semantic events and a full state snapshot
7272+- **or1-monitor.AC5.6 Edge:** Stepping when simulation is finished (`env.peek() == inf`) returns `finished=True` without error
7373+7474+## Glossary
7575+7676+- **SimPy**: A Python discrete-event simulation library. The emulator models time as a series of events processed by a single-threaded `Environment`; "stepping" means advancing the environment by one event or one unit of simulation time.
7777+- **PE (Processing Element)**: A compute node in the dataflow CPU. Each PE holds an instruction RAM (IRAM), a matching store, and generation counters. It consumes tokens from an input queue, pairs dyadic operands, executes ALU instructions, and emits result tokens.
7878+- **SM (Structure Memory)**: A memory unit implementing I-structure semantics — each cell has a presence state (`EMPTY`, `RESERVED`, `FULL`, `WAITING`) and supports deferred reads: a read on a non-full cell blocks until a subsequent write satisfies it.
7979+- **Token**: The unit of data movement in the dataflow machine. A `CMToken` targets a PE (carrying operand data to a specific IRAM offset and context slot); an `SMToken` targets a Structure Memory (carrying a read/write operation).
8080+- **DyadToken / MonadToken**: Subtypes of `CMToken`. A `DyadToken` carries one operand of a two-input (dyadic) instruction and must be paired with its partner before execution proceeds. A `MonadToken` carries a complete single-operand input.
8181+- **IRAM (Instruction RAM)**: Per-PE memory storing `ALUInst` and `SMInst` records. A token's `offset` field indexes into IRAM to select which instruction to execute.
8282+- **Matching store**: A 2D array inside each PE (`[ctx_slots][offsets]`) that buffers one operand of a dyadic instruction while waiting for the other. When both operands arrive, the match completes and execution proceeds.
8383+- **Context slot (`ctx`)**: An index into the matching store that provides re-entrancy — different invocations of the same IRAM instruction can be in flight simultaneously, each occupying a distinct context slot.
8484+- **Generation counter**: A per-context counter in each PE used to detect and discard stale tokens from superseded invocations.
8585+- **I-structure semantics**: A single-assignment memory discipline where each cell can be written exactly once; reads that arrive before the write are deferred and satisfied automatically when the write occurs.
8686+- **T0 / T1 (memory tiers)**: Two address ranges within Structure Memory. T1 (below the tier boundary) is the normal I-structure tier with presence tracking and deferred reads. T0 (at or above the tier boundary) is shared raw storage across all SM instances.
8787+- **SimEvent**: A frozen dataclass union (`TokenReceived | Matched | Executed | Emitted | ...`) that captures a single observable occurrence during simulation, including its simulation timestamp and the component that fired it.
8888+- **EventCallback**: A `Callable[[SimEvent], None]` injected into PE and SM constructors. When provided, it is called synchronously at each instrumentation point. Defaults to no-op.
8989+- **SimulationBackend**: The `monitor/backend.py` class that owns the SimPy environment in a dedicated thread, receives `SimCommand` objects via a queue, and returns `StepResult` objects via a result queue.
9090+- **StateSnapshot**: A pure-Python dataclass capturing a complete dump of all PE and SM state at a moment in simulation time.
9191+- **StepResult**: The response type returned by the backend after any stepping command. Contains the list of `SimEvent`s that fired, a `StateSnapshot`, the current simulation time, and a `finished` flag.
9292+- **dfasm**: The domain-specific assembly language for the OR1 dataflow CPU. Programs are compiled by the `asm/` package into PE/SM configurations.
9393+- **IRGraph**: The intermediate representation produced by the assembler's lowering pass, describing the dataflow graph before code generation. Rendered as JSON in the frontend.
9494+- **Cytoscape.js**: A JavaScript graph visualisation library used for rendering the dataflow graph in the browser.
9595+- **ELK (Eclipse Layout Kernel)**: A graph layout algorithm library (used via `cytoscape-elk`) that automatically positions graph nodes. Supports logical and physical layout modes.
9696+- **`frontend-common/`**: A shared TypeScript library extracted from `dfgraph/frontend/` to avoid duplicating graph rendering, layout switching, and export logic.
9797+- **`cmd.Cmd`**: Python's standard-library base class for line-oriented command interpreters. The CLI REPL subclasses it.
9898+9999+## Architecture
100100+101101+New top-level `monitor/` package that integrates the assembler (`asm/`) and emulator (`emu/`) into an interactive simulation tool. Two observation mechanisms work together: typed event callbacks in PE/SM for semantic events (what happened), and external state snapshots for full system dumps (what everything looks like now).
102102+103103+A persistent simulation thread runs SimPy, controlled by a thread-safe command queue. Both the CLI REPL and web GUI send commands to the same backend and receive the same event/snapshot responses — they differ only in presentation.
104104+105105+### Dependency Flow
106106+107107+```
108108+cm_inst.py <-- tokens.py <-- emu/events.py (new, self-contained)
109109+ |
110110+ emu/pe.py, emu/sm.py (modified: accept on_event)
111111+ |
112112+ emu/types.py (modified: on_event in PEConfig/SMConfig)
113113+ |
114114+ emu/network.py (modified: wire on_event through build_topology)
115115+ |
116116+ asm/ -----------------> monitor/backend.py
117117+ |
118118+ monitor/snapshot.py
119119+ monitor/repl.py
120120+ monitor/server.py
121121+ |
122122+ monitor/frontend/ --> frontend-common/
123123+ dfgraph/frontend/ --> frontend-common/
124124+```
125125+126126+`emu/events.py` imports only from `cm_inst` and `tokens` (which `emu/` already depends on). No circular dependencies introduced. `monitor/` imports from both `emu/` and `asm/` but neither imports from `monitor/`.
127127+128128+### Observability Layer (`emu/events.py`)
129129+130130+Union of frozen dataclasses, pattern-matchable with `match/case`:
131131+132132+```python
133133+@dataclass(frozen=True)
134134+class TokenReceived:
135135+ time: float
136136+ component: str # 'pe:0', 'sm:1'
137137+ token: Token
138138+139139+@dataclass(frozen=True)
140140+class Matched:
141141+ time: float
142142+ component: str
143143+ left: int
144144+ right: int
145145+ ctx: int
146146+ offset: int
147147+148148+@dataclass(frozen=True)
149149+class Executed:
150150+ time: float
151151+ component: str
152152+ op: ALUOp | MemOp
153153+ result: int
154154+ bool_out: bool
155155+156156+# ... Emitted, IRAMWritten, CellWritten, DeferredRead, DeferredSatisfied, ResultSent
157157+158158+SimEvent = TokenReceived | Matched | Executed | Emitted | IRAMWritten | CellWritten | DeferredRead | DeferredSatisfied | ResultSent
159159+EventCallback = Callable[[SimEvent], None]
160160+```
161161+162162+PE fires: `TokenReceived`, `Matched`, `Executed`, `Emitted`, `IRAMWritten`.
163163+SM fires: `TokenReceived`, `CellWritten`, `DeferredRead`, `DeferredSatisfied`, `ResultSent`.
164164+165165+PE and SM constructors gain `on_event: EventCallback | None = None`. `PEConfig` and `SMConfig` gain the same field so `build_topology()` can wire callbacks through. Zero-cost when unused (defaults to no-op).
166166+167167+### Command/Result Protocol (`monitor/backend.py`)
168168+169169+Commands sent to the simulation thread:
170170+171171+```python
172172+SimCommand = LoadCmd | StepTickCmd | StepEventCmd | RunUntilCmd | InjectCmd | SendCmd | ResetCmd | StopCmd
173173+```
174174+175175+- `LoadCmd(source: str)` — assemble, build topology, inject seed tokens, wire callbacks
176176+- `StepTickCmd` — loop `env.step()` while `env.peek() == current_time`
177177+- `StepEventCmd` — call `env.step()` exactly once
178178+- `RunUntilCmd(until: float)` — step continuously to target time, batch events per tick
179179+- `InjectCmd(token: Token)` — direct append via `sys.inject()` (pre-sim setup, does not wake processes)
180180+- `SendCmd(token: Token)` — spawns SimPy process calling `sys.send()` (respects FIFO, wakes waiting processes)
181181+- `ResetCmd` — tear down environment, optionally reload last program
182182+- `StopCmd` — terminate simulation thread
183183+184184+Results returned via event queue:
185185+186186+```python
187187+@dataclass
188188+class StepResult:
189189+ events: list[SimEvent]
190190+ snapshot: StateSnapshot
191191+ sim_time: float
192192+ finished: bool # True when env.peek() == inf
193193+```
194194+195195+### State Snapshots (`monitor/snapshot.py`)
196196+197197+Reads all PE/SM state from existing public attributes:
198198+199199+- PE: `iram`, `matching_store`, `gen_counters`, `input_store.items`, `output_log`
200200+- SM: `cells` (presence + data), `deferred_read`, `t0_store`, `input_store.items`
201201+- System: `env.now`, `env.peek()`
202202+203203+### WebSocket Protocol (`monitor/server.py`)
204204+205205+Bidirectional WebSocket at `/ws`:
206206+207207+Server → Client: `GraphLoaded` (IR graph JSON + initial snapshot), `StepResult` (events + snapshot), `Error`.
208208+209209+Client → Server: `step_tick`, `step_event`, `run_until`, `pause`, `send`, `inject`.
210210+211211+REST endpoints for non-WebSocket clients: `POST /load`, `POST /reset`, `GET /state`.
212212+213213+### CLI REPL (`monitor/repl.py`)
214214+215215+Built on `cmd.Cmd`. Commands: `load`, `reset`, `step`, `stepe`, `run`, `send`, `inject`, `state`, `pe`, `sm`, `log`, `time`, `help`, `quit`. Synchronous queue interaction — puts commands, blocks on results. Pretty-printed output with ANSI colours.
216216+217217+### Frontend
218218+219219+Shared `frontend-common/` library extracted from `dfgraph/frontend/`: Cytoscape.js setup, ELK layout engine, base node/edge styles, logical/physical layout switching, SVG/PNG export. Both `dfgraph/frontend/` and `monitor/frontend/` depend on it.
220220+221221+Monitor frontend has three panels:
222222+223223+- **Graph View** — Dataflow graph with execution overlay. Physical layout by default (grouped by PE). Nodes highlight on execution, edges animate on token traversal. Click node to inspect PE state at that offset.
224224+- **Event Log** — Scrolling list of `SimEvent`s, filterable by component and event type. Click to highlight relevant graph element.
225225+- **State Inspector** — Collapsible tree view of full `StateSnapshot`. PE section (IRAM, matching store, gen counters), SM section (cells, deferred reads, T0 store).
226226+227227+Controls: load file, step tick/event, run until, run/pause, send token, inject token, reset.
228228+229229+## Existing Patterns
230230+231231+### dfgraph Server Architecture
232232+233233+`dfgraph/server.py` establishes the FastAPI + WebSocket pattern: `ConnectionManager` for WebSocket broadcast, lifespan context manager for startup/shutdown, background file watcher on a separate thread bridged to async via `asyncio.run_coroutine_threadsafe()`. Monitor server follows this pattern with the addition of bidirectional WebSocket messages (dfgraph is server-push-only).
234234+235235+### dfgraph Frontend Stack
236236+237237+`dfgraph/frontend/` uses Cytoscape.js with the ELK layout plugin, TypeScript types matching the JSON schema, and SVG/PNG export. These patterns are extracted into `frontend-common/` rather than duplicated.
238238+239239+### Graph JSON Schema
240240+241241+`dfgraph/graph_json.py` produces `GraphUpdate` messages with nodes (id, opcode, category, colour, pe, iram_offset, ctx) and edges (source, target, port, addr). Monitor extends this schema with execution state fields rather than replacing it.
242242+243243+### Emulator Test Patterns
244244+245245+Tests drive the emulator via `assemble() → build_topology() → inject() → env.run() → inspect output_log`. The monitor backend follows the same pipeline but replaces `env.run()` with controlled stepping and replaces post-hoc `output_log` inspection with real-time event callbacks.
246246+247247+### No Existing Observability
248248+249249+Investigation found no event hooks, callback systems, or trace facilities in `emu/` beyond `PE.output_log` (append-only list) and standard Python `logging` calls. The typed event system in `emu/events.py` is a new pattern.
250250+251251+## Implementation Phases
252252+253253+<!-- START_PHASE_1 -->
254254+### Phase 1: Observability Hooks
255255+**Goal:** Add typed event system to `emu/` so PE and SM can emit structured events during simulation
256256+257257+**Components:**
258258+- `emu/events.py` — SimEvent union type, all event dataclasses, `EventCallback` type alias
259259+- `emu/pe.py` — accept `on_event` callback, fire events at token_received, matched, executed, emitted, iram_written points
260260+- `emu/sm.py` — accept `on_event` callback, fire events at token_received, cell_written, deferred_read, deferred_satisfied, result_sent points
261261+- `emu/types.py` — add `on_event: EventCallback | None` field to `PEConfig` and `SMConfig`
262262+- `emu/network.py` — wire `on_event` from configs through `build_topology()`
263263+264264+**Dependencies:** None (first phase)
265265+266266+**Done when:**
267267+- All existing tests pass unchanged (callbacks default to no-op)
268268+- New tests verify each event type fires at the correct point with correct payload
269269+- Covers `or1-monitor.AC2.*`
270270+<!-- END_PHASE_1 -->
271271+272272+<!-- START_PHASE_2 -->
273273+### Phase 2: Simulation Backend
274274+**Goal:** Controllable simulation engine with command queue, stepping, and state snapshots
275275+276276+**Components:**
277277+- `monitor/__init__.py` — package init
278278+- `monitor/snapshot.py` — `StateSnapshot` dataclass, `capture(sys: System)` function
279279+- `monitor/backend.py` — `SimulationBackend` class: persistent thread, command queue, event queue, all command handlers (LoadCmd through StopCmd)
280280+281281+**Dependencies:** Phase 1 (event callbacks)
282282+283283+**Done when:**
284284+- Backend can load a dfasm program, step by tick and by event, run until target time, inject/send tokens, reset
285285+- State snapshots capture all PE/SM state correctly
286286+- Covers `or1-monitor.AC1.*`, `or1-monitor.AC5.*`
287287+<!-- END_PHASE_2 -->
288288+289289+<!-- START_PHASE_3 -->
290290+### Phase 3: CLI REPL
291291+**Goal:** Usable command-line interface for interactive simulation
292292+293293+**Components:**
294294+- `monitor/repl.py` — `cmd.Cmd` subclass with all commands
295295+- `monitor/__main__.py` — CLI entry point (`python -m monitor`)
296296+297297+**Dependencies:** Phase 2 (simulation backend)
298298+299299+**Done when:**
300300+- REPL can load a dfasm file, step through execution, inspect PE/SM state, inject tokens, reset
301301+- Output is readable with ANSI colour formatting
302302+- Covers `or1-monitor.AC4.*`
303303+<!-- END_PHASE_3 -->
304304+305305+<!-- START_PHASE_4 -->
306306+### Phase 4: Frontend Common Extraction
307307+**Goal:** Extract shared graph rendering code from dfgraph into a reusable library
308308+309309+**Components:**
310310+- `frontend-common/` — shared TypeScript library (graph renderer, types, layout switching, export)
311311+- `dfgraph/frontend/` — refactored to import from `frontend-common/`
312312+313313+**Dependencies:** None (can run in parallel with Phases 1-3, but sequenced here for clarity)
314314+315315+**Done when:**
316316+- `frontend-common/` builds independently
317317+- `dfgraph/frontend/` works identically to before but imports shared code from `frontend-common/`
318318+- dfgraph's existing functionality is unaffected
319319+<!-- END_PHASE_4 -->
320320+321321+<!-- START_PHASE_5 -->
322322+### Phase 5: Web Server
323323+**Goal:** FastAPI server with WebSocket protocol for the monitor frontend
324324+325325+**Components:**
326326+- `monitor/server.py` — FastAPI app, WebSocket endpoint, REST endpoints, async event queue polling
327327+- `monitor/graph_json.py` — graph JSON with execution overlay fields
328328+329329+**Dependencies:** Phase 2 (simulation backend)
330330+331331+**Done when:**
332332+- Server starts, accepts WebSocket connections, handles all client commands
333333+- `GraphLoaded` message sent on program load with IR graph + initial snapshot
334334+- `StepResult` messages broadcast after each step
335335+- Covers `or1-monitor.AC3.*` (server-side), `or1-monitor.AC5.*` (shared backend)
336336+<!-- END_PHASE_5 -->
337337+338338+<!-- START_PHASE_6 -->
339339+### Phase 6: Monitor Frontend
340340+**Goal:** Browser-based simulation monitor with graph overlay, event log, and state inspector
341341+342342+**Components:**
343343+- `monitor/frontend/` — TypeScript frontend
344344+ - Graph view with execution overlay (physical layout default, logical available)
345345+ - Event log panel (filterable, clickable)
346346+ - State inspector panel (collapsible tree view)
347347+ - Controls (load, step, run, pause, send, inject, reset)
348348+349349+**Dependencies:** Phase 4 (frontend-common), Phase 5 (web server)
350350+351351+**Done when:**
352352+- Graph renders with execution state overlay (active nodes, token flow animation)
353353+- Event log displays and filters SimEvents
354354+- State inspector shows full PE/SM state
355355+- All controls functional
356356+- Covers `or1-monitor.AC3.*` (client-side)
357357+<!-- END_PHASE_6 -->
358358+359359+## Additional Considerations
360360+361361+**SimPy threading:** SimPy is single-threaded and synchronous. The simulation thread owns the `Environment` exclusively. All state reads from the async server or REPL happen via the command/result queues or snapshots taken within the sim thread. No concurrent access to SimPy objects.
362362+363363+**Backpressure during RunUntil:** When free-running to a target time, events are batched per tick before pushing to the event queue. This prevents flooding the WebSocket with thousands of individual events during a long run. The frontend receives tick-level batches and can animate them at its own pace.
364364+365365+**SendCmd implementation:** Since `sys.send()` is a SimPy generator, `SendCmd` spawns a one-shot SimPy process that calls `yield from sys.send(token)`. The backend then steps the environment to process the send event, which wakes any process waiting on the destination store.