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.
Files changed (38) hide show
  1. journeygraph/__init__.py +5 -0
  2. journeygraph/__main__.py +6 -0
  3. journeygraph/analytics/__init__.py +6 -0
  4. journeygraph/analytics/_adapters.py +144 -0
  5. journeygraph/analytics/analyzer.py +410 -0
  6. journeygraph/analytics/metrics.py +70 -0
  7. journeygraph/api.py +95 -0
  8. journeygraph/cli.py +213 -0
  9. journeygraph/data/__init__.py +1 -0
  10. journeygraph/data/demo.jsonl +45 -0
  11. journeygraph/domain/__init__.py +5 -0
  12. journeygraph/domain/models.py +183 -0
  13. journeygraph/exceptions.py +45 -0
  14. journeygraph/graph/__init__.py +13 -0
  15. journeygraph/graph/builder.py +119 -0
  16. journeygraph/graph/models.py +78 -0
  17. journeygraph/ingestion/__init__.py +5 -0
  18. journeygraph/ingestion/otlp.py +388 -0
  19. journeygraph/ingestion/readers.py +254 -0
  20. journeygraph/normalization/__init__.py +5 -0
  21. journeygraph/normalization/pipeline.py +717 -0
  22. journeygraph/normalization/privacy.py +208 -0
  23. journeygraph/py.typed +1 -0
  24. journeygraph/reporting/__init__.py +5 -0
  25. journeygraph/reporting/html.py +241 -0
  26. journeygraph/reporting/serialize.py +31 -0
  27. journeygraph/reporting/svg.py +129 -0
  28. journeygraph/reporting/writer.py +176 -0
  29. journeygraph/schemas/__init__.py +1 -0
  30. journeygraph/schemas/analysis-v1.schema.json +495 -0
  31. journeygraph/schemas/event-v1.schema.json +48 -0
  32. journeygraph/version.py +3 -0
  33. journeygraph-0.1.1.dist-info/METADATA +285 -0
  34. journeygraph-0.1.1.dist-info/RECORD +38 -0
  35. journeygraph-0.1.1.dist-info/WHEEL +5 -0
  36. journeygraph-0.1.1.dist-info/entry_points.txt +2 -0
  37. journeygraph-0.1.1.dist-info/licenses/LICENSE +201 -0
  38. journeygraph-0.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,5 @@
1
+ """JourneyGraph public package."""
2
+
3
+ from journeygraph.version import __version__
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,6 @@
1
+ """Run the JourneyGraph CLI with ``python -m journeygraph``."""
2
+
3
+ from journeygraph.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1,6 @@
1
+ """Deterministic journey analytics."""
2
+
3
+ from .analyzer import ANALYSIS_SCHEMA_VERSION, analyze_dataset
4
+ from .metrics import summarize_metric
5
+
6
+ __all__ = ["ANALYSIS_SCHEMA_VERSION", "analyze_dataset", "summarize_metric"]
@@ -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
+ }