tickflow
A small Petri-style workflow control framework for building short, auditable,
reversible flows. You describe the graph in a mermaid-like syntax; the engine
runs it as synchronous Petri-net steps over a boolean slot marking, with
all runtime state centralized in RunState — making snapshots, pause, rewind,
and replay cheap.
Is this a state machine? No — it's a Petri net (specifically a marked graph with AND/OR joins). A finite state machine is the degenerate case where exactly one token is live in the whole net at any time.
tickflowsupports multiple concurrent starts, AND-joins (wait for all upstream), OR-joins (fire on any upstream), and cycles — none of which plain FSMs express natively. See Design notes.
Quick start
python -m tickflow run examples/counter_loop.txt -b examples/counter_loop_beh.py
The graph file declares structure only; a Python "behaviours" file
registers the actual body/guard callables on tickflow.registry.
Graph syntax
[A]-->B # A is a start node; plain edge A→B (always True)
B--|g1|-->C # guarded edge: slot is True iff guard g1(view) is True
B--|g1|-->A # cycle / loop-back edge
C.inputs: A, B[2] # C reads A (latest_before) and B's 2nd fire (1-based)
C.body: compute_c # C's body is the registered callable compute_c
C.join: OR # override join (AND is the default)
[A]marks a start node. Multiple starts are allowed and fire concurrently at tick 0.-->is a plain data-flow edge: it always writesTrueinto the downstream slot when its source fires.--|name|-->is a guarded edge: it writesguard(view)(so a failing guard writesFalse, an explicit clobber — a staleTruefrom a previous iteration can never leak into a loop).node.inputs:andnode.body:lines bind behaviour. Both are optional (default body = identity/echo; default inputs = every producer, each withlatest_before).inputsmay reference non-producer nodes (e.g.A[k]to pin A's k-th fire) as long as the referenced node is upstream in the graph (has a directed path to the consumer).#starts a comment.
Behaviours file
from tickflow import registry
from tickflow.views import Missing
@registry.body("incr")
def incr(v):
return v.A.value + 1 # v.A is the resolved value of producer A
@registry.guard("cont_lt3")
def cont(v):
return v.B.value < 3 # guard on edge out of B sees B's output
A body or guard receives a DictView: v.A (or v["A"]) resolves the
declared producer per the node's input policy. v.A.value is the bare value;
v.A.k is the fire index used (or None for latest_before). A producer
with no qualifying fire yet yields the Missing sentinel (falsy).
Architecture
RunState — single source of truth
All runtime state lives in RunState, organized in three internal layers
with distinct responsibilities:
RunState
_edges dict[node, list[(tick, output)]] windowed (last 2 fires/node), for resolve()
_state dict[node, dict] current mutable state per node, O(1)
_records list[NodeState] audit in memory: keep_records AND no persistent backend
_edges— windowed fast-lookup index forresolve(): only the last two firings per node stay in memory; older firings live in the backend. Memory usage is O(nodes × 2 × output size), independent of how many times a node fired._state— current mutable state per node (what bodies write viaview.state). Always maintained, O(1) access. Replaces the oldMarking.node_state._records— fullNodeStaterecords in memory. Maintained only whenkeep_records=Trueand no persistent backend. With a backend (the default), the audit lives on disk andaudit()queries it.
Derived artefacts are extracted from these three layers — or from the backend when one is attached:
| Query | Source (persistent backend) | Source (NullBackend) |
|---|---|---|
resolve(latest) — input resolution |
_edges window (O(1), zero I/O) |
_edges window (same) |
resolve(index) — k-th fire |
window first, then backend.firing_at |
window; outside → Missing |
firings_of() — output history |
backend.firings_of (full) |
_edges window |
audit() — full audit log |
backend.list_firings |
_records (keep_records) |
to_snapshot_data() — snapshot |
window + state (+ fire_counts) |
same |
NodeState — one firing, all data
@dataclass
class NodeState:
tick: int # which tick
node: str # which node
inputs: dict[str, Any] # resolved input values
output: Any # body return value
edges_fired: list[tuple[...]] # downstream edge results (filled in Phase B)
status: "ok" | "failed" | "aborted"
error: str | None
mutable_state: dict[str, Any] # node state after body ran
This is the single source of truth for everything that happened to a node
at a given tick. It replaces the old Firing class (same fields, unified
ownership).
Memory-saving mode
rn = Runner(graph, registry, keep_records=False)
When keep_records=False, _records is not populated — saving memory — but
_edges and _state are still maintained. Input resolution and node mutable
state work correctly regardless of this switch. The snapshot omits the
"records" key. Backend persistence (firings, snapshots) is unaffected.
By default (backend=None) the Runner creates a temporary SQLite backend in
the system temp dir, auto-generates a session_id, and removes the database
file when the Runner is garbage-collected — so persistence, audit, checkpoints
and A[k] index reads work out of the box. Pass NullBackend() explicitly
for a zero-I/O in-memory run, or a concrete backend for a persistent one.
Semantics
The model
The engine is a pure function:
tick: (marking_t, run_state, t) -> (marking_{t+1}, firings_t)
Only two mutable state containers exist:
- marking —
dict[(dst, src), bool], one boolean slot per incoming edge, plus a set of armed start nodes (one-shot). - run_state —
RunStateinstance (windowed history, state, audit).
There is no hidden in-flight state. That's what makes snapshots cheap.
A tick
- Compute the set of fireable nodes: a node fires iff
- it is an armed start (fires once, then disarmed), or
- its join predicate holds over its input slots:
allfor AND,anyfor OR.
- Each fireable node fires concurrently: its body reads inputs
(
latest_before(t)— strictly prior ticks, so peers can't see each other's same-tick writes), its output is recorded inRunState, and its input slots are consumed (reset toFalse). - After all fires, each fired node's out-edges write into downstream slots:
plain edges write
True, guarded edges writeguard(view).
Input policies
latest(default): the producer's most recent fire withtick < t. This is the marking-consistent read — a node cannot see a peer's same-tick write. In a loop, this means "the previous iteration's output".A[k](index): the producer'sk-th fire overall (1-based), independent of tick. For cross-iteration pinning and audit replays.
A producer with no qualifying fire yields Missing (falsy); bodies are
expected to handle it.
Inputs may reference nodes that are not direct producers (no edge into the
consumer), e.g. C.inputs: A[1] where A is upstream of C via a longer path.
This is valid as long as A has a directed path to C (so A fires before C).
Joins
- AND-join (default): fire iff all input slots are
True. Use for "wait for all upstream". - OR-join: fire iff ≥1 input slot is
True. Declare withnode.join: OR. Use when a node has multiple producers but should fire on any one (e.g. a node that's both a loop member and seeded by a one-shot start).
Deadlock detection
If an AND-join M has ≥2 producers lying on mutually-exclusive branches
of an XOR-splitter B (a node with ≥2 guarded out-edges), M would
deadlock: each fire of B sets at most one branch's slots True, so M
waits forever for the other half. The checker flags this and (in the CLI)
prompts to promote M to OR-join. OR-joins don't deadlock here because the
synchronous step semantics make the "≥1 slot" predicate decidable — no
"will more tokens arrive?" question (the open Petri-net OR-join problem).
from tickflow import parse, check, promote
g = parse(text, registry=r)
for s in check(g): # list[DeadlockSuggestion]
promote(s, g) # flips s.node.join to "OR"
Constructing a Runner with unresolved suggestions raises DeadlockError
(no silent deadlocks).
Static warnings (parse time)
The parser emits warnings for common pitfalls:
| Warning | Condition |
|---|---|
| Consumer reads from bodyless producer | C.inputs references a node with no body — C will receive None |
| Non-producer input | C.inputs references a node not connected by an edge — resolves via history, not token flow |
| Unguarded cycle | A cycle has no guarded edge — will loop forever |
At runtime, the engine warns when a node returning Failure has guarded
out-edges (guards are never evaluated for failed nodes — all out-edges
write False).
Snapshot, pause, rewind
rn = Runner(graph, registry)
rn.run_until_idle(max_ticks=100, pause_at={5}) # stop before tick 5
snap = rn.snapshot() # JSON-able dict
rn.run_until_idle(max_ticks=100) # finish
rn.restore(snap) # rewind to tick 5
rn.run_until_idle(max_ticks=100) # replay (identical if bodies pure)
snapshot()returns{"tick", "marking", "run_state", "status", "cancel_reason", "fireable"}— pure JSON.run_statecontainsedges(windowed output index),state(mutable state per node), andrecords(in-memory audit, populated only whenkeep_records=Trueand no backend).restore(snap)rewinds: sets tick/marking/run_state/status fromsnap. RunState records withtick >= snap["tick"]are dropped. A restored terminal status (ABORTED/CANCELLED/FAILED) is reset to IDLE so the run can resume.pause_at={n}stops at the tick boundary before tickn— no half-fired state to save.- Branching / what-if:
copy.deepcopy(snap)andrestoreinto separate Runners. The library doesn't maintain a timeline forest. to_json()/from_json()serialize the full state as a single JSON object. The graph and registry are not stored — supply them on reload.
Body purity: bodies should be pure functions of their input view (state writes aside). If a body is non-pure, restore-then-replay may diverge from the original run (the audit log still records what originally happened).
Failure, status, and control
Failure (body error signalling)
A body may return Failure(error, type=...) instead of a normal value:
from tickflow import Failure
@registry.body("call_llm")
def call_llm(v):
try:
return llm_client.chat(...)
except NetworkError as e:
return Failure(str(e), type="infrastructure") # halts the run
except ParseError as e:
return Failure(str(e), type="llm") # downstream skipped
type="llm"(default): a logical/recoverable failure. The node's out-edges writeFalse, so downstream AND-joins don't fire (= "upstream failed, skip downstream"). The run continues.type="infrastructure": an unrecoverable failure. Out-edges writeFalseand the runner entersABORTED, halting all further ticks.
A Failure is still recorded in RunState and the audit log with
NodeState.status ("failed" / "aborted") and NodeState.error.
Important: a failed node writes
Falseto all out-edges — guarded edges are not evaluated. To implement controllable routing (e.g. retry on failure), have the body return a result dict and let the guard inspect the output value, rather than returningFailure.
RunStatus
Runner.status is a RunStatus enum:
| Status | Meaning |
|---|---|
IDLE |
quiescent (nothing fired last tick, or never started) |
RUNNING |
a tick fired (transient; becomes IDLE/terminal next) |
ABORTED |
an infrastructure Failure occurred; halted |
CANCELLED |
cancel() was called; halted |
FAILED |
(reserved) all nodes failed and nothing fireable |
rn.cancel("user requested") # -> CANCELLED; ticks become no-ops
rn.is_idle() # status == IDLE
rn.is_terminal() # ABORTED/CANCELLED/FAILED, or IDLE with nothing pending
rn.reset() # clear a non-RUNNING status back to IDLE
Node state (view.state)
Each node has a mutable state dict managed by RunState (so it participates
in snapshots/restore) and is visible to guards. Bodies read/write their own
state; guards receive a read-only view:
@registry.body("retryable")
def retryable(v):
v.state["attempts"] = v.state.get("attempts", 0) + 1
return {"ok": v.state["attempts"] >= 3, "attempt": v.state["attempts"]}
@registry.guard("should_retry")
def should_retry(v):
out = v.B.value
return isinstance(out, dict) and not out.get("ok") and v.state.get("attempts", 0) < 3
This is how a retry self-loop is expressed: the body tracks attempts in
state, returns a result dict, and the guard checks both the result and the
state to decide whether to loop.
Hooks (the observer seam)
on_fire / on_tick_end / on_tick_start are the single seam between
tickflow and the outside world (events, record stores, progress). They fire
on every node fire / tick end / tick start. Hook exceptions are logged and
swallowed so a misbehaving observer can't corrupt the run.
rn.on_fire(lambda ns: event_bus.emit(ns.node, ns.output))
rn.on_tick_start(lambda tick, fireable: ui.highlight(fireable))
rn.on_tick_end(lambda tick, firings: db.save(tick, rn.snapshot()))
The AsyncRunner accepts async hooks (async def).
Persistence backend
A Runner constructed with backend=... and session_id=... persists,
at the end of every tick: each NodeState (the process record) and a full
snapshot at the new tick index.
from tickflow import JsonBackend
be = JsonBackend("tickflow/sessions")
rn = Runner(graph, registry, backend=be, session_id="sess-1")
rn.run_until_idle(max_ticks=100)
# Later / elsewhere: resume from the latest persisted tick.
snap = be.load_snapshot("sess-1", be.latest_tick("sess-1"))
rn2 = Runner(graph, registry)
rn2.restore(snap)
Backend(Protocol):save_snapshot/load_snapshot/latest_tick/list_snapshots/save_firing/list_firings/save_checkpoint/list_checkpoints/load_checkpoint.JsonBackend(storage_dir): one dir per session,tick_<N>.json+firings.jsonl+checkpoints.json. Default; human-inspectable.SqliteBackend(db_path): single SQLite file withsnapshots/firings/checkpointstables. Better for high tick-throughput.NullBackend: in-memory, for tests.
Named checkpoints
Layered on the backend:
rn.checkpoint("after_prep") # save current state under a label
rn.list_checkpoints() # [(label, tick), ...]
rn.rollback_to("after_prep") # restore to that checkpoint
Graph remap (hot-swap graph structure)
After a rollback, you can replace the graph structure and/or registry before resuming:
rn.rollback_to("safe_point")
rn.remap_graph(new_graph, registry=new_registry)
rn.run_until_idle(max_ticks=100)
Slots that exist in both graphs keep their current value; new slots start
False; removed slots are discarded. RunState is filtered to keep only
nodes present in the new graph.
AsyncRunner
For graphs whose bodies do IO (LLM calls, HTTP, DB), use AsyncRunner.
Bodies and guards may be async def; fireable nodes fire concurrently
within a tick via asyncio.gather. Semantics are identical to the sync
Runner.
from tickflow.async_runner import AsyncRunner
@registry.body("harness")
async def harness(v):
return await llm_client.chat(prompt=v.seed.value)
rn = AsyncRunner(graph, registry, backend=be, session_id="sess-1")
await rn.run_until_idle(max_ticks=100)
Sync and async bodies/guards may be mixed in the same graph. Runner and
AsyncRunner share all state-management logic via _BaseRunner.
Visualization & front-end integration
Static graph export
graph.to_dict() # JSON-able {nodes, edges, starts} for front-end rendering
graph.to_mermaid() # mermaid "graph TD" text (READMEs / debuggers)
to_dict() includes derived producers per node so the front-end doesn't
recompute adjacency. to_mermaid() renders start nodes with stadium shape
([name]), plain edges A --> B, guarded edges A -->|g| B.
Fireable preview & node state
rn.fireable() # [node names] that would fire on the next tick
rn.node_states() # {node: {state dict}} — read-only copy (e.g. attempts)
fireable() is computed identically to the engine's internal check, so it
exactly predicts the next tick(). node_states() returns copies from
RunState.all_mutable_states().
Tick lifecycle hooks
on_tick_start(tick, fireable[]) # before any node fires this tick
→ engine runs (body/guard execute)
→ on_fire(ns) × N # after each node fires (receives NodeState)
on_tick_end(tick, firings[]) # after all fires committed
Audit log
for ns in rn.audit_log(): # list[NodeState]
print(f"t{ns.tick} {ns.node}: {ns.inputs} → {ns.output} [{ns.status}]")
rn.audit_json() # JSON string
Each NodeState record includes inputs (what the body read), output
(what it returned), edges_fired (how tokens propagated), status, error,
and mutable_state (node state snapshot after the body ran).
CLI
python -m tickflow run graph.txt -b beh.py [--max-ticks N] [--pause-at T ...]
python -m tickflow step graph.txt -b beh.py [--from-snapshot snap.json] --ticks N
python -m tickflow snapshot graph.txt -b beh.py --out snap.json [--max-ticks N]
python -m tickflow audit run.json
Deadlock suggestions: use --auto-promote to accept all, or --no-promote
to reject (and error). In non-interactive environments (CI, scripts), the
CLI defaults to erroring out with guidance — no hanging on stdin.
Examples
| Example | Description |
|---|---|
counter_loop |
Counter that loops while < 3, then stops. Cycles + terminating guard + OR-join. |
xor_merge |
XOR branch feeding a Merge. Checker flags AND-join deadlock → promote to OR. |
retry_loop |
Retry with view.state counter. Guard reads both output and state. |
fan_out |
Parallel workers + AND-join merge. Three branches fire concurrently. |
pipeline |
Three-stage pipe with A[k] index policy pinning A's first fire. |
state_machine |
Approval workflow: submit → review → approve/reject → done. XOR-splitter. |
checkpoint_restore |
Full workflow: checkpoint → rollback → remap → resume. Python script. |
keep_records_false |
Memory-saving mode demo: no audit, but state persists. Python script. |
Design notes
Why Petri net, not FSM? The original design — a boolean slot per
incoming edge, AND over slots to fire, consume-on-fire, produce-into-
downstream — is exactly a marked graph (a Petri net subclass). An FSM is the
special case with exactly one live token; tickflow allows multiple
concurrent starts and AND/OR joins, which FSMs don't express natively.
Cycles (loops) are natural in Petri nets and forbidden in DAG schedulers.
Why synchronous steps? It makes the OR-join decidable (no "could more tokens arrive?" question) and makes snapshots a trivial JSON dict — there's no in-flight partial firing to save, so pause lands cleanly on tick boundaries.
Why three-layer RunState? The old design had History, audit list, and
Marking.node_state as three separate, partially redundant structures. The
three-layer RunState (_edges + _state + _records) unifies them under
one owner with clear responsibilities. _edges and _state are always
maintained (engine needs them); _records (detailed audit) is gated on
keep_records and the absence of a persistent backend.
What's deliberately out of scope: inclusive/XOR-join syntax beyond OR;
distributed/multi-worker scheduling; a built-in timeline forest (use
deepcopy); LLM token streaming (that's a harness/EventBus concern — tickflow
records at node granularity).
Project layout
tickflow/
ir.py Node, Edge, Graph, InputPolicy, Failure
parser.py mermaid-like text → Graph (with static warnings)
checker.py deadlock detection, OR-join promotion, unguarded cycle detection
state.py NodeState, RunState — single source of truth for all runtime data
engine.py Marking, tick (pure function), join logic
runner.py Runner, _BaseRunner (shared sync/async logic)
async_runner.py AsyncRunner (async bodies, concurrent firing)
registry.py body/guard registration
views.py DictView, Resolved, Missing
persistence.py Backend protocol, JsonBackend, SqliteBackend, NullBackend
cli.py python -m tickflow ...
tests/ 17 files, 163 tests
examples/ 8 examples (6 graph + 2 Python scripts)
Running the tests
python -m pytest tests/ -q
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file tickflow_py-0.1.0.tar.gz.
File metadata
- Download URL: tickflow_py-0.1.0.tar.gz
- Upload date:
- Size: 81.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6dff957ce35fea431e437a9ec086cb146db1d6a97a0c2890e447288abab608ea
|
|
| MD5 |
2f80dc29313b6cbf540f13a62c79861f
|
|
| BLAKE2b-256 |
9fb609fed58538480f87172d9a38f203712962280d49a49721c31673352b31c7
|
File details
Details for the file tickflow_py-0.1.0-py3-none-any.whl.
File metadata
- Download URL: tickflow_py-0.1.0-py3-none-any.whl
- Upload date:
- Size: 56.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1fe6dc8cc2af7a5741d32a006f2f4ed84843126d9517389923021290cd2ca27c
|
|
| MD5 |
0abbda8392101e8cd4afa885712550ad
|
|
| BLAKE2b-256 |
28dc70d9be76896865105639189f957f7747e7fb802956aba454ceaef6c7d522
|