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/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
+ }