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.
@@ -0,0 +1,126 @@
1
+ """
2
+ trace_examiner — Post-execution diagnostic module.
3
+
4
+ Governed by: STRUCTURE_TRACE_SCHEMA_V0 §10, Trace Examiner spec
5
+
6
+ Public API:
7
+ analyze(trace_path) -> DiagnosticReport
8
+
9
+ Reads completed JSONL trace, classifies failures deterministically,
10
+ resolves artifact paths, generates prescriptive fix hints.
11
+
12
+ Module boundaries (spec §6):
13
+ - No imports from execution/machine/
14
+ - parser.py imports only json, pathlib
15
+ - locator.py imports only structure.path_registry
16
+ - classifier.py, hint_engine.py, reporter.py — pure functions
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from pathlib import Path
22
+
23
+ from runtime.examine.parser import parse_trace, ParsedTrace, TraceParseError
24
+ from runtime.examine.classifier import classify, FailureClass, STRUCTURAL_FAILURES
25
+ from runtime.examine.locator import locate_artifact
26
+ from runtime.examine.hint_engine import generate_hint
27
+ from runtime.examine.reporter import DiagnosticReport, SideEffectOutcome
28
+
29
+
30
+ def _extract_side_effect_outcomes(trace: ParsedTrace) -> list[SideEffectOutcome]:
31
+ """
32
+ Extract side-effect outcomes from capability dispatch + node_end correlation.
33
+
34
+ Identifies CC nodes that dispatched capability contracts and correlates
35
+ them with their node_end status to produce business-visible outcomes.
36
+ """
37
+ dispatched_cc: dict[str, str] = {} # node_id -> cc_code
38
+ for event in trace.capability_dispatch_events:
39
+ p = event.get("payload", {})
40
+ node_id = p.get("node_id", "")
41
+ cc_code = p.get("cc_code", "")
42
+ if node_id and cc_code:
43
+ dispatched_cc[node_id] = cc_code
44
+
45
+ # Correlate with node_end events to get result status
46
+ outcomes: list[SideEffectOutcome] = []
47
+ for event in trace.node_end_events:
48
+ p = event.get("payload", {})
49
+ node_id = p.get("node_id", "")
50
+ if node_id in dispatched_cc:
51
+ outcomes.append(SideEffectOutcome(
52
+ cc_code=dispatched_cc[node_id],
53
+ result_status=p.get("status", "UNKNOWN"),
54
+ ))
55
+
56
+ return outcomes
57
+
58
+
59
+ def analyze(trace_path: Path) -> DiagnosticReport:
60
+ """
61
+ Analyze a completed trace file and produce a diagnostic report.
62
+
63
+ This is the single public entry point for the Trace Examiner.
64
+
65
+ Snapshot root is derived from the trace path per STRUCTURE layout convention:
66
+ trace_path = {workspace}/traces/{trace_id}/{trace_id}.jsonl
67
+ snapshot = {workspace}/protocol_snapshot
68
+
69
+ Args:
70
+ trace_path: Path to a completed .jsonl trace file.
71
+
72
+ Returns:
73
+ DiagnosticReport with failure classification, artifact path, and fix hint.
74
+
75
+ Raises:
76
+ TraceParseError: If the trace file is missing, empty, or malformed.
77
+ """
78
+ # Derive snapshot root from trace path (STRUCTURE layout: traces/{id}/{id}.jsonl)
79
+ snapshot_root: Path | None = None
80
+ candidate = trace_path.parent.parent.parent / "protocol_snapshot"
81
+ if candidate.is_dir():
82
+ snapshot_root = candidate
83
+
84
+ # 1. Parse
85
+ trace = parse_trace(trace_path)
86
+
87
+ # 2. Classify
88
+ result = classify(trace)
89
+
90
+ # 3. Locate artifact
91
+ artifact_path = None
92
+ if result.failure_class is not None:
93
+ artifact_path = locate_artifact(trace, result, snapshot_root)
94
+
95
+ # 4. Generate hint
96
+ fix_hint = generate_hint(trace, result, artifact_path)
97
+
98
+ # 5. Extract side-effect outcomes
99
+ side_effect_outcomes = _extract_side_effect_outcomes(trace)
100
+
101
+ # 6. Build report
102
+ is_structural = (
103
+ result.failure_class is not None
104
+ and result.failure_class in STRUCTURAL_FAILURES
105
+ )
106
+
107
+ return DiagnosticReport(
108
+ execution_id=trace.execution_id,
109
+ workflow_code=trace.workflow_code,
110
+ has_structural_failure=is_structural,
111
+ failure_class=result.failure_class,
112
+ failing_node=result.node_id,
113
+ reason=result.message,
114
+ artifact_path=artifact_path,
115
+ fix_hint=fix_hint,
116
+ side_effect_outcomes=side_effect_outcomes,
117
+ )
118
+
119
+
120
+ __all__ = [
121
+ "analyze",
122
+ "DiagnosticReport",
123
+ "SideEffectOutcome",
124
+ "FailureClass",
125
+ "TraceParseError",
126
+ ]
@@ -0,0 +1,350 @@
1
+ """
2
+ classifier.py — Deterministic failure classification.
3
+
4
+ Governed by: STRUCTURE_TRACE_SCHEMA_V0 §10, Trace Examiner spec §4-5
5
+
6
+ Classification is rule-based and deterministic — driven by structured
7
+ trace fields only. Never string-parse error messages.
8
+
9
+ Rules are applied in spec-defined order. First match wins.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+ from enum import Enum
16
+ from typing import Any
17
+
18
+ from runtime.examine.parser import ParsedTrace, TraceEventDict
19
+
20
+
21
+ class FailureClass(Enum):
22
+ """Failure classification categories per spec §4."""
23
+
24
+ BUSINESS_VIOLATION = "BUSINESS_VIOLATION"
25
+ EXPRESSION_ERROR = "EXPRESSION_ERROR"
26
+ SCHEMA_ERROR = "SCHEMA_ERROR"
27
+ CT_STRUCTURE_ERROR = "CT_STRUCTURE_ERROR"
28
+ BINDING_ERROR = "BINDING_ERROR"
29
+ CS_RUNTIME_ERROR = "CS_RUNTIME_ERROR"
30
+ GRAPH_STRUCTURE_ERROR = "GRAPH_STRUCTURE_ERROR"
31
+ ADMISSION_ERROR = "ADMISSION_ERROR"
32
+
33
+
34
+ # Failure classes that trigger escalation (exit(1)) in authoring mode
35
+ STRUCTURAL_FAILURES = frozenset(FailureClass) - {FailureClass.BUSINESS_VIOLATION}
36
+
37
+
38
+ @dataclass
39
+ class ClassificationResult:
40
+ """Result of classifying a trace."""
41
+
42
+ failure_class: FailureClass | None
43
+ root_event: TraceEventDict | None # The earliest causal event
44
+ is_structural: bool # True if failure_class is not BUSINESS_VIOLATION
45
+ node_id: str | None # Failing node if identifiable
46
+ error_code: str | None # Structured error code if from error event
47
+ message: str # Human-readable reason
48
+
49
+
50
+ def _payload(event: TraceEventDict) -> dict[str, Any]:
51
+ """Extract payload from event, defaulting to empty dict."""
52
+ return event.get("payload", {})
53
+
54
+
55
+ def _classify_error_event(event: TraceEventDict) -> FailureClass:
56
+ """
57
+ Classify a single error event per spec §4 rules 1-2, 4-5, 8-9.
58
+
59
+ Rules applied in order:
60
+ Rule 1: error_code == EXPRESSION_RESOLUTION_FAILED → EXPRESSION_ERROR
61
+ Rule 2: error_code == SCHEMA_VALIDATION_FAILED → SCHEMA_ERROR
62
+ Rule 4: node_category == CT → CT_STRUCTURE_ERROR
63
+ Rule 5: node_category == CS → CS_RUNTIME_ERROR
64
+ Rule 8: error_code == BINDING_RESOLUTION_FAILED → BINDING_ERROR
65
+ Rule 9: catch-all error → CT_STRUCTURE_ERROR
66
+ """
67
+ p = _payload(event)
68
+ error_code = p.get("error_code", "")
69
+ node_category = p.get("node_category", "")
70
+
71
+ # Rule 1
72
+ if error_code == "EXPRESSION_RESOLUTION_FAILED":
73
+ return FailureClass.EXPRESSION_ERROR
74
+
75
+ # Rule 2
76
+ if error_code == "SCHEMA_VALIDATION_FAILED":
77
+ return FailureClass.SCHEMA_ERROR
78
+
79
+ # Rule 4
80
+ if node_category == "CT":
81
+ return FailureClass.CT_STRUCTURE_ERROR
82
+
83
+ # Rule 5
84
+ if node_category == "CS":
85
+ return FailureClass.CS_RUNTIME_ERROR
86
+
87
+ # Rule 8
88
+ if error_code == "BINDING_RESOLUTION_FAILED":
89
+ return FailureClass.BINDING_ERROR
90
+
91
+ # Admission errors (pre-DAG enforcement failures)
92
+ if error_code in ("ADMISSION_BINDING_MISSING_FIELD", "ADMISSION_DENIED"):
93
+ return FailureClass.ADMISSION_ERROR
94
+
95
+ # Rule 9: catch-all structural
96
+ return FailureClass.CT_STRUCTURE_ERROR
97
+
98
+
99
+ def _is_business_violation(event: TraceEventDict, trace: ParsedTrace) -> bool:
100
+ """
101
+ Check Rule 3: node_end with NACK status on intent node.
102
+
103
+ We need to look up the node_type from the corresponding node_start event.
104
+ """
105
+ p = _payload(event)
106
+ if event.get("event_type") != "node_end":
107
+ return False
108
+ if p.get("status") != "NACK":
109
+ return False
110
+
111
+ # Determine node_type from matching node_start event
112
+ node_id = p.get("node_id", "")
113
+ for ev in trace.events:
114
+ if (
115
+ ev.get("event_type") == "node_start"
116
+ and _payload(ev).get("node_id") == node_id
117
+ ):
118
+ if _payload(ev).get("node_type") == "intent":
119
+ return True
120
+ break
121
+
122
+ return False
123
+
124
+
125
+ def classify(trace: ParsedTrace) -> ClassificationResult:
126
+ """
127
+ Classify a parsed trace per spec §4-5.
128
+
129
+ Scans events in trace order. Applies classification rules.
130
+ Returns on first structural match (single-root diagnosis, V0 constraint).
131
+
132
+ If no error event exists, falls back to workflow_complete authority (§5A).
133
+
134
+ Args:
135
+ trace: Parsed trace from parser.parse_trace()
136
+
137
+ Returns:
138
+ ClassificationResult with failure class, root event, and metadata.
139
+ """
140
+ # Fast path: successful execution — but scan for unhappy-path exits
141
+ if trace.status == "SUCCESS":
142
+ unhappy = _detect_unhappy_path(trace)
143
+ if unhappy is not None:
144
+ return unhappy
145
+ return ClassificationResult(
146
+ failure_class=None,
147
+ root_event=None,
148
+ is_structural=False,
149
+ node_id=None,
150
+ error_code=None,
151
+ message="Execution completed successfully",
152
+ )
153
+
154
+ # Scan events in sequence order for root cause (spec §5).
155
+ # Parser guarantees events are sorted by sequence number.
156
+ # First structural match wins (single-root, V0).
157
+ for event in trace.events:
158
+ event_type = event.get("event_type")
159
+ p = _payload(event)
160
+
161
+ # Rule 3: business violation check (node_end with NACK on intent)
162
+ if event_type == "node_end" and p.get("status") == "NACK":
163
+ if _is_business_violation(event, trace):
164
+ return ClassificationResult(
165
+ failure_class=FailureClass.BUSINESS_VIOLATION,
166
+ root_event=event,
167
+ is_structural=False,
168
+ node_id=p.get("node_id"),
169
+ error_code=None,
170
+ message=f"Business violation at {p.get('node_id', 'unknown')}: NACK",
171
+ )
172
+
173
+ # Rules 1, 2, 4, 5, 8, 9: error event classification
174
+ if event_type == "error":
175
+ failure_class = _classify_error_event(event)
176
+ return ClassificationResult(
177
+ failure_class=failure_class,
178
+ root_event=event,
179
+ is_structural=failure_class in STRUCTURAL_FAILURES,
180
+ node_id=p.get("node_id"),
181
+ error_code=p.get("error_code"),
182
+ message=p.get("message", "Unknown error"),
183
+ )
184
+
185
+ # Also check node_end with failure status (spec §5 criterion 2)
186
+ # Schema defines lowercase "failed"; also accept uppercase for robustness
187
+ if event_type == "node_end" and p.get("status", "").lower() in ("failed", "failure", "error"):
188
+ # No error event preceded this — classify as generic structural
189
+ return ClassificationResult(
190
+ failure_class=FailureClass.CT_STRUCTURE_ERROR,
191
+ root_event=event,
192
+ is_structural=True,
193
+ node_id=p.get("node_id"),
194
+ error_code=None,
195
+ message=f"Node {p.get('node_id', 'unknown')} ended with {p.get('status')}",
196
+ )
197
+
198
+ # §5A — Workflow Complete Authority
199
+ # No error event found, but workflow failed. Use exit_reason_code.
200
+ wc = trace.workflow_complete_event
201
+ if wc and trace.status in ("FAILURE", "ABORT", "TIMEOUT"):
202
+ return _classify_from_workflow_complete(trace)
203
+
204
+ # No failure detected despite non-SUCCESS status
205
+ return ClassificationResult(
206
+ failure_class=None,
207
+ root_event=None,
208
+ is_structural=False,
209
+ node_id=None,
210
+ error_code=None,
211
+ message=f"Workflow ended with status {trace.status} but no classifiable failure found",
212
+ )
213
+
214
+
215
+ _HAPPY_STATUSES = frozenset({"SUCCESS", "ACK", "completed"})
216
+
217
+
218
+ def _detect_unhappy_path(trace: ParsedTrace) -> ClassificationResult | None:
219
+ """
220
+ Detect unhappy-path exits on SUCCESS workflows.
221
+
222
+ Even when the workflow completes successfully (valid EXIT path), a
223
+ capability contract ending with NOT_FOUND, VIOLATION, etc. may indicate
224
+ an early-exit path. We flag it as BUSINESS_VIOLATION only when the
225
+ non-success CC status led directly to an EXIT/TERMINAL node — meaning
226
+ the workflow terminated early rather than continuing normally.
227
+ """
228
+ # Build ordered list of node transitions from trace events
229
+ node_sequence: list[tuple[str, str, str]] = [] # (node_id, status, node_type)
230
+ node_types: dict[str, str] = {}
231
+
232
+ for event in trace.events:
233
+ et = event.get("event_type")
234
+ p = _payload(event)
235
+ if et == "node_start":
236
+ node_types[p.get("node_id", "")] = p.get("node_type", "")
237
+ elif et == "node_end":
238
+ nid = p.get("node_id", "")
239
+ node_sequence.append((nid, p.get("status", ""), node_types.get(nid, "")))
240
+
241
+ # Scan for CC nodes with non-happy status that are immediately followed by EXIT
242
+ for i, (node_id, status, node_type) in enumerate(node_sequence):
243
+ if node_type != "capability_contract":
244
+ continue
245
+ if status in _HAPPY_STATUSES:
246
+ continue
247
+
248
+ # Check if the very next node is EXIT or TERMINAL
249
+ if i + 1 < len(node_sequence):
250
+ next_node_id = node_sequence[i + 1][0]
251
+ next_node_type = node_types.get(next_node_id, "")
252
+ if next_node_id in ("EXIT", "TERMINAL") or next_node_type == "exit":
253
+ # Find the original node_end event for reporting
254
+ for event in trace.node_end_events:
255
+ if _payload(event).get("node_id") == node_id:
256
+ return ClassificationResult(
257
+ failure_class=FailureClass.BUSINESS_VIOLATION,
258
+ root_event=event,
259
+ is_structural=False,
260
+ node_id=node_id,
261
+ error_code=None,
262
+ message=(
263
+ f"{node_id} returned {status} — workflow took "
264
+ f"early-exit path. Check payload data matches "
265
+ f"expected state"
266
+ ),
267
+ )
268
+
269
+ return None
270
+
271
+
272
+ def _classify_from_workflow_complete(trace: ParsedTrace) -> ClassificationResult:
273
+ """
274
+ Classify failure from workflow_complete event (§5A authority).
275
+
276
+ Used when no error events exist but workflow_complete indicates failure.
277
+ Classification is based on exit_reason_code.
278
+ """
279
+ wc = trace.workflow_complete_event
280
+ p = _payload(wc) if wc else {}
281
+ exit_reason_code = trace.exit_reason_code
282
+
283
+ # Rules 6, 7: graph structure errors
284
+ if exit_reason_code in ("NO_TRANSITION", "NO_ENTRY_NODE", "NODE_NOT_FOUND", "EXIT_NOT_FOUND"):
285
+ return ClassificationResult(
286
+ failure_class=FailureClass.GRAPH_STRUCTURE_ERROR,
287
+ root_event=wc,
288
+ is_structural=True,
289
+ node_id=None,
290
+ error_code=None,
291
+ message=f"Graph structure error: {exit_reason_code}",
292
+ )
293
+
294
+ if exit_reason_code == "TIMEOUT":
295
+ return ClassificationResult(
296
+ failure_class=None,
297
+ root_event=wc,
298
+ is_structural=False,
299
+ node_id=None,
300
+ error_code=None,
301
+ message="Execution timed out",
302
+ )
303
+
304
+ if exit_reason_code == "ABORT":
305
+ return ClassificationResult(
306
+ failure_class=None,
307
+ root_event=wc,
308
+ is_structural=False,
309
+ node_id=None,
310
+ error_code=None,
311
+ message="Execution aborted by policy",
312
+ )
313
+
314
+ # Business/policy rejections
315
+ if exit_reason_code in (
316
+ "ADMISSION_DENIED",
317
+ "GOVERNANCE_VIOLATION",
318
+ "EXIT_VIOLATION",
319
+ "EXIT_REJECTED",
320
+ "EXIT_ALREADY_EXISTS",
321
+ ):
322
+ return ClassificationResult(
323
+ failure_class=FailureClass.BUSINESS_VIOLATION,
324
+ root_event=wc,
325
+ is_structural=False,
326
+ node_id=None,
327
+ error_code=None,
328
+ message=f"Policy rejection: {exit_reason_code}",
329
+ )
330
+
331
+ # Backend/infrastructure failure
332
+ if exit_reason_code == "EXIT_BACKEND_ERROR":
333
+ return ClassificationResult(
334
+ failure_class=FailureClass.CS_RUNTIME_ERROR,
335
+ root_event=wc,
336
+ is_structural=True,
337
+ node_id=None,
338
+ error_code=None,
339
+ message=f"Backend error at exit: {trace.exit_condition}",
340
+ )
341
+
342
+ # EXECUTION_ERROR or unknown
343
+ return ClassificationResult(
344
+ failure_class=FailureClass.CT_STRUCTURE_ERROR,
345
+ root_event=wc,
346
+ is_structural=True,
347
+ node_id=None,
348
+ error_code=None,
349
+ message=f"Execution failed: {trace.exit_condition}",
350
+ )
runtime/examine/cli.py ADDED
@@ -0,0 +1,35 @@
1
+ """
2
+ cli.py — Standalone entry point for the trace examiner.
3
+
4
+ Usage:
5
+ python -m runtime.examine.cli <trace_file.jsonl>
6
+
7
+ Exits 0 on clean execution or business violation.
8
+ Exits 1 on structural failure (bug in protocol wiring, CT, or binding).
9
+ """
10
+
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from runtime.examine import analyze, TraceParseError
15
+
16
+
17
+ def main() -> None:
18
+ if len(sys.argv) != 2:
19
+ print(
20
+ "Usage: python -m runtime.examine.cli <trace_file.jsonl>",
21
+ file=sys.stderr,
22
+ )
23
+ sys.exit(2)
24
+
25
+ trace_path = Path(sys.argv[1])
26
+
27
+ report = analyze(trace_path)
28
+ print(report.format())
29
+
30
+ if report.has_structural_failure:
31
+ sys.exit(1)
32
+
33
+
34
+ if __name__ == "__main__":
35
+ main()