Create, Compile, and Run a Phased Reactive System
Recap
By this point the video player has three layers of declarations:
- node instances such as
Network,QualityPolicy, andMediaSession; - phases such as
measure,decide,drop_quality, andplay; - transitions that say how one phase hands control to the next.
The running example: an adaptive-bitrate video player
The player samples network bandwidth, decides whether the current bitrate is sustainable, optionally lowers quality, and then plays the next chunk. The buffer and bitrate persist across ticks, so the next tick starts from the state left by the previous one.
flowchart LR
init([init]) --> measure
measure --> decide[decide]
decide -->|healthy| play
decide -->|stalling| drop_quality
drop_quality --> play
play --> done([⊥])
classDef measure fill:#2f6fed22,stroke:#2f6fed;
classDef decide fill:#7c3aed22,stroke:#7c3aed;
classDef dropQuality fill:#d9770622,stroke:#d97706;
classDef play fill:#15803d22,stroke:#15803d;
class measure measure;
class decide decide;
class drop_quality dropQuality;
class play play;
| Phase | Nodes | What happens |
|---|---|---|
| measure | Network |
Sample the current bandwidth from the system clock. |
| decide | QualityPolicy |
Compare projected drain rate against the buffer; set stalling. |
| drop_quality | BitrateController |
Drop the target bitrate by one rung. |
| play | Decoder, MediaSession, Logger |
Compute downloaded seconds, integrate the buffer, log. |
At node level, the same model looks like this.
Solid arrows show that one node reads another node's state variable; dashed
arrows from state show self-reads, where a node reads its own state variable from
the previous tick.
The node colors correspond to the phase colors in the table above:
flowchart LR
network["Network"]
policy["QualityPolicy"]
controller["BitrateController"]
decoder["Decoder"]
session["MediaSession"]
logger["Logger"]
controller_state(("state"))
session_state(("state"))
logger_state(("state"))
network --> policy
network --> decoder
network --> logger
controller --> policy
controller --> decoder
controller --> logger
decoder --> session
session --> logger
session --> policy
policy --> logger
controller_state -.-> controller
session_state -.-> session
logger_state -.-> logger
classDef measure fill:#2f6fed22,stroke:#2f6fed;
classDef decide fill:#7c3aed22,stroke:#7c3aed;
classDef dropQuality fill:#d9770622,stroke:#d97706;
classDef play fill:#15803d22,stroke:#15803d;
classDef state fill:#94a3b822,stroke:#94a3b8,stroke-dasharray:3 3;
class network measure;
class policy decide;
class controller dropQuality;
class decoder,session,logger play;
class controller_state,session_state,logger_state state;
Full code listing: examples/video_player/video_player.py
"""Adaptive-bitrate video player as a feedback control loop.
Each tick the player asks one question: do I have enough buffered video to
keep playing at the current quality? If yes, the short branch just plays the
next chunk. If no, the long branch first lowers the target bitrate, then
plays. Buffer and bitrate persist across ticks, so the branching pattern is
driven by the closed loop between the network, the buffer, and the policy.
Phase graph::
measure (init) -> decide -+-[healthy]--> play -> bottom
|
+-[stalling]-> drop_quality -> play -> bottom
"""
from __future__ import annotations
import regelum as rg
TICK_DT_SECONDS = 1.0
BITRATE_LADDER_KBPS = (240, 480, 720, 1080, 2160)
TOP_BITRATE_KBPS = BITRATE_LADDER_KBPS[-1]
STALL_HORIZON_SECONDS = 4.0
class Network(rg.Node):
"""Stochastic-looking but deterministic bandwidth model.
Drops to a slow link in the middle of the run so the policy has to react,
then recovers so the buffer can refill. The schedule is fixed to keep the
example reproducible.
"""
class Inputs(rg.NodeInputs):
tick: int = rg.src(rg.Clock.tick)
class State(rg.NodeState):
bandwidth_kbps: float = rg.var(init=float(TOP_BITRATE_KBPS))
def update(self, inputs: Inputs) -> State:
if inputs.tick < 6:
value = 2400.0
elif inputs.tick < 14:
value = 600.0
elif inputs.tick < 22:
value = 1100.0
else:
value = 2400.0
return self.State(bandwidth_kbps=value)
class QualityPolicy(rg.Node):
"""Decides whether the player is about to stall.
The estimated drain rate is ``1 - bandwidth / bitrate`` seconds of video
lost per wall-second. If buffered seconds will not survive
``STALL_HORIZON_SECONDS`` at that drain rate, mark the tick as stalling.
"""
class Inputs(rg.NodeInputs):
buffer_seconds: float = rg.src(lambda: MediaSession.State.buffer_seconds)
bitrate_kbps: int = rg.src(lambda: BitrateController.State.value)
bandwidth_kbps: float = rg.src(Network.State.bandwidth_kbps)
class State(rg.NodeState):
stalling: bool = rg.var(init=False)
def update(self, inputs: Inputs) -> State:
bitrate = max(inputs.bitrate_kbps, 1)
drain = max(0.0, 1.0 - inputs.bandwidth_kbps / bitrate)
if drain <= 0.0:
return self.State(stalling=False)
time_to_empty = inputs.buffer_seconds / drain
return self.State(stalling=time_to_empty < STALL_HORIZON_SECONDS)
class BitrateController(rg.Node):
"""Owns the current target bitrate. Drops one rung when invoked."""
class Inputs(rg.NodeInputs):
current: int = rg.src(lambda: BitrateController.State.value)
class State(rg.NodeState):
value: int = rg.var(init=TOP_BITRATE_KBPS)
def update(self, inputs: Inputs) -> State:
try:
index = BITRATE_LADDER_KBPS.index(inputs.current)
except ValueError:
index = len(BITRATE_LADDER_KBPS) - 1
next_index = max(0, index - 1)
return self.State(value=BITRATE_LADDER_KBPS[next_index])
class Decoder(rg.Node):
"""Models how many seconds of video can be downloaded in one tick.
With ``bandwidth_kbps`` of throughput and a video encoded at
``bitrate_kbps``, one wall-second of downloading produces
``bandwidth / bitrate`` seconds of playable content.
"""
class Inputs(rg.NodeInputs):
bandwidth_kbps: float = rg.src(Network.State.bandwidth_kbps)
bitrate_kbps: int = rg.src(lambda: BitrateController.State.value)
class State(rg.NodeState):
fetched_seconds: float
def update(self, inputs: Inputs) -> State:
bitrate = max(inputs.bitrate_kbps, 1)
return self.State(fetched_seconds=inputs.bandwidth_kbps / bitrate * TICK_DT_SECONDS)
class MediaSession(rg.Node):
"""The plant. Buffer fills with newly fetched video, drains with playback."""
class Inputs(rg.NodeInputs):
previous: float = rg.src(lambda: MediaSession.State.buffer_seconds)
fetched: float = rg.src(Decoder.State.fetched_seconds)
class State(rg.NodeState):
buffer_seconds: float = rg.var(init=10.0)
def update(self, inputs: Inputs) -> State:
next_buffer = inputs.previous + inputs.fetched - TICK_DT_SECONDS
return self.State(buffer_seconds=max(0.0, next_buffer))
class Logger(rg.Node):
"""Appends a per-tick record so the trajectory is visible after the run."""
Sample = tuple[int, float, int, float, bool]
class Inputs(rg.NodeInputs):
tick: int = rg.src(rg.Clock.tick)
bandwidth_kbps: float = rg.src(Network.State.bandwidth_kbps)
bitrate_kbps: int = rg.src(lambda: BitrateController.State.value)
buffer_seconds: float = rg.src(lambda: MediaSession.State.buffer_seconds)
stalling: bool = rg.src(QualityPolicy.State.stalling)
history: list["Logger.Sample"] = rg.src(lambda: Logger.State.history)
class State(rg.NodeState):
history: list["Logger.Sample"] = rg.var(init=lambda: [])
def update(self, inputs: Inputs) -> State:
record: Logger.Sample = (
inputs.tick,
inputs.bandwidth_kbps,
inputs.bitrate_kbps,
inputs.buffer_seconds,
inputs.stalling,
)
inputs.history.append(record)
return self.State(history=inputs.history)
def build_system() -> rg.PhasedReactiveSystem:
network = Network()
policy = QualityPolicy()
controller = BitrateController()
decoder = Decoder()
session = MediaSession()
logger = Logger()
return rg.PhasedReactiveSystem(
phases=[
rg.Phase(
"measure",
nodes=(network,),
transitions=(rg.Goto("decide"),),
is_initial=True,
),
rg.Phase(
"decide",
nodes=(policy,),
transitions=(
rg.If(rg.V(policy.State.stalling), "drop_quality", name="stalling"),
rg.Else("play", name="healthy"),
),
),
rg.Phase(
"drop_quality",
nodes=(controller,),
transitions=(rg.Goto("play"),),
),
rg.Phase(
"play",
nodes=(decoder, session, logger),
transitions=(rg.Goto(rg.terminate),),
),
],
)
def main() -> None:
system = build_system()
print(f"compile_ok = {system.compile_report.ok}")
print(
"phase schedules: "
+ " | ".join(
f"{name}={schedule}" for name, schedule in system.compile_report.phase_schedules.items()
)
)
print()
print("tick | bw(kbps) | bitrate | buffer(s) | stall? | path")
print("-----+----------+---------+-----------+--------+----------------------")
for _ in range(30):
records = system.step()
path_phases: list[str] = []
for record in records:
if not path_phases or path_phases[-1] != record.phase:
path_phases.append(record.phase)
path = " -> ".join(path_phases)
snapshot = system.snapshot()
print(
f"{system.read(rg.Clock.tick):4d} | "
f"{snapshot['Network.bandwidth_kbps']:8.0f} | "
f"{snapshot['BitrateController.value']:7d} | "
f"{snapshot['MediaSession.buffer_seconds']:9.2f} | "
f"{str(snapshot['QualityPolicy.stalling']):>6} | "
f"{path}"
)
if __name__ == "__main__":
main()
What Regelum Checks
The PRS declaration is intentionally flexible: a model author can create
overlapping conditional transitions, omit a transition case, place mutually
dependent nodes in the same phase, or create a phase graph whose control flow
never reaches terminate.
Regelum therefore compiles and checks the declarative PRS structure before the
first runtime step.
The formal conditions are:
| Condition | Meaning | Runtime problem it prevents |
|---|---|---|
C1 |
Every phase-local node dependency graph is acyclic. | Nodes in one phase cannot require each other's current output in a circular order. |
C2 |
The phase-transition graph is acyclic. | A tick cannot loop forever by following phase edges. |
C3 |
For every phase and state, exactly one outgoing transition is enabled. | A phase cannot have either no next step or multiple possible next steps. |
C2* |
A semantic cycle check: a phase cycle is allowed only when the guards cannot be satisfied for enough consecutive traversals. | Dead or self-blocking cycles can be accepted while live infinite loops are rejected. |
C1, C2, and C3 are syntactic sufficient conditions.
They are intentionally conservative: if all three hold, the compiled system has
an executable node order, a terminating tick graph, and deterministic branch
selection.
C2* is a less conservative termination check for cyclic phase graphs.
Instead of rejecting every phase cycle, it asks whether a cycle can actually be
followed repeatedly under the symbolic guards that lead around the cycle.
The finite bound is computed from R_C, the guard-relevant cycle-state set:
state variables that are both read by cycle guards and written by phases in the
same cycle.
Current Regelum checks these items concretely:
- Node and phase consistency. Every node state path must be unique; every
node name must be unique; every input source must be connected, known, and
unambiguous; every instance-bound input or guard source must belong to a node
assigned to a phase; every phase target must resolve to another declared
phase or to
terminate. - Transition structure.
Gotocannot be mixed withIf/ElseIf/Elsechains.ElseIfandElsemust follow an openIfchain, and a chain cannot continue afterElse. - C1 phase schedulability. For each phase, Regelum builds edges from each
active node to active nodes that read its state in the same phase. The phase
is accepted only if those edges admit a topological order. The resolved order
is stored in
compile_report.phase_schedules. - C3 guarded-transition determinism. Regelum converts
If/ElseIf/Elsechains into effective predicates. For symbolic predicates built withrg.V(...), it uses Z3 to prove that no two effective predicates overlap and that their disjunction covers the relevant state space. For callable guards, it samples finite state domains when possible. A failure reports either an overlapping transition pair or a state where no transition is enabled. - C2 and C2* tick termination. If the phase graph has no cycles, it satisfies
C2 directly. If the graph has cycles, Regelum extracts each simple cycle and
tries C2*: every edge around that cycle must have symbolic guards, and all
variables in
R_Cmust have finite domains. The checker asks Z3 whether the cycle can be traversed for|D^{R_C}|consecutive traversals. If such a witness exists, the cycle is rejected; if the formula is unsatisfiable, the cycle is accepted as dead or self-blocking. - Continuous-phase contract. When a system has a continuous phase, Regelum also checks that a tick reaches exactly one continuous phase on every feasible path. This avoids silently choosing semantics for repeated continuous integration inside one logical tick.
Build The Runtime System
At this point the declarations are still just Python objects: node instances, phase objects, and transition objects. They describe the intended feedback loop, but nothing has been scheduled or executed yet.
rg.PhasedReactiveSystem is the boundary between declaration and runtime.
When you construct it, Regelum compiles the phase list into a concrete runtime
model: it resolves references, builds node schedules, checks transition
targets, computes initial-state requirements, and stores the result in
compile_report.
import regelum as rg
def build_system() -> rg.PhasedReactiveSystem:
network = Network()
policy = QualityPolicy()
controller = BitrateController()
decoder = Decoder()
session = MediaSession()
logger = Logger()
return rg.PhasedReactiveSystem(
phases=[
rg.Phase(
"measure",
nodes=(network,),
transitions=(rg.Goto("decide"),),
is_initial=True,
),
rg.Phase(
"decide",
nodes=(policy,),
transitions=(
rg.If(
rg.V(policy.State.stalling),
"drop_quality",
name="stalling",
),
rg.Else("play", name="healthy"),
),
),
rg.Phase(
"drop_quality",
nodes=(controller,),
transitions=(rg.Goto("play"),),
),
rg.Phase(
"play",
nodes=(decoder, session, logger),
transitions=(rg.Goto(rg.terminate),),
),
],
)
rg.PhasedReactiveSystem is the object you keep and run.
It owns the compiled phase graph, the current runtime state, and the compile
report.
Constructor Behavior
The constructor receives the phases that define the system.
During construction, regelum compiles the system.
If compilation succeeds, the returned object is ready for step(), update(),
snapshot(), read(...), and reset(...).
If compilation fails, the default behavior is to raise CompileError.
Use strict=False when you want to inspect a broken system instead of raising
immediately:
system = rg.PhasedReactiveSystem(
phases=phases,
strict=False,
)
print(system.compile_report.issues)
What compilation resolves
Compilation resolves:
- node names;
- input sources;
- instance connections;
- state paths;
- phase targets;
- guard references;
- phase schedules;
- dependency edges;
- required initial state variables.
For the video player, the report's phase_schedules shows the topologically
ordered nodes per phase. minimal_initial_state_vars lists the state variables
that must exist before their first read:
BitrateController.value, Logger.history, and
MediaSession.buffer_seconds.
Compile report
Every rg.PhasedReactiveSystem stores a compile_report.
Read it before debugging runtime behavior; it tells you what the constructor
resolved and what it rejected.
system = build_system()
report = system.compile_report
print(report.ok)
print(report.issues)
print(report.warnings)
print(report.phase_schedules)
print(report.minimal_initial_state_vars)
print(report.required_initial_state_vars)
For the video player this prints:
True
()
()
{'measure': ('Network',), 'decide': ('QualityPolicy',), 'drop_quality': ('BitrateController',), 'play': ('Decoder', 'MediaSession', 'Logger')}
('BitrateController.value', 'Logger.history', 'MediaSession.buffer_seconds')
{'BitrateController.value': ('BitrateController.current', 'Decoder.bitrate_kbps', 'Logger.bitrate_kbps', 'QualityPolicy.bitrate_kbps'), 'Logger.history': ('Logger.history',), 'MediaSession.buffer_seconds': ('MediaSession.previous', 'QualityPolicy.buffer_seconds')}
Read this output as follows:
ok=Truemeans compilation produced no errors.issues=()means nothing was rejected. If a C1/C2/C3 check fails, this tuple containsCompileIssueobjects naming the phase and condition.warnings=()means there are no non-fatal structural warnings.phase_schedulesis the per-phase topological order. Theplayphase runsDecoderfirst, thenMediaSession, thenLogger, because the session readsDecoder.State.fetched_secondsand the logger reads the session state.minimal_initial_state_varsis the smallest set of state variables that must have a tick-zero value for this compiled graph.required_initial_state_varsmaps each such state variable to the inputs that may read it before it is written in the current tick. The map is not an error by itself: in this example all three variables haverg.var(init=...), so the constructor can initialize them automatically.
Use format() for the same report plus dependency edges:
ok = True
issues = ()
warnings = ()
minimal_initial_state_vars = ('BitrateController.value', 'Logger.history', 'MediaSession.buffer_seconds')
state_vars_without_initial = ('Decoder.fetched_seconds',)
required_initial_state_vars = {'BitrateController.value': ('BitrateController.current', 'Decoder.bitrate_kbps', 'Logger.bitrate_kbps', 'QualityPolicy.bitrate_kbps'), 'Logger.history': ('Logger.history',), 'MediaSession.buffer_seconds': ('MediaSession.previous', 'QualityPolicy.buffer_seconds')}
phase_schedules = {'measure': ('Network',), 'decide': ('QualityPolicy',), 'drop_quality': ('BitrateController',), 'play': ('Decoder', 'MediaSession', 'Logger')}
phase_dependency_edges = {'measure': (), 'decide': (), 'drop_quality': (), 'play': (('Decoder', 'MediaSession'), ('MediaSession', 'Logger'))}
state_vars_without_initial can contain variables that are still safe.
Here Decoder.fetched_seconds has no init, but it is written in play
before MediaSession reads it in the same phase. Therefore it is not listed in
required_initial_state_vars and does not produce an issue.
minimal_initial_state_vars is the smallest set of state variables that must
have a tick-zero value for this compiled graph.
Those values may come from rg.var(init=...), from a callable initializer, or
from a runtime initial_state override.
required_initial_state_vars tells you which state variables are read before
they are guaranteed to be written by the compiled phase schedule.
If any of those variables has no declared init and no runtime override,
Regelum reports an issue.
You can use that list to build an initial_state mapping:
missing = system.compile_report.required_initial_state_vars
print(missing)
system.reset(
initial_state={
MediaSession.State.buffer_seconds: 5.0,
BitrateController.State.value: 720,
}
)
In the video player, all required tick-zero state variables have either a static initial value or a callable initializer.
Common compile issues
Typical issues include:
- input source is not connected;
- input source is unknown;
- class-level reference is ambiguous;
- state path is duplicated;
- explicit node names are duplicated;
- state variable without initial value is read too early;
- phase graph is incomplete;
- transition target is unknown;
- transition chain is malformed.
C1, C2, C3, and C2* Examples
Compilation rejects cyclic dependency graphs inside a phase.
This is the C1 check.
For the video player, the only non-trivial intra-phase dependency chain is
Decoder → MediaSession → Logger in play, which is acyclic.
For finite state variable domains, compilation also checks C3 by requiring exactly one
enabled transition per sampled state.
The branching in decide is If(V(QualityPolicy.State.stalling),
"drop_quality") plus Else("play"), with stalling: bool — boolean has a
finite domain, so C3 is verified statically.
The video-player phase graph is acyclic, so it satisfies C2 directly and does not need a C2* cycle proof.
You can see all three facts in the compile report:
from examples.video_player.video_player import build_system
system = build_system()
print(system.compile_report.format())
ok = True
issues = ()
warnings = ()
minimal_initial_state_vars = ('BitrateController.value', 'Logger.history', 'MediaSession.buffer_seconds')
state_vars_without_initial = ('Decoder.fetched_seconds',)
required_initial_state_vars = {'BitrateController.value': ('BitrateController.current', 'Decoder.bitrate_kbps', 'Logger.bitrate_kbps', 'QualityPolicy.bitrate_kbps'), 'Logger.history': ('Logger.history',), 'MediaSession.buffer_seconds': ('MediaSession.previous', 'QualityPolicy.buffer_seconds')}
phase_schedules = {'measure': ('Network',), 'decide': ('QualityPolicy',), 'drop_quality': ('BitrateController',), 'play': ('Decoder', 'MediaSession', 'Logger')}
phase_dependency_edges = {'measure': (), 'decide': (), 'drop_quality': (), 'play': (('Decoder', 'MediaSession'), ('MediaSession', 'Logger'))}
For C1, inspect phase_dependency_edges: only play has intra-phase edges,
and they form a chain, not a cycle.
For C3, issues=() means the symbolic branch from decide has neither
overlap nor a missing case.
For C2, the phase graph has no edge returning to an earlier phase:
measure -> decide -> drop_quality -> play -> terminate and
measure -> decide -> play -> terminate are both finite paths.
C1 Violation: One Phase Has A Node Cycle
C1 is local to a single phase. The phase is invalid if the active nodes cannot be topologically ordered. This example puts two mutually dependent nodes in one phase:
flowchart LR
subgraph coupled["phase: coupled"]
first["First<br/>reads Second.b<br/>writes First.a"]
second["Second<br/>reads First.a<br/>writes Second.b"]
first -->|"First.a"| second
second -->|"Second.b"| first
end
coupled --> done([⊥])
classDef bad fill:#dc262622,stroke:#dc2626;
class first,second bad;
The corresponding script is examples/compile_checks/c1_violation.py.
It prints:
Full code listing: examples/compile_checks/c1_violation.py
from __future__ import annotations
import regelum as rg
class First(rg.Node):
class Inputs(rg.NodeInputs):
b: float = rg.src("Second.State.b")
class State(rg.NodeState):
a: float = rg.var(init=0.0)
def update(self, inputs: Inputs) -> State:
return self.State(a=inputs.b + 1.0)
class Second(rg.Node):
class Inputs(rg.NodeInputs):
a: float = rg.src(First.State.a)
class State(rg.NodeState):
b: float = rg.var(init=0.0)
def update(self, inputs: Inputs) -> State:
return self.State(b=inputs.a + 1.0)
def build_system() -> rg.PhasedReactiveSystem:
first = First()
second = Second()
return rg.PhasedReactiveSystem(
phases=[
rg.Phase(
"coupled",
nodes=(first, second),
transitions=(rg.Goto(rg.terminate),),
is_initial=True,
)
],
)
def main() -> None:
try:
build_system()
except rg.CompileError as exc:
for issue in exc.report.issues:
print(f"{issue.location}: {issue.message}")
return
raise RuntimeError("Expected C1 violation, but system compiled.")
if __name__ == "__main__":
main()
The fix is to split the instantaneous cycle across phases, or to make one edge read a state value from a previous tick instead of another node's current output in the same phase.
C2 Violated, But C2* Passes: A Dead Phase Cycle
C2 is syntactic: it rejects every cycle in the phase-transition graph. That is safe, but conservative. C2* is more precise: it checks whether the cycle can actually be traversed repeatedly under the symbolic guards.
The split-write example has a cycle in the phase graph:
flowchart LR
phi0["phi0<br/>writes X.x"]
phi1["phi1<br/>writes Y.y"]
done([⊥])
phi0 -->|"~X.x & ~Y.y"| phi1
phi0 -->|"else"| done
phi1 -->|"~X.x & Y.y"| phi0
phi1 -->|"else"| done
classDef phase fill:#4f46e522,stroke:#4f46e5;
classDef done fill:#64748b22,stroke:#64748b;
class phi0,phi1 phase;
class done done;
The first traversal can go phi0 -> phi1 -> phi0, but after phi1 writes
Y.y, the guard from phi0 back into the cycle requires ~Y.y.
The cycle blocks itself. Therefore C2 fails syntactically, but C2* accepts the
system:
Full code listing: examples/compile_checks/c2star_split_writes.py
from __future__ import annotations
# ruff: noqa: I001
import regelum as rg
import random
class X(rg.Node):
class State(rg.NodeState):
x: bool = rg.var(init=False)
def update(self, x: bool = rg.src(lambda: X.State.x)) -> State:
return self.State(x=x & bool(random.getrandbits(1)))
class Y(rg.Node):
class State(rg.NodeState):
y: bool = rg.var(init=False)
def update(self, y: bool = rg.src(lambda: Y.State.y)) -> State:
return self.State(y=y & bool(random.getrandbits(1)))
def build_system(
p_x: float = 0.5,
p_y: float = 0.5,
seed: int | None = 0,
) -> rg.PhasedReactiveSystem:
del p_x, p_y, seed
return rg.PhasedReactiveSystem(
phases=[
rg.Phase(
"phi0",
nodes=(X(),),
transitions=(
rg.If(~rg.V(X.State.x) & ~rg.V(Y.State.y), "phi1"),
rg.Else(rg.terminate),
),
is_initial=True,
),
rg.Phase(
"phi1",
nodes=(Y(),),
transitions=(
rg.If(~rg.V(X.State.x) & rg.V(Y.State.y), "phi0"),
rg.Else(rg.terminate),
),
),
],
)
def main() -> None:
system = build_system()
print(f"compile ok = {system.compile_report.ok}")
print("C2*(2) status = pass")
print("cycle phi0 -> phi1 -> phi0 is dead after one traversal")
if __name__ == "__main__":
main()
This is the kind of cyclic phase graph C2* is meant to keep: the graph contains a loop, but no infinite tick execution can keep following it.
C2* Violation: A Feasible Infinite Loop
Now compare a phase cycle whose guards can remain true forever:
flowchart LR
a["a<br/>writes Mode.flag"]
b["b<br/>writes Mode.flag"]
done([⊥])
a -->|"Mode.flag"| b
a -->|"~Mode.flag"| done
b -->|"Mode.flag"| a
b -->|"~Mode.flag"| done
classDef bad fill:#dc262622,stroke:#dc2626;
classDef done fill:#64748b22,stroke:#64748b;
class a,b bad;
class done done;
Both phases write the same guard-relevant state variable, Mode.flag.
Under the compile-time abstraction, an update method may produce any
type-correct value for its state variable. Z3 can therefore find a witness in
which Mode.flag remains true on every traversal. Regelum rejects the cycle:
Full code listing: examples/compile_checks/c2star_cycle.py
from __future__ import annotations
import regelum as rg
class Mode(rg.Node):
class State(rg.NodeState):
flag: bool = rg.var(init=False)
def build_dead_cycle_system() -> rg.PhasedReactiveSystem:
mode = Mode()
return rg.PhasedReactiveSystem(
phases=[
rg.Phase(
"a",
nodes=(mode,),
transitions=(
rg.If(rg.V(Mode.State.flag), "b", name="to-b"),
rg.If(~rg.V(Mode.State.flag), rg.terminate, name="stop-a"),
),
is_initial=True,
),
rg.Phase(
"b",
nodes=(mode,),
transitions=(
rg.If(~rg.V(Mode.State.flag), "a", name="to-a"),
rg.If(rg.V(Mode.State.flag), rg.terminate, name="stop-b"),
),
),
],
)
def build_live_cycle_system() -> rg.PhasedReactiveSystem:
mode = Mode()
return rg.PhasedReactiveSystem(
phases=[
rg.Phase(
"a",
nodes=(mode,),
transitions=(
rg.If(rg.V(Mode.State.flag), "b", name="to-b"),
rg.If(~rg.V(Mode.State.flag), rg.terminate, name="stop-a"),
),
is_initial=True,
),
rg.Phase(
"b",
nodes=(mode,),
transitions=(
rg.If(rg.V(Mode.State.flag), "a", name="to-a"),
rg.If(~rg.V(Mode.State.flag), rg.terminate, name="stop-b"),
),
),
],
)
def main() -> None:
try:
dead = build_dead_cycle_system()
except rg.CompileError as exc:
print("dead cycle: compile=False")
for issue in exc.report.issues:
print(f"{issue.location}: {issue.message}")
else:
print(f"dead cycle: compile={dead.compile_report.ok}")
try:
build_live_cycle_system()
except rg.CompileError as exc:
print("live cycle: compile=False")
for issue in exc.report.issues:
print(f"{issue.location}: {issue.message}")
return
print("live cycle: compile=True")
if __name__ == "__main__":
main()
a -> b -> a: C2*(2) violation: cycle is feasible for 2 traversal(s), R_C=['Mode.flag'], witness={...}
The important part is R_C=['Mode.flag']: the cycle controls the same state
variable that its continuation guards read. Since the two-phase cycle can keep
choosing values that satisfy the cycle guards, it is a possible infinite loop.
Plain C2 Violation: An Unconditional Cycle
An unconditional phase cycle is the simplest infinite-loop shape:
flowchart LR
a["a"]
b["b"]
a -->|"Goto b"| b
b -->|"Goto a"| a
classDef bad fill:#dc262622,stroke:#dc2626;
class a,b bad;
There is no guard that can block re-entry. C2 fails because the phase graph is cyclic, and C2* also fails immediately:
Full code listing: examples/compile_checks/unconditional_cycle.py
from __future__ import annotations
import regelum as rg
def build_system() -> rg.PhasedReactiveSystem:
return rg.PhasedReactiveSystem(
phases=[
rg.Phase(
"a",
nodes=(),
transitions=(rg.Goto("b"),),
is_initial=True,
),
rg.Phase(
"b",
nodes=(),
transitions=(rg.Goto("a"),),
),
],
)
def main() -> None:
try:
build_system()
except rg.CompileError as exc:
for issue in exc.report.issues:
print(f"{issue.location}: {issue.message}")
if __name__ == "__main__":
main()
R_C=[] means no cycle-owned state variable is needed to keep the loop alive;
the unconditional edges alone are enough.
Runtime
After construction, the same rg.PhasedReactiveSystem object is the runtime
handle.
Runtime executes the compiled phase schedules and updates system state.
It does not reinterpret declarations on every step.
The tick
A tick walks the phase graph from the initial phase until a transition reaches
terminate.
For the video player, a healthy tick visits measure → decide → play; a
stalling tick visits measure → decide → drop_quality → play.
The feedback loop closes between ticks: MediaSession.buffer_seconds written
in play of tick N is read by QualityPolicy in decide of tick N+1, and
that read is what selects the branch.
Every system also has a built-in rg.Clock.
Clock.tick is incremented after the whole tick terminates.
In a discrete-only system, Clock.time advances with the tick by base_dt.
When a tick contains a continuous phase, Clock.time advances immediately
after that continuous phase, so later phases in the same tick can observe the
new physical time.
See Continuous dynamics for the ODE resolution rules.
Step order
One step() starts at the initial phase and follows transitions until the
tick terminates.
For each phase:
- run active nodes in the compiled schedule;
- build each node input namespace;
- call
update; - normalize returned state variables;
- write state variables into state;
- choose the next phase from transitions.
Each record contains the phase, node, inputs, and state variables. A 30-tick run of the player produces records like:
for record in records:
print(record.phase, record.node, record.state)
# measure Network {'bandwidth_kbps': 600.0}
# decide QualityPolicy {'stalling': False}
# play Decoder {'fetched_seconds': 0.278}
# play MediaSession {'buffer_seconds': 9.11}
# play Logger {'history': [...]}
Running multiple ticks
Use update(steps=...) to execute several ticks.
Each tick starts from the initial phase again.
State persists across ticks unless reset() is called.
State access
Use snapshot() to inspect current state.
It returns user node state variables and committed ODE state values; system clock
fields are read explicitly.
snapshot = system.snapshot()
print(snapshot["MediaSession.buffer_seconds"])
print(snapshot["BitrateController.value"])
Use read(...) when code has a state reference.
buffer = system.read(session.State.buffer_seconds)
tick = system.read(rg.Clock.tick)
time = system.read(rg.Clock.time)
Reset
reset() clears runtime state and history.
It then applies declared initial values and optional overrides.
Logging nodes
A logger is just another node.
It sees the values available at the point where its scheduled phase runs.
The video player puts Logger last in play so it observes the buffer
update from MediaSession and the freshly committed bitrate.
Rules
- Read the compile report before debugging runtime behavior.
- Create systems with
rg.PhasedReactiveSystem(phases=[...]). - Use
strict=Falsefor diagnostics. - Resolve ambiguous class references with instance connections.
- Add initial values only for state variables that must exist before execution.
- Runtime follows compiled phase schedules.
- State persists between ticks.
reset()clears state and history.step()returns execution records.snapshot()returns user-visible state; useread(rg.Clock.time)for clock fields.