pgs-runtime 0.3.0__tar.gz → 0.4.0__tar.gz

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.
Files changed (34) hide show
  1. {pgs_runtime-0.3.0 → pgs_runtime-0.4.0}/MANIFEST.in +0 -1
  2. {pgs_runtime-0.3.0 → pgs_runtime-0.4.0}/PKG-INFO +1 -1
  3. pgs_runtime-0.4.0/pgs_runtime/__init__.py +0 -0
  4. pgs_runtime-0.4.0/pgs_runtime/cli.py +310 -0
  5. pgs_runtime-0.4.0/pgs_runtime/conformance.py +232 -0
  6. pgs_runtime-0.4.0/pgs_runtime/ct_errors.py +33 -0
  7. pgs_runtime-0.4.0/pgs_runtime/ct_execute.py +84 -0
  8. pgs_runtime-0.4.0/pgs_runtime/ct_executor.py +312 -0
  9. pgs_runtime-0.4.0/pgs_runtime/dispatcher.py +365 -0
  10. pgs_runtime-0.4.0/pgs_runtime/evidence.py +173 -0
  11. pgs_runtime-0.4.0/pgs_runtime/examine/__init__.py +126 -0
  12. pgs_runtime-0.4.0/pgs_runtime/examine/classifier.py +350 -0
  13. pgs_runtime-0.4.0/pgs_runtime/examine/cli.py +35 -0
  14. pgs_runtime-0.4.0/pgs_runtime/examine/conformance_test.py +211 -0
  15. pgs_runtime-0.4.0/pgs_runtime/examine/hint_engine.py +245 -0
  16. pgs_runtime-0.4.0/pgs_runtime/examine/locator.py +148 -0
  17. pgs_runtime-0.4.0/pgs_runtime/examine/parser.py +223 -0
  18. pgs_runtime-0.4.0/pgs_runtime/examine/reporter.py +110 -0
  19. pgs_runtime-0.4.0/pgs_runtime/loader.py +281 -0
  20. pgs_runtime-0.4.0/pgs_runtime/memory.py +116 -0
  21. pgs_runtime-0.4.0/pgs_runtime/scheduler.py +159 -0
  22. pgs_runtime-0.4.0/pgs_runtime/server.py +455 -0
  23. pgs_runtime-0.4.0/pgs_runtime/trace_viz.py +264 -0
  24. pgs_runtime-0.4.0/pgs_runtime.egg-info/PKG-INFO +213 -0
  25. pgs_runtime-0.4.0/pgs_runtime.egg-info/SOURCES.txt +31 -0
  26. pgs_runtime-0.4.0/pgs_runtime.egg-info/dependency_links.txt +1 -0
  27. pgs_runtime-0.4.0/pgs_runtime.egg-info/entry_points.txt +2 -0
  28. pgs_runtime-0.4.0/pgs_runtime.egg-info/top_level.txt +1 -0
  29. {pgs_runtime-0.3.0 → pgs_runtime-0.4.0}/pyproject.toml +1 -1
  30. pgs_runtime-0.3.0/pgs_runtime.egg-info/SOURCES.txt +0 -5
  31. {pgs_runtime-0.3.0 → pgs_runtime-0.4.0}/LICENSE +0 -0
  32. {pgs_runtime-0.3.0 → pgs_runtime-0.4.0}/NOTICE +0 -0
  33. {pgs_runtime-0.3.0 → pgs_runtime-0.4.0}/README.md +0 -0
  34. {pgs_runtime-0.3.0 → pgs_runtime-0.4.0}/setup.cfg +0 -0
@@ -4,6 +4,5 @@ exclude *.sh
4
4
 
5
5
  prune scripts
6
6
  prune testbed
7
- prune pgs_*
8
7
 
9
8
  global-exclude __pycache__ *.pyc
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgs_runtime
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: PGS runtime for Protocol-Governed Systems
5
5
  Author: Bachi
6
6
  License-Expression: Apache-2.0
File without changes
@@ -0,0 +1,310 @@
1
+ """
2
+ cli.py — Token-native CLI entry point for the pgs_runtime.
3
+
4
+ Commands:
5
+ run — Execute a workflow against the tokenized snapshot.
6
+ examine — Analyze a completed trace file and print a diagnostic report.
7
+ visualize — Render execution-path PNG from a completed trace file.
8
+
9
+ Execution path (run):
10
+ 1. Load tokenized snapshot for the domain via loader.load_domain()
11
+ — verifies topology hash against trust attestation; fails hard on mismatch
12
+ 2. Generate deterministic trace ID from (domain, wf_fqdn, payload)
13
+ 3. Open TraceWriter at traces/<domain>/<wf_code>/<trace_id>/
14
+ 4. Drive workflow topology via scheduler.run_wf()
15
+ 5. Print result summary; exit 1 on non-SUCCESS
16
+ 6. If --visualize: invoke evidence projection (trace_viz) to render PNG
17
+
18
+ All runtime behavior comes from the compiled tokenized_snapshot.
19
+ The CLI does not implement any domain logic.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import json
26
+ import os
27
+ import sys
28
+ import time
29
+ from pathlib import Path
30
+
31
+ from pgs_runtime.evidence import TraceWriter, make_trace_id
32
+ from pgs_runtime.loader import load_domain
33
+ from pgs_runtime.scheduler import run_wf
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Argument parsing
38
+ # ---------------------------------------------------------------------------
39
+
40
+ def _build_parser() -> argparse.ArgumentParser:
41
+ parser = argparse.ArgumentParser(
42
+ prog="pgs_runtime",
43
+ description="PGS token-native workflow runtime",
44
+ )
45
+ subs = parser.add_subparsers(dest="command", required=True)
46
+
47
+ # ── run ──────────────────────────────────────────────────────
48
+ run_p = subs.add_parser("run", help="Execute a workflow")
49
+
50
+ run_p.add_argument(
51
+ "--wf",
52
+ required=True,
53
+ metavar="FQDN",
54
+ help="Workflow FQDN (e.g. blockchain::WF_REGISTER_ACTOR_UNVERIFIED_V0)",
55
+ )
56
+ run_p.add_argument(
57
+ "--payload",
58
+ metavar="FILE",
59
+ help="Path to JSON payload file (omit for empty payload)",
60
+ )
61
+ run_p.add_argument(
62
+ "--data-root",
63
+ dest="data_root",
64
+ metavar="PATH",
65
+ help="Absolute path for CS domain data root (or set PGS_DATA_ROOT)",
66
+ )
67
+ run_p.add_argument(
68
+ "--workspace",
69
+ metavar="PATH",
70
+ help="Absolute path to pgs_workspace root (or set PGS_WORKSPACE); "
71
+ "tokenized_snapshot/ and traces/ live here",
72
+ )
73
+
74
+ # ── run: optional visualization flag ─────────────────────────
75
+ run_p.add_argument(
76
+ "--visualize",
77
+ action="store_true",
78
+ help="Render execution-path PNG after run (requires graphviz)",
79
+ )
80
+
81
+ # ── examine ───────────────────────────────────────────────────
82
+ ex_p = subs.add_parser("examine", help="Analyze a completed trace file")
83
+ ex_p.add_argument(
84
+ "trace_file",
85
+ metavar="FILE",
86
+ help="Path to a completed .jsonl trace file",
87
+ )
88
+
89
+ # ── visualize ─────────────────────────────────────────────────
90
+ viz_p = subs.add_parser(
91
+ "visualize",
92
+ help="Render execution-path PNG from a completed trace file",
93
+ )
94
+ viz_p.add_argument(
95
+ "trace_file",
96
+ metavar="FILE",
97
+ help="Path to a completed .jsonl trace file",
98
+ )
99
+ viz_p.add_argument(
100
+ "--workspace",
101
+ metavar="PATH",
102
+ help="Absolute path to pgs_workspace root (or set PGS_WORKSPACE)",
103
+ )
104
+
105
+ return parser
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # Handlers
110
+ # ---------------------------------------------------------------------------
111
+
112
+ def _handle_run(args: argparse.Namespace) -> None:
113
+ wf_fqdn = args.wf
114
+
115
+ # Extract domain from FQDN (part before ::)
116
+ if "::" not in wf_fqdn:
117
+ _fatal(f"Invalid WF FQDN (expected <domain>::<CODE>): {wf_fqdn!r}")
118
+ domain = wf_fqdn.split("::")[0]
119
+
120
+ # Resolve paths from args or environment
121
+ data_root_str = args.data_root or os.environ.get("PGS_DATA_ROOT")
122
+ if not data_root_str:
123
+ _fatal("--data-root PATH or PGS_DATA_ROOT is required")
124
+ data_root = Path(data_root_str)
125
+ if not data_root.is_absolute():
126
+ _fatal(f"--data-root must be an absolute path, got: {data_root_str}")
127
+
128
+ workspace_str = args.workspace or os.environ.get("PGS_WORKSPACE")
129
+ if not workspace_str:
130
+ _fatal("--workspace PATH or PGS_WORKSPACE is required")
131
+ workspace = Path(workspace_str)
132
+ if not workspace.is_absolute():
133
+ _fatal(f"--workspace must be an absolute path, got: {workspace_str}")
134
+
135
+ # Load payload
136
+ payload = _load_payload(args.payload)
137
+
138
+ # Load tokenized snapshot (verifies hash; raises on mismatch or missing files)
139
+ print(f"[pgs_runtime] Loading {domain} snapshot...")
140
+ try:
141
+ pkg = load_domain(workspace, domain)
142
+ except (FileNotFoundError, RuntimeError, ValueError) as exc:
143
+ _fatal(str(exc))
144
+
145
+ # Generate trace ID and resolve trace directory
146
+ # Structure: traces/<domain>/<WF_CODE>/<trace_id>/
147
+ trace_id = make_trace_id(domain, wf_fqdn, payload)
148
+ wf_code = wf_fqdn.split("::")[-1] # e.g. "WF_CREATE_WALLET_V0"
149
+ trace_dir = workspace / "traces" / domain / wf_code / trace_id
150
+ trace_dir.mkdir(parents=True, exist_ok=True)
151
+
152
+ print(f"[pgs_runtime] Workflow: {wf_fqdn}")
153
+ print(f"[pgs_runtime] Trace ID: {trace_id}")
154
+ print(f"[pgs_runtime] Trace dir: {trace_dir}")
155
+ print()
156
+
157
+ # Resolve WF address for TraceWriter
158
+ try:
159
+ wf_addr = pkg.vocab.addr(wf_fqdn)
160
+ except KeyError:
161
+ _fatal(f"WF FQDN not in vocab: {wf_fqdn!r}")
162
+
163
+ writer = TraceWriter(
164
+ trace_dir = trace_dir,
165
+ trace_id = trace_id,
166
+ domain = domain,
167
+ wf_addr = wf_addr,
168
+ wf_fqdn = wf_fqdn,
169
+ )
170
+
171
+ t0 = time.monotonic()
172
+ try:
173
+ result_status, surface = run_wf(
174
+ wf_fqdn = wf_fqdn,
175
+ payload = payload,
176
+ pkg = pkg,
177
+ writer = writer,
178
+ data_root = str(data_root),
179
+ )
180
+ except Exception as exc:
181
+ writer.error(str(exc))
182
+ writer.close()
183
+ _fatal(f"Runtime error: {exc}")
184
+ finally:
185
+ writer.close()
186
+
187
+ duration_ms = int((time.monotonic() - t0) * 1000)
188
+
189
+ # Evidence projection: render execution-path PNG if requested
190
+ trace_path = trace_dir / f"{trace_id}.jsonl"
191
+ png_path = None
192
+ if args.visualize:
193
+ png_path = _render_visualization(workspace, trace_path)
194
+
195
+ print("=" * 60)
196
+ print("[pgs_runtime] Workflow Complete")
197
+ print("=" * 60)
198
+ print(f"Workflow: {wf_fqdn}")
199
+ print(f"Status: {result_status}")
200
+ print(f"Trace ID: {trace_id}")
201
+ print(f"Duration: {duration_ms}ms")
202
+ if surface:
203
+ print(f"Output: {json.dumps(surface, separators=(',', ':'))}")
204
+ if png_path:
205
+ print(f"Visual: {png_path}")
206
+ elif args.visualize:
207
+ print("Visual: (graphviz not available — PNG skipped)")
208
+ print("=" * 60)
209
+
210
+ if result_status not in ("SUCCESS", "ALREADY_EXISTS"):
211
+ sys.exit(1)
212
+
213
+
214
+ def _handle_visualize(args: argparse.Namespace) -> None:
215
+ trace_path = Path(args.trace_file)
216
+ if not trace_path.exists():
217
+ _fatal(f"Trace file not found: {args.trace_file}")
218
+
219
+ workspace_str = args.workspace or os.environ.get("PGS_WORKSPACE")
220
+ if not workspace_str:
221
+ _fatal("--workspace PATH or PGS_WORKSPACE is required")
222
+ workspace = Path(workspace_str)
223
+ if not workspace.is_absolute():
224
+ _fatal(f"--workspace must be an absolute path, got: {workspace_str}")
225
+
226
+ png_path = _render_visualization(workspace, trace_path)
227
+ if png_path:
228
+ print(f"[pgs_runtime] Execution path PNG: {png_path}")
229
+ else:
230
+ print(
231
+ "[pgs_runtime] Visualization skipped — graphviz (dot) not available.",
232
+ file=sys.stderr,
233
+ )
234
+ sys.exit(1)
235
+
236
+
237
+ def _handle_examine(args: argparse.Namespace) -> None:
238
+ trace_path = Path(args.trace_file)
239
+ if not trace_path.exists():
240
+ _fatal(f"Trace file not found: {args.trace_file}")
241
+
242
+ # Delegate to the examine module (reads JSONL trace format)
243
+ try:
244
+ from pgs_runtime.examine import analyze, TraceParseError
245
+ except ImportError:
246
+ _fatal(
247
+ "Trace examiner unavailable — pgs_runtime may not be fully installed.\n"
248
+ " Re-install with: pip install -e /path/to/pgs_runtime"
249
+ )
250
+
251
+ try:
252
+ report = analyze(trace_path)
253
+ except Exception as exc:
254
+ _fatal(f"Trace parse error: {exc}")
255
+
256
+ print(report.format())
257
+
258
+ if report.has_structural_failure:
259
+ sys.exit(1)
260
+
261
+
262
+ # ---------------------------------------------------------------------------
263
+ # Utilities
264
+ # ---------------------------------------------------------------------------
265
+
266
+ def _render_visualization(workspace: Path, trace_path: Path) -> "Path | None":
267
+ """Invoke evidence projection to render execution-path PNG. Best-effort."""
268
+ from pgs_runtime.trace_viz import render_trace_png
269
+ try:
270
+ return render_trace_png(workspace, trace_path)
271
+ except (FileNotFoundError, ValueError) as exc:
272
+ print(f"[pgs_runtime] Visualization error: {exc}", file=sys.stderr)
273
+ return None
274
+
275
+
276
+ def _load_payload(payload_path: str | None) -> dict:
277
+ if not payload_path:
278
+ return {}
279
+ path = Path(payload_path)
280
+ if not path.exists():
281
+ _fatal(f"Payload file not found: {payload_path}")
282
+ try:
283
+ return json.loads(path.read_text(encoding="utf-8"))
284
+ except json.JSONDecodeError as exc:
285
+ _fatal(f"Payload file is not valid JSON: {exc}")
286
+
287
+
288
+ def _fatal(message: str) -> None:
289
+ print(f"[pgs_runtime] Error: {message}", file=sys.stderr)
290
+ sys.exit(1)
291
+
292
+
293
+ # ---------------------------------------------------------------------------
294
+ # Entry point
295
+ # ---------------------------------------------------------------------------
296
+
297
+ def main() -> None:
298
+ parser = _build_parser()
299
+ args = parser.parse_args()
300
+
301
+ if args.command == "run":
302
+ _handle_run(args)
303
+ elif args.command == "examine":
304
+ _handle_examine(args)
305
+ elif args.command == "visualize":
306
+ _handle_visualize(args)
307
+
308
+
309
+ if __name__ == "__main__":
310
+ main()
@@ -0,0 +1,232 @@
1
+ """
2
+ conformance.py — CT Conformance Runner
3
+
4
+ Loads compiled CT_CONFORMANCE artifacts from protocol_snapshot/conformance/
5
+ and executes each via CTExecutor, asserting expected outputs against actual outputs.
6
+
7
+ Called by pgs build — not by pgs_runtime run.
8
+ Does NOT write snapshot_status.json; that is the caller's responsibility.
9
+ """
10
+
11
+ import hashlib
12
+ import json
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from pgs_runtime.ct_executor import CTExecutor, CTExecutionError
18
+
19
+
20
+ @dataclass
21
+ class CaseResult:
22
+ fqdn: str
23
+ passed: bool
24
+ error: str | None = None
25
+
26
+
27
+ @dataclass
28
+ class ConformanceResult:
29
+ passed: int = 0
30
+ failed: int = 0
31
+ artifact_count: int = 0
32
+ snapshot_hash: str = ""
33
+ cases: list[CaseResult] = field(default_factory=list)
34
+
35
+ @property
36
+ def all_passed(self) -> bool:
37
+ return self.failed == 0 and self.artifact_count > 0
38
+
39
+
40
+ _ALLOWED_MODES: frozenset[str] = frozenset({"exact", "property", "schema"})
41
+ _ALLOWED_TYPES: dict[str, frozenset[str]] = {
42
+ "property": frozenset({"hex_string", "byte_length_range", "non_zero"}),
43
+ "schema": frozenset({"json_schema"}),
44
+ }
45
+
46
+
47
+ def _assert_structural(actual: dict[str, Any], assertions: dict[str, Any]) -> str | None:
48
+ """
49
+ Validate structural assertions for non-deterministic fields.
50
+
51
+ Assertion spec shape (INVARIANT_CONFORMANCE_ASSERTION_MODE_VALID_V0):
52
+ {field_name: {mode: <mode>, type: <type>, ...params}}
53
+
54
+ Mode vocabulary: { exact, property, schema }
55
+ Type vocabulary per mode:
56
+ property → { hex_string, byte_length_range, non_zero }
57
+ schema → { json_schema }
58
+
59
+ Returns an error message string if any assertion fails, else None.
60
+ Raises AssertionError for unknown modes or types (hard failure — never silent).
61
+ """
62
+ for field_name, spec in assertions.items():
63
+ if field_name not in actual:
64
+ return f"assertion field '{field_name}' missing from actual output"
65
+ value = actual[field_name]
66
+
67
+ mode = spec.get("mode")
68
+ if mode is None:
69
+ raise AssertionError(
70
+ f"Assertion spec for '{field_name}' missing required 'mode' field. "
71
+ f"Allowed: {sorted(_ALLOWED_MODES)}. "
72
+ f"This indicates invalid TEST_DATA that should have been caught at compile time."
73
+ )
74
+ if mode not in _ALLOWED_MODES:
75
+ raise AssertionError(
76
+ f"Assertion for '{field_name}' has unknown mode '{mode}'. "
77
+ f"Allowed: {sorted(_ALLOWED_MODES)}. "
78
+ f"This indicates invalid TEST_DATA that should have been caught at compile time."
79
+ )
80
+
81
+ if mode == "exact":
82
+ # exact mode: field is checked via expected dict, not assertions block
83
+ continue
84
+
85
+ assert_type = spec.get("type")
86
+ allowed_types = _ALLOWED_TYPES.get(mode, frozenset())
87
+ if assert_type is None:
88
+ raise AssertionError(
89
+ f"Assertion for '{field_name}' with mode '{mode}' missing required 'type' field. "
90
+ f"Allowed types: {sorted(allowed_types)}."
91
+ )
92
+ if assert_type not in allowed_types:
93
+ raise AssertionError(
94
+ f"Assertion for '{field_name}' has unknown type '{assert_type}' for mode '{mode}'. "
95
+ f"Allowed: {sorted(allowed_types)}. "
96
+ f"This indicates invalid TEST_DATA that should have been caught at compile time."
97
+ )
98
+
99
+ if mode == "property":
100
+ if assert_type == "hex_string":
101
+ if not isinstance(value, str):
102
+ return f"field '{field_name}': expected hex string, got {type(value).__name__}"
103
+ hex_val = value[2:] if value.startswith("0x") else value
104
+ try:
105
+ raw = bytes.fromhex(hex_val)
106
+ except ValueError:
107
+ return f"field '{field_name}': not a valid hex string: {value!r}"
108
+ byte_length = spec.get("byte_length")
109
+ if byte_length is not None and len(raw) != byte_length:
110
+ return (
111
+ f"field '{field_name}': expected {byte_length} bytes, "
112
+ f"got {len(raw)} bytes (value: {value!r})"
113
+ )
114
+
115
+ elif assert_type == "byte_length_range":
116
+ min_len = spec["min"]
117
+ max_len = spec["max"]
118
+ hex_val = value[2:] if isinstance(value, str) and value.startswith("0x") else value
119
+ try:
120
+ raw = bytes.fromhex(hex_val) if isinstance(value, str) else value
121
+ except (ValueError, AttributeError):
122
+ return f"field '{field_name}': cannot determine byte length: {value!r}"
123
+ if not (min_len <= len(raw) <= max_len):
124
+ return (
125
+ f"field '{field_name}': expected {min_len}–{max_len} bytes, "
126
+ f"got {len(raw)} bytes"
127
+ )
128
+
129
+ elif assert_type == "non_zero":
130
+ if value == 0 or value == "0x0" or value == b"\x00" or value == "" or value is None:
131
+ return f"field '{field_name}': expected non-zero value, got {value!r}"
132
+
133
+ elif mode == "schema":
134
+ raise AssertionError(
135
+ f"Assertion for '{field_name}': schema/json_schema validation is not supported in the conformance runner."
136
+ )
137
+
138
+ return None
139
+
140
+
141
+ def _resolve_outputs(ct_ir: dict[str, Any], vars_result: dict[str, Any]) -> dict[str, Any]:
142
+ """
143
+ Resolve ct_ir output mapping from executor result vars.
144
+
145
+ ct_ir.outputs maps output key → {"from": "<var_name>"}
146
+ Each output key is looked up inside the named var dict.
147
+ """
148
+ outputs_spec = ct_ir.get("outputs", {})
149
+ if not outputs_spec:
150
+ return vars_result
151
+
152
+ actual: dict[str, Any] = {}
153
+ for output_key, output_spec in outputs_spec.items():
154
+ from_var = output_spec.get("from")
155
+ if not from_var or from_var not in vars_result:
156
+ continue
157
+ source = vars_result[from_var]
158
+ if isinstance(source, dict) and output_key in source:
159
+ actual[output_key] = source[output_key]
160
+ else:
161
+ actual[output_key] = source
162
+ return actual
163
+
164
+
165
+ def _snapshot_hash(conformance_dir: Path) -> str:
166
+ """Stable SHA-256 over sorted conformance artifact names + contents."""
167
+ h = hashlib.sha256()
168
+ for path in sorted(conformance_dir.glob("*.json")):
169
+ h.update(path.name.encode())
170
+ h.update(path.read_bytes())
171
+ return h.hexdigest()[:16]
172
+
173
+
174
+ def run(snapshot_root: Path) -> ConformanceResult:
175
+ """
176
+ Execute all CT conformance tests in snapshot_root/conformance/.
177
+
178
+ Returns ConformanceResult with per-case pass/fail detail.
179
+ Raises FileNotFoundError if conformance dir is missing.
180
+ """
181
+ conformance_dir = snapshot_root / "conformance"
182
+ if not conformance_dir.exists():
183
+ raise FileNotFoundError(f"Conformance directory not found: {conformance_dir}")
184
+
185
+ case_files = sorted(conformance_dir.glob("*.json"))
186
+ result = ConformanceResult(
187
+ artifact_count=len(case_files),
188
+ snapshot_hash=_snapshot_hash(conformance_dir),
189
+ )
190
+
191
+ executor = CTExecutor()
192
+
193
+ for case_file in case_files:
194
+ artifact = json.loads(case_file.read_text())
195
+ fqdn = artifact.get("fqdn", case_file.stem)
196
+ ct_ir = artifact.get("ct_ir", {})
197
+ inputs = ct_ir.get("inputs", {})
198
+ expected = artifact.get("expected", {})
199
+ assertions = artifact.get("assertions", {})
200
+
201
+ try:
202
+ vars_result = executor.execute(ct_ir=ct_ir, inputs=inputs)
203
+ actual = _resolve_outputs(ct_ir, vars_result)
204
+
205
+ # Structural assertions validate non-deterministic fields by shape/type/size.
206
+ # Fields covered by assertions are excluded from exact-match comparison.
207
+ assertion_error = _assert_structural(actual, assertions) if assertions else None
208
+
209
+ # Exact match on fields NOT covered by assertions
210
+ asserted_keys = set(assertions.keys())
211
+ expected_exact = {k: v for k, v in expected.items() if k not in asserted_keys}
212
+ actual_exact = {k: v for k, v in actual.items() if k not in asserted_keys}
213
+
214
+ if assertion_error:
215
+ result.failed += 1
216
+ result.cases.append(CaseResult(fqdn=fqdn, passed=False, error=f"assertion failed: {assertion_error}"))
217
+ elif actual_exact != expected_exact:
218
+ result.failed += 1
219
+ result.cases.append(CaseResult(
220
+ fqdn=fqdn,
221
+ passed=False,
222
+ error=f"output mismatch\n expected: {json.dumps(expected_exact)}\n actual: {json.dumps(actual_exact)}",
223
+ ))
224
+ else:
225
+ result.passed += 1
226
+ result.cases.append(CaseResult(fqdn=fqdn, passed=True))
227
+
228
+ except CTExecutionError as e:
229
+ result.failed += 1
230
+ result.cases.append(CaseResult(fqdn=fqdn, passed=False, error=str(e)))
231
+
232
+ return result
@@ -0,0 +1,33 @@
1
+ """
2
+ errors.py — Structured error types for CT execution.
3
+
4
+ All CT execution exceptions that carry structured error metadata
5
+ MUST subclass StructuredError. This enables the workflow runner
6
+ to emit deterministic, schema-compliant error trace events.
7
+
8
+ Never raise StructuredError without error_code.
9
+ """
10
+
11
+
12
+ class StructuredError(RuntimeError):
13
+ """
14
+ Base exception carrying structured error metadata.
15
+
16
+ Fields:
17
+ error_code: One of the codes defined in STRUCTURE_TRACE_SCHEMA_V0 §10.2
18
+ node_category: One of WF, IN, CC, CT, CS per §10.3
19
+ message: Human-readable description
20
+ cause: Original exception if wrapping an unstructured error
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ error_code: str,
26
+ node_category: str,
27
+ message: str,
28
+ cause: Exception | None = None,
29
+ ):
30
+ super().__init__(message)
31
+ self.error_code = error_code
32
+ self.node_category = node_category
33
+ self.cause = cause
@@ -0,0 +1,84 @@
1
+ """
2
+ execute.py — CT-IR execution entry point.
3
+
4
+ Adapter between the dispatcher and CTExecutor.
5
+ """
6
+
7
+ from typing import Any
8
+
9
+ from pgs_runtime.ct_errors import StructuredError
10
+ from pgs_runtime.ct_executor import CTExecutor
11
+
12
+ # Module-level executor singleton — avoids re-creating per call,
13
+ # preserves federated IR directory cache and atom loading.
14
+ # Lazy initialization to avoid import-time side effects (bootstrap requirement)
15
+ _executor: CTExecutor | None = None
16
+
17
+
18
+ def _get_executor() -> CTExecutor:
19
+ """Get or create the CT executor singleton (lazy initialization)."""
20
+ global _executor
21
+ if _executor is None:
22
+ _executor = CTExecutor()
23
+ return _executor
24
+
25
+
26
+ def execute_ct(ct_ir: dict[str, Any], inputs: dict[str, Any]) -> Any:
27
+ """
28
+ Execute CT-IR and adapt result to CC expectation.
29
+
30
+ CONTRACT (STRICT):
31
+ - CT-IR MUST be pre-validated by compiler before reaching execution
32
+ - CTExecutor blindly executes pre-validated CT-IR
33
+ - CT-IR declares exactly one output
34
+ - That output is returned directly (unwrapped)
35
+
36
+ SOVEREIGNTY: Execution layer is a blind executor.
37
+ Validation is compiler's responsibility.
38
+ """
39
+
40
+ # ---- Execution (CT-IR assumed pre-validated by compiler) ----
41
+ symbol_table = _get_executor().execute(
42
+ ct_ir=ct_ir,
43
+ inputs=inputs,
44
+ )
45
+
46
+ # ---- Output adaptation ----
47
+ outputs = ct_ir.get("outputs")
48
+ if not outputs:
49
+ raise StructuredError(
50
+ error_code="CT_EXECUTION_FAILED",
51
+ node_category="CT",
52
+ message="CT must declare at least one output",
53
+ )
54
+
55
+ # Build CT outputs by mapping from symbol table
56
+ ct_outputs = {}
57
+ for output_name, spec in outputs.items():
58
+ from_symbol = spec["from"]
59
+
60
+ if from_symbol not in symbol_table:
61
+ raise StructuredError(
62
+ error_code="CT_EXECUTION_FAILED",
63
+ node_category="CT",
64
+ message=f"CT output symbol '{from_symbol}' was not produced by CT execution",
65
+ )
66
+
67
+ symbol_value = symbol_table[from_symbol]
68
+
69
+ # Extract output field from atom result dict
70
+ # Atoms return a single dict containing all output fields
71
+ # e.g., {"valid": True, "failed_rule": None}
72
+ if isinstance(symbol_value, dict):
73
+ if output_name in symbol_value:
74
+ # Multi-output atom: extract this specific output field
75
+ symbol_value = symbol_value[output_name]
76
+ elif len(symbol_value) == 1:
77
+ # Single-output atom: unwrap the single field
78
+ symbol_value = next(iter(symbol_value.values()))
79
+
80
+ ct_outputs[output_name] = symbol_value
81
+
82
+ # Return contract-shaped outputs (always as dict, never unwrap)
83
+ # Contract declares output shape - return exactly that shape
84
+ return ct_outputs