matview-cache 0.1.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.
matview/graph.py ADDED
@@ -0,0 +1,876 @@
1
+ """Questions about the recorded DAG: lineage, impact and cost.
2
+
3
+ matview records a complete observed dependency graph as it computes, and until now
4
+ offered almost no way to interrogate it. These are the three questions worth asking of
5
+ it:
6
+
7
+ - **Lineage** — which inputs produced this number? The audit question, and the
8
+ reproducibility question.
9
+ - **Impact** — what breaks if this changes? Asked of a changed artifact, a recipe
10
+ version bump, and a drop.
11
+ - **Cost** — what did this number cost to produce, and which step dominates?
12
+
13
+ Everything here is a metadata query against the store. Nothing resolves a recipe, so
14
+ all of it works against a database whose recipes this process has never imported — the
15
+ same division `matview.cli` draws. Nothing here writes.
16
+
17
+ The renderers live here rather than on the store on purpose. A store persists; it should
18
+ not know about Graphviz, and the `Store` protocol should not grow methods a remote
19
+ backend has no business implementing. `GraphStore` below is the read surface these
20
+ queries actually need, satisfied structurally by `SqliteStore`. See DECISIONS.md D49.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ from collections.abc import Iterable, Mapping, Sequence
27
+ from dataclasses import dataclass
28
+ from typing import Any, Literal, Protocol, runtime_checkable
29
+
30
+ from matview.errors import MatviewError
31
+ from matview.types import ArtifactRef, DepEdge
32
+
33
+ __all__ = [
34
+ "ArtifactGraph",
35
+ "CostReport",
36
+ "Direction",
37
+ "GraphStore",
38
+ "ImpactGroup",
39
+ "ImpactReport",
40
+ "PathStep",
41
+ "RecipeCost",
42
+ "RecipeCount",
43
+ "critical_path",
44
+ "embodied_cost",
45
+ "impact_graph",
46
+ "impact_of_bump",
47
+ "impact_of_change",
48
+ "impact_of_drop",
49
+ "lineage",
50
+ "recipe_costs",
51
+ "ref_to_dict",
52
+ ]
53
+
54
+ #: Which way along `dep` a graph was walked. `"inputs"` is the lineage direction, up
55
+ #: towards raw inputs; `"dependents"` is the invalidation direction, down towards
56
+ #: consumers. An `ArtifactGraph` carries it so a renderer knows which end of an edge to
57
+ #: descend from, and so the rendering can say which question it is answering.
58
+ Direction = Literal["inputs", "dependents"]
59
+
60
+
61
+ @runtime_checkable
62
+ class GraphStore(Protocol):
63
+ """The store reads these queries need.
64
+
65
+ Declared here rather than added to `Store` so that the persistence seam stays the
66
+ set of operations a backend must implement to hold artifacts. A backend that also
67
+ wants to answer graph questions satisfies this structurally, as `SqliteStore` does.
68
+ """
69
+
70
+ def ref_by_id(self, artifact_id: int) -> ArtifactRef | None: ...
71
+
72
+ def refs_by_ids(self, artifact_ids: Sequence[int]) -> list[ArtifactRef]: ...
73
+
74
+ def refs_by_key_hash(self, key_hash: str, recipe: str | None = None) -> list[ArtifactRef]: ...
75
+
76
+ def deps_of_many(self, artifact_ids: Sequence[int]) -> dict[int, list[DepEdge]]: ...
77
+
78
+ def inputs_of(self, artifact_ids: Sequence[int], *, depth: int | None = ...) -> list[int]: ...
79
+
80
+ def dependents_of(
81
+ self, artifact_ids: Sequence[int], *, depth: int | None = ...
82
+ ) -> list[int]: ...
83
+
84
+ def ids_of_recipe(self, recipe: str, version: int | None = ...) -> list[int]: ...
85
+
86
+ def counts_by_recipe(self, artifact_ids: Sequence[int]) -> list[dict[str, Any]]: ...
87
+
88
+ def recipe_rows(self) -> list[dict[str, Any]]: ...
89
+
90
+ def recipe_cost_rows(self) -> list[dict[str, Any]]: ...
91
+
92
+
93
+ def ref_to_dict(ref: ArtifactRef) -> dict[str, Any]:
94
+ """One artifact's metadata as JSON-ready data, with its key parsed back to an object.
95
+
96
+ One shape for every machine-readable rendering in the library, so that a node in a
97
+ lineage graph and a row from `matview ls` describe an artifact the same way.
98
+ """
99
+ return {
100
+ "id": ref.id,
101
+ "recipe": ref.recipe,
102
+ "version": ref.version,
103
+ "part": ref.part,
104
+ "valid": ref.valid,
105
+ "key_hash": ref.key_hash,
106
+ "key": json.loads(ref.key_json),
107
+ "digest": ref.digest,
108
+ "fingerprint": ref.fingerprint,
109
+ "n_bytes": ref.n_bytes,
110
+ "computed_at": ref.computed_at,
111
+ "refreshed_at": ref.refreshed_at,
112
+ "compute_ms": ref.compute_ms,
113
+ }
114
+
115
+
116
+ def _value_of(ref: ArtifactRef) -> tuple[str, int, str]:
117
+ """The identity a *value* has, as distinct from an artifact row.
118
+
119
+ A partitioned value is several artifacts sharing one `(recipe, version, key_hash)`.
120
+ Any cost aggregate must group on this and never on the artifact id; see the note on
121
+ `embodied_cost`.
122
+ """
123
+ return (ref.recipe, ref.version, ref.key_hash)
124
+
125
+
126
+ # --- the graph value type ---------------------------------------------------
127
+
128
+
129
+ @dataclass(frozen=True, slots=True)
130
+ class ArtifactGraph:
131
+ """A walked piece of the recorded DAG, with no reference to the store it came from.
132
+
133
+ A plain value object on purpose: build it once, render it four ways. `edges` is
134
+ always `(dependent, dependency)` whichever direction the walk ran in, because that
135
+ is the direction the `dep` table records and reversing it in storage would make the
136
+ two walks disagree about what an edge means. `direction` says which end of an edge a
137
+ renderer should descend from.
138
+ """
139
+
140
+ #: Every artifact in the walk, including the roots, by row id.
141
+ nodes: Mapping[int, ArtifactRef]
142
+ #: `(dependent, dependency)` pairs, both ends present in `nodes`.
143
+ edges: tuple[tuple[int, int], ...]
144
+ #: The artifacts the walk started from.
145
+ roots: tuple[int, ...]
146
+ direction: Direction = "inputs"
147
+ #: True when a `limit` cut the walk short, so a reader knows the picture is partial.
148
+ truncated: bool = False
149
+
150
+ def __len__(self) -> int:
151
+ return len(self.nodes)
152
+
153
+ def children_of(self, artifact_id: int) -> tuple[int, ...]:
154
+ """The nodes one step further from the roots, in the walk's own direction."""
155
+ if self.direction == "inputs":
156
+ found = [b for a, b in self.edges if a == artifact_id]
157
+ else:
158
+ found = [a for a, b in self.edges if b == artifact_id]
159
+ return tuple(sorted(set(found)))
160
+
161
+ # -- renderings ---------------------------------------------------------
162
+
163
+ def to_tree(self) -> str:
164
+ """An indented tree, roots first.
165
+
166
+ The DAG is not a tree, so a node reachable by more than one path is printed
167
+ again and marked rather than expanded twice — which both keeps the output
168
+ bounded on a diamond and makes the sharing visible.
169
+ """
170
+ lines: list[str] = []
171
+ expanded: set[int] = set()
172
+
173
+ def walk(node_id: int, prefix: str, connector: str) -> None:
174
+ ref = self.nodes.get(node_id)
175
+ label = f"[{node_id}] (outside this walk)" if ref is None else _label(ref)
176
+ seen = node_id in expanded
177
+ lines.append(prefix + connector + label + (" (shown above)" if seen else ""))
178
+ if seen or ref is None:
179
+ return
180
+ expanded.add(node_id)
181
+ children = self.children_of(node_id)
182
+ below = prefix if not connector else prefix + (_SPACE if connector == _LAST else _BAR)
183
+ for i, child in enumerate(children):
184
+ walk(child, below, _LAST if i == len(children) - 1 else _TEE)
185
+
186
+ for root in self.roots:
187
+ walk(root, "", "")
188
+ if self.truncated:
189
+ lines.append("... (truncated)")
190
+ return "\n".join(lines)
191
+
192
+ def to_json(self) -> dict[str, Any]:
193
+ """The graph as data: nodes, edges and roots, with the walk's direction."""
194
+ return {
195
+ "direction": self.direction,
196
+ "roots": list(self.roots),
197
+ "truncated": self.truncated,
198
+ "nodes": [ref_to_dict(self.nodes[i]) for i in sorted(self.nodes)],
199
+ "edges": [{"dependent": a, "dependency": b} for a, b in self.edges],
200
+ }
201
+
202
+ def to_dot(self) -> str:
203
+ """Graphviz DOT. Arrows run the way data does: dependency to dependent."""
204
+ out = [
205
+ f"digraph {self.direction} {{",
206
+ " rankdir=LR;",
207
+ ' node [shape=box, fontname="Helvetica", fontsize=10];',
208
+ ]
209
+ roots = set(self.roots)
210
+ for node_id in sorted(self.nodes):
211
+ ref = self.nodes[node_id]
212
+ attrs = [f'label="{_dot_escape(_multiline_label(ref))}"']
213
+ if not ref.valid:
214
+ attrs.append("style=dashed")
215
+ if node_id in roots:
216
+ attrs.append("penwidth=2")
217
+ out.append(f" n{node_id} [{', '.join(attrs)}];")
218
+ for dependent, dependency in self.edges:
219
+ out.append(f" n{dependency} -> n{dependent};")
220
+ out.append("}")
221
+ return "\n".join(out)
222
+
223
+ def to_mermaid(self) -> str:
224
+ """Mermaid `graph LR`, with the same arrow direction as `to_dot`."""
225
+ out = ["graph LR"]
226
+ for node_id in sorted(self.nodes):
227
+ ref = self.nodes[node_id]
228
+ label = _mermaid_escape(_multiline_label(ref)).replace("\n", "<br/>")
229
+ out.append(f' n{node_id}["{label}"]')
230
+ for dependent, dependency in self.edges:
231
+ out.append(f" n{dependency} --> n{dependent}")
232
+ stale = [i for i in sorted(self.nodes) if not self.nodes[i].valid]
233
+ if stale:
234
+ out.append(" classDef stale stroke-dasharray: 4 3;")
235
+ out.append(f" class {','.join(f'n{i}' for i in stale)} stale;")
236
+ return "\n".join(out)
237
+
238
+ def render(self, fmt: str) -> str:
239
+ """Render by format name: `tree`, `json`, `dot` or `mermaid`."""
240
+ if fmt == "tree":
241
+ return self.to_tree()
242
+ if fmt == "json":
243
+ return json.dumps(self.to_json(), indent=2, sort_keys=True, default=str)
244
+ if fmt == "dot":
245
+ return self.to_dot()
246
+ if fmt == "mermaid":
247
+ return self.to_mermaid()
248
+ raise MatviewError(f"unknown format {fmt!r}; choose tree, json, dot or mermaid")
249
+
250
+
251
+ #: Box-drawing pieces for `ArtifactGraph.to_tree`.
252
+ _TEE = "\u251c\u2500 "
253
+ _LAST = "\u2514\u2500 "
254
+ _BAR = "\u2502 "
255
+ _SPACE = " "
256
+
257
+
258
+ def _label(ref: ArtifactRef) -> str:
259
+ """One line describing a node: what it is, whether to trust it, and which bytes.
260
+
261
+ Validity is on it because a stale node in someone's lineage is very often exactly
262
+ what they opened the command to find.
263
+ """
264
+ part = "" if ref.part is None else f" part={ref.part!r}"
265
+ state = "valid" if ref.valid else "STALE"
266
+ return f"[{ref.id}] {ref.recipe} v{ref.version}{part} {ref.key_json} {state} {ref.digest[:12]}"
267
+
268
+
269
+ def _multiline_label(ref: ArtifactRef) -> str:
270
+ part = "" if ref.part is None else f" part={ref.part}"
271
+ state = "" if ref.valid else "\nSTALE"
272
+ return f"{ref.recipe} v{ref.version}{part}\n{ref.key_json}\n{ref.digest[:12]}{state}"
273
+
274
+
275
+ def _dot_escape(text: str) -> str:
276
+ return text.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
277
+
278
+
279
+ def _mermaid_escape(text: str) -> str:
280
+ return text.replace('"', "#quot;").replace("[", "#91;").replace("]", "#93;")
281
+
282
+
283
+ # --- lineage ----------------------------------------------------------------
284
+
285
+
286
+ def lineage(store: GraphStore, artifact_id: int, *, depth: int | None = None) -> ArtifactGraph:
287
+ """The input closure of one artifact: everything that went into producing it.
288
+
289
+ Answers "which raw inputs produced this number?" from what was actually read at
290
+ compute time, not from what a recipe declares it might read. `depth=1` stops at the
291
+ artifact's direct dependencies.
292
+
293
+ Needs no recipe registry: it reads the recorded DAG.
294
+ """
295
+ ref = store.ref_by_id(artifact_id)
296
+ if ref is None:
297
+ raise MatviewError(f"no artifact with id {artifact_id}")
298
+ ids = [artifact_id, *store.inputs_of([artifact_id], depth=depth)]
299
+ return _assemble(store, ids, roots=(artifact_id,), direction="inputs")
300
+
301
+
302
+ def _assemble(
303
+ store: GraphStore, ids: Sequence[int], *, roots: tuple[int, ...], direction: Direction
304
+ ) -> ArtifactGraph:
305
+ """Turn a walked id set into nodes and the edges that fall *inside* it.
306
+
307
+ Edges to artifacts outside the set are dropped rather than pulling their nodes in,
308
+ which is what makes a `depth`-bounded or `limit`-bounded walk a closed picture
309
+ instead of one with dangling arrows.
310
+ """
311
+ nodes = {ref.id: ref for ref in store.refs_by_ids(ids)}
312
+ edges: list[tuple[int, int]] = []
313
+ for dependent, dep_edges in store.deps_of_many(list(nodes)).items():
314
+ for edge in dep_edges:
315
+ if edge.artifact_id in nodes:
316
+ edges.append((dependent, edge.artifact_id))
317
+ return ArtifactGraph(
318
+ nodes=nodes,
319
+ edges=tuple(sorted(set(edges))),
320
+ roots=roots,
321
+ direction=direction,
322
+ )
323
+
324
+
325
+ # --- impact -----------------------------------------------------------------
326
+
327
+
328
+ @dataclass(frozen=True, slots=True)
329
+ class RecipeCount:
330
+ """How many artifacts of one recipe version are in some affected set."""
331
+
332
+ recipe: str
333
+ version: int
334
+ artifacts: int
335
+ #: How many of them are already stale, and so would not change state again.
336
+ stale: int
337
+
338
+
339
+ @dataclass(frozen=True, slots=True)
340
+ class ImpactGroup:
341
+ """One named set of affected artifacts within an impact answer."""
342
+
343
+ label: str
344
+ total: int
345
+ by_recipe: tuple[RecipeCount, ...]
346
+ #: Populated only when the caller asked to enumerate. A summary leaves it empty.
347
+ refs: tuple[ArtifactRef, ...] = ()
348
+ #: True when `limit` cut `refs` short. `total` is still the real figure.
349
+ truncated: bool = False
350
+ #: Every id in the group, kept so a caller can render the set as a graph.
351
+ ids: tuple[int, ...] = ()
352
+
353
+ def to_json(self) -> dict[str, Any]:
354
+ return {
355
+ "label": self.label,
356
+ "total": self.total,
357
+ "truncated": self.truncated,
358
+ "by_recipe": [
359
+ {
360
+ "recipe": c.recipe,
361
+ "version": c.version,
362
+ "artifacts": c.artifacts,
363
+ "already_stale": c.stale,
364
+ }
365
+ for c in self.by_recipe
366
+ ],
367
+ "artifacts": [ref_to_dict(r) for r in self.refs],
368
+ }
369
+
370
+
371
+ @dataclass(frozen=True, slots=True)
372
+ class ImpactReport:
373
+ """What would happen, for one of the three questions impact answers.
374
+
375
+ `notes` carries the prose. For a version bump that prose *is* the answer, and is the
376
+ part of the fingerprint spec that confuses every new reader, so it is data rather
377
+ than something the command-line layer prints on the side.
378
+ """
379
+
380
+ question: Literal["changed", "bump", "drop"]
381
+ subject: str
382
+ notes: tuple[str, ...]
383
+ groups: tuple[ImpactGroup, ...] = ()
384
+
385
+ def to_json(self) -> dict[str, Any]:
386
+ return {
387
+ "question": self.question,
388
+ "subject": self.subject,
389
+ "notes": list(self.notes),
390
+ "groups": [g.to_json() for g in self.groups],
391
+ }
392
+
393
+ def describe(self) -> str:
394
+ lines = [f"{self.subject}", ""]
395
+ lines.extend(self.notes)
396
+ for group in self.groups:
397
+ lines.append("")
398
+ lines.append(f"{group.label}: {group.total}")
399
+ for count in group.by_recipe:
400
+ stale = f", {count.stale} already stale" if count.stale else ""
401
+ lines.append(f" {count.recipe} v{count.version} {count.artifacts}{stale}")
402
+ if not group.by_recipe:
403
+ lines.append(" (none)")
404
+ for ref in group.refs:
405
+ lines.append(f" {_label(ref)}")
406
+ if group.truncated:
407
+ lines.append(f" ... ({group.total - len(group.refs)} more)")
408
+ return "\n".join(lines)
409
+
410
+
411
+ def _group(
412
+ store: GraphStore,
413
+ label: str,
414
+ ids: Sequence[int],
415
+ *,
416
+ enumerate_all: bool,
417
+ limit: int | None,
418
+ ) -> ImpactGroup:
419
+ """Summarize an affected set, counting in SQL and materializing only on request.
420
+
421
+ The default is a summary because it has to be: in the recorded 250k benchmark graph
422
+ one shared root's impact is 255,000 artifacts, and listing those helps nobody.
423
+ """
424
+ ids = list(ids)
425
+ counts = tuple(
426
+ RecipeCount(
427
+ recipe=str(row["name"]),
428
+ version=int(row["version"]),
429
+ artifacts=int(row["artifacts"]),
430
+ stale=int(row["invalid"]),
431
+ )
432
+ for row in store.counts_by_recipe(ids)
433
+ )
434
+ refs: tuple[ArtifactRef, ...] = ()
435
+ truncated = False
436
+ if enumerate_all and ids:
437
+ wanted = ids if limit is None else ids[:limit]
438
+ refs = tuple(store.refs_by_ids(wanted))
439
+ truncated = len(refs) < len(ids)
440
+ return ImpactGroup(
441
+ label=label,
442
+ total=len(ids),
443
+ by_recipe=counts,
444
+ refs=refs,
445
+ truncated=truncated,
446
+ ids=tuple(ids),
447
+ )
448
+
449
+
450
+ def impact_of_change(
451
+ store: GraphStore,
452
+ artifact_id: int,
453
+ *,
454
+ depth: int | None = None,
455
+ enumerate_all: bool = False,
456
+ limit: int | None = None,
457
+ ) -> ImpactReport:
458
+ """What would go stale if this artifact's bytes changed.
459
+
460
+ This is the cascade the store already runs inside a write, asked as a question. The
461
+ walk is the same one; only artifacts currently valid would actually change state,
462
+ which the summary reports separately, because an already-stale dependent is not
463
+ additional damage.
464
+ """
465
+ ref = store.ref_by_id(artifact_id)
466
+ if ref is None:
467
+ raise MatviewError(f"no artifact with id {artifact_id}")
468
+ affected = store.dependents_of([artifact_id], depth=depth)
469
+ group = _group(
470
+ store,
471
+ "would be marked stale",
472
+ affected,
473
+ enumerate_all=enumerate_all,
474
+ limit=limit,
475
+ )
476
+ would_change = group.total - sum(c.stale for c in group.by_recipe)
477
+ notes = (
478
+ f"If these bytes changed, the cascade would reach {group.total} artifact(s).",
479
+ f"{would_change} of them are valid today and would be marked stale; the rest already are.",
480
+ "Nothing is deleted by an invalidation. The bytes stay, the valid flag clears, "
481
+ "and a later read revalidates without recomputing if the fingerprint still "
482
+ "matches.",
483
+ )
484
+ return ImpactReport(
485
+ question="changed", subject=f"if {_label(ref)} changed", notes=notes, groups=(group,)
486
+ )
487
+
488
+
489
+ def impact_of_bump(
490
+ store: GraphStore,
491
+ recipe: str,
492
+ *,
493
+ enumerate_all: bool = False,
494
+ limit: int | None = None,
495
+ ) -> ImpactReport:
496
+ """What happens if this recipe's version is bumped.
497
+
498
+ The honest answer is counter-intuitive and is most of this function's value:
499
+ **nothing goes stale.** Under rule 8 a dependency's recipe version is not part of a
500
+ dependent's fingerprint, so a bump on its own invalidates nothing anywhere.
501
+
502
+ What actually happens is that the bump creates a new `recipe` row. The existing
503
+ artifacts are still in the database, intact, but nothing resolves to them any more.
504
+ On the next read each dependent re-resolves the dependency at the new version, finds
505
+ it absent, recomputes it — and cascades only if the recomputed bytes differ from
506
+ what the edge recorded. If the new version produces identical bytes, the whole graph
507
+ above it stays valid and nothing is recomputed. That is early cutoff, and it is what
508
+ rule 8 is for.
509
+ """
510
+ versions = sorted(
511
+ {int(row["version"]) for row in store.recipe_rows() if str(row["name"]) == recipe}
512
+ )
513
+ if not versions:
514
+ raise MatviewError(f"no recipe named {recipe!r} is stored in this database")
515
+ current = versions[-1]
516
+
517
+ unreachable = store.ids_of_recipe(recipe, current)
518
+ superseded = [i for v in versions[:-1] for i in store.ids_of_recipe(recipe, v)]
519
+ re_resolve = [i for i in store.dependents_of(unreachable, depth=1) if i not in set(unreachable)]
520
+
521
+ notes = [
522
+ f"Bumping {recipe} past v{current} marks NOTHING stale.",
523
+ "",
524
+ "A dependency's recipe version is not part of a dependent's fingerprint (rule 8 "
525
+ "of the spec), so the bump alone invalidates nothing. What changes is "
526
+ "reachability: the bump writes a new recipe row, and the artifacts below stay in "
527
+ "the database, intact, with nothing resolving to them.",
528
+ "",
529
+ f"On the next read each of the dependents below re-resolves {recipe} at the new "
530
+ "version, finds it absent, and recomputes it. Only then, and only if the "
531
+ "recomputed bytes differ from the digest the edge recorded, does the cascade "
532
+ "run. If the new version produces identical bytes, nothing above it is "
533
+ "recomputed at all — that is early cutoff, and it is what rule 8 buys.",
534
+ "",
535
+ "To rebuild regardless of the bytes, use force_deps.",
536
+ ]
537
+ if superseded:
538
+ notes.append(
539
+ f"cache.gc() is what removes the {len(superseded)} artifact(s) earlier bumps "
540
+ f"have already left behind, once nothing depends on them."
541
+ )
542
+ groups = (
543
+ _group(
544
+ store,
545
+ f"become unreachable (currently v{current})",
546
+ unreachable,
547
+ enumerate_all=enumerate_all,
548
+ limit=limit,
549
+ ),
550
+ _group(
551
+ store,
552
+ "re-resolve on their next read (direct dependents)",
553
+ re_resolve,
554
+ enumerate_all=enumerate_all,
555
+ limit=limit,
556
+ ),
557
+ _group(
558
+ store,
559
+ "already unreachable from earlier bumps",
560
+ superseded,
561
+ enumerate_all=enumerate_all,
562
+ limit=limit,
563
+ ),
564
+ )
565
+ return ImpactReport(
566
+ question="bump",
567
+ subject=f"bump {recipe} (stored at v{current})",
568
+ notes=tuple(notes),
569
+ groups=groups,
570
+ )
571
+
572
+
573
+ def impact_of_drop(
574
+ store: GraphStore,
575
+ recipe: str,
576
+ *,
577
+ version: int | None = None,
578
+ enumerate_all: bool = False,
579
+ limit: int | None = None,
580
+ ) -> ImpactReport:
581
+ """What `cache.drop()` would delete, and what it would mark stale.
582
+
583
+ `drop` is deliberately not a command-line operation; being able to see what it would
584
+ do is the useful half. Per D19 a drop deletes the recipe's artifacts outright — the
585
+ one place in the library where bytes go away — and marks every transitive dependent
586
+ stale, since their inputs no longer exist to revalidate against.
587
+ """
588
+ doomed = store.ids_of_recipe(recipe, version)
589
+ if not doomed:
590
+ where = recipe if version is None else f"{recipe} v{version}"
591
+ raise MatviewError(f"no stored artifacts of {where}")
592
+ invalidated = [i for i in store.dependents_of(doomed) if i not in set(doomed)]
593
+
594
+ notes = (
595
+ f"cache.drop({recipe!r}"
596
+ + ("" if version is None else f", version={version}")
597
+ + f") would delete {len(doomed)} artifact(s) and mark {len(invalidated)} stale.",
598
+ "",
599
+ "Deleting is what makes drop different from everything else here: invalidation "
600
+ "keeps the bytes, and drop does not. The dependents keep theirs, but they cannot "
601
+ "be revalidated without recomputing, because the inputs their fingerprints refer "
602
+ "to are gone.",
603
+ "",
604
+ "Nothing has been modified by this command.",
605
+ )
606
+ groups = (
607
+ _group(store, "deleted", doomed, enumerate_all=enumerate_all, limit=limit),
608
+ _group(store, "marked stale", invalidated, enumerate_all=enumerate_all, limit=limit),
609
+ )
610
+ where = recipe if version is None else f"{recipe} v{version}"
611
+ return ImpactReport(question="drop", subject=f"drop {where}", notes=notes, groups=groups)
612
+
613
+
614
+ def impact_graph(store: GraphStore, report: ImpactReport, root: int | None = None) -> ArtifactGraph:
615
+ """Everything an impact report reaches, as one renderable graph.
616
+
617
+ One graph over the union of the report's groups rather than one per group. A bump's
618
+ unreachable artifacts and the dependents that will re-resolve them are joined by real
619
+ edges, so drawing them apart would hide the only thing a drawing is good for — and
620
+ two `graph LR` blocks are not a Mermaid document anyway. The per-group counts stay in
621
+ the tree and JSON renderings, which is where a count belongs.
622
+ """
623
+ roots = () if root is None else (root,)
624
+ seen: dict[int, None] = {}
625
+ for group in report.groups:
626
+ seen.update(dict.fromkeys(group.ids))
627
+ seen.update(dict.fromkeys(roots))
628
+ graph = _assemble(store, list(seen), roots=roots, direction="dependents")
629
+ return ArtifactGraph(
630
+ nodes=graph.nodes,
631
+ edges=graph.edges,
632
+ roots=graph.roots,
633
+ direction=graph.direction,
634
+ truncated=any(group.truncated for group in report.groups),
635
+ )
636
+
637
+
638
+ # --- cost -------------------------------------------------------------------
639
+
640
+
641
+ @dataclass(frozen=True, slots=True)
642
+ class RecipeCost:
643
+ """One recipe version's share of what this database cost to build."""
644
+
645
+ recipe: str
646
+ version: int
647
+ codec: str
648
+ artifacts: int
649
+ values: int
650
+ compute_ms: int
651
+ mean_compute_ms: float | None
652
+ n_bytes: int
653
+
654
+
655
+ @dataclass(frozen=True, slots=True)
656
+ class PathStep:
657
+ """One node on a critical path, with the running total up to and including it."""
658
+
659
+ ref: ArtifactRef
660
+ compute_ms: int
661
+ cumulative_ms: int
662
+
663
+
664
+ @dataclass(frozen=True, slots=True)
665
+ class CostReport:
666
+ """What one artifact embodies, and optionally where that cost is concentrated."""
667
+
668
+ root: ArtifactRef
669
+ #: Artifact *rows* in the closure, roots included.
670
+ artifacts: int
671
+ #: Distinct `(recipe, version, key_hash)` values, which is the unit cost is summed in.
672
+ values: int
673
+ compute_ms: int
674
+ own_compute_ms: int | None
675
+ #: Values in the closure with no recorded `compute_ms`, so the total understates.
676
+ unmeasured_values: int
677
+ n_bytes: int
678
+ #: The `depth` the walk was bounded at, or None for the whole closure. Reported
679
+ #: rather than a `truncated` flag, because whether a bound actually cut anything off
680
+ #: cannot be known without doing the unbounded walk this bound exists to avoid.
681
+ depth: int | None = None
682
+ path: tuple[PathStep, ...] = ()
683
+
684
+ def to_json(self) -> dict[str, Any]:
685
+ return {
686
+ "artifact": ref_to_dict(self.root),
687
+ "artifacts": self.artifacts,
688
+ "values": self.values,
689
+ "compute_ms": self.compute_ms,
690
+ "own_compute_ms": self.own_compute_ms,
691
+ "unmeasured_values": self.unmeasured_values,
692
+ "n_bytes": self.n_bytes,
693
+ "depth": self.depth,
694
+ "critical_path": [
695
+ {
696
+ "artifact": ref_to_dict(step.ref),
697
+ "compute_ms": step.compute_ms,
698
+ "cumulative_ms": step.cumulative_ms,
699
+ }
700
+ for step in self.path
701
+ ],
702
+ }
703
+
704
+ def describe(self) -> str:
705
+ lines = [
706
+ _label(self.root),
707
+ "",
708
+ f" embodied compute {self.compute_ms} ms",
709
+ f" of which its own {self.own_compute_ms} ms",
710
+ f" closure {self.values} value(s) in {self.artifacts} artifact(s)",
711
+ f" closure bytes {self.n_bytes}",
712
+ ]
713
+ if self.unmeasured_values:
714
+ lines.append(
715
+ f" unmeasured {self.unmeasured_values} value(s) record no "
716
+ f"compute_ms, so the total is a floor"
717
+ )
718
+ lines.append(" Cost is summed per value, not per artifact: every part of a partitioned")
719
+ lines.append(" value records the cost of the one compute that produced them all.")
720
+ if self.path:
721
+ lines.append("")
722
+ lines.append(
723
+ f" critical path: {self.path[-1].cumulative_ms} ms over "
724
+ f"{len(self.path)} step(s), the artifact first and its deepest input last"
725
+ )
726
+ for step in self.path:
727
+ lines.append(
728
+ f" {step.compute_ms:>8} ms {step.cumulative_ms:>9} cumulative "
729
+ f"{_label(step.ref)}"
730
+ )
731
+ return "\n".join(lines)
732
+
733
+
734
+ def _closure(
735
+ store: GraphStore, artifact_id: int, depth: int | None
736
+ ) -> tuple[ArtifactRef, list[ArtifactRef]]:
737
+ root = store.ref_by_id(artifact_id)
738
+ if root is None:
739
+ raise MatviewError(f"no artifact with id {artifact_id}")
740
+ ids = [artifact_id, *store.inputs_of([artifact_id], depth=depth)]
741
+ return root, store.refs_by_ids(ids)
742
+
743
+
744
+ def _value_costs(refs: Iterable[ArtifactRef]) -> dict[tuple[str, int, str], int | None]:
745
+ """Collapse artifact rows to values, keeping each value's one recorded cost.
746
+
747
+ **This is the deduplication the whole cost feature turns on.** `compute_ms` is
748
+ recorded once per artifact, and a partitioned value is several artifacts that each
749
+ carry the cost of the single compute that produced all of them. Summing the column
750
+ over artifact rows multiplies such a value by its part count — confidently, silently,
751
+ and with no symptom other than a wrong number. See DECISIONS.md D51.
752
+ """
753
+ costs: dict[tuple[str, int, str], int | None] = {}
754
+ for ref in refs:
755
+ value = _value_of(ref)
756
+ if ref.compute_ms is None:
757
+ costs.setdefault(value, None)
758
+ else:
759
+ costs[value] = ref.compute_ms
760
+ return costs
761
+
762
+
763
+ def embodied_cost(
764
+ store: GraphStore,
765
+ artifact_id: int,
766
+ *,
767
+ depth: int | None = None,
768
+ with_critical_path: bool = False,
769
+ ) -> CostReport:
770
+ """What this artifact and everything it was built from cost to compute.
771
+
772
+ Summed over distinct values rather than artifact rows, so a partitioned dependency
773
+ counts once. The value count is reported alongside the total precisely so that the
774
+ deduplication is visible rather than something a reader has to take on trust.
775
+
776
+ A value whose `compute_ms` was never recorded contributes nothing, and is counted
777
+ under `unmeasured_values`: the total is a floor, not an estimate.
778
+ """
779
+ root, refs = _closure(store, artifact_id, depth)
780
+ costs = _value_costs(refs)
781
+ total = sum(ms for ms in costs.values() if ms is not None)
782
+ path = critical_path(store, artifact_id, depth=depth) if with_critical_path else ()
783
+ return CostReport(
784
+ root=root,
785
+ artifacts=len(refs),
786
+ values=len(costs),
787
+ compute_ms=total,
788
+ own_compute_ms=root.compute_ms,
789
+ unmeasured_values=sum(1 for ms in costs.values() if ms is None),
790
+ n_bytes=sum(ref.n_bytes for ref in refs),
791
+ depth=depth,
792
+ path=path,
793
+ )
794
+
795
+
796
+ def critical_path(
797
+ store: GraphStore, artifact_id: int, *, depth: int | None = None
798
+ ) -> tuple[PathStep, ...]:
799
+ """The most expensive chain through an artifact's input closure.
800
+
801
+ The chain, not just its length: the point of asking is to see which step dominates,
802
+ and a number alone does not answer that.
803
+
804
+ A topological walk in Python, which is the right shape here. Input closures are
805
+ small — around fifty nodes for an aggregate in the recorded benchmark — and this is
806
+ the input direction, not the dependent direction where one shared root reaches
807
+ 255,000 artifacts.
808
+
809
+ No deduplication is needed along a path: a single chain passes through at most one
810
+ part of any value, so each step's own `compute_ms` is that value's cost, counted
811
+ once.
812
+ """
813
+ root_ref = store.ref_by_id(artifact_id)
814
+ if root_ref is None:
815
+ raise MatviewError(f"no artifact with id {artifact_id}")
816
+ ids = [artifact_id, *store.inputs_of([artifact_id], depth=depth)]
817
+ refs = {ref.id: ref for ref in store.refs_by_ids(ids)}
818
+ edges = store.deps_of_many(list(refs))
819
+
820
+ # Iterative rather than recursive, and with an explicit `open_` set: the stack keeps
821
+ # a deep chain from exhausting the interpreter's, and refusing to descend into a node
822
+ # already being expanded terminates on a cycle a corrupted database could contain
823
+ # rather than looping. `best[node]` is the most expensive chain starting at `node`.
824
+ best: dict[int, tuple[int, tuple[int, ...]]] = {}
825
+ open_: set[int] = set()
826
+ stack: list[tuple[int, bool]] = [(artifact_id, False)]
827
+ while stack:
828
+ node, expanded = stack.pop()
829
+ if expanded:
830
+ below = [
831
+ best[e.artifact_id]
832
+ for e in edges.get(node, ())
833
+ if e.artifact_id in best and e.artifact_id != node
834
+ ]
835
+ own = refs[node].compute_ms or 0
836
+ deeper = max(below, default=(0, ()))
837
+ best[node] = (own + deeper[0], (node, *deeper[1]))
838
+ open_.discard(node)
839
+ continue
840
+ if node in best or node in open_:
841
+ continue
842
+ open_.add(node)
843
+ stack.append((node, True))
844
+ for edge in edges.get(node, ()):
845
+ if edge.artifact_id in refs and edge.artifact_id not in best:
846
+ stack.append((edge.artifact_id, False))
847
+
848
+ steps: list[PathStep] = []
849
+ running = 0
850
+ for node in best[artifact_id][1]:
851
+ own = refs[node].compute_ms or 0
852
+ running += own
853
+ steps.append(PathStep(ref=refs[node], compute_ms=own, cumulative_ms=running))
854
+ return tuple(steps)
855
+
856
+
857
+ def recipe_costs(store: GraphStore) -> tuple[RecipeCost, ...]:
858
+ """Per recipe version: artifacts, distinct values, total and mean cost, and bytes.
859
+
860
+ No graph walk at all — a `GROUP BY` over the artifact table — which makes it the
861
+ cheapest question here and, for anyone about to optimize something, usually the
862
+ first one worth asking.
863
+ """
864
+ return tuple(
865
+ RecipeCost(
866
+ recipe=str(row["name"]),
867
+ version=int(row["version"]),
868
+ codec=str(row["codec"]),
869
+ artifacts=int(row["artifacts"]),
870
+ values=int(row["n_values"]),
871
+ compute_ms=int(row["compute_ms"]),
872
+ mean_compute_ms=row["mean_compute_ms"],
873
+ n_bytes=int(row["n_bytes"]),
874
+ )
875
+ for row in store.recipe_cost_rows()
876
+ )