agent-tracegraph 0.2.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,19 @@
1
+ """Shape checks at untrusted JSON boundaries; diagnostics never echo field values."""
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+
7
+ def loads(text: str) -> Any:
8
+ try:
9
+ return json.loads(text)
10
+ except RecursionError as exc:
11
+ raise ValueError("JSON nesting too deep") from exc
12
+
13
+
14
+ def object_field(value: Any, path: str) -> dict:
15
+ if value is None:
16
+ return {}
17
+ if not isinstance(value, dict):
18
+ raise ValueError(f"{path} must be an object")
19
+ return value
tracegraph/model.py ADDED
@@ -0,0 +1,225 @@
1
+ """The frozen contract: the normalized causal-graph data model.
2
+
3
+ This module is the *contract* the rest of tracegraph is built against. Two rules,
4
+ fixed here in Phase 0, must never drift:
5
+
6
+ 1. **Edge direction is effect → cause.** Every causal edge points from a step to
7
+ one of its predecessors, so traversing outbound edges walks *backward* toward
8
+ root causes.
9
+
10
+ 2. **Two layers, one vocabulary.**
11
+ - ``CAUSED_BY`` is the *raw* layer and the system of record: it keeps **every**
12
+ real predecessor of a step (fan-in, retries, nested tool/LLM calls). Nothing
13
+ is dropped. Root-cause analysis reads this layer.
14
+ - ``TREE_PARENT`` is the *derived* layer: a computed projection that picks
15
+ exactly one parent per non-root step, yielding the single-parent forest the
16
+ structural (AHU) diff needs. It is **not** asserted to be "the cause" — it is
17
+ a projection parent for diffing. A step that had more than one ``CAUSED_BY``
18
+ is flagged ``projection_lossy=True`` so the lossiness is always visible.
19
+
20
+ See ``normalize.py`` for how the derived layer is computed from the raw layer.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from enum import Enum
26
+ from typing import Literal
27
+
28
+ from pydantic import BaseModel, Field
29
+
30
+
31
+ class StepKind(str, Enum):
32
+ """What a causal-event step represents.
33
+
34
+ Mirrors OpenInference span kinds so the OTLP adapter maps cleanly, plus ``DATA`` for
35
+ state/data nodes. LangGraph super-steps that can't be classified more precisely default
36
+ to ``CHAIN``.
37
+ """
38
+
39
+ AGENT = "AGENT"
40
+ CHAIN = "CHAIN"
41
+ TOOL = "TOOL"
42
+ LLM = "LLM"
43
+ RETRIEVER = "RETRIEVER"
44
+ EMBEDDING = "EMBEDDING"
45
+ RERANKER = "RERANKER"
46
+ GUARDRAIL = "GUARDRAIL"
47
+ EVALUATOR = "EVALUATOR"
48
+ PROMPT = "PROMPT"
49
+ UNKNOWN = "UNKNOWN"
50
+ DATA = "DATA"
51
+
52
+
53
+ class StepSource(str, Enum):
54
+ """How the step entered the trace (mirrors LangGraph ``metadata.source``).
55
+
56
+ ``TASK`` is the one value that does *not* come from ``metadata.source``: it marks a step
57
+ derived from a checkpoint's **pending writes** (one LangGraph task) rather than from a
58
+ checkpoint itself. It is explicit provenance, not a naming convention — ``step_id`` is an
59
+ unrestricted string, so nothing about an id may be read as evidence of how a step was made.
60
+ """
61
+
62
+ INPUT = "input"
63
+ LOOP = "loop"
64
+ UPDATE = "update"
65
+ FORK = "fork"
66
+ TASK = "task"
67
+
68
+
69
+ class StepStatus(str, Enum):
70
+ OK = "ok"
71
+ ERROR = "error"
72
+ UNSET = "unset"
73
+
74
+
75
+ class EdgeOrigin(str, Enum):
76
+ """Declared evidence behind a raw causal edge."""
77
+
78
+ CHECKPOINT_PARENT = "checkpoint_parent"
79
+ GRAPH_PARENT = "graph_parent"
80
+ SPAN_LINK = "span_link"
81
+ SPAN_PARENT_FALLBACK = "span_parent_fallback"
82
+ LEGACY_UNKNOWN = "legacy_unknown"
83
+
84
+
85
+ class CausalFidelity(str, Enum):
86
+ """How much of the source system's causal vocabulary survived export."""
87
+
88
+ DECLARED_DAG = "declared_dag"
89
+ PARENT_ONLY = "parent_only"
90
+ LEGACY_UNKNOWN = "legacy_unknown"
91
+
92
+
93
+ class EvaluationSummary(BaseModel):
94
+ """Body-free Phoenix/OpenInference annotation evidence."""
95
+
96
+ name: str
97
+ label: str | None = None
98
+ score: float | None = None
99
+
100
+
101
+ class StepEvidence(BaseModel):
102
+ """Allowlisted operational telemetry; never prompt/output bodies."""
103
+
104
+ end_ts: str | None = None
105
+ duration_ms: float | None = None
106
+ prompt_tokens: int | None = None
107
+ completion_tokens: int | None = None
108
+ total_tokens: int | None = None
109
+ prompt_cost: str | None = None
110
+ completion_cost: str | None = None
111
+ total_cost: str | None = None
112
+ cost_currency: str | None = None
113
+ artifact_digest: str | None = Field(default=None, pattern=r"^sha256:[0-9a-f]{64}$")
114
+ evaluations: list[EvaluationSummary] = Field(default_factory=list)
115
+
116
+
117
+ class DecisionEvidence(BaseModel):
118
+ """Body-free evidence from an external pre-execution policy decision.
119
+
120
+ It is trace metadata, never a causal edge: a preflight can constrain a run without
121
+ proving that it caused a later outcome.
122
+ """
123
+
124
+ source: Literal["toolgraph_preflight"] = "toolgraph_preflight"
125
+ artifact_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$")
126
+ graph_generation: int = Field(ge=0)
127
+ verdict: str = Field(min_length=1, max_length=64, pattern=r"^[a-z][a-z0-9_-]*$")
128
+
129
+
130
+ class EdgeType(str, Enum):
131
+ """The frozen edge vocabulary. Direction is effect → cause for causal edges."""
132
+
133
+ CAUSED_BY = "CAUSED_BY" # raw layer: step -> a real predecessor (keep ALL)
134
+ TREE_PARENT = "TREE_PARENT" # derived layer: step -> its single chosen parent
135
+ BELONGS_TO = "BELONGS_TO" # step -> trace
136
+ WROTE = "WROTE" # step -> channel (optional, flagged)
137
+ READ = "READ" # step -> channel (optional, flagged)
138
+
139
+
140
+ class Step(BaseModel):
141
+ """A causal-event node: one LangGraph super-step/checkpoint or one OTLP span."""
142
+
143
+ step_id: str
144
+ trace_id: str
145
+ seq: int = Field(description="Monotonic execution order within the trace.")
146
+ ts: str | None = Field(default=None, description="ISO-8601 timestamp, when available.")
147
+ kind: StepKind = StepKind.CHAIN
148
+ source: StepSource = StepSource.LOOP
149
+ name: str | None = None
150
+ status: StepStatus = StepStatus.OK
151
+ error_msg: str | None = None
152
+ evidence: StepEvidence | None = None
153
+ #: Set during normalization: True iff this step had >1 raw CAUSED_BY edge and
154
+ #: the derived TREE_PARENT projection therefore dropped at least one real cause.
155
+ projection_lossy: bool = False
156
+
157
+
158
+ class Edge(BaseModel):
159
+ """A typed, directed edge. For causal edges, ``src`` is the effect and ``dst`` the cause."""
160
+
161
+ type: EdgeType
162
+ src: str
163
+ dst: str
164
+ origin: EdgeOrigin | None = None
165
+
166
+
167
+ class Trace(BaseModel):
168
+ """One agent run / thread."""
169
+
170
+ trace_id: str
171
+ source_kind: str = Field(description="Which adapter produced this trace, e.g. 'langgraph'.")
172
+ run_id: str | None = Field(
173
+ default=None,
174
+ description="Optional cross-system run identifier (for example, a SyncMill run_id).",
175
+ )
176
+ thread_id: str | None = None
177
+ status: StepStatus = StepStatus.OK
178
+ causal_fidelity: CausalFidelity = CausalFidelity.LEGACY_UNKNOWN
179
+ links_preserved: bool | None = None
180
+ decision_evidence: list[DecisionEvidence] = Field(default_factory=list)
181
+
182
+
183
+ class RawTrace(BaseModel):
184
+ """What an adapter emits: a trace, its steps, and the RAW causal edges only.
185
+
186
+ ``causal_edges`` must all be ``CAUSED_BY`` (effect → cause) — the system of record,
187
+ keeping every real predecessor. Adapters never compute the derived tree; they hand a
188
+ ``RawTrace`` to :func:`tracegraph.normalize.normalize`, which validates it and returns
189
+ a :class:`NormalizedTrace`. Keeping the two as **distinct types** is the contract
190
+ guard: an un-normalized trace (no ``TREE_PARENT`` layer) cannot reach ``diff``/
191
+ ``inspect`` by accident.
192
+ """
193
+
194
+ trace: Trace
195
+ steps: list[Step] = Field(default_factory=list)
196
+ causal_edges: list[Edge] = Field(default_factory=list)
197
+ #: Disclosures that are only knowable while reading the source (e.g. a checkpoint whose
198
+ #: task writes could not be attributed to a node). They describe the *ingestion*, not the
199
+ #: run, so they never reach ``NormalizedTrace`` or the artifact — the CLI prints them.
200
+ ingest_warnings: list[str] = Field(default_factory=list)
201
+
202
+ def steps_by_id(self) -> dict[str, Step]:
203
+ return {s.step_id: s for s in self.steps}
204
+
205
+
206
+ class NormalizedTrace(BaseModel):
207
+ """The portable bundle: a trace plus its steps and BOTH edge layers.
208
+
209
+ Produced **only** by :func:`tracegraph.normalize.normalize` — never constructed
210
+ directly by adapters. It is what ``artifact.py`` (de)serializes and what the stores
211
+ load. The raw ``CAUSED_BY`` edges are the system of record; the derived
212
+ ``TREE_PARENT`` edges (one per non-root step) are recorded alongside them.
213
+ """
214
+
215
+ trace: Trace
216
+ steps: list[Step] = Field(default_factory=list)
217
+ edges: list[Edge] = Field(default_factory=list)
218
+
219
+ # --- convenience views (no business logic; just typed filters) ---
220
+
221
+ def steps_by_id(self) -> dict[str, Step]:
222
+ return {s.step_id: s for s in self.steps}
223
+
224
+ def edges_of(self, edge_type: EdgeType) -> list[Edge]:
225
+ return [e for e in self.edges if e.type is edge_type]
@@ -0,0 +1,248 @@
1
+ """The correctness core: validate the raw causal graph, then derive the tree.
2
+
3
+ Adapters emit a :class:`~tracegraph.model.RawTrace` carrying steps and the **raw**
4
+ ``CAUSED_BY`` edges (every real predecessor of every step). :func:`normalize`:
5
+
6
+ * **validates** the raw graph (:func:`validate_raw`) — endpoints exist, edges are
7
+ ``CAUSED_BY``, no self-edges, and every cause precedes its effect (which also makes
8
+ the raw graph acyclic by construction),
9
+ * derives the ``TREE_PARENT`` layer — exactly one parent per non-root step — by picking
10
+ each step's *temporally-earliest* cause,
11
+ * flags ``projection_lossy=True`` on any step whose real causality had to be collapsed
12
+ (more than one ``CAUSED_BY``), and
13
+ * re-checks the derived layer is a forest (:func:`validate_tree`).
14
+
15
+ The raw layer is never mutated. This is the boundary Codex flagged: keep the lossy
16
+ projection *explicit and inspectable* so root-cause analysis (which reads the raw layer)
17
+ never silently trusts a fabricated single parent — and reject malformed input here rather
18
+ than letting it become a malformed artifact downstream.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from tracegraph.model import Edge, EdgeType, NormalizedTrace, RawTrace, Step, StepStatus
24
+
25
+
26
+ def _check_steps(trace_id: str, steps: list[Step]) -> None:
27
+ ids = [s.step_id for s in steps]
28
+ if len(ids) != len(set(ids)):
29
+ raise ValueError("duplicate step_id in trace")
30
+ for s in steps:
31
+ if s.trace_id != trace_id:
32
+ raise ValueError(
33
+ f"step {s.step_id!r} has trace_id {s.trace_id!r}, expected {trace_id!r}"
34
+ )
35
+
36
+
37
+ def _check_caused_by(steps: dict[str, Step], edges: list[Edge]) -> None:
38
+ """Validate the raw CAUSED_BY layer: typed, endpoints known/distinct, cause precedes effect.
39
+
40
+ Also rejects duplicate edges — a repeated ``(src, dst)`` would double-count in pattern
41
+ matches and child traversals.
42
+ """
43
+ seen: set[tuple[str, str]] = set()
44
+ for e in edges:
45
+ if e.type is not EdgeType.CAUSED_BY:
46
+ raise ValueError(f"raw causal edges must all be CAUSED_BY, got {e.type.value}")
47
+ if e.src not in steps:
48
+ raise ValueError(f"CAUSED_BY src {e.src!r} is not a known step")
49
+ if e.dst not in steps:
50
+ raise ValueError(f"CAUSED_BY dst {e.dst!r} is not a known step")
51
+ if e.src == e.dst:
52
+ raise ValueError(f"self-causal edge on step {e.src!r}")
53
+ if (e.src, e.dst) in seen:
54
+ raise ValueError(f"duplicate CAUSED_BY edge {e.src!r} -> {e.dst!r}")
55
+ seen.add((e.src, e.dst))
56
+ if not steps[e.dst].seq < steps[e.src].seq:
57
+ raise ValueError(
58
+ f"cause {e.dst!r} (seq {steps[e.dst].seq}) does not precede effect "
59
+ f"{e.src!r} (seq {steps[e.src].seq})"
60
+ )
61
+
62
+
63
+ def validate_raw(raw: RawTrace) -> None:
64
+ """Assert a :class:`RawTrace` is well-formed before projection. Raises ``ValueError``.
65
+
66
+ Checks: unique step ids; steps belong to the trace; every edge is ``CAUSED_BY`` with
67
+ both endpoints known and distinct; and each cause's ``seq`` strictly precedes its
68
+ effect's. The seq rule both encodes "a cause happens before its effect" and guarantees
69
+ the raw graph has no cycles (so traversal always terminates).
70
+ """
71
+ _check_steps(raw.trace.trace_id, raw.steps)
72
+ _check_caused_by(raw.steps_by_id(), raw.causal_edges)
73
+
74
+
75
+ def validate_structure(nt: NormalizedTrace) -> None:
76
+ """Assert a :class:`NormalizedTrace` is structurally sound, raising ``ValueError``.
77
+
78
+ The subset of :func:`validate_normalized` that every *reader* depends on: unique step ids,
79
+ a well-formed raw ``CAUSED_BY`` layer (known endpoints, cause precedes effect) and a
80
+ ``TREE_PARENT`` forest. Analyses index steps by id and rely on ``seq`` ordering to walk
81
+ causes in one pass, so on a malformed trace they would otherwise fail with a raw
82
+ ``KeyError`` — neither ``OSError`` nor ``ValueError``, and therefore straight past every
83
+ load-boundary handler. This is the cheap check a library entry point can afford; it
84
+ deliberately does *not* re-derive the artifact, so a caller holding a consistent but
85
+ non-canonical trace (an appended step, say) can still analyze it.
86
+ """
87
+ _check_steps(nt.trace.trace_id, nt.steps)
88
+ _check_caused_by(nt.steps_by_id(), nt.edges_of(EdgeType.CAUSED_BY))
89
+ validate_tree(nt)
90
+
91
+
92
+ def validate_normalized(nt: NormalizedTrace) -> None:
93
+ """Validate a :class:`NormalizedTrace` end to end: raw layer **and** that the whole
94
+ artifact is *exactly* what :func:`normalize` would produce from its raw layer.
95
+
96
+ The strong check — re-deriving and comparing — is what makes the artifact a true
97
+ system of record. Without it, a hand-edited or partly-corrupt artifact (missing
98
+ ``BELONGS_TO``, missing ``TREE_PARENT``, wrong ``projection_lossy``, or simply edges
99
+ in non-canonical order) would pass the cheap structural checks and then silently
100
+ drift: a backend rebuilding from the raw layer would emit different artifact bytes
101
+ on the next save. Rejecting here keeps every loader (InMemoryStore, LadybugStore, future
102
+ caches) honest about "the JSON is what normalize() produces, full stop."
103
+ """
104
+ validate_structure(nt)
105
+
106
+ # The trace header is part of the system of record: a stored status that disagrees with
107
+ # the steps would let a "clean" artifact hide a failed run. normalize() derives it, so an
108
+ # honest artifact always matches — we check it explicitly here for a clear error message
109
+ # (the re-derive below would also catch it, but only as a generic "not canonical").
110
+ any_error = any(s.status is StepStatus.ERROR for s in nt.steps)
111
+ expected_status = StepStatus.ERROR if any_error else StepStatus.OK
112
+ if nt.trace.status is not expected_status:
113
+ raise ValueError(
114
+ f"trace.status {nt.trace.status.value!r} disagrees with its steps (expected "
115
+ f"{expected_status.value!r}: {'a' if any_error else 'no'} step has status=error)"
116
+ )
117
+
118
+ # Reconstruct what the canonical form should look like from the raw layer alone. We
119
+ # strip projection_lossy from the steps so normalize() resets it from scratch —
120
+ # otherwise a corrupt input flag would survive into the "expected" side and the
121
+ # check would tautologically pass.
122
+ raw = RawTrace(
123
+ trace=nt.trace,
124
+ steps=[s.model_copy(update={"projection_lossy": False}) for s in nt.steps],
125
+ causal_edges=nt.edges_of(EdgeType.CAUSED_BY),
126
+ )
127
+ expected = normalize(raw)
128
+ if expected != nt:
129
+ raise ValueError(
130
+ "normalized trace is not in canonical form: derived edges, projection_lossy "
131
+ "flags, or step/edge ordering do not match what normalize() produces from "
132
+ "the raw CAUSED_BY layer. The JSON artifact must be the output of normalize() "
133
+ "verbatim — anything else would drift on the next save."
134
+ )
135
+
136
+
137
+ def _parents_by_step(caused_by: list[Edge]) -> dict[str, list[str]]:
138
+ """Raw causes per effect step (CAUSED_BY points effect → cause), built in one pass.
139
+
140
+ Preserves the order of ``caused_by``, so feeding it the canonically sorted edge
141
+ list yields each step's parents in canonical order.
142
+ """
143
+ parents: dict[str, list[str]] = {}
144
+ for e in caused_by:
145
+ parents.setdefault(e.src, []).append(e.dst)
146
+ return parents
147
+
148
+
149
+ def _primary_parent(parent_ids: list[str], steps: dict[str, Step]) -> str:
150
+ """Pick the single TREE_PARENT: the temporally-earliest cause.
151
+
152
+ Ordering key is ``(seq, ts, step_id)`` so the choice is deterministic even when
153
+ seq or ts collide. The chosen parent is the one that happened first.
154
+ """
155
+
156
+ def key(pid: str) -> tuple[int, str, str]:
157
+ p = steps[pid]
158
+ return (p.seq, p.ts or "", p.step_id)
159
+
160
+ return min(parent_ids, key=key)
161
+
162
+
163
+ def normalize(raw: RawTrace) -> NormalizedTrace:
164
+ """Validate a raw trace and return its normalized form (raw + derived layers).
165
+
166
+ Deterministic and **canonically ordered**: the returned trace's steps and edges are
167
+ in a fixed, input-independent order so two semantically-equivalent ``RawTrace`` inputs
168
+ (e.g. the same trace from two adapters, or the same artifact reloaded through any
169
+ backend) produce byte-identical artifacts. The canonical layout is:
170
+
171
+ * **steps** — sorted by ``(seq, step_id)``;
172
+ * **CAUSED_BY** edges — sorted by ``(effect.seq, cause.seq, src, dst)`` (the order a
173
+ walk-forward-in-time would discover them);
174
+ * **BELONGS_TO** then **TREE_PARENT** for each step, emitted in the canonical step order.
175
+
176
+ This guarantee — not "the adapter happened to enumerate canonically" — is what makes
177
+ the optional LadybugDB cache a true accelerator: any backend that rebuilds a trace from
178
+ its raw layer lands on the same artifact bytes.
179
+ """
180
+ validate_raw(raw)
181
+ steps_by_id = raw.steps_by_id()
182
+
183
+ # Canonicalize the raw inputs ONCE; derived edges then fall out in canonical order
184
+ # naturally because they're emitted per-step in the same loop.
185
+ steps_canonical = sorted(raw.steps, key=lambda s: (s.seq, s.step_id))
186
+ caused_by = sorted(
187
+ raw.causal_edges,
188
+ key=lambda e: (steps_by_id[e.src].seq, steps_by_id[e.dst].seq, e.src, e.dst),
189
+ )
190
+
191
+ edges: list[Edge] = list(caused_by) # carry the raw layer through unchanged
192
+ parents_by_step = _parents_by_step(caused_by)
193
+ new_steps: list[Step] = []
194
+ for step in steps_canonical:
195
+ parents = parents_by_step.get(step.step_id, [])
196
+ # Flag lossiness on a copy so we never mutate the caller's objects.
197
+ step = step.model_copy(update={"projection_lossy": len(parents) > 1})
198
+ new_steps.append(step)
199
+
200
+ edges.append(Edge(type=EdgeType.BELONGS_TO, src=step.step_id, dst=raw.trace.trace_id))
201
+ if parents:
202
+ chosen = _primary_parent(parents, steps_by_id)
203
+ edges.append(Edge(type=EdgeType.TREE_PARENT, src=step.step_id, dst=chosen))
204
+
205
+ # The trace-level status is a derived view of its steps, not independent data: make it
206
+ # canonical here so the artifact header can never lie about whether the run errored.
207
+ any_error = any(s.status is StepStatus.ERROR for s in new_steps)
208
+ trace = raw.trace.model_copy(
209
+ update={"status": StepStatus.ERROR if any_error else StepStatus.OK}
210
+ )
211
+ nt = NormalizedTrace(trace=trace, steps=new_steps, edges=edges)
212
+ validate_tree(nt)
213
+ return nt
214
+
215
+
216
+ def validate_tree(nt: NormalizedTrace) -> None:
217
+ """Assert the **derived** ``TREE_PARENT`` layer is a forest. Never touches the raw layer.
218
+
219
+ Raises ``ValueError`` if any step has more than one ``TREE_PARENT``, an edge
220
+ references an unknown step, or following parents reveals a cycle.
221
+ """
222
+ ids = {s.step_id for s in nt.steps}
223
+ seq_of = {s.step_id: s.seq for s in nt.steps}
224
+ parent_of: dict[str, str] = {}
225
+ for e in nt.edges_of(EdgeType.TREE_PARENT):
226
+ if e.src not in ids or e.dst not in ids:
227
+ raise ValueError(f"TREE_PARENT edge references unknown step: {e.src} -> {e.dst}")
228
+ if e.src in parent_of:
229
+ raise ValueError(f"step {e.src!r} has more than one TREE_PARENT (not a forest)")
230
+ parent_of[e.src] = e.dst
231
+
232
+ # Cycle check: walking parents from any node must terminate at a root. Nodes on an
233
+ # already-proven-acyclic path are skipped, keeping the whole check O(n) overall.
234
+ proven: set[str] = set()
235
+ # Iterate steps in canonical (seq, step_id) order, not set order: which member of a cycle
236
+ # the error names would otherwise vary with PYTHONHASHSEED, making the message — and any
237
+ # test or bug report quoting it — irreproducible. The raise/no-raise decision is unaffected.
238
+ for start in sorted(ids, key=lambda sid: (seq_of[sid], sid)):
239
+ path: list[str] = []
240
+ on_path: set[str] = set()
241
+ cur: str | None = start
242
+ while cur is not None and cur not in proven:
243
+ if cur in on_path:
244
+ raise ValueError(f"cycle in TREE_PARENT layer involving step {cur!r}")
245
+ path.append(cur)
246
+ on_path.add(cur)
247
+ cur = parent_of.get(cur)
248
+ proven.update(path)
tracegraph/py.typed ADDED
File without changes
@@ -0,0 +1,136 @@
1
+ """Versioned, body-free review candidates produced from pattern matches."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import tempfile
8
+ from pathlib import Path
9
+ from typing import Literal
10
+
11
+ from pydantic import BaseModel, ConfigDict, Field
12
+
13
+ from tracegraph.analysis.patterns import Match, PathPattern
14
+ from tracegraph.model import NormalizedTrace, Step, StepKind, StepStatus
15
+
16
+
17
+ REVIEW_CANDIDATE_SCHEMA_VERSION = 1
18
+ REVIEW_CANDIDATE_KIND = "tracegraph.review-candidates"
19
+
20
+
21
+ class ReviewCandidate(BaseModel):
22
+ """One minimal governance-review input; no trace body or local path is retained."""
23
+
24
+ model_config = ConfigDict(extra="allow")
25
+
26
+ run_id: str = Field(min_length=1, max_length=256, pattern=r"^\S+$")
27
+ pattern_id: str = Field(pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
28
+ pattern_version: int = Field(ge=1)
29
+ tool_key: str = Field(min_length=4, max_length=512, pattern=r"^\S+::\S+$")
30
+ artifact_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$")
31
+
32
+
33
+ class ReviewCandidateReport(BaseModel):
34
+ """Schema-v1 deterministic envelope for zero or more review candidates."""
35
+
36
+ model_config = ConfigDict(extra="allow")
37
+
38
+ schema_version: Literal[1] = REVIEW_CANDIDATE_SCHEMA_VERSION
39
+ kind: Literal["tracegraph.review-candidates"] = REVIEW_CANDIDATE_KIND
40
+ candidates: list[ReviewCandidate] = Field(default_factory=list)
41
+
42
+
43
+ def is_review_exportable(pattern: PathPattern) -> bool:
44
+ """A review candidate must end at an unconstrained, explicitly failing TOOL.
45
+
46
+ Operational pseudo-tools such as ``gate:*`` use a name constraint and are diagnosis
47
+ signals, not server-qualified governance candidates.
48
+ """
49
+ if not pattern.review_eligible or not pattern.steps:
50
+ return False
51
+ endpoint = pattern.steps[-1]
52
+ return (
53
+ endpoint.kind is StepKind.TOOL
54
+ and endpoint.status is StepStatus.ERROR
55
+ and endpoint.name is None
56
+ and endpoint.name_prefix is None
57
+ )
58
+
59
+
60
+ def build_report(
61
+ pattern: PathPattern,
62
+ matches: list[Match],
63
+ traces: dict[str, NormalizedTrace],
64
+ artifact_digests: dict[str, str],
65
+ ) -> ReviewCandidateReport:
66
+ """Build a sorted, de-duplicated report from matches and their exact artifacts."""
67
+ if pattern.pattern_id is None or pattern.pattern_version is None:
68
+ raise ValueError("review export requires a versioned preset pattern")
69
+ if not is_review_exportable(pattern):
70
+ raise ValueError(f"preset {pattern.pattern_id!r} is not review-exportable")
71
+
72
+ steps_by_trace: dict[str, dict[str, Step]] = {}
73
+ unique: dict[tuple[str, str, int, str, str], ReviewCandidate] = {}
74
+ for match in matches:
75
+ trace = traces[match.trace_id]
76
+ run_id = trace.trace.run_id
77
+ if run_id is None:
78
+ raise ValueError(f"trace {match.trace_id!r} has no run_id")
79
+ steps = steps_by_trace.get(match.trace_id)
80
+ if steps is None:
81
+ steps = trace.steps_by_id()
82
+ steps_by_trace[match.trace_id] = steps
83
+ endpoint = steps[match.step_ids[-1]]
84
+ if endpoint.kind is not StepKind.TOOL or not endpoint.name:
85
+ raise ValueError(f"trace {match.trace_id!r} matched an unnamed or non-TOOL endpoint")
86
+ tool_key = endpoint.name
87
+ if any(ch.isspace() for ch in tool_key) or "::" not in tool_key:
88
+ raise ValueError(
89
+ f"trace {match.trace_id!r} matched a TOOL without a server-qualified tool_key"
90
+ )
91
+ server, tool = tool_key.split("::", 1)
92
+ if not server or not tool:
93
+ raise ValueError(
94
+ f"trace {match.trace_id!r} matched a TOOL without a server-qualified tool_key"
95
+ )
96
+ digest = artifact_digests[match.trace_id]
97
+ candidate = ReviewCandidate(
98
+ run_id=run_id,
99
+ pattern_id=pattern.pattern_id,
100
+ pattern_version=pattern.pattern_version,
101
+ tool_key=tool_key,
102
+ artifact_digest=digest,
103
+ )
104
+ key = (
105
+ candidate.run_id,
106
+ candidate.pattern_id,
107
+ candidate.pattern_version,
108
+ candidate.tool_key,
109
+ candidate.artifact_digest,
110
+ )
111
+ unique[key] = candidate
112
+
113
+ return ReviewCandidateReport(candidates=[unique[key] for key in sorted(unique)])
114
+
115
+
116
+ def dumps(report: ReviewCandidateReport) -> str:
117
+ """Stable JSON representation used by the CLI and contract goldens."""
118
+ return json.dumps(report.model_dump(mode="json"), indent=2, sort_keys=True) + "\n"
119
+
120
+
121
+ def save_atomic(report: ReviewCandidateReport, path: str | Path) -> None:
122
+ """Atomically replace ``path`` without leaving a partial report on failure."""
123
+ target = Path(path)
124
+ # Same contract as artifact.save_atomic / diagnose.save_atomic: create the parent
125
+ # directory so `--out newdir/report.json` works instead of failing inside mkstemp.
126
+ target.parent.mkdir(parents=True, exist_ok=True)
127
+ fd, temporary = tempfile.mkstemp(prefix=f".{target.name}.", suffix=".tmp", dir=target.parent)
128
+ try:
129
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
130
+ handle.write(dumps(report))
131
+ handle.flush()
132
+ os.fsync(handle.fileno())
133
+ os.replace(temporary, target)
134
+ except BaseException:
135
+ Path(temporary).unlink(missing_ok=True)
136
+ raise