pgc-runtime 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
runtime/scheduler.py ADDED
@@ -0,0 +1,264 @@
1
+ """
2
+ scheduler.py — WF-level topology driver for the token-native runtime.
3
+
4
+ Traverses the compiled execution topology for a single workflow invocation.
5
+ Drives CC execution in declared order, resolves WF-level input bindings from
6
+ the ExecutionContext, routes between nodes on result status, and emits
7
+ WF-level trace events.
8
+
9
+ The scheduler is a blind executor:
10
+ - All routing is read from dispatch.routing (compiled by S2/S3)
11
+ - All CC input bindings are read from dispatch.bindings (compiled by S6)
12
+ - Condition resolution uses the vocab (transition:: / outcome:: addresses)
13
+ - No domain logic, no semantic inference, no path construction
14
+
15
+ Topology traversal rules:
16
+ - Entry point is dispatch.entry[wf_addr]["start"]
17
+ - Each CC produces a result_status; that status resolves to a condition address
18
+ - The condition address is looked up in dispatch.routing[wf_addr][cc_addr] → next node
19
+ - Traversal ends when no routing entry exists for the current (cc_addr, condition)
20
+
21
+ Boundary nodes (IN_, EXIT_):
22
+ Nodes without a pipeline entry (not in dispatch.pipeline) are boundary nodes.
23
+ IN_ nodes perform admission gating; prior to admission_snapshot integration,
24
+ they pass through as ACK. The routing table routes ACK forward.
25
+ EXIT_ nodes (no routing) terminate the loop naturally.
26
+
27
+ Bindings path grammar (WF-level, compiler-emitted):
28
+ $.payload.<field> — from the original payload
29
+ $.inputs.<field> — alias for $.payload.<field>
30
+ $.results.<cc_addr>.<field>— from a prior CC's result surface (int cc_addr)
31
+ <literal> — returned as-is
32
+
33
+ Result:
34
+ (result_status, surface) from the last CC executed.
35
+ result_status: the WF terminal outcome string (e.g. "SUCCESS", "VIOLATION")
36
+ surface: the last CC's output dict (for transport/egress use)
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ from typing import Any
42
+
43
+ from runtime.dispatcher import execute_cc
44
+ from runtime.evidence import TraceWriter
45
+ from runtime.loader import RuntimePackage
46
+ from runtime.memory import ExecutionContext
47
+
48
+ # Guard against pathological graphs (cycles, runaway traversal)
49
+ _MAX_HOPS = 64
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Public API
54
+ # ---------------------------------------------------------------------------
55
+
56
+ def _admit(payload: dict, contract: dict) -> str:
57
+ """Determine admission against the gate's declared input contract — ACK or NACK.
58
+
59
+ Determined from what the IN declares and nothing else: a required field absent, or a declared
60
+ type unsatisfied, is NACK. The workflow routes on that outcome exactly as it routes on any other,
61
+ so a refusal here is carried by the topology rather than raised past it.
62
+
63
+ The IN also carries prose `extensions.admission_rules` ("each element must be a positive
64
+ integer"). Prose determines nothing (MB-1) and is not consulted. Where a gate must enforce more
65
+ than its declared contract, the contract is what needs to say so.
66
+ """
67
+ _TYPES = {"array": list, "string": str, "integer": int, "number": (int, float),
68
+ "boolean": bool, "object": dict}
69
+ for field, spec in contract.items():
70
+ present = field in payload
71
+ if spec.get("required") and not present:
72
+ return "NACK"
73
+ if not present:
74
+ continue
75
+ expected = _TYPES.get(spec.get("type"))
76
+ if expected is not None and not isinstance(payload[field], expected):
77
+ return "NACK"
78
+ return "ACK"
79
+
80
+
81
+ class UnroutedOutcomeError(RuntimeError):
82
+ """An outcome with neither declared routing nor a declared ending (EX-5, RT-9)."""
83
+
84
+
85
+ def run_wf(
86
+ wf_fqdn: str,
87
+ payload: dict[str, Any],
88
+ pkg: RuntimePackage,
89
+ writer: TraceWriter,
90
+ data_root: str,
91
+ ) -> tuple[str, dict[str, Any]]:
92
+ """
93
+ Execute a workflow topology and return (result_status, surface).
94
+
95
+ Args:
96
+ wf_fqdn: Fully-qualified name of the workflow (e.g. "blockchain::WF_...").
97
+ payload: Inbound payload dict (already normalized by transport layer).
98
+ pkg: Frozen RuntimePackage (loader output for this domain).
99
+ writer: TraceWriter for this execution trace.
100
+ data_root: Absolute data directory root for CS path expansion.
101
+
102
+ Returns:
103
+ (result_status, surface) where:
104
+ result_status — terminal WF outcome (e.g. "SUCCESS", "VIOLATION")
105
+ surface — last CC output dict (passed to transport egress)
106
+
107
+ Raises:
108
+ KeyError: WF FQDN not in vocab or entry table.
109
+ RuntimeError: Hop limit exceeded (indicates a compiler-emitted cycle).
110
+ """
111
+ wf_addr = pkg.vocab.addr(wf_fqdn)
112
+
113
+ entry = pkg.dispatch.entry.get(wf_addr)
114
+ if entry is None:
115
+ raise RuntimeError(
116
+ f"No entry point for WF {wf_fqdn!r} (addr {wf_addr}) — "
117
+ f"snapshot may be stale or domain mismatch"
118
+ )
119
+
120
+ rb_addr = entry.get("rb", -1) # -1 = no runtime binding (CT-only workflow, no CS to govern)
121
+ current_addr: int | None = entry["start"]
122
+ current_node_key: str = entry.get("start_key", "")
123
+ actor_context = entry.get("actor") # Authority: actor FQDN bound to this WF
124
+
125
+ # Bind the actor into the execution context — genuinely propagated through the run (not merely
126
+ # logged), then attributed in the trace. No authorization is enforced (authority model TBD).
127
+ ctx = ExecutionContext(payload, actor=actor_context)
128
+ writer.wf_start(payload, actor=ctx.actor)
129
+
130
+ result_status = "SUCCESS"
131
+ surface: dict[str, Any] = {}
132
+ hops = 0
133
+
134
+ while current_addr is not None:
135
+ if hops >= _MAX_HOPS:
136
+ raise RuntimeError(
137
+ f"WF {wf_fqdn!r} exceeded {_MAX_HOPS} topology hops — "
138
+ f"possible cycle in compiled routing"
139
+ )
140
+ hops += 1
141
+
142
+ if current_addr in pkg.dispatch.pipeline:
143
+ # CC node — resolve WF-level bindings and execute.
144
+ # Bindings are keyed by node_key (not CC addr) so that distinct WF
145
+ # usages of the same CC (e.g. four denial audit nodes) each carry
146
+ # their own literal inputs (e.g. different denial_reason values).
147
+ wf_bindings = (
148
+ pkg.dispatch.bindings
149
+ .get(wf_addr, {})
150
+ .get(current_node_key, {})
151
+ )
152
+ cc_inputs = ctx.resolve_inputs(wf_bindings)
153
+
154
+ result_status, surface = execute_cc(
155
+ current_addr, rb_addr, cc_inputs, pkg, writer, data_root, wf_addr
156
+ )
157
+ ctx.record_result(current_addr, surface)
158
+
159
+ # Observation: a CC outcome that routes to an announcing exit states the moments that
160
+ # act completed, in the order the composition sealed. The order is normative — it is what
161
+ # a reader of the account sees — so the sequence is announced as sealed and never
162
+ # reordered here. A sequence of one is the ordinary case.
163
+ announced = pkg.dispatch.emits.get(wf_addr, {}).get(current_addr, {}).get(result_status)
164
+ if announced:
165
+ # A single moment was sealed as a string before announcements could be plural. Read
166
+ # here rather than refused, so a snapshot built by an older compiler still runs.
167
+ if isinstance(announced, str):
168
+ announced = [announced]
169
+ for ev_fqdn in announced:
170
+ if not ev_fqdn:
171
+ # A moment declared for this transition and absent from what was sealed. The
172
+ # act says so rather than announcing nothing: silence is indistinguishable
173
+ # from a moment nobody declared, which is the failure this exists to end.
174
+ writer.event("", {"unannounceable": True, "transition": result_status})
175
+ continue
176
+ writer.event(ev_fqdn, surface)
177
+
178
+ else:
179
+ # Boundary node — an IN admission gate. EXIT nodes carry no address and are reached as
180
+ # a declared ending rather than traversed, so this branch is IN only.
181
+ #
182
+ # This returned an unconditional "ACK". A declared admission point that determines
183
+ # nothing produces the same outcome as one that permits, which is `1c` AI-6 — the
184
+ # invariant whose breach is least visible, because the system behaves like a governed
185
+ # one until the case arrives that governance would have refused.
186
+ contract = pkg.dispatch.admission.get(current_addr)
187
+ if contract is None:
188
+ writer.error("no admission contract", node=current_addr)
189
+ writer.wf_complete("VIOLATION")
190
+ raise UnroutedOutcomeError(
191
+ f"admission gate {current_addr} declares no input contract — there is nothing "
192
+ f"to determine admission against, and absence is not permission (1c AI-6)."
193
+ )
194
+ result_status = _admit(payload, contract)
195
+ writer.cc_step(current_addr, current_addr, pkg.vocab.fqdn(current_addr),
196
+ "ADMIT", {"outcome": result_status})
197
+
198
+ # Resolve result_status → condition address and route to next node.
199
+ # Routing values are {"addr": int, "key": str} — addr is the next CC address,
200
+ # key is the next node_key for bindings disambiguation.
201
+ previous_addr = current_addr
202
+ condition_addr = _condition_addr(result_status, pkg)
203
+ routing = pkg.dispatch.routing.get(wf_addr, {}).get(current_addr, {})
204
+ next_entry = routing.get(condition_addr)
205
+
206
+ if isinstance(next_entry, dict):
207
+ current_addr = next_entry.get("addr")
208
+ current_node_key = next_entry.get("key", "")
209
+ elif next_entry is not None:
210
+ current_addr = next_entry # bare int (legacy)
211
+ current_node_key = ""
212
+ else:
213
+ # No continuation. Two cases that were one, and reporting success for both is what
214
+ # `3a` EX-5 and `3c` RT-9 forbid: an outcome the declarations do not answer for MUST
215
+ # refuse, and ending the traversal instead made a dead end indistinguishable from a
216
+ # declared ending. Termination is now declared (`dispatch.terminal`); its absence is
217
+ # the dead end.
218
+ ending = pkg.dispatch.terminal.get(wf_addr, {}).get(current_addr, {}).get(condition_addr)
219
+ if ending is None:
220
+ writer.error(
221
+ "unrouted outcome",
222
+ node=current_addr, outcome=result_status, condition_addr=condition_addr,
223
+ )
224
+ writer.route(from_addr=current_addr, condition=result_status, to_addr=None)
225
+ writer.wf_complete("VIOLATION")
226
+ raise UnroutedOutcomeError(
227
+ f"outcome {result_status!r} from node {current_addr} has neither declared "
228
+ f"routing nor a declared ending in this workflow — the declarations do not "
229
+ f"answer for it (3a EX-5, 3c RT-9)."
230
+ )
231
+ current_addr = None
232
+ current_node_key = ""
233
+ declared_ending = ending.get("exit", "")
234
+
235
+ # Evidence the routing determination, not only its effect (`3e` §3.1 point 4).
236
+ writer.route(from_addr=previous_addr, condition=result_status, to_addr=current_addr)
237
+
238
+ writer.wf_complete(result_status)
239
+ return result_status, surface
240
+
241
+
242
+ # ---------------------------------------------------------------------------
243
+ # Internal helpers
244
+ # ---------------------------------------------------------------------------
245
+
246
+ def _condition_addr(result_status: str, pkg: RuntimePackage) -> int:
247
+ """
248
+ Resolve a result_status string to its transition address integer.
249
+
250
+ Lookup order:
251
+ 1. transition::<result_status> (primary — WF routing namespace)
252
+ 2. outcome::<result_status> (fallback — CC outcome namespace)
253
+
254
+ Returns -1 if the status has no registered address (no routing will match).
255
+ """
256
+ try:
257
+ return pkg.vocab.addr(f"transition::{result_status}")
258
+ except KeyError:
259
+ pass
260
+ try:
261
+ return pkg.vocab.addr(f"outcome::{result_status}")
262
+ except KeyError:
263
+ pass
264
+ return -1
runtime/trace_viz.py ADDED
@@ -0,0 +1,276 @@
1
+ """
2
+ trace_viz.py — Evidence projection: execution path behavior logic.
3
+
4
+ Reads a completed trace (.jsonl) and the compiled workflow graph (.graph.json)
5
+ from the protocol snapshot behavior_logic directory, then generates a PNG with
6
+ the actual execution path overlaid in red on the static compiled graph.
7
+
8
+ This is Evidence Projection — not a runtime execution feature:
9
+
10
+ topology (graph.json)
11
+ +
12
+ evidence (trace.jsonl)
13
+ ───────────────────────
14
+ → execution path PNG
15
+
16
+ Inputs (both already materialized, read-only):
17
+ protocol_snapshot/behavior_logic/<WF_CODE>/<WF_CODE>.graph.json
18
+ traces/<domain>/<wf_code>/<trace_id>/<trace_id>.jsonl
19
+
20
+ Output:
21
+ traces/<domain>/<wf_code>/<trace_id>/<trace_id>.png
22
+
23
+ Architectural invariant:
24
+ This module reads ONLY from:
25
+ - protocol_snapshot/behavior_logic/ (compiled graph artifacts)
26
+ - the caller-supplied trace .jsonl (execution evidence)
27
+ It does NOT walk protocol_snapshot/artifacts/ or any other canonical
28
+ protocol location. Behavior logic overlay is read-only — no protocol interpretation.
29
+
30
+ Uses graphviz (dot) — returns None silently if dot is not available.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ import subprocess
37
+ from pathlib import Path
38
+ from typing import Optional
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Public API
43
+ # ---------------------------------------------------------------------------
44
+
45
+ def render_trace_png(
46
+ workspace: Path,
47
+ trace_path: Path,
48
+ ) -> Optional[Path]:
49
+ """
50
+ Generate execution-path overlay PNG for a completed trace.
51
+
52
+ Reads CC_COMPLETE events from trace_path to reconstruct the actual
53
+ execution path, then overlays it (red) on the compiled workflow graph.
54
+
55
+ Args:
56
+ workspace: Absolute path to pgs_workspace root.
57
+ trace_path: Path to the completed .jsonl trace file.
58
+
59
+ Returns:
60
+ Path to the generated PNG, or None if graphviz is unavailable.
61
+
62
+ Raises:
63
+ FileNotFoundError: trace_path or graph.json does not exist.
64
+ ValueError: Trace is empty or missing WF_START event.
65
+ """
66
+ if not trace_path.exists():
67
+ raise FileNotFoundError(f"Trace file not found: {trace_path}")
68
+
69
+ # Parse trace events
70
+ events: list[dict] = []
71
+ for line in trace_path.read_text(encoding="utf-8").splitlines():
72
+ line = line.strip()
73
+ if line:
74
+ events.append(json.loads(line))
75
+
76
+ if not events:
77
+ raise ValueError(f"Trace file is empty: {trace_path}")
78
+
79
+ # Extract WF code from WF_START event
80
+ wf_start = next((e for e in events if e["event_type"] == "WF_START"), None)
81
+ if wf_start is None:
82
+ raise ValueError(f"No WF_START event found in: {trace_path}")
83
+
84
+ wf_fqdn = wf_start["detail"]["wf_fqdn"]
85
+ wf_code = wf_fqdn.split("::")[-1] # e.g. "WF_REGISTER_ACTOR_UNVERIFIED_V0"
86
+
87
+ # Load compiled graph from protocol_snapshot/behavior_logic/
88
+ graph_path = (
89
+ workspace
90
+ / "protocol_snapshot"
91
+ / "behavior_logic"
92
+ / wf_code
93
+ / f"{wf_code}.graph.json"
94
+ )
95
+ if not graph_path.exists():
96
+ raise FileNotFoundError(
97
+ f"Compiled graph not found: {graph_path}\n"
98
+ f"Re-run the compiler to regenerate behavior_logic artifacts."
99
+ )
100
+
101
+ graph = json.loads(graph_path.read_text(encoding="utf-8"))
102
+
103
+ # Reconstruct actual execution path from trace events + graph edges
104
+ path = _extract_execution_path(events, graph)
105
+
106
+ # Build visited node and taken edge sets
107
+ visited_nodes: set[str] = set()
108
+ taken_edges: set[tuple[str, str, str]] = set() # (from_node, to_node, condition)
109
+ for from_node, condition, to_node in path:
110
+ visited_nodes.add(from_node)
111
+ visited_nodes.add(to_node)
112
+ taken_edges.add((from_node, to_node, condition))
113
+
114
+ # Generate DOT source with execution-path overlay
115
+ dot_content = _generate_dot(graph, visited_nodes, taken_edges)
116
+
117
+ # Render: write DOT, invoke graphviz, clean up DOT
118
+ png_path = trace_path.with_suffix(".png")
119
+ dot_path = trace_path.with_suffix(".dot")
120
+ dot_path.write_text(dot_content, encoding="utf-8")
121
+
122
+ try:
123
+ subprocess.run(
124
+ ["dot", "-Tpng", str(dot_path), "-o", str(png_path)],
125
+ check=True,
126
+ capture_output=True,
127
+ )
128
+ dot_path.unlink()
129
+ return png_path
130
+ except (subprocess.CalledProcessError, FileNotFoundError):
131
+ dot_path.unlink(missing_ok=True)
132
+ return None
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # Path reconstruction
137
+ # ---------------------------------------------------------------------------
138
+
139
+ def _extract_execution_path(
140
+ events: list[dict],
141
+ graph: dict,
142
+ ) -> list[tuple[str, str, str]]:
143
+ """
144
+ Reconstruct [(from_node, condition, to_node), ...] from trace events.
145
+
146
+ Uses CC_COMPLETE events (in emission order) and graph edges to walk
147
+ the actual execution path. IN_ boundary nodes always yield ACK in
148
+ the current runtime (admission_snapshot not yet integrated).
149
+
150
+ Args:
151
+ events: Parsed JSONL trace events.
152
+ graph: Compiled graph dict (from graph.json).
153
+
154
+ Returns:
155
+ Ordered list of (from_node_id, condition, to_node_id) tuples.
156
+ """
157
+ entry_node: str = graph["entry"] # e.g. "IN_ACTOR_REGISTERED_V0"
158
+
159
+ # (from_node, condition) → to_node
160
+ edge_map: dict[tuple[str, str], str] = {
161
+ (e["from"], e["condition"]): e["to"]
162
+ for e in graph["edges"]
163
+ }
164
+
165
+ # Filter to top-level workflow CC events only.
166
+ # Sub-workflows emit CC_COMPLETE events with the same wf_addr as the
167
+ # top-level workflow (the runtime reuses the same address), so wf_addr
168
+ # filtering is insufficient. Instead, restrict to CCs that are declared
169
+ # nodes in the top-level graph — sub-workflow CCs won't appear there.
170
+ graph_cc_nodes: set[str] = {
171
+ n["id"] for n in graph["nodes"] if n["type"] == "CC"
172
+ }
173
+ cc_completions = [
174
+ e for e in events
175
+ if e["event_type"] == "CC_COMPLETE"
176
+ and e["detail"]["cc_fqdn"].split("::")[-1] in graph_cc_nodes
177
+ ]
178
+
179
+ if not cc_completions:
180
+ # No CC nodes executed — IN_ gated as NACK and routed to EXIT
181
+ to_node = edge_map.get((entry_node, "NACK"), "EXIT")
182
+ return [(entry_node, "NACK", to_node)]
183
+
184
+ path: list[tuple[str, str, str]] = []
185
+
186
+ # IN_ → first CC: always ACK (admission passes through in current runtime)
187
+ first_cc_code = cc_completions[0]["detail"]["cc_fqdn"].split("::")[-1]
188
+ path.append((entry_node, "ACK", first_cc_code))
189
+
190
+ # CC → CC (or EXIT) — follow result_status routing
191
+ for cc_event in cc_completions:
192
+ cc_code = cc_event["detail"]["cc_fqdn"].split("::")[-1]
193
+ result_status = cc_event["result_status"]
194
+ to_node = edge_map.get((cc_code, result_status))
195
+ if to_node is None:
196
+ break # no further routing — terminal
197
+ path.append((cc_code, result_status, to_node))
198
+
199
+ return path
200
+
201
+
202
+ # ---------------------------------------------------------------------------
203
+ # DOT generation
204
+ # ---------------------------------------------------------------------------
205
+
206
+ def _generate_dot(
207
+ graph: dict,
208
+ visited_nodes: set[str],
209
+ taken_edges: set[tuple[str, str, str]],
210
+ ) -> str:
211
+ """
212
+ Generate Graphviz DOT with actual execution path highlighted in red.
213
+
214
+ Visited nodes: red border + solid red fill.
215
+ Taken edges: red, bold, red label.
216
+ Unvisited: standard fill, grey border, grey edges.
217
+ """
218
+ lines = [
219
+ f'digraph "{graph["wf_id"]}" {{',
220
+ " rankdir=LR;",
221
+ ' node [fontname="Arial"];',
222
+ "",
223
+ ]
224
+
225
+ # --- Nodes ---
226
+ for node in graph["nodes"]:
227
+ node_id = node["id"]
228
+ node_type = node["type"]
229
+ visited = node_id in visited_nodes
230
+
231
+ if node_type == "IN":
232
+ fill = "tomato" if visited else "lightblue"
233
+ shape = "ellipse"
234
+ elif node_type == "CC":
235
+ fill = "tomato" if visited else "lightgreen"
236
+ shape = "box"
237
+ elif node_type == "EXIT":
238
+ fill = "tomato" if visited else "lightcoral"
239
+ shape = "ellipse"
240
+ else:
241
+ fill = "white"
242
+ shape = "box"
243
+
244
+ if visited:
245
+ lines.append(
246
+ f' "{node_id}" [label="{node_id}", shape={shape},'
247
+ f" style=filled, fillcolor={fill}, color=red, penwidth=2.5];"
248
+ )
249
+ else:
250
+ lines.append(
251
+ f' "{node_id}" [label="{node_id}", shape={shape},'
252
+ f" style=filled, fillcolor={fill}, color=gray];"
253
+ )
254
+
255
+ lines.append("")
256
+
257
+ # --- Edges ---
258
+ for edge in graph["edges"]:
259
+ from_id = edge["from"]
260
+ to_id = edge["to"]
261
+ condition = edge["condition"]
262
+ taken = (from_id, to_id, condition) in taken_edges
263
+
264
+ if taken:
265
+ lines.append(
266
+ f' "{from_id}" -> "{to_id}"'
267
+ f' [label="{condition}", color=red, penwidth=2.5, fontcolor=red];'
268
+ )
269
+ else:
270
+ lines.append(
271
+ f' "{from_id}" -> "{to_id}"'
272
+ f' [label="{condition}", color=gray, fontcolor=gray];'
273
+ )
274
+
275
+ lines.append("}")
276
+ return "\n".join(lines)