journeygraph 0.1.1__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.
- journeygraph/__init__.py +5 -0
- journeygraph/__main__.py +6 -0
- journeygraph/analytics/__init__.py +6 -0
- journeygraph/analytics/_adapters.py +144 -0
- journeygraph/analytics/analyzer.py +410 -0
- journeygraph/analytics/metrics.py +70 -0
- journeygraph/api.py +95 -0
- journeygraph/cli.py +213 -0
- journeygraph/data/__init__.py +1 -0
- journeygraph/data/demo.jsonl +45 -0
- journeygraph/domain/__init__.py +5 -0
- journeygraph/domain/models.py +183 -0
- journeygraph/exceptions.py +45 -0
- journeygraph/graph/__init__.py +13 -0
- journeygraph/graph/builder.py +119 -0
- journeygraph/graph/models.py +78 -0
- journeygraph/ingestion/__init__.py +5 -0
- journeygraph/ingestion/otlp.py +388 -0
- journeygraph/ingestion/readers.py +254 -0
- journeygraph/normalization/__init__.py +5 -0
- journeygraph/normalization/pipeline.py +717 -0
- journeygraph/normalization/privacy.py +208 -0
- journeygraph/py.typed +1 -0
- journeygraph/reporting/__init__.py +5 -0
- journeygraph/reporting/html.py +241 -0
- journeygraph/reporting/serialize.py +31 -0
- journeygraph/reporting/svg.py +129 -0
- journeygraph/reporting/writer.py +176 -0
- journeygraph/schemas/__init__.py +1 -0
- journeygraph/schemas/analysis-v1.schema.json +495 -0
- journeygraph/schemas/event-v1.schema.json +48 -0
- journeygraph/version.py +3 -0
- journeygraph-0.1.1.dist-info/METADATA +285 -0
- journeygraph-0.1.1.dist-info/RECORD +38 -0
- journeygraph-0.1.1.dist-info/WHEEL +5 -0
- journeygraph-0.1.1.dist-info/entry_points.txt +2 -0
- journeygraph-0.1.1.dist-info/licenses/LICENSE +201 -0
- journeygraph-0.1.1.dist-info/top_level.txt +1 -0
journeygraph/__init__.py
ADDED
journeygraph/__main__.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Small structural adapters at the analytics/domain boundary.
|
|
2
|
+
|
|
3
|
+
The domain package owns validation. Analytics intentionally relies only on the stable
|
|
4
|
+
attributes in the public domain contract so its algorithms remain pure and independently
|
|
5
|
+
testable.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
from collections.abc import Mapping, Sequence
|
|
13
|
+
from decimal import Decimal
|
|
14
|
+
from typing import Protocol, cast
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EventLike(Protocol):
|
|
18
|
+
@property
|
|
19
|
+
def trace_id(self) -> str: ...
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def step_id(self) -> str: ...
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def operation_type(self) -> object: ...
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def component(self) -> object: ...
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def duration_ms(self) -> Decimal: ...
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def status(self) -> object: ...
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def outcome(self) -> object | None: ...
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def input_tokens(self) -> int | None: ...
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def output_tokens(self) -> int | None: ...
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def cost_usd(self) -> Decimal | None: ...
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def metadata(self) -> Mapping[str, object]: ...
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TraceLike(Protocol):
|
|
53
|
+
@property
|
|
54
|
+
def trace_id(self) -> str: ...
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def events(self) -> Sequence[EventLike]: ...
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def outcome(self) -> object: ...
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def outcome_source(self) -> object: ...
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class DatasetLike(Protocol):
|
|
67
|
+
@property
|
|
68
|
+
def events(self) -> Sequence[EventLike]: ...
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def traces(self) -> Sequence[TraceLike]: ...
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def warnings(self) -> Sequence[object]: ...
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def text(value: object) -> str:
|
|
78
|
+
"""Convert strings and string-valued enums to canonical text."""
|
|
79
|
+
|
|
80
|
+
enum_value = getattr(value, "value", value)
|
|
81
|
+
return str(enum_value)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def json_number(value: Decimal | int | float) -> int | float:
|
|
85
|
+
"""Convert a validated finite numeric value to a JSON-native number."""
|
|
86
|
+
|
|
87
|
+
if isinstance(value, bool):
|
|
88
|
+
return int(value)
|
|
89
|
+
if isinstance(value, int):
|
|
90
|
+
return value
|
|
91
|
+
decimal_value = value if isinstance(value, Decimal) else Decimal(str(value))
|
|
92
|
+
if decimal_value == decimal_value.to_integral_value():
|
|
93
|
+
return int(decimal_value)
|
|
94
|
+
converted = float(decimal_value)
|
|
95
|
+
if not math.isfinite(converted):
|
|
96
|
+
raise ValueError("numeric aggregate is outside the supported JSON number range")
|
|
97
|
+
return converted
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def json_scalar(value: object) -> None | bool | int | float | str:
|
|
101
|
+
"""Return a deterministic JSON scalar for an allowlisted metadata value."""
|
|
102
|
+
|
|
103
|
+
enum_value = getattr(value, "value", value)
|
|
104
|
+
if enum_value is None or isinstance(enum_value, (bool, str)):
|
|
105
|
+
return enum_value
|
|
106
|
+
if isinstance(enum_value, (Decimal, int, float)):
|
|
107
|
+
return json_number(enum_value)
|
|
108
|
+
return str(enum_value)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def scalar_identity(value: object) -> str:
|
|
112
|
+
"""Produce a type-aware representation suitable for sorting and grouping."""
|
|
113
|
+
|
|
114
|
+
scalar = json_scalar(value)
|
|
115
|
+
representation = {
|
|
116
|
+
"type": "null" if scalar is None else type(scalar).__name__,
|
|
117
|
+
"value": scalar,
|
|
118
|
+
}
|
|
119
|
+
return json.dumps(representation, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _first_attribute(instance: object, names: tuple[str, ...]) -> object | None:
|
|
123
|
+
for name in names:
|
|
124
|
+
if hasattr(instance, name):
|
|
125
|
+
return cast(object, getattr(instance, name))
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def issue_payload(issue: object) -> dict[str, object]:
|
|
130
|
+
"""Adapt a public domain Issue to the stable analysis warning shape."""
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
"code": text(_first_attribute(issue, ("code",)) or "unknown"),
|
|
134
|
+
"severity": text(_first_attribute(issue, ("severity",)) or "warning"),
|
|
135
|
+
"location": json_scalar(_first_attribute(issue, ("source_location", "location", "source"))),
|
|
136
|
+
"message": text(_first_attribute(issue, ("message", "problem")) or ""),
|
|
137
|
+
"hint": text(_first_attribute(issue, ("hint", "corrective_hint")) or ""),
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def issue_sort_key(payload: Mapping[str, object]) -> tuple[str, ...]:
|
|
142
|
+
"""Return the documented stable warning sort key."""
|
|
143
|
+
|
|
144
|
+
return tuple(text(payload.get(field, "")) for field in payload)
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
"""Pure deterministic analytics over normalized JourneyGraph datasets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from collections import Counter, defaultdict
|
|
8
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from itertools import pairwise
|
|
11
|
+
|
|
12
|
+
from journeygraph.graph import AggregateGraph, build_graph, stable_node_id, stable_path_id
|
|
13
|
+
|
|
14
|
+
from ._adapters import (
|
|
15
|
+
DatasetLike,
|
|
16
|
+
EventLike,
|
|
17
|
+
TraceLike,
|
|
18
|
+
issue_payload,
|
|
19
|
+
issue_sort_key,
|
|
20
|
+
json_scalar,
|
|
21
|
+
scalar_identity,
|
|
22
|
+
text,
|
|
23
|
+
)
|
|
24
|
+
from .metrics import summarize_metric
|
|
25
|
+
|
|
26
|
+
ANALYSIS_SCHEMA_VERSION = "1.0"
|
|
27
|
+
_STANDARD_OUTCOMES = ("success", "failure", "handoff", "dropoff", "unknown")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class _TraceView:
|
|
32
|
+
trace: TraceLike
|
|
33
|
+
events: tuple[EventLike, ...]
|
|
34
|
+
node_ids: tuple[str, ...]
|
|
35
|
+
labels: tuple[str, ...]
|
|
36
|
+
outcome: str
|
|
37
|
+
outcome_source: str
|
|
38
|
+
path_id: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _rate(numerator: int, denominator: int) -> float:
|
|
42
|
+
return 0.0 if denominator == 0 else round(numerator / denominator, 12)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _ordered_outcomes(counter: Mapping[str, int]) -> tuple[str, ...]:
|
|
46
|
+
extras = sorted(set(counter) - set(_STANDARD_OUTCOMES))
|
|
47
|
+
return (*_STANDARD_OUTCOMES, *extras)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _outcome_counts(counter: Mapping[str, int]) -> dict[str, int]:
|
|
51
|
+
return {outcome: counter.get(outcome, 0) for outcome in _ordered_outcomes(counter)}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _outcome_rates(counter: Mapping[str, int], denominator: int) -> dict[str, float]:
|
|
55
|
+
return {
|
|
56
|
+
outcome: _rate(counter.get(outcome, 0), denominator)
|
|
57
|
+
for outcome in _ordered_outcomes(counter)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _outcome_payload(counter: Mapping[str, int], denominator: int) -> dict[str, object]:
|
|
62
|
+
return {
|
|
63
|
+
"counts": _outcome_counts(counter),
|
|
64
|
+
"rates": _outcome_rates(counter, denominator),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _trace_views(dataset: DatasetLike) -> tuple[_TraceView, ...]:
|
|
69
|
+
views: list[_TraceView] = []
|
|
70
|
+
for trace in dataset.traces:
|
|
71
|
+
events = tuple(trace.events)
|
|
72
|
+
node_ids = tuple(stable_node_id(event.operation_type, event.component) for event in events)
|
|
73
|
+
labels = tuple(f"{text(event.operation_type)}:{text(event.component)}" for event in events)
|
|
74
|
+
views.append(
|
|
75
|
+
_TraceView(
|
|
76
|
+
trace=trace,
|
|
77
|
+
events=events,
|
|
78
|
+
node_ids=node_ids,
|
|
79
|
+
labels=labels,
|
|
80
|
+
outcome=text(trace.outcome),
|
|
81
|
+
outcome_source=text(trace.outcome_source),
|
|
82
|
+
path_id=stable_path_id(node_ids),
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
return tuple(views)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _point_payload(counts: Mapping[str, int], labels: Mapping[str, str]) -> list[dict[str, object]]:
|
|
89
|
+
return [
|
|
90
|
+
{"node_id": node_id, "label": labels[node_id], "count": counts[node_id]}
|
|
91
|
+
for node_id in sorted(counts)
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _paths(views: Sequence[_TraceView]) -> list[dict[str, object]]:
|
|
96
|
+
grouped: dict[str, list[_TraceView]] = defaultdict(list)
|
|
97
|
+
for view in views:
|
|
98
|
+
grouped[view.path_id].append(view)
|
|
99
|
+
|
|
100
|
+
result: list[dict[str, object]] = []
|
|
101
|
+
for path_id in sorted(grouped):
|
|
102
|
+
path_views = grouped[path_id]
|
|
103
|
+
first = path_views[0]
|
|
104
|
+
outcomes = Counter(view.outcome for view in path_views)
|
|
105
|
+
success_count = outcomes.get("success", 0)
|
|
106
|
+
result.append(
|
|
107
|
+
{
|
|
108
|
+
"path_id": path_id,
|
|
109
|
+
"node_ids": list(first.node_ids),
|
|
110
|
+
"labels": list(first.labels),
|
|
111
|
+
"count": len(path_views),
|
|
112
|
+
"rate": _rate(len(path_views), len(views)),
|
|
113
|
+
"outcomes": _outcome_counts(outcomes),
|
|
114
|
+
"outcome_rates": _outcome_rates(outcomes, len(path_views)),
|
|
115
|
+
"success_count": success_count,
|
|
116
|
+
"non_success_count": len(path_views) - success_count,
|
|
117
|
+
"success_rate": _rate(success_count, len(path_views)),
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _retries(views: Sequence[_TraceView]) -> list[dict[str, object]]:
|
|
124
|
+
occurrences: Counter[str] = Counter()
|
|
125
|
+
traces: dict[str, set[str]] = defaultdict(set)
|
|
126
|
+
labels: dict[str, str] = {}
|
|
127
|
+
for view in views:
|
|
128
|
+
labels.update(zip(view.node_ids, view.labels, strict=True))
|
|
129
|
+
seen: set[str] = set()
|
|
130
|
+
for previous, current in pairwise(view.node_ids):
|
|
131
|
+
if previous == current:
|
|
132
|
+
occurrences[current] += 1
|
|
133
|
+
seen.add(current)
|
|
134
|
+
for node_id in seen:
|
|
135
|
+
traces[node_id].add(view.trace.trace_id)
|
|
136
|
+
return [
|
|
137
|
+
{
|
|
138
|
+
"node_id": node_id,
|
|
139
|
+
"label": labels[node_id],
|
|
140
|
+
"node_ids": [node_id],
|
|
141
|
+
"labels": [labels[node_id]],
|
|
142
|
+
"count": occurrences[node_id],
|
|
143
|
+
"trace_count": len(traces[node_id]),
|
|
144
|
+
}
|
|
145
|
+
for node_id in sorted(occurrences)
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _sequence_digest(namespace: str, sequence: Iterable[str]) -> str:
|
|
150
|
+
encoded = json.dumps([namespace, *sequence], ensure_ascii=False, separators=(",", ":")).encode(
|
|
151
|
+
"utf-8"
|
|
152
|
+
)
|
|
153
|
+
return hashlib.sha256(encoded).hexdigest()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _loops(views: Sequence[_TraceView]) -> list[dict[str, object]]:
|
|
157
|
+
"""Count minimal non-adjacent returns to an exact event category.
|
|
158
|
+
|
|
159
|
+
The most recent earlier visit starts the exact loop sequence. An adjacent repeat is a
|
|
160
|
+
retry and is intentionally excluded from loops, keeping the two measures distinct.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
occurrences: Counter[tuple[str, ...]] = Counter()
|
|
164
|
+
traces: dict[tuple[str, ...], set[str]] = defaultdict(set)
|
|
165
|
+
labels: dict[str, str] = {}
|
|
166
|
+
for view in views:
|
|
167
|
+
labels.update(zip(view.node_ids, view.labels, strict=True))
|
|
168
|
+
last_index: dict[str, int] = {}
|
|
169
|
+
for index, node_id in enumerate(view.node_ids):
|
|
170
|
+
previous_index = last_index.get(node_id)
|
|
171
|
+
if previous_index is not None and index - previous_index > 1:
|
|
172
|
+
sequence = tuple(view.node_ids[previous_index : index + 1])
|
|
173
|
+
occurrences[sequence] += 1
|
|
174
|
+
traces[sequence].add(view.trace.trace_id)
|
|
175
|
+
last_index[node_id] = index
|
|
176
|
+
|
|
177
|
+
payloads = [
|
|
178
|
+
{
|
|
179
|
+
"loop_id": _sequence_digest("journeygraph.loop/v1", sequence),
|
|
180
|
+
"node_ids": list(sequence),
|
|
181
|
+
"labels": [labels[node_id] for node_id in sequence],
|
|
182
|
+
"count": occurrences[sequence],
|
|
183
|
+
"trace_count": len(traces[sequence]),
|
|
184
|
+
}
|
|
185
|
+
for sequence in occurrences
|
|
186
|
+
]
|
|
187
|
+
return sorted(payloads, key=lambda payload: str(payload["loop_id"]))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _failure_points(
|
|
191
|
+
views: Sequence[_TraceView], labels: Mapping[str, str]
|
|
192
|
+
) -> list[dict[str, object]]:
|
|
193
|
+
occurrences: Counter[str] = Counter()
|
|
194
|
+
error_events: Counter[str] = Counter()
|
|
195
|
+
terminal_failures: Counter[str] = Counter()
|
|
196
|
+
traces: dict[str, set[str]] = defaultdict(set)
|
|
197
|
+
|
|
198
|
+
for view in views:
|
|
199
|
+
for node_id, event in zip(view.node_ids, view.events, strict=True):
|
|
200
|
+
if text(event.status) == "error":
|
|
201
|
+
occurrences[node_id] += 1
|
|
202
|
+
error_events[node_id] += 1
|
|
203
|
+
traces[node_id].add(view.trace.trace_id)
|
|
204
|
+
|
|
205
|
+
if view.outcome == "failure" and view.node_ids:
|
|
206
|
+
terminal_node = view.node_ids[-1]
|
|
207
|
+
terminal_failures[terminal_node] += 1
|
|
208
|
+
traces[terminal_node].add(view.trace.trace_id)
|
|
209
|
+
if text(view.events[-1].status) != "error":
|
|
210
|
+
occurrences[terminal_node] += 1
|
|
211
|
+
|
|
212
|
+
node_ids = sorted(set(occurrences) | set(terminal_failures))
|
|
213
|
+
return [
|
|
214
|
+
{
|
|
215
|
+
"node_id": node_id,
|
|
216
|
+
"label": labels[node_id],
|
|
217
|
+
"count": occurrences[node_id],
|
|
218
|
+
"trace_count": len(traces[node_id]),
|
|
219
|
+
"error_event_count": error_events[node_id],
|
|
220
|
+
"terminal_failure_count": terminal_failures[node_id],
|
|
221
|
+
}
|
|
222
|
+
for node_id in node_ids
|
|
223
|
+
]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _dropoff_points(
|
|
227
|
+
views: Sequence[_TraceView], labels: Mapping[str, str]
|
|
228
|
+
) -> list[dict[str, object]]:
|
|
229
|
+
counts: Counter[str] = Counter()
|
|
230
|
+
source_counts: dict[str, Counter[str]] = defaultdict(Counter)
|
|
231
|
+
for view in views:
|
|
232
|
+
if view.outcome == "dropoff" and view.node_ids:
|
|
233
|
+
terminal_node = view.node_ids[-1]
|
|
234
|
+
counts[terminal_node] += 1
|
|
235
|
+
source_counts[terminal_node][view.outcome_source] += 1
|
|
236
|
+
|
|
237
|
+
return [
|
|
238
|
+
{
|
|
239
|
+
"node_id": node_id,
|
|
240
|
+
"label": labels[node_id],
|
|
241
|
+
"count": counts[node_id],
|
|
242
|
+
"explicit_count": source_counts[node_id].get("explicit", 0),
|
|
243
|
+
"inferred_count": counts[node_id] - source_counts[node_id].get("explicit", 0),
|
|
244
|
+
"outcome_sources": [
|
|
245
|
+
{"source": source, "count": count}
|
|
246
|
+
for source, count in sorted(source_counts[node_id].items())
|
|
247
|
+
],
|
|
248
|
+
}
|
|
249
|
+
for node_id in sorted(counts)
|
|
250
|
+
]
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _path_comparison(views: Sequence[_TraceView]) -> list[dict[str, object]]:
|
|
254
|
+
successful = [view for view in views if view.outcome == "success"]
|
|
255
|
+
non_successful = [view for view in views if view.outcome != "success"]
|
|
256
|
+
non_success_outcomes = Counter(view.outcome for view in non_successful)
|
|
257
|
+
return [
|
|
258
|
+
{
|
|
259
|
+
"group": "success",
|
|
260
|
+
"trace_count": len(successful),
|
|
261
|
+
"path_count": len({view.path_id for view in successful}),
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
"group": "non_success",
|
|
265
|
+
"trace_count": len(non_successful),
|
|
266
|
+
"path_count": len({view.path_id for view in non_successful}),
|
|
267
|
+
"outcomes": _outcome_counts(non_success_outcomes),
|
|
268
|
+
"outcome_rates": _outcome_rates(non_success_outcomes, len(non_successful)),
|
|
269
|
+
},
|
|
270
|
+
]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _metrics(events: Sequence[EventLike]) -> dict[str, object]:
|
|
274
|
+
total_tokens = [
|
|
275
|
+
None
|
|
276
|
+
if event.input_tokens is None and event.output_tokens is None
|
|
277
|
+
else (event.input_tokens or 0) + (event.output_tokens or 0)
|
|
278
|
+
for event in events
|
|
279
|
+
]
|
|
280
|
+
return {
|
|
281
|
+
"scope": "events",
|
|
282
|
+
"duration_ms": summarize_metric(event.duration_ms for event in events),
|
|
283
|
+
"input_tokens": summarize_metric((event.input_tokens for event in events), integral=True),
|
|
284
|
+
"output_tokens": summarize_metric((event.output_tokens for event in events), integral=True),
|
|
285
|
+
"total_tokens": summarize_metric(total_tokens, integral=True),
|
|
286
|
+
"cost_usd": summarize_metric(event.cost_usd for event in events),
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _cohort_value(view: _TraceView, cohort_key: str) -> tuple[object, bool, bool]:
|
|
291
|
+
values: list[object] = []
|
|
292
|
+
identities: set[str] = set()
|
|
293
|
+
for event in view.events:
|
|
294
|
+
if cohort_key in event.metadata:
|
|
295
|
+
value = event.metadata[cohort_key]
|
|
296
|
+
identity = scalar_identity(value)
|
|
297
|
+
if identity not in identities:
|
|
298
|
+
identities.add(identity)
|
|
299
|
+
values.append(value)
|
|
300
|
+
if not values:
|
|
301
|
+
return None, True, False
|
|
302
|
+
return values[0], False, len(values) > 1
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _cohorts(views: Sequence[_TraceView], cohort_key: str | None) -> dict[str, object]:
|
|
306
|
+
if cohort_key is None:
|
|
307
|
+
return {
|
|
308
|
+
"key": None,
|
|
309
|
+
"missing_trace_count": len(views),
|
|
310
|
+
"conflicting_trace_count": 0,
|
|
311
|
+
"items": [],
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
grouped: dict[str, list[_TraceView]] = defaultdict(list)
|
|
315
|
+
values: dict[str, object] = {}
|
|
316
|
+
missing_count = 0
|
|
317
|
+
conflicting_count = 0
|
|
318
|
+
for view in views:
|
|
319
|
+
value, missing, conflicting = _cohort_value(view, cohort_key)
|
|
320
|
+
identity = scalar_identity(value)
|
|
321
|
+
values[identity] = value
|
|
322
|
+
grouped[identity].append(view)
|
|
323
|
+
missing_count += int(missing)
|
|
324
|
+
conflicting_count += int(conflicting)
|
|
325
|
+
|
|
326
|
+
items: list[dict[str, object]] = []
|
|
327
|
+
for identity in sorted(grouped):
|
|
328
|
+
cohort_views = grouped[identity]
|
|
329
|
+
cohort_outcomes = Counter(view.outcome for view in cohort_views)
|
|
330
|
+
events = tuple(event for view in cohort_views for event in view.events)
|
|
331
|
+
scalar = json_scalar(values[identity])
|
|
332
|
+
cohort_id = _sequence_digest("journeygraph.cohort/v1", (cohort_key, identity))
|
|
333
|
+
items.append(
|
|
334
|
+
{
|
|
335
|
+
"cohort_id": cohort_id,
|
|
336
|
+
"key": cohort_key,
|
|
337
|
+
"value": scalar,
|
|
338
|
+
"trace_count": len(cohort_views),
|
|
339
|
+
"event_count": len(events),
|
|
340
|
+
"path_count": len({view.path_id for view in cohort_views}),
|
|
341
|
+
"outcomes": _outcome_counts(cohort_outcomes),
|
|
342
|
+
"outcome_rates": _outcome_rates(cohort_outcomes, len(cohort_views)),
|
|
343
|
+
"metrics": _metrics(events),
|
|
344
|
+
}
|
|
345
|
+
)
|
|
346
|
+
items.sort(key=lambda item: str(item["cohort_id"]))
|
|
347
|
+
return {
|
|
348
|
+
"key": cohort_key,
|
|
349
|
+
"missing_trace_count": missing_count,
|
|
350
|
+
"conflicting_trace_count": conflicting_count,
|
|
351
|
+
"items": items,
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _warnings(dataset: DatasetLike) -> list[dict[str, object]]:
|
|
356
|
+
payloads = [issue_payload(issue) for issue in dataset.warnings]
|
|
357
|
+
return sorted(payloads, key=issue_sort_key)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def analyze_dataset(
|
|
361
|
+
dataset: DatasetLike,
|
|
362
|
+
cohort_key: str | None = "cohort",
|
|
363
|
+
tool_version: str = "0.1.0",
|
|
364
|
+
) -> dict[str, object]:
|
|
365
|
+
"""Return the complete deterministic ``journeygraph.analysis/v1`` payload.
|
|
366
|
+
|
|
367
|
+
The function consumes only an accepted normalized dataset. It performs no I/O and does
|
|
368
|
+
not mutate the dataset or its metadata.
|
|
369
|
+
"""
|
|
370
|
+
|
|
371
|
+
graph: AggregateGraph = build_graph(dataset)
|
|
372
|
+
views = _trace_views(dataset)
|
|
373
|
+
labels = {node.node_id: node.label for node in graph.nodes}
|
|
374
|
+
paths = _paths(views)
|
|
375
|
+
outcomes = Counter(view.outcome for view in views)
|
|
376
|
+
|
|
377
|
+
entries: Counter[str] = Counter(view.node_ids[0] for view in views if view.node_ids)
|
|
378
|
+
terminals: Counter[str] = Counter(view.node_ids[-1] for view in views if view.node_ids)
|
|
379
|
+
transition_count = sum(edge.weight for edge in graph.edges)
|
|
380
|
+
traced_event_count = sum(len(view.events) for view in views)
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
"schema_version": ANALYSIS_SCHEMA_VERSION,
|
|
384
|
+
"tool_version": tool_version,
|
|
385
|
+
"config": {"cohort_key": cohort_key},
|
|
386
|
+
"totals": {
|
|
387
|
+
"input_records": int(getattr(dataset, "input_record_count", traced_event_count)),
|
|
388
|
+
"events": traced_event_count,
|
|
389
|
+
"traces": len(views),
|
|
390
|
+
"nodes": len(graph.nodes),
|
|
391
|
+
"transitions": transition_count,
|
|
392
|
+
"unique_transitions": len(graph.edges),
|
|
393
|
+
"paths": len(paths),
|
|
394
|
+
"warnings": len(dataset.warnings),
|
|
395
|
+
},
|
|
396
|
+
"outcomes": _outcome_payload(outcomes, len(views)),
|
|
397
|
+
"nodes": [node.to_payload() for node in graph.nodes],
|
|
398
|
+
"transitions": [edge.to_payload() for edge in graph.edges],
|
|
399
|
+
"entries": _point_payload(entries, labels),
|
|
400
|
+
"terminals": _point_payload(terminals, labels),
|
|
401
|
+
"paths": paths,
|
|
402
|
+
"retries": _retries(views),
|
|
403
|
+
"loops": _loops(views),
|
|
404
|
+
"failure_points": _failure_points(views, labels),
|
|
405
|
+
"dropoff_points": _dropoff_points(views, labels),
|
|
406
|
+
"path_comparison": _path_comparison(views),
|
|
407
|
+
"cohorts": _cohorts(views, cohort_key),
|
|
408
|
+
"metrics": _metrics(tuple(event for view in views for event in view.events)),
|
|
409
|
+
"warnings": _warnings(dataset),
|
|
410
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Explainable deterministic metric summaries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
|
|
9
|
+
from ._adapters import json_number
|
|
10
|
+
|
|
11
|
+
Numeric = Decimal | int | float
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _decimal(value: Numeric) -> Decimal:
|
|
15
|
+
return value if isinstance(value, Decimal) else Decimal(str(value))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _nearest_rank(values: list[Decimal], percentile: Decimal) -> Decimal:
|
|
19
|
+
"""Return a nearest-rank percentile from a non-empty sorted sequence."""
|
|
20
|
+
|
|
21
|
+
rank = max(1, math.ceil(float(percentile * len(values))))
|
|
22
|
+
return values[rank - 1]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def summarize_metric(
|
|
26
|
+
values: Iterable[Numeric | None], *, integral: bool = False
|
|
27
|
+
) -> dict[str, object]:
|
|
28
|
+
"""Summarize present values and explicitly count missing observations.
|
|
29
|
+
|
|
30
|
+
Percentiles use the simple one-indexed nearest-rank definition. Missing values never
|
|
31
|
+
enter sums, means, extrema, or percentiles.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
observations = list(values)
|
|
35
|
+
present = sorted(_decimal(value) for value in observations if value is not None)
|
|
36
|
+
missing_count = len(observations) - len(present)
|
|
37
|
+
|
|
38
|
+
if not present:
|
|
39
|
+
return {
|
|
40
|
+
"count": 0,
|
|
41
|
+
"missing_count": missing_count,
|
|
42
|
+
"sum": 0,
|
|
43
|
+
"min": None,
|
|
44
|
+
"max": None,
|
|
45
|
+
"mean": None,
|
|
46
|
+
"p50": None,
|
|
47
|
+
"p95": None,
|
|
48
|
+
"percentile_method": "nearest_rank",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
total = sum(present, Decimal(0))
|
|
52
|
+
mean = total / len(present)
|
|
53
|
+
|
|
54
|
+
def number(value: Decimal) -> int | float:
|
|
55
|
+
converted = json_number(value)
|
|
56
|
+
if integral and value == value.to_integral_value():
|
|
57
|
+
return int(value)
|
|
58
|
+
return converted
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
"count": len(present),
|
|
62
|
+
"missing_count": missing_count,
|
|
63
|
+
"sum": number(total),
|
|
64
|
+
"min": number(present[0]),
|
|
65
|
+
"max": number(present[-1]),
|
|
66
|
+
"mean": number(mean),
|
|
67
|
+
"p50": number(_nearest_rank(present, Decimal("0.50"))),
|
|
68
|
+
"p95": number(_nearest_rank(present, Decimal("0.95"))),
|
|
69
|
+
"percentile_method": "nearest_rank",
|
|
70
|
+
}
|