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.
- agent_tracegraph-0.2.0.dist-info/METADATA +380 -0
- agent_tracegraph-0.2.0.dist-info/RECORD +28 -0
- agent_tracegraph-0.2.0.dist-info/WHEEL +4 -0
- agent_tracegraph-0.2.0.dist-info/entry_points.txt +2 -0
- agent_tracegraph-0.2.0.dist-info/licenses/LICENSE +191 -0
- tracegraph/__init__.py +38 -0
- tracegraph/adapters/__init__.py +13 -0
- tracegraph/adapters/base.py +22 -0
- tracegraph/adapters/langgraph_checkpoint.py +804 -0
- tracegraph/adapters/otlp_spans.py +703 -0
- tracegraph/adapters/phoenix_export.py +218 -0
- tracegraph/analysis/__init__.py +55 -0
- tracegraph/analysis/ahu.py +329 -0
- tracegraph/analysis/diagnose.py +676 -0
- tracegraph/analysis/explain.py +46 -0
- tracegraph/analysis/patterns.py +628 -0
- tracegraph/artifact.py +192 -0
- tracegraph/cli.py +1242 -0
- tracegraph/input_validation.py +19 -0
- tracegraph/model.py +225 -0
- tracegraph/normalize.py +248 -0
- tracegraph/py.typed +0 -0
- tracegraph/review_candidates.py +136 -0
- tracegraph/sqlite_snapshot.py +65 -0
- tracegraph/store/__init__.py +22 -0
- tracegraph/store/base.py +43 -0
- tracegraph/store/in_memory.py +94 -0
- tracegraph/store/ladybug.py +505 -0
|
@@ -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]
|
tracegraph/normalize.py
ADDED
|
@@ -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
|