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/__init__.py +78 -0
- matview/cache.py +750 -0
- matview/canon.py +116 -0
- matview/cli.py +900 -0
- matview/codecs/__init__.py +159 -0
- matview/codecs/_arrow.py +104 -0
- matview/codecs/_arrow_ipc.py +89 -0
- matview/codecs/_arrow_pandas.py +76 -0
- matview/codecs/_arrow_parts.py +99 -0
- matview/codecs/_json.py +36 -0
- matview/codecs/_npy.py +53 -0
- matview/context.py +87 -0
- matview/errors.py +96 -0
- matview/fingerprint.py +99 -0
- matview/foreign.py +436 -0
- matview/graph.py +876 -0
- matview/policy.py +67 -0
- matview/py.typed +0 -0
- matview/registry.py +293 -0
- matview/store/__init__.py +6 -0
- matview/store/base.py +99 -0
- matview/store/sqlite.py +1188 -0
- matview/types.py +341 -0
- matview_cache-0.1.0.dist-info/METADATA +262 -0
- matview_cache-0.1.0.dist-info/RECORD +28 -0
- matview_cache-0.1.0.dist-info/WHEEL +4 -0
- matview_cache-0.1.0.dist-info/entry_points.txt +2 -0
- matview_cache-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
)
|