Architecture
How the engine works under the hood. For the DSL vocabulary (tags, rungs, instructions), see Core Concepts. For the execution API, see Runner.
The Redux model
pyrung is architected like Redux: state is immutable, logic is a pure function, and execution is consumer-driven.
Every step() call takes the current SystemState, evaluates all rungs as pure functions, and produces a new SystemState. The old state is still accessible. This makes programs deterministic, testable, and debuggable — the same state plus the same inputs always produce the same next state.
SystemState
class SystemState(PRecord):
scan_id : int # scan counter (resets to 0 on STOP→RUN/reboot)
timestamp : float # simulation clock (seconds)
tags : PMap # tag values, keyed by name string
memory : PMap # engine-internal state (edge detection, timer fractionals)
tags is everything user code touches. memory is internal engine bookkeeping — edge detection bits (rise/fall), timer fractional accumulators, etc.
SystemState is a PRecord from the pyrsistent library — a frozen, persistent data structure that shares structure between versions for memory efficiency. Each scan produces a new SystemState without modifying the previous one.
Scan cycle
Every step() executes exactly one complete scan cycle through nine phases:
Phase 0 SCAN START Dialect resets (e.g., Click auto-clears SC40/SC43/SC44)
Phase 1 APPLY PATCH One-shot inputs from patch() written to context
Phase 2 READ INPUTS InputBlock values copied from external source
Phase 3 APPLY FORCES Pre-logic force pass (debug overrides)
Phase 4 EXECUTE LOGIC Rungs evaluated top-to-bottom
Phase 5 APPLY FORCES Post-logic force pass (re-assert force values)
Phase 6 WRITE OUTPUTS OutputBlock values pushed to external sink
Phase 7 ADVANCE CLOCK scan_id += 1, timestamp updated per TimeMode
Phase 8 SNAPSHOT New SystemState committed
All writes within a scan are batched in a ScanContext and committed atomically at phase 8. Rungs see each other's writes immediately — a write in rung 3 is visible to rung 4 in the same scan.
ScanContext
ScanContext is the mutable working space for a single scan. It holds pending tag writes, memory updates, and force state. The engine creates one at scan start and commits it at phase 8 to produce the next immutable SystemState.
User code never touches ScanContext directly — it's an internal detail of the scan cycle. The runner.active() context manager reads and writes through it transparently.
Consumer-driven execution
The engine never runs unsolicited. The consumer drives execution at whatever granularity it needs:
runner.step() # one complete scan
runner.run(cycles=100) # N scans
runner.run_for(1.0) # advance by simulation time
runner.run_until(~Motor) # stop on condition
This inversion of control is what makes pyrung suitable for testing, GUIs, and debuggers. A pytest test calls step() and asserts. A VS Code extension calls scan_steps_debug() and renders decorations. A soft PLC calls step() in a loop driven by a Modbus server.
Source location capture
During the DSL build phase (Rung, rise(), out(), operators, builder-style APIs), each element captures its source file and line number. This metadata enables mapping from engine objects back to user code for editor integration.
Captured metadata per element:
- source_file: str | None
- source_line: int | None
- end_line: int | None (for block contexts like Rung and branch; best-effort via AST end_lineno)
Builder flows (shift(...).clock(...).reset(...), count_up(...).reset(...), etc.) preserve the original callsite metadata through the chain.
If rungs are built in a loop, multiple rung objects may share source lines. The mapping is best-effort in this case; explicit DSL declarations maintain a clean one-to-one mapping.
Debug stepping APIs
These APIs are used by the DAP adapter and are not part of the typical user workflow.
scan_steps() — rung-boundary generator
for rung_index, rung, ctx in runner.scan_steps():
print(f"After rung {rung_index}: {dict(ctx._tags_pending)}")
# scan commits when generator is exhausted
Executes one scan, yielding after each top-level rung evaluation. The scan only commits atomically when the generator is fully exhausted. Partially consuming the generator leaves the runner in a partially-evaluated state.
scan_steps_debug() — instruction-level stepping
for step in runner.scan_steps_debug():
print(step.rung_index, step.kind, step.source_line, step.enabled_state)
Yields ScanStep objects at rung, branch, subroutine, and instruction boundaries. This is the API the DAP adapter uses to drive execution with source location information. Same commit semantics as scan_steps().
Rung inspection
inspect() and inspect_event() return retained debug trace data. Currently populated only through scan_steps_debug() (including DAP stepping paths) — scans produced by step()/run() do not retain rung trace.
inspect(rung_id, scan_id=None)
Returns a RungTrace for one rung in one scan:
RungTrace.scan_id— committed scan idRungTrace.rung_id— top-level rung index (0-based)RungTrace.events— ordered tuple ofRungTraceEvent
Each RungTraceEvent captures one debug boundary:
kind:"rung"|"branch"|"subroutine"|"instruction"source_file,source_line,end_linesubroutine_name,depth,call_stackenabled_state,instruction_kindtrace:TraceEvent | None
If scan_id is omitted, uses runner.playhead. Missing/evicted scans raise KeyError(scan_id). Existing scans with no retained trace raise KeyError(rung_id).
inspect_event()
Returns the latest debug-trace event as (scan_id, rung_id, RungTraceEvent) or None:
- Prefers in-flight events from an active
scan_steps_debug()scan - Falls back to latest committed retained debug-path event
- Returns
Nonewhen no debug trace context exists