coltrace 0.1.0.dev4__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.
- coltrace/__init__.py +9 -0
- coltrace/__main__.py +6 -0
- coltrace/analysis.py +2206 -0
- coltrace/callbacks.py +579 -0
- coltrace/cli.py +668 -0
- coltrace/collector.py +561 -0
- coltrace/exporters.py +676 -0
- coltrace/graph.py +380 -0
- coltrace/history.py +441 -0
- coltrace/journey.py +578 -0
- coltrace/lineage_models.py +112 -0
- coltrace/models.py +92 -0
- coltrace/py.typed +0 -0
- coltrace/scanner.py +236 -0
- coltrace/templates/graph.html +418 -0
- coltrace/visual_layout.py +159 -0
- coltrace-0.1.0.dev4.dist-info/METADATA +241 -0
- coltrace-0.1.0.dev4.dist-info/RECORD +22 -0
- coltrace-0.1.0.dev4.dist-info/WHEEL +5 -0
- coltrace-0.1.0.dev4.dist-info/entry_points.txt +2 -0
- coltrace-0.1.0.dev4.dist-info/licenses/LICENSE +21 -0
- coltrace-0.1.0.dev4.dist-info/top_level.txt +1 -0
coltrace/history.py
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""A column's recorded history, separate from its value dependency graph.
|
|
2
|
+
|
|
3
|
+
Value edges answer whether one value can affect another. History also includes
|
|
4
|
+
allocations, row filtering, callback statements, overwrites and destinations.
|
|
5
|
+
Only explicit recorder facts establish execution order; source line numbers do
|
|
6
|
+
not establish an ordering between functions or call contexts.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections import defaultdict, deque
|
|
12
|
+
from dataclasses import asdict
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .lineage_models import Column, LineageReport
|
|
16
|
+
|
|
17
|
+
_SOURCE_KINDS = {"source", "allocation", "parameter", "external", "create_dataframe"}
|
|
18
|
+
_USE_KINDS = {"read", "consume", "consumption", "sink", "write_file", "return"}
|
|
19
|
+
_BOUNDARY_KINDS = {"opaque", "boundary", "unresolved", "opaque_call"}
|
|
20
|
+
_COMPLETENESS = {"unresolved": 0, "partial": 1, "complete": 2}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def lifetime_versions(report: LineageReport, selected: Column) -> set[str]:
|
|
24
|
+
"""Versions in one label lifetime; an inplace rename ends that lifetime.
|
|
25
|
+
|
|
26
|
+
Recreating ``a`` after renaming it to ``renamed`` creates an independent
|
|
27
|
+
label. A shared frame ID and label alone cannot reconnect those values.
|
|
28
|
+
"""
|
|
29
|
+
columns = {column.id: column for column in report.columns}
|
|
30
|
+
created = {
|
|
31
|
+
identifier: event.sequence for event in report.history for identifier in event.outputs
|
|
32
|
+
}
|
|
33
|
+
selected_sequence = created.get(selected.id)
|
|
34
|
+
vacated = [
|
|
35
|
+
event.sequence
|
|
36
|
+
for event in report.history
|
|
37
|
+
if event.kind == "rename"
|
|
38
|
+
and any(
|
|
39
|
+
identifier in columns
|
|
40
|
+
and (columns[identifier].dataframe_id, columns[identifier].name)
|
|
41
|
+
== (selected.dataframe_id, selected.name)
|
|
42
|
+
for identifier in event.inputs
|
|
43
|
+
)
|
|
44
|
+
and any(
|
|
45
|
+
identifier in columns
|
|
46
|
+
and columns[identifier].dataframe_id == selected.dataframe_id
|
|
47
|
+
and columns[identifier].name != selected.name
|
|
48
|
+
for identifier in event.outputs
|
|
49
|
+
)
|
|
50
|
+
]
|
|
51
|
+
lower = max(
|
|
52
|
+
(
|
|
53
|
+
sequence
|
|
54
|
+
for sequence in vacated
|
|
55
|
+
if selected_sequence is not None and sequence < selected_sequence
|
|
56
|
+
),
|
|
57
|
+
default=-1,
|
|
58
|
+
)
|
|
59
|
+
upper = min(
|
|
60
|
+
(
|
|
61
|
+
sequence
|
|
62
|
+
for sequence in vacated
|
|
63
|
+
if selected_sequence is not None and sequence >= selected_sequence
|
|
64
|
+
),
|
|
65
|
+
default=float("inf"),
|
|
66
|
+
)
|
|
67
|
+
return {
|
|
68
|
+
column.id
|
|
69
|
+
for column in report.columns
|
|
70
|
+
if (column.dataframe_id, column.name) == (selected.dataframe_id, selected.name)
|
|
71
|
+
and (column.id not in created or lower < created[column.id] <= upper)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _compact_stages(stages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
76
|
+
"""Collapse bookkeeping at one statement; retain raw event IDs for the DAG."""
|
|
77
|
+
result: list[dict[str, Any]] = []
|
|
78
|
+
groups: dict[tuple[str, str, str, int, str], dict[str, Any]] = {}
|
|
79
|
+
row_kinds = {"filter", "dropna", "projection"}
|
|
80
|
+
for stage in stages:
|
|
81
|
+
if stage["kind"] == "read" and any(
|
|
82
|
+
other["kind"] != "read"
|
|
83
|
+
and other["location"]["path"] == stage["location"]["path"]
|
|
84
|
+
and other["location"]["line"] == stage["location"]["line"]
|
|
85
|
+
and other["context"] == stage["context"]
|
|
86
|
+
and set(stage["inputs"]).intersection((*other["inputs"], *other["outputs"]))
|
|
87
|
+
for other in stages
|
|
88
|
+
):
|
|
89
|
+
continue
|
|
90
|
+
if stage["kind"] == "consume" and stage["label"] in {
|
|
91
|
+
"argument",
|
|
92
|
+
"apply input",
|
|
93
|
+
"callback input",
|
|
94
|
+
"string transform",
|
|
95
|
+
}:
|
|
96
|
+
continue
|
|
97
|
+
kind = stage["kind"]
|
|
98
|
+
key_kind = "rows" if kind in row_kinds else kind
|
|
99
|
+
if kind == "bind" and any(
|
|
100
|
+
other["kind"] in row_kinds
|
|
101
|
+
and other["location"]["path"] == stage["location"]["path"]
|
|
102
|
+
and other["location"]["line"] == stage["location"]["line"]
|
|
103
|
+
and other["context"] == stage["context"]
|
|
104
|
+
for other in stages
|
|
105
|
+
):
|
|
106
|
+
key_kind = "rows"
|
|
107
|
+
key = (
|
|
108
|
+
stage["context"],
|
|
109
|
+
stage["location"]["path"],
|
|
110
|
+
stage["scope"],
|
|
111
|
+
stage["location"]["line"],
|
|
112
|
+
key_kind,
|
|
113
|
+
)
|
|
114
|
+
# Separate callback assignments and positional return components even on
|
|
115
|
+
# a shared source line. Their branch identities matter.
|
|
116
|
+
merge = key_kind in {"rows", "sink", "boundary"}
|
|
117
|
+
previous = groups.get(key) if merge else None
|
|
118
|
+
if previous is None:
|
|
119
|
+
stage = {**stage, "event_ids": [stage["id"]]}
|
|
120
|
+
if key_kind == "rows":
|
|
121
|
+
stage["label"] = (
|
|
122
|
+
"Project selected columns; preserve their values"
|
|
123
|
+
if kind == "projection"
|
|
124
|
+
else "Filter rows; preserve values of retained columns"
|
|
125
|
+
)
|
|
126
|
+
result.append(stage)
|
|
127
|
+
if merge:
|
|
128
|
+
groups[key] = stage
|
|
129
|
+
else:
|
|
130
|
+
previous["event_ids"].append(stage["id"])
|
|
131
|
+
for field in ("inputs", "outputs", "dataframe_ids", "parents"):
|
|
132
|
+
previous[field] = list(dict.fromkeys((*previous[field], *stage[field])))
|
|
133
|
+
if kind == "sink" and stage["label"] != "sink":
|
|
134
|
+
previous["label"], previous["expression"] = stage["label"], stage["expression"]
|
|
135
|
+
elif kind == "bind":
|
|
136
|
+
previous["expression"] = stage["expression"]
|
|
137
|
+
return result
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _category(kind: str) -> str:
|
|
141
|
+
if kind in {"callback_control", "callback_configuration", "callback_context"}:
|
|
142
|
+
return kind.removeprefix("callback_")
|
|
143
|
+
if kind in _SOURCE_KINDS or kind.startswith("source"):
|
|
144
|
+
return "source"
|
|
145
|
+
if kind in _USE_KINDS or kind.startswith("sink"):
|
|
146
|
+
return "use"
|
|
147
|
+
if kind in _BOUNDARY_KINDS or "opaque" in kind or "unresolved" in kind:
|
|
148
|
+
return "boundary"
|
|
149
|
+
return "transform"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _fallback_events(report: LineageReport) -> list[dict[str, Any]]:
|
|
153
|
+
"""Preserve usable old reports without claiming unrecorded inner steps."""
|
|
154
|
+
columns = {column.id: column for column in report.columns}
|
|
155
|
+
reads: dict[str, list[str]] = defaultdict(list)
|
|
156
|
+
writes: dict[str, list[str]] = defaultdict(list)
|
|
157
|
+
for edge in report.edges:
|
|
158
|
+
if edge.kind == "read":
|
|
159
|
+
reads[edge.target].append(edge.source)
|
|
160
|
+
elif edge.kind == "write":
|
|
161
|
+
writes[edge.source].append(edge.target)
|
|
162
|
+
events: list[dict[str, Any]] = []
|
|
163
|
+
for frame in report.dataframes:
|
|
164
|
+
events.append(
|
|
165
|
+
{
|
|
166
|
+
"id": f"history-source:{frame.id}",
|
|
167
|
+
"sequence": None,
|
|
168
|
+
"kind": "parameter" if "parameter" in frame.origin else "source",
|
|
169
|
+
"label": frame.origin,
|
|
170
|
+
"location": asdict(frame.location),
|
|
171
|
+
"module": frame.module,
|
|
172
|
+
"scope": frame.scope,
|
|
173
|
+
"context": frame.context,
|
|
174
|
+
"dataframe_ids": [frame.id],
|
|
175
|
+
"inputs": [],
|
|
176
|
+
"outputs": [],
|
|
177
|
+
"parents": [],
|
|
178
|
+
"expression": "",
|
|
179
|
+
"evidence": frame.evidence,
|
|
180
|
+
"completeness": "partial",
|
|
181
|
+
}
|
|
182
|
+
)
|
|
183
|
+
for operation in report.operations:
|
|
184
|
+
inputs = sorted(reads[operation.id])
|
|
185
|
+
outputs = sorted(writes[operation.id])
|
|
186
|
+
events.append(
|
|
187
|
+
{
|
|
188
|
+
**asdict(operation),
|
|
189
|
+
"sequence": None,
|
|
190
|
+
"label": operation.kind,
|
|
191
|
+
"dataframe_ids": sorted(
|
|
192
|
+
{columns[item].dataframe_id for item in inputs + outputs if item in columns}
|
|
193
|
+
),
|
|
194
|
+
"inputs": inputs,
|
|
195
|
+
"outputs": outputs,
|
|
196
|
+
"parents": [],
|
|
197
|
+
"expression": "",
|
|
198
|
+
}
|
|
199
|
+
)
|
|
200
|
+
for index, consumption in enumerate(report.consumptions):
|
|
201
|
+
events.append(
|
|
202
|
+
{
|
|
203
|
+
**asdict(consumption),
|
|
204
|
+
"id": f"history-use:{index}:{consumption.column_id}",
|
|
205
|
+
"sequence": None,
|
|
206
|
+
"kind": "consume",
|
|
207
|
+
"label": consumption.kind,
|
|
208
|
+
"dataframe_ids": [columns[consumption.column_id].dataframe_id],
|
|
209
|
+
"inputs": [consumption.column_id],
|
|
210
|
+
"outputs": [],
|
|
211
|
+
"parents": [],
|
|
212
|
+
"expression": "",
|
|
213
|
+
"evidence": "confirmed",
|
|
214
|
+
"completeness": "partial",
|
|
215
|
+
}
|
|
216
|
+
)
|
|
217
|
+
return events
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _reachable_columns(report: LineageReport, selected_id: str) -> set[str]:
|
|
221
|
+
"""Ancestors and descendants, without following unrelated sibling branches."""
|
|
222
|
+
reads: dict[str, set[str]] = defaultdict(set)
|
|
223
|
+
writes: dict[str, set[str]] = defaultdict(set)
|
|
224
|
+
for edge in report.edges:
|
|
225
|
+
if edge.kind == "read":
|
|
226
|
+
reads[edge.target].add(edge.source)
|
|
227
|
+
elif edge.kind == "write":
|
|
228
|
+
writes[edge.source].add(edge.target)
|
|
229
|
+
forward: dict[str, set[str]] = defaultdict(set)
|
|
230
|
+
reverse: dict[str, set[str]] = defaultdict(set)
|
|
231
|
+
for operation_id, inputs in reads.items():
|
|
232
|
+
for source in inputs:
|
|
233
|
+
for target in writes[operation_id]:
|
|
234
|
+
forward[source].add(target)
|
|
235
|
+
reverse[target].add(source)
|
|
236
|
+
result = {selected_id}
|
|
237
|
+
for adjacent in (forward, reverse):
|
|
238
|
+
queue = deque([selected_id])
|
|
239
|
+
seen = {selected_id}
|
|
240
|
+
while queue:
|
|
241
|
+
for target in adjacent[queue.popleft()]:
|
|
242
|
+
if target not in seen:
|
|
243
|
+
seen.add(target)
|
|
244
|
+
queue.append(target)
|
|
245
|
+
result.update(seen)
|
|
246
|
+
return result
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def build_history(
|
|
250
|
+
report: LineageReport,
|
|
251
|
+
selected_id: str,
|
|
252
|
+
included_ids: set[str] | None = None,
|
|
253
|
+
*,
|
|
254
|
+
evidence: str = "all",
|
|
255
|
+
) -> dict[str, Any]:
|
|
256
|
+
"""Return an event DAG and ordered narrative for a recorded column lifetime.
|
|
257
|
+
|
|
258
|
+
``included_ids`` is the value-query slice. Earlier versions of the selected
|
|
259
|
+
logical column remain visible as history even when a constant overwrite
|
|
260
|
+
correctly has no dependency edge. A ``supersedes`` edge records chronology
|
|
261
|
+
only and must never be interpreted as a value dependency.
|
|
262
|
+
"""
|
|
263
|
+
columns = {column.id: column for column in report.columns}
|
|
264
|
+
if selected_id not in columns:
|
|
265
|
+
raise ValueError(f"Unknown column ID: {selected_id}")
|
|
266
|
+
selected = columns[selected_id]
|
|
267
|
+
lifetime = lifetime_versions(report, selected)
|
|
268
|
+
included = (
|
|
269
|
+
_reachable_columns(report, selected_id) if included_ids is None else set(included_ids)
|
|
270
|
+
)
|
|
271
|
+
included.add(selected_id)
|
|
272
|
+
included.update(lifetime)
|
|
273
|
+
included.intersection_update(columns)
|
|
274
|
+
frame_ids = {columns[item].dataframe_id for item in included}
|
|
275
|
+
recorded = getattr(report, "history", ())
|
|
276
|
+
events = [asdict(event) for event in recorded] if recorded else _fallback_events(report)
|
|
277
|
+
if evidence == "confirmed":
|
|
278
|
+
events = [event for event in events if event["evidence"] == "confirmed"]
|
|
279
|
+
by_event = {event["id"]: event for event in events}
|
|
280
|
+
cutoffs: dict[str, int] = {}
|
|
281
|
+
event_ids: set[str] = set()
|
|
282
|
+
disconnected: set[str] = set()
|
|
283
|
+
for event in events:
|
|
284
|
+
touches = bool(
|
|
285
|
+
included.intersection(event["outputs"] if event["outputs"] else event["inputs"])
|
|
286
|
+
) and not event["kind"].startswith("callback_")
|
|
287
|
+
related_frames = frame_ids.intersection(event["dataframe_ids"])
|
|
288
|
+
sequence = event.get("sequence")
|
|
289
|
+
beyond = sequence is not None and any(
|
|
290
|
+
frame in cutoffs and sequence > cutoffs[frame] for frame in related_frames
|
|
291
|
+
)
|
|
292
|
+
frame_only = (
|
|
293
|
+
(_category(event["kind"]) == "source" and not event["outputs"])
|
|
294
|
+
or event["kind"] in {"bind", "call", "return", "sink"}
|
|
295
|
+
or (event["kind"] == "boundary" and not event["inputs"] and not event["outputs"])
|
|
296
|
+
)
|
|
297
|
+
if touches or (related_frames and frame_only and not beyond):
|
|
298
|
+
event_ids.add(event["id"])
|
|
299
|
+
if _category(event["kind"]) == "boundary" and sequence is not None:
|
|
300
|
+
for frame in related_frames:
|
|
301
|
+
cutoffs.setdefault(frame, sequence)
|
|
302
|
+
elif related_frames and event["kind"] == "sink" and beyond:
|
|
303
|
+
event_ids.add(event["id"])
|
|
304
|
+
disconnected.add(event["id"])
|
|
305
|
+
for event in events:
|
|
306
|
+
if event["id"] in disconnected:
|
|
307
|
+
event.update(
|
|
308
|
+
label="Later export of same DataFrame; column survival is unverified",
|
|
309
|
+
inputs=[],
|
|
310
|
+
outputs=[],
|
|
311
|
+
parents=[],
|
|
312
|
+
evidence="unresolved",
|
|
313
|
+
completeness="unresolved",
|
|
314
|
+
continuity="unresolved",
|
|
315
|
+
)
|
|
316
|
+
queue = deque(sorted(event_ids))
|
|
317
|
+
while queue:
|
|
318
|
+
event = by_event[queue.popleft()]
|
|
319
|
+
for parent in event.get("parents", ()):
|
|
320
|
+
if parent in by_event and parent not in event_ids:
|
|
321
|
+
event_ids.add(parent)
|
|
322
|
+
queue.append(parent)
|
|
323
|
+
chosen = [event for event in events if event["id"] in event_ids]
|
|
324
|
+
chosen.sort(
|
|
325
|
+
key=lambda event: (
|
|
326
|
+
event.get("sequence") is None,
|
|
327
|
+
event.get("sequence") if event.get("sequence") is not None else 0,
|
|
328
|
+
event["id"],
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
# Co-inputs explain an included transformation but do not pull in unrelated
|
|
332
|
+
# downstream branches of those co-inputs.
|
|
333
|
+
displayed_columns = included | {
|
|
334
|
+
column_id for event in chosen for column_id in event["inputs"] if column_id in columns
|
|
335
|
+
}
|
|
336
|
+
nodes: list[dict[str, Any]] = [
|
|
337
|
+
{
|
|
338
|
+
**asdict(columns[column_id]),
|
|
339
|
+
"node_type": "column",
|
|
340
|
+
"selected": column_id == selected_id,
|
|
341
|
+
"context_only": column_id not in included,
|
|
342
|
+
}
|
|
343
|
+
for column_id in sorted(displayed_columns)
|
|
344
|
+
]
|
|
345
|
+
stages: list[dict[str, Any]] = []
|
|
346
|
+
edges: list[dict[str, Any]] = []
|
|
347
|
+
for event in chosen:
|
|
348
|
+
stage = {**event, "category": _category(event["kind"])}
|
|
349
|
+
stages.append(stage)
|
|
350
|
+
nodes.append({**stage, "node_type": "event", "selected": False})
|
|
351
|
+
for source in event["inputs"]:
|
|
352
|
+
if source in displayed_columns:
|
|
353
|
+
edges.append(
|
|
354
|
+
{
|
|
355
|
+
"source": source,
|
|
356
|
+
"target": event["id"],
|
|
357
|
+
"kind": "input",
|
|
358
|
+
"evidence": event["evidence"],
|
|
359
|
+
}
|
|
360
|
+
)
|
|
361
|
+
for target in event["outputs"]:
|
|
362
|
+
if target in displayed_columns:
|
|
363
|
+
edges.append(
|
|
364
|
+
{
|
|
365
|
+
"source": event["id"],
|
|
366
|
+
"target": target,
|
|
367
|
+
"kind": "output",
|
|
368
|
+
"evidence": event["evidence"],
|
|
369
|
+
}
|
|
370
|
+
)
|
|
371
|
+
for parent in event.get("parents", ()):
|
|
372
|
+
if parent in event_ids:
|
|
373
|
+
edges.append(
|
|
374
|
+
{
|
|
375
|
+
"source": parent,
|
|
376
|
+
"target": event["id"],
|
|
377
|
+
"kind": "step",
|
|
378
|
+
"evidence": event["evidence"],
|
|
379
|
+
}
|
|
380
|
+
)
|
|
381
|
+
versions = sorted(
|
|
382
|
+
(column for column in report.columns if column.id in lifetime and column.id in included),
|
|
383
|
+
key=lambda column: column.version,
|
|
384
|
+
)
|
|
385
|
+
for previous, current in zip(versions, versions[1:], strict=False):
|
|
386
|
+
creators = [event for event in chosen if current.id in event["outputs"]]
|
|
387
|
+
if creators and previous.id not in creators[0]["inputs"]:
|
|
388
|
+
edges.append(
|
|
389
|
+
{
|
|
390
|
+
"source": previous.id,
|
|
391
|
+
"target": creators[0]["id"],
|
|
392
|
+
"kind": "supersedes",
|
|
393
|
+
"evidence": creators[0]["evidence"],
|
|
394
|
+
}
|
|
395
|
+
)
|
|
396
|
+
source_events = [stage for stage in stages if stage["category"] == "source"]
|
|
397
|
+
# A frame allocation precedes observations of its external schema. This is
|
|
398
|
+
# origin metadata, not a claim that a read_csv expression defines a column.
|
|
399
|
+
for event in source_events:
|
|
400
|
+
for column_id in sorted(included):
|
|
401
|
+
column = columns[column_id]
|
|
402
|
+
if (
|
|
403
|
+
column.dataframe_id in event["dataframe_ids"]
|
|
404
|
+
and column.origin in {"external", "external_unknown", "parameter"}
|
|
405
|
+
and not any(edge["target"] == column_id for edge in edges)
|
|
406
|
+
):
|
|
407
|
+
edges.append(
|
|
408
|
+
{
|
|
409
|
+
"source": event["id"],
|
|
410
|
+
"target": column_id,
|
|
411
|
+
"kind": "observed_column",
|
|
412
|
+
"evidence": column.evidence,
|
|
413
|
+
}
|
|
414
|
+
)
|
|
415
|
+
stages = _compact_stages(stages)
|
|
416
|
+
groups = {
|
|
417
|
+
f"{category}s": [stage for stage in stages if stage["category"] == category]
|
|
418
|
+
for category in ("source", "transform", "use", "boundary")
|
|
419
|
+
}
|
|
420
|
+
# Correct plural for the public narrative group.
|
|
421
|
+
groups["boundaries"] = groups.pop("boundarys")
|
|
422
|
+
completeness = min(
|
|
423
|
+
[columns[item].completeness for item in included]
|
|
424
|
+
+ [event["completeness"] for event in chosen]
|
|
425
|
+
+ (["partial"] if not recorded else ["complete"]),
|
|
426
|
+
key=_COMPLETENESS.__getitem__,
|
|
427
|
+
)
|
|
428
|
+
if completeness == "unresolved" and any(
|
|
429
|
+
columns[item].completeness != "unresolved" for item in included
|
|
430
|
+
):
|
|
431
|
+
completeness = "partial"
|
|
432
|
+
return {
|
|
433
|
+
"selected_column_id": selected_id,
|
|
434
|
+
"nodes": nodes,
|
|
435
|
+
"edges": edges,
|
|
436
|
+
"stages": stages,
|
|
437
|
+
**groups,
|
|
438
|
+
"completeness": completeness,
|
|
439
|
+
"ordering": "recorded" if recorded else "unavailable",
|
|
440
|
+
"recorded": bool(recorded),
|
|
441
|
+
}
|