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/cli.py ADDED
@@ -0,0 +1,387 @@
1
+ """
2
+ cli.py — Token-native CLI entry point for the 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
+ behavior-logic — 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 --behavior-logic: 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 runtime.api import run_workflow
32
+ from runtime.boot import boot, default_snapshot_root
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Argument parsing
37
+ # ---------------------------------------------------------------------------
38
+
39
+ def _build_parser() -> argparse.ArgumentParser:
40
+ parser = argparse.ArgumentParser(
41
+ prog="protocol_runtime",
42
+ description="PGC token-native workflow runtime — warm-boots and executes an assembled snapshot",
43
+ )
44
+ subs = parser.add_subparsers(dest="command", required=True)
45
+
46
+ # ── run ──────────────────────────────────────────────────────
47
+ run_p = subs.add_parser("run", help="Execute a workflow")
48
+
49
+ run_p.add_argument(
50
+ "--wf",
51
+ required=True,
52
+ metavar="FQDN",
53
+ help="Workflow FQDN (e.g. blockchain::WF_REGISTER_ACTOR_UNVERIFIED_V0)",
54
+ )
55
+ run_p.add_argument(
56
+ "--payload",
57
+ metavar="FILE",
58
+ help="Path to JSON payload file (omit for empty payload)",
59
+ )
60
+ run_p.add_argument(
61
+ "--data-root",
62
+ dest="data_root",
63
+ metavar="PATH",
64
+ help="Absolute instance root for CS state + traces (or set PGC_DATA_ROOT)",
65
+ )
66
+ run_p.add_argument(
67
+ "--snapshot",
68
+ dest="snapshot",
69
+ metavar="PATH",
70
+ help="Assembled snapshot root (or set PGC_SNAPSHOT_ROOT); manifest.json lives here. "
71
+ "Default: sibling ../snapshot",
72
+ )
73
+
74
+ # ── run: optional behavior-logic flag ────────────────────────
75
+ run_p.add_argument(
76
+ "--behavior-logic",
77
+ action="store_true",
78
+ dest="behavior_logic",
79
+ help="Render execution-path PNG after run (requires graphviz)",
80
+ )
81
+
82
+ # ── boot ──────────────────────────────────────────────────────
83
+ boot_p = subs.add_parser(
84
+ "boot",
85
+ help="Warm-boot the assembled snapshot (load + hash-verify all manifest domains)",
86
+ )
87
+ boot_p.add_argument(
88
+ "--snapshot",
89
+ dest="snapshot",
90
+ metavar="PATH",
91
+ help="Assembled snapshot root (or set PGC_SNAPSHOT_ROOT); default: sibling ../snapshot",
92
+ )
93
+
94
+ # ── examine ───────────────────────────────────────────────────
95
+ ex_p = subs.add_parser("examine", help="Analyze a completed trace file")
96
+ ex_p.add_argument(
97
+ "trace_file",
98
+ metavar="FILE",
99
+ help="Path to a completed .jsonl trace file",
100
+ )
101
+
102
+ # ── behavior-logic ────────────────────────────────────────────
103
+ bl_p = subs.add_parser(
104
+ "behavior-logic",
105
+ help="Render execution-path PNG from a completed trace file",
106
+ )
107
+ bl_p.add_argument(
108
+ "trace_file",
109
+ metavar="FILE",
110
+ help="Path to a completed .jsonl trace file",
111
+ )
112
+ bl_p.add_argument(
113
+ "--workspace",
114
+ metavar="PATH",
115
+ help="Absolute path to pgs_workspace root (or set PGS_WORKSPACE)",
116
+ )
117
+
118
+ return parser
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # Handlers
123
+ # ---------------------------------------------------------------------------
124
+
125
+ def _handle_run(args: argparse.Namespace) -> None:
126
+ wf_fqdn = args.wf
127
+
128
+ # Extract domain from FQDN (part before ::)
129
+ if "::" not in wf_fqdn:
130
+ _fatal(f"Invalid WF FQDN (expected <domain>::<CODE>): {wf_fqdn!r}")
131
+ domain = wf_fqdn.split("::")[0]
132
+
133
+ # Resolve paths from args or environment
134
+ data_root_str = args.data_root or os.environ.get("PGC_DATA_ROOT")
135
+ if not data_root_str:
136
+ _fatal("--data-root PATH or PGC_DATA_ROOT is required (instance root for CS state + traces)")
137
+ data_root = Path(data_root_str)
138
+ if not data_root.is_absolute():
139
+ _fatal(f"--data-root must be an absolute path, got: {data_root_str}")
140
+
141
+ # Snapshot root: arg or env, else the default sibling ../snapshot (resolved by boot).
142
+ snapshot_str = args.snapshot or os.environ.get("PGC_SNAPSHOT_ROOT")
143
+ snapshot_root = Path(snapshot_str) if snapshot_str else default_snapshot_root()
144
+ if not snapshot_root.is_absolute():
145
+ _fatal(f"--snapshot must be an absolute path, got: {snapshot_str}")
146
+
147
+ # Load payload
148
+ payload = _load_payload(args.payload)
149
+
150
+ # Drive the workflow via the programmatic API. The API warm-boots the snapshot (manifest root
151
+ # of trust), opens the trace under the instance root, and returns status + surface.
152
+ print(f"[runtime] Booting snapshot for {domain}...")
153
+ t0 = time.monotonic()
154
+ try:
155
+ run = run_workflow(wf_fqdn=wf_fqdn, payload=payload,
156
+ data_root=str(data_root), snapshot_root=snapshot_root)
157
+ except KeyError as exc:
158
+ _fatal(f"WF FQDN not in vocab: {exc}")
159
+ except (FileNotFoundError, RuntimeError, ValueError) as exc:
160
+ _fatal(str(exc))
161
+ except Exception as exc:
162
+ _fatal(f"Runtime error: {exc}")
163
+
164
+ result_status, surface = run.status, run.surface
165
+ trace_id, trace_dir = run.trace_id, run.trace_dir
166
+ duration_ms = int((time.monotonic() - t0) * 1000)
167
+
168
+ print(f"[runtime] Workflow: {wf_fqdn}")
169
+ print(f"[runtime] Trace ID: {trace_id}")
170
+ print(f"[runtime] Trace dir: {trace_dir}")
171
+ print()
172
+
173
+ # Evidence projection: render execution-path PNG if requested
174
+ trace_path = trace_dir / f"{trace_id}.jsonl"
175
+ png_path = None
176
+ if args.behavior_logic:
177
+ png_path = _render_behavior_logic(snapshot_root, trace_path)
178
+
179
+ print("=" * 60)
180
+ print("[runtime] Workflow Complete")
181
+ print("=" * 60)
182
+ print(f"Workflow: {wf_fqdn}")
183
+ print(f"Status: {result_status}")
184
+ print(f"Trace ID: {trace_id}")
185
+ print(f"Duration: {duration_ms}ms")
186
+ if surface:
187
+ print("Output:")
188
+ print(_format_surface(surface))
189
+ if png_path:
190
+ print(f"Graph: {png_path}")
191
+ elif args.behavior_logic:
192
+ print("Graph: (graphviz not available — PNG skipped)")
193
+ print("=" * 60)
194
+
195
+ if result_status not in ("SUCCESS", "ALREADY_EXISTS"):
196
+ sys.exit(1)
197
+
198
+
199
+ def _handle_boot(args: argparse.Namespace) -> None:
200
+ snapshot_str = args.snapshot or os.environ.get("PGC_SNAPSHOT_ROOT")
201
+ snapshot_root = Path(snapshot_str) if snapshot_str else default_snapshot_root()
202
+
203
+ print(f"[runtime] Warm-booting assembled snapshot: {snapshot_root}")
204
+ try:
205
+ booted = boot(snapshot_root)
206
+ except (FileNotFoundError, RuntimeError, ValueError) as exc:
207
+ _fatal(str(exc))
208
+ except Exception as exc:
209
+ _fatal(f"Boot error: {exc}")
210
+
211
+ print("=" * 60)
212
+ print("[runtime] Warm reboot complete — snapshot resident + hash-verified")
213
+ print("=" * 60)
214
+ print(booted.summary())
215
+ print("=" * 60)
216
+ print(_health_line(snapshot_root, booted))
217
+ print("=" * 60)
218
+
219
+
220
+ def _health_line(snapshot_root: Path, booted) -> str:
221
+ """Consolidated completion attestation: what warm boot verified, in one line.
222
+
223
+ Reports only checks that actually ran: every manifest domain was made resident and its hashes
224
+ verified against the root of trust. Governance provenance is surfaced where a domain carries it
225
+ (bound at compile, verified at assembly) so the health line reflects the full trust chain.
226
+
227
+ A governance surface imports no governance — it *is* the governance other domains compile
228
+ against, so it carries no `imported_governance` and is not counted as unbound. Counting it as
229
+ such reported a permanent shortfall (`4/5`) on a healthy snapshot and invited the same
230
+ investigation on every boot. The surface is derived from what the other attestations name, not
231
+ hardcoded, so a composition with a differently-named surface reports correctly too.
232
+ """
233
+ import json
234
+
235
+ n = len(booted.domains)
236
+ bound: set[str] = set()
237
+ surfaces: set[str] = set()
238
+ for name in booted.domains:
239
+ att = snapshot_root / "trust" / name / "structure_attestation.json"
240
+ try:
241
+ imported = json.loads(att.read_text(encoding="utf-8")).get("imported_governance")
242
+ except (OSError, ValueError):
243
+ continue
244
+ if imported:
245
+ bound.add(name)
246
+ source = imported.get("import_domain")
247
+ if source:
248
+ surfaces.add(source)
249
+
250
+ if not bound:
251
+ return f"[runtime] ✓ Snapshot healthy — {n} domain(s) resident and hash-verified. No issues."
252
+
253
+ importing = [d for d in booted.domains if d not in surfaces]
254
+ unbound = sorted(d for d in importing if d not in bound)
255
+ surface_note = f", {'/'.join(sorted(surfaces))} is the governance surface" if surfaces else ""
256
+
257
+ if unbound:
258
+ gov = (
259
+ f"; governance provenance bound for {len(bound)}/{len(importing)} importing domain(s)"
260
+ f"{surface_note} — UNBOUND: {', '.join(unbound)}"
261
+ )
262
+ return f"[runtime] ✓ Snapshot healthy — {n} domain(s) resident and hash-verified{gov}."
263
+
264
+ gov = (
265
+ f"; governance provenance bound for all {len(importing)} importing domain(s)"
266
+ f"{surface_note}"
267
+ )
268
+ return f"[runtime] ✓ Snapshot healthy — {n} domain(s) resident and hash-verified{gov}. No issues."
269
+
270
+
271
+ def _handle_behavior_logic(args: argparse.Namespace) -> None:
272
+ trace_path = Path(args.trace_file)
273
+ if not trace_path.exists():
274
+ _fatal(f"Trace file not found: {args.trace_file}")
275
+
276
+ workspace_str = args.workspace or os.environ.get("PGS_WORKSPACE")
277
+ if not workspace_str:
278
+ _fatal("--workspace PATH or PGS_WORKSPACE is required")
279
+ workspace = Path(workspace_str)
280
+ if not workspace.is_absolute():
281
+ _fatal(f"--workspace must be an absolute path, got: {workspace_str}")
282
+
283
+ png_path = _render_behavior_logic(workspace, trace_path)
284
+ if png_path:
285
+ print(f"[runtime] Execution path PNG: {png_path}")
286
+ else:
287
+ print(
288
+ "[runtime] Behavior logic render skipped — graphviz (dot) not available.",
289
+ file=sys.stderr,
290
+ )
291
+ sys.exit(1)
292
+
293
+
294
+ def _handle_examine(args: argparse.Namespace) -> None:
295
+ trace_path = Path(args.trace_file)
296
+ if not trace_path.exists():
297
+ _fatal(f"Trace file not found: {args.trace_file}")
298
+
299
+ # Delegate to the examine module (reads JSONL trace format)
300
+ try:
301
+ from runtime.examine import analyze, TraceParseError
302
+ except ImportError:
303
+ _fatal(
304
+ "Trace examiner unavailable — runtime may not be fully installed.\n"
305
+ " Re-install with: pip install -e /path/to/protocol_runtime"
306
+ )
307
+
308
+ try:
309
+ report = analyze(trace_path)
310
+ except Exception as exc:
311
+ _fatal(f"Trace parse error: {exc}")
312
+
313
+ print(report.format())
314
+
315
+ if report.has_structural_failure:
316
+ sys.exit(1)
317
+
318
+
319
+ # ---------------------------------------------------------------------------
320
+ # Utilities
321
+ # ---------------------------------------------------------------------------
322
+
323
+ def _render_behavior_logic(workspace: Path, trace_path: Path) -> "Path | None":
324
+ """Invoke evidence projection to render execution-path PNG. Best-effort."""
325
+ from runtime.trace_viz import render_trace_png
326
+ try:
327
+ return render_trace_png(workspace, trace_path)
328
+ except (FileNotFoundError, ValueError) as exc:
329
+ print(f"[runtime] Behavior logic render error: {exc}", file=sys.stderr)
330
+ return None
331
+
332
+
333
+ def _load_payload(payload_path: str | None) -> dict:
334
+ if not payload_path:
335
+ return {}
336
+ path = Path(payload_path)
337
+ if not path.exists():
338
+ _fatal(f"Payload file not found: {payload_path}")
339
+ try:
340
+ return json.loads(path.read_text(encoding="utf-8"))
341
+ except json.JSONDecodeError as exc:
342
+ _fatal(f"Payload file is not valid JSON: {exc}")
343
+
344
+
345
+ def _format_surface(surface: dict) -> str:
346
+ """Readable, domain-agnostic rendering of a WF result surface.
347
+
348
+ Top-level keys each on their own line; a nested dict value (e.g. per-seed sequences) expands one
349
+ level so each entry lands on its own line with a compact value. Purely presentational — no
350
+ knowledge of any specific workflow.
351
+ """
352
+ lines: list[str] = []
353
+ for key, value in surface.items():
354
+ if isinstance(value, dict) and value:
355
+ lines.append(f" {key}:")
356
+ for sub_key, sub_value in value.items():
357
+ lines.append(f" {sub_key}: {json.dumps(sub_value, separators=(',', ':'))}")
358
+ else:
359
+ lines.append(f" {key}: {json.dumps(value, separators=(',', ':'))}")
360
+ return "\n".join(lines)
361
+
362
+
363
+ def _fatal(message: str) -> None:
364
+ print(f"[runtime] Error: {message}", file=sys.stderr)
365
+ sys.exit(1)
366
+
367
+
368
+ # ---------------------------------------------------------------------------
369
+ # Entry point
370
+ # ---------------------------------------------------------------------------
371
+
372
+ def main() -> None:
373
+ parser = _build_parser()
374
+ args = parser.parse_args()
375
+
376
+ if args.command == "run":
377
+ _handle_run(args)
378
+ elif args.command == "boot":
379
+ _handle_boot(args)
380
+ elif args.command == "examine":
381
+ _handle_examine(args)
382
+ elif args.command == "behavior-logic":
383
+ _handle_behavior_logic(args)
384
+
385
+
386
+ if __name__ == "__main__":
387
+ main()
runtime/conformance.py ADDED
@@ -0,0 +1,249 @@
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 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 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
+ expected_outcome = artifact.get("expected_outcome", "SUCCESS")
202
+
203
+ try:
204
+ vars_result = executor.execute(ct_ir=ct_ir, inputs=inputs)
205
+ actual = _resolve_outputs(ct_ir, vars_result)
206
+
207
+ if expected_outcome == "VIOLATION":
208
+ # CT completed without error but a VIOLATION was expected.
209
+ result.failed += 1
210
+ result.cases.append(CaseResult(
211
+ fqdn=fqdn,
212
+ passed=False,
213
+ error="expected CTExecutionError (VIOLATION) but CT completed without raising",
214
+ ))
215
+ continue
216
+
217
+ # Structural assertions validate non-deterministic fields by shape/type/size.
218
+ # Fields covered by assertions are excluded from exact-match comparison.
219
+ assertion_error = _assert_structural(actual, assertions) if assertions else None
220
+
221
+ # Exact match on fields NOT covered by assertions
222
+ asserted_keys = set(assertions.keys())
223
+ expected_exact = {k: v for k, v in expected.items() if k not in asserted_keys}
224
+ actual_exact = {k: v for k, v in actual.items() if k not in asserted_keys}
225
+
226
+ if assertion_error:
227
+ result.failed += 1
228
+ result.cases.append(CaseResult(fqdn=fqdn, passed=False, error=f"assertion failed: {assertion_error}"))
229
+ elif actual_exact != expected_exact:
230
+ result.failed += 1
231
+ result.cases.append(CaseResult(
232
+ fqdn=fqdn,
233
+ passed=False,
234
+ error=f"output mismatch\n expected: {json.dumps(expected_exact)}\n actual: {json.dumps(actual_exact)}",
235
+ ))
236
+ else:
237
+ result.passed += 1
238
+ result.cases.append(CaseResult(fqdn=fqdn, passed=True))
239
+
240
+ except CTExecutionError as e:
241
+ if expected_outcome == "VIOLATION":
242
+ # CT raised as expected — PASS.
243
+ result.passed += 1
244
+ result.cases.append(CaseResult(fqdn=fqdn, passed=True))
245
+ else:
246
+ result.failed += 1
247
+ result.cases.append(CaseResult(fqdn=fqdn, passed=False, error=str(e)))
248
+
249
+ return result
runtime/ct_errors.py ADDED
@@ -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