Repository navigation
Expand file tree
/
Copy pathtest_graph_engine.py
More file actions
205 lines (161 loc) · 7.35 KB
/
Copy pathtest_graph_engine.py
File metadata and controls
205 lines (161 loc) · 7.35 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
"""Graph engine mechanics — pure-function nodes, zero LLM, zero network.
These pin the contracts everything downstream relies on: execution order
(graph_end.path), real parallelism inside a wave, the disjoint-key rule, both
cycle guards, error draining, and the observer event schemas the dashboard
renders from.
"""
from __future__ import annotations
import time
import pytest
from tini.graph import END, START, Graph, GraphStateCollision, Node, run_graph
from tini.graph.nodes import key_router
def collect_events():
events: list[tuple[str, dict]] = []
return events, lambda kind, ev: events.append((kind, ev))
def linear_graph() -> Graph:
g = Graph("linear")
g.add_node(Node("a", lambda s: {"a": 1}))
g.add_node(Node("b", lambda s: {"b": s["a"] + 1}))
g.add_node(Node("c", lambda s: {"c": s["b"] + 1}))
g.add_edge(START, "a")
g.add_edge("a", "b")
g.add_edge("b", "c")
g.add_edge("c", END)
return g
def test_linear_topology_runs_in_edge_order():
events, observer = collect_events()
state = run_graph(linear_graph(), {}, observer=observer)
assert (state["a"], state["b"], state["c"]) == (1, 2, 3)
end = dict(events)["graph_end"]
assert end["path"] == ["a", "b", "c"]
assert end["error"] is None
def test_fanout_runs_in_parallel_and_fanin_waits_for_all():
g = Graph("fan")
def slow(key):
def fn(state):
time.sleep(0.15)
return {key: key}
return fn
for key in ("x", "y", "z"):
g.add_node(Node(key, slow(key)))
g.add_edge(START, key)
g.add_node(Node("join", lambda s: {"joined": s["x"] + s["y"] + s["z"]}))
for key in ("x", "y", "z"):
g.add_edge(key, "join")
g.add_edge("join", END)
t0 = time.perf_counter()
state = run_graph(g, {})
elapsed = time.perf_counter() - t0
assert state["joined"] == "xyz" # fan-in saw all three keys
assert elapsed < 0.35 # 3 x 0.15s ran together, not in series
def routed_graph() -> Graph:
g = Graph("routed")
g.add_node(Node("decide", lambda s: {"route": s["want"]}))
g.add_node(Node("quick", lambda s: {"took": "quick"}))
g.add_node(Node("full", lambda s: {"took": "full"}))
g.add_edge(START, "decide")
g.add_router("decide", key_router("route", default="full"),
{"quick": "quick", "full": "full"})
g.add_edge("quick", END)
g.add_edge("full", END)
return g
def test_router_picks_the_target_from_state():
events, observer = collect_events()
state = run_graph(routed_graph(), {"want": "quick"}, observer=observer)
assert state["took"] == "quick"
route = dict(events)["route"]
assert route["target"] == "quick" and route["reason"] == "quick"
assert run_graph(routed_graph(), {"want": "full"})["took"] == "full"
def test_router_unknown_label_records_error_and_drains():
events, observer = collect_events()
state = run_graph(routed_graph(), {"want": "sideways"}, observer=observer)
assert "took" not in state
assert "unknown label" in state["errors"]["decide"]
assert dict(events)["graph_end"]["error"] is not None
def test_unknown_edge_and_router_targets_raise_at_build_time():
g = Graph("bad")
g.add_node(Node("a", lambda s: {}))
with pytest.raises(ValueError):
g.add_edge("a", "ghost")
with pytest.raises(ValueError):
g.add_router("a", key_router("k", default="x"), {"x": "ghost"})
def test_parallel_key_collision_raises():
g = Graph("collide")
g.add_node(Node("left", lambda s: {"same": 1}))
g.add_node(Node("right", lambda s: {"same": 2}))
g.add_edge(START, "left")
g.add_edge(START, "right")
with pytest.raises(GraphStateCollision):
run_graph(g, {})
def test_sequential_overwrite_is_allowed():
state = run_graph(linear_graph(), {"a": 99}) # node 'a' rewrites key 'a'
assert state["a"] == 1
def cycle_graph(max_visits: int) -> Graph:
g = Graph("cycle")
g.add_node(Node("work", lambda s: {"count": s.get("count", 0) + 1},
max_visits=max_visits))
g.add_edge(START, "work")
g.add_router("work", lambda s: "again" if s["count"] < 99 else "done",
{"again": "work", "done": END})
return g
def test_cycle_stops_at_max_visits():
state = run_graph(cycle_graph(max_visits=3), {})
assert state["count"] == 3
assert "max_visits=3" in state["errors"]["work"]
def test_max_steps_is_the_global_hard_stop():
events, observer = collect_events()
state = run_graph(cycle_graph(max_visits=99), {}, observer=observer, max_steps=5)
assert state["count"] == 5
assert "max_steps=5" in state["errors"]["engine"]
assert dict(events)["graph_end"]["steps"] == 5
def test_node_exception_routes_to_on_error_and_never_raises():
g = Graph("boom")
g.add_node(Node("explode", lambda s: 1 / 0, on_error="fallback"))
g.add_node(Node("fallback", lambda s: {"saved": True}))
g.add_edge(START, "explode")
g.add_edge("fallback", END)
state = run_graph(g, {})
assert state["saved"] is True
assert "ZeroDivisionError" in state["errors"]["explode"]
def test_node_exception_without_on_error_drains_to_end():
g = Graph("boom2")
g.add_node(Node("explode", lambda s: 1 / 0))
g.add_node(Node("after", lambda s: {"ran": True}))
g.add_edge(START, "explode")
g.add_edge("explode", "after")
events, observer = collect_events()
state = run_graph(g, {}, observer=observer)
assert "ran" not in state # downstream starved, run ended
assert dict(events)["graph_end"]["error"] is not None
def test_observer_event_sequence_and_payload_schemas():
"""Pins the exact kinds + keys the dashboard's graph view consumes."""
events, observer = collect_events()
run_graph(routed_graph(), {"want": "quick"}, observer=observer)
kinds = [k for k, _ in events]
assert kinds[0] == "graph_start" and kinds[-1] == "graph_end"
assert kinds == ["graph_start", "node_start", "node_end", "route",
"node_start", "node_end", "graph_end"]
schemas = {"graph_start": {"workflow", "nodes"},
"node_start": {"workflow", "node", "visit"},
"node_end": {"workflow", "node", "ms", "keys", "error"},
"route": {"workflow", "router", "target", "reason"},
"graph_end": {"workflow", "ms", "steps", "path", "error"}}
for kind, ev in events:
assert schemas[kind] <= set(ev), f"{kind} missing {schemas[kind] - set(ev)}"
def test_describe_reports_every_node_and_edge():
d = routed_graph().describe()
assert d["name"] == "routed"
assert {n["name"] for n in d["nodes"]} == {"decide", "quick", "full"}
conditional = {(e["src"], e["dst"]) for e in d["edges"] if e["conditional"]}
static = {(e["src"], e["dst"]) for e in d["edges"] if not e["conditional"]}
assert conditional == {("decide", "quick"), ("decide", "full")}
assert static == {(START, "decide"), ("quick", END), ("full", END)}
def test_reserved_underscore_keys_never_merge_or_leak():
g = Graph("private")
g.add_node(Node("sneaky", lambda s: {"_secret": 1, "open": 2}))
g.add_edge(START, "sneaky")
events, observer = collect_events()
state = run_graph(g, {}, observer=observer)
assert "_secret" not in state and state["open"] == 2
assert dict(events)["node_end"]["keys"] == ["open"]
assert "_notify" not in state # engine plumbing stays internal