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.
- {pgs_runtime-0.3.0 → pgs_runtime-0.4.0}/MANIFEST.in +0 -1
- {pgs_runtime-0.3.0 → pgs_runtime-0.4.0}/PKG-INFO +1 -1
- pgs_runtime-0.4.0/pgs_runtime/__init__.py +0 -0
- pgs_runtime-0.4.0/pgs_runtime/cli.py +310 -0
- pgs_runtime-0.4.0/pgs_runtime/conformance.py +232 -0
- pgs_runtime-0.4.0/pgs_runtime/ct_errors.py +33 -0
- pgs_runtime-0.4.0/pgs_runtime/ct_execute.py +84 -0
- pgs_runtime-0.4.0/pgs_runtime/ct_executor.py +312 -0
- pgs_runtime-0.4.0/pgs_runtime/dispatcher.py +365 -0
- pgs_runtime-0.4.0/pgs_runtime/evidence.py +173 -0
- pgs_runtime-0.4.0/pgs_runtime/examine/__init__.py +126 -0
- pgs_runtime-0.4.0/pgs_runtime/examine/classifier.py +350 -0
- pgs_runtime-0.4.0/pgs_runtime/examine/cli.py +35 -0
- pgs_runtime-0.4.0/pgs_runtime/examine/conformance_test.py +211 -0
- pgs_runtime-0.4.0/pgs_runtime/examine/hint_engine.py +245 -0
- pgs_runtime-0.4.0/pgs_runtime/examine/locator.py +148 -0
- pgs_runtime-0.4.0/pgs_runtime/examine/parser.py +223 -0
- pgs_runtime-0.4.0/pgs_runtime/examine/reporter.py +110 -0
- pgs_runtime-0.4.0/pgs_runtime/loader.py +281 -0
- pgs_runtime-0.4.0/pgs_runtime/memory.py +116 -0
- pgs_runtime-0.4.0/pgs_runtime/scheduler.py +159 -0
- pgs_runtime-0.4.0/pgs_runtime/server.py +455 -0
- pgs_runtime-0.4.0/pgs_runtime/trace_viz.py +264 -0
- pgs_runtime-0.4.0/pgs_runtime.egg-info/PKG-INFO +213 -0
- pgs_runtime-0.4.0/pgs_runtime.egg-info/SOURCES.txt +31 -0
- pgs_runtime-0.4.0/pgs_runtime.egg-info/dependency_links.txt +1 -0
- pgs_runtime-0.4.0/pgs_runtime.egg-info/entry_points.txt +2 -0
- pgs_runtime-0.4.0/pgs_runtime.egg-info/top_level.txt +1 -0
- {pgs_runtime-0.3.0 → pgs_runtime-0.4.0}/pyproject.toml +1 -1
- pgs_runtime-0.3.0/pgs_runtime.egg-info/SOURCES.txt +0 -5
- {pgs_runtime-0.3.0 → pgs_runtime-0.4.0}/LICENSE +0 -0
- {pgs_runtime-0.3.0 → pgs_runtime-0.4.0}/NOTICE +0 -0
- {pgs_runtime-0.3.0 → pgs_runtime-0.4.0}/README.md +0 -0
- {pgs_runtime-0.3.0 → pgs_runtime-0.4.0}/setup.cfg +0 -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
|