metrik-cli 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.
- metrik/cli/__init__.py +36 -0
- metrik/cli/commands/__init__.py +7 -0
- metrik/cli/commands/bench.py +119 -0
- metrik/cli/commands/explore.py +370 -0
- metrik/cli/commands/plugins.py +167 -0
- metrik/cli/commands/runs.py +254 -0
- metrik/cli/commands/store.py +262 -0
- metrik/cli/commands/viz.py +102 -0
- metrik/cli/config.py +93 -0
- metrik/cli/console.py +65 -0
- metrik/cli/main.py +98 -0
- metrik/cli/py.typed +0 -0
- metrik_cli-0.1.0.dist-info/METADATA +58 -0
- metrik_cli-0.1.0.dist-info/RECORD +16 -0
- metrik_cli-0.1.0.dist-info/WHEEL +4 -0
- metrik_cli-0.1.0.dist-info/entry_points.txt +2 -0
metrik/cli/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""The ``metrik`` command-line interface.
|
|
2
|
+
|
|
3
|
+
ADR 012: Typer for type-hints-to-CLI, Rich for output quality. ``plan.md`` §10 correction 1
|
|
4
|
+
moves the CLI early -- it grows with each module from Phase 2 rather than arriving at Phase 9,
|
|
5
|
+
so there is a usable surface (and therefore real feedback) while the design can still change
|
|
6
|
+
cheaply.
|
|
7
|
+
|
|
8
|
+
The CLI is designed dual-mode (``plan.md`` §3.1): direct and in-process against the store, or
|
|
9
|
+
``--remote`` against a daemon. Only the direct mode exists today; the daemon arrives in
|
|
10
|
+
Phase 10. The split is worth keeping visible now because it is what lets the CLI ship without
|
|
11
|
+
the API existing.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _distribution_version(name: str) -> str:
|
|
18
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
return version(name)
|
|
22
|
+
except PackageNotFoundError: # pragma: no cover - only in an uninstalled source tree
|
|
23
|
+
return "0+unknown"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
#: Read from the installed distribution rather than hardcoded.
|
|
27
|
+
#:
|
|
28
|
+
#: Both existed for a while and drifted: a 0.1.0 build reported 0.0.1 from `metrik --version`,
|
|
29
|
+
#: found by installing the actual wheel rather than by any test. A version string is exactly
|
|
30
|
+
#: the kind of duplicate that stays wrong quietly, because nothing imports it in anger.
|
|
31
|
+
#:
|
|
32
|
+
#: The fallback covers a source checkout with nothing installed, where there is no metadata to
|
|
33
|
+
#: read and no released version to name.
|
|
34
|
+
__version__ = _distribution_version("metrik-cli")
|
|
35
|
+
|
|
36
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""``metrik bench`` -- run a benchmark, or explain why it will not.
|
|
2
|
+
|
|
3
|
+
ADR 019. HumanEval and MBPP ask a model to write code and then execute it, so this command
|
|
4
|
+
establishes whether the machine can contain that **before** anything is generated. A refusal
|
|
5
|
+
after an hour of decoding is a refusal that arrives too late to be a safety feature.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Annotated
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
|
|
16
|
+
from metrik.bench import TASKS, detect_runtimes, require_runtime, resolve_task
|
|
17
|
+
from metrik.cli.console import out
|
|
18
|
+
from metrik.core.errors import UnsupportedError
|
|
19
|
+
|
|
20
|
+
__all__ = ["register"]
|
|
21
|
+
|
|
22
|
+
_STYLE = {"ready": "measured", "not_running": "warn", "not_installed": "muted"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def register(app: typer.Typer) -> None:
|
|
26
|
+
@app.command("bench")
|
|
27
|
+
def bench(
|
|
28
|
+
model: Annotated[
|
|
29
|
+
str | None, typer.Argument(help="Model to evaluate. Omit with --check.")
|
|
30
|
+
] = None,
|
|
31
|
+
task: Annotated[str | None, typer.Option("--task", help="Which benchmark to run.")] = None,
|
|
32
|
+
check: Annotated[
|
|
33
|
+
bool,
|
|
34
|
+
typer.Option("--check", help="Report what isolation is available and stop."),
|
|
35
|
+
] = False,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Evaluate a model under container isolation -- or refuse, and say why.
|
|
38
|
+
|
|
39
|
+
There is no unsandboxed mode. A task that executes model-generated code runs in a
|
|
40
|
+
container or does not run (ADR 019), and no flag changes that.
|
|
41
|
+
"""
|
|
42
|
+
if check:
|
|
43
|
+
_report_capability()
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
if model is None or task is None:
|
|
47
|
+
raise UnsupportedError(
|
|
48
|
+
"a model and a --task are required",
|
|
49
|
+
remediation="Run `metrik bench --check` to see the tasks and what can run here.",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
spec = resolve_task(task)
|
|
53
|
+
if spec.executes_code:
|
|
54
|
+
# Before generation, deliberately. This raises when nothing can contain the run,
|
|
55
|
+
# and there is no branch that continues past it.
|
|
56
|
+
runtime = require_runtime(task_id=spec.id)
|
|
57
|
+
out.print(
|
|
58
|
+
f"[measured]{runtime.name} {runtime.version} ready[/measured] "
|
|
59
|
+
f"[muted]- {spec.id} will run under container isolation[/muted]"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
raise UnsupportedError(
|
|
63
|
+
f"the {spec.id} runner is not implemented yet",
|
|
64
|
+
remediation=(
|
|
65
|
+
"Phase 5 ships the sandbox gate before the runner it gates. `metrik bench "
|
|
66
|
+
"--check` reports what isolation this machine has."
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _report_capability() -> None:
|
|
72
|
+
"""What was probed, what each said, and what that permits."""
|
|
73
|
+
runtimes = detect_runtimes()
|
|
74
|
+
|
|
75
|
+
table = Table(box=None, pad_edge=False)
|
|
76
|
+
table.add_column("runtime", no_wrap=True)
|
|
77
|
+
table.add_column("state", no_wrap=True)
|
|
78
|
+
table.add_column("detail", overflow="fold")
|
|
79
|
+
for runtime in runtimes:
|
|
80
|
+
table.add_row(
|
|
81
|
+
Text(runtime.name),
|
|
82
|
+
Text(runtime.availability.value, style=_STYLE.get(runtime.availability.value, "")),
|
|
83
|
+
Text(runtime.version or runtime.detail),
|
|
84
|
+
)
|
|
85
|
+
out.print(table)
|
|
86
|
+
out.print()
|
|
87
|
+
|
|
88
|
+
usable = next((r for r in runtimes if r.ready), None)
|
|
89
|
+
tasks = Table(box=None, pad_edge=False)
|
|
90
|
+
tasks.add_column("task", style="type", no_wrap=True)
|
|
91
|
+
tasks.add_column("executes code", no_wrap=True)
|
|
92
|
+
tasks.add_column("can run here", no_wrap=True)
|
|
93
|
+
tasks.add_column("what it is", overflow="fold")
|
|
94
|
+
for spec in sorted(TASKS.values(), key=lambda s: s.id):
|
|
95
|
+
# The distinction the whole command exists to make visible: a task that executes code
|
|
96
|
+
# needs a runtime, and one that does not never did. Requiring a container for MMLU
|
|
97
|
+
# would push people to disable the check they need for HumanEval.
|
|
98
|
+
runnable = usable is not None or not spec.executes_code
|
|
99
|
+
tasks.add_row(
|
|
100
|
+
Text(spec.id),
|
|
101
|
+
Text("yes" if spec.executes_code else "no", style="warn" if spec.executes_code else ""),
|
|
102
|
+
Text("yes" if runnable else "no", style="measured" if runnable else "err"),
|
|
103
|
+
Text(spec.summary),
|
|
104
|
+
)
|
|
105
|
+
out.print(tasks)
|
|
106
|
+
out.print()
|
|
107
|
+
|
|
108
|
+
if usable is not None:
|
|
109
|
+
out.print(f"[measured]container isolation available via {usable.name}[/measured]")
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
out.print("[warn]no container runtime is available[/warn]")
|
|
113
|
+
for runtime in runtimes:
|
|
114
|
+
if runtime.remediation:
|
|
115
|
+
out.print(f" [muted]{runtime.remediation}[/muted]")
|
|
116
|
+
out.print(
|
|
117
|
+
"[muted]Tasks that do not execute code still run. There is no unsandboxed mode "
|
|
118
|
+
"(ADR 019).[/muted]"
|
|
119
|
+
)
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""``metrik explore`` -- what is in this model?"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated, Any
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from metrik.cli.config import resolve_store_root
|
|
15
|
+
from metrik.cli.console import out
|
|
16
|
+
from metrik.core.errors import UserError
|
|
17
|
+
from metrik.core.plugins import PluginRegistry
|
|
18
|
+
from metrik.core.runs import RunRecorder
|
|
19
|
+
from metrik.core.store import ArtifactStore
|
|
20
|
+
from metrik.sdk import API_VERSION, LocalSpine
|
|
21
|
+
|
|
22
|
+
__all__ = ["register"]
|
|
23
|
+
|
|
24
|
+
#: The loader that drives the full chain when the chosen one only supplies a graph. A name
|
|
25
|
+
#: resolved through the registry, not an import: the CLI must not depend on a plugin package.
|
|
26
|
+
_DRIVER = "explorer"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _human(n: float | None) -> str:
|
|
30
|
+
if n is None:
|
|
31
|
+
return "-"
|
|
32
|
+
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
|
|
33
|
+
if abs(n) < 1024 or unit == "TiB":
|
|
34
|
+
return f"{n:.1f} {unit}" if unit != "B" else f"{int(n)} B"
|
|
35
|
+
n /= 1024
|
|
36
|
+
return f"{n:.1f} TiB" # pragma: no cover - loop always returns
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _count(n: int | None) -> str:
|
|
40
|
+
if n is None:
|
|
41
|
+
return "-"
|
|
42
|
+
for threshold, suffix in ((1e9, "B"), (1e6, "M"), (1e3, "K")):
|
|
43
|
+
if abs(n) >= threshold:
|
|
44
|
+
return f"{n / threshold:.2f}{suffix}"
|
|
45
|
+
return str(n)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def register(app: typer.Typer) -> None:
|
|
49
|
+
@app.command("explore")
|
|
50
|
+
def explore(
|
|
51
|
+
model: Annotated[
|
|
52
|
+
str, typer.Argument(help="Path to a model directory or .safetensors file.")
|
|
53
|
+
],
|
|
54
|
+
store: Annotated[
|
|
55
|
+
Path | None, typer.Option("--store", help="Where to write artifacts.")
|
|
56
|
+
] = None,
|
|
57
|
+
batch_size: Annotated[int, typer.Option(help="Memory estimate: batch size.")] = 1,
|
|
58
|
+
sequence_length: Annotated[
|
|
59
|
+
int, typer.Option(help="Memory estimate: prompt length in tokens.")
|
|
60
|
+
] = 2048,
|
|
61
|
+
generated_tokens: Annotated[
|
|
62
|
+
int, typer.Option(help="Memory estimate: tokens to generate.")
|
|
63
|
+
] = 256,
|
|
64
|
+
loader: Annotated[
|
|
65
|
+
str, typer.Option("--loader", help="Which model loader plugin to use.")
|
|
66
|
+
] = "explorer",
|
|
67
|
+
allow_plugin: Annotated[
|
|
68
|
+
list[str] | None,
|
|
69
|
+
typer.Option("--allow-plugin", help="Opt into a third-party plugin by name."),
|
|
70
|
+
] = None,
|
|
71
|
+
as_json: Annotated[
|
|
72
|
+
bool, typer.Option("--json", help="Emit JSON instead of a report.")
|
|
73
|
+
] = False,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Analyse a model's architecture, parameters, and memory -- with no forward pass.
|
|
76
|
+
|
|
77
|
+
Reads safetensors headers and config.json only. No torch, no GPU, no weights loaded,
|
|
78
|
+
so this is cheap enough to run on anything and cannot accidentally be a benchmark.
|
|
79
|
+
"""
|
|
80
|
+
location = resolve_store_root(store)
|
|
81
|
+
handle = ArtifactStore(location.path)
|
|
82
|
+
|
|
83
|
+
# Resolved through the registry, never imported by name. This module does not mention
|
|
84
|
+
# `metrik.explorer` anywhere, which is plan.md Verification #2: the built-in Explorer
|
|
85
|
+
# loads the way any third-party plugin would. If that stopped working we would find
|
|
86
|
+
# out here, in Phase 2, rather than in Phase 9 when the SDK is supposed to ship.
|
|
87
|
+
registry = PluginRegistry.discover(api_version=API_VERSION, allow=allow_plugin or ())
|
|
88
|
+
chosen = registry.get(loader, kind="model_loader").load()()
|
|
89
|
+
|
|
90
|
+
# Two roles, and most loaders fill only one. A *driver* runs the whole
|
|
91
|
+
# fingerprint/graph/summary/memory chain; a *graph provider* supplies just the middle
|
|
92
|
+
# step, which is all plan.md sec. 6.1 asks a ModelLoader for (URI -> ModelGraph).
|
|
93
|
+
#
|
|
94
|
+
# When the chosen loader is only a provider, the chain is driven by whichever loader
|
|
95
|
+
# can drive one -- resolved through the registry, never imported by name, so this
|
|
96
|
+
# module still mentions no plugin package anywhere (plan.md Verification #2).
|
|
97
|
+
driver, provider = chosen, None
|
|
98
|
+
if not hasattr(chosen, "run"):
|
|
99
|
+
if not hasattr(chosen, "provide_graph"):
|
|
100
|
+
raise UserError(
|
|
101
|
+
f"the {loader!r} loader can neither run an explore chain nor supply a graph",
|
|
102
|
+
remediation=f"Run `metrik plugins show {loader}` to see what it does.",
|
|
103
|
+
)
|
|
104
|
+
driver = registry.get(_DRIVER, kind="model_loader").load()()
|
|
105
|
+
provider = chosen.provide_graph
|
|
106
|
+
|
|
107
|
+
params = _params_for(
|
|
108
|
+
driver,
|
|
109
|
+
loader,
|
|
110
|
+
{
|
|
111
|
+
"batch_size": (batch_size, 1),
|
|
112
|
+
"sequence_length": (sequence_length, 2048),
|
|
113
|
+
"generated_tokens": (generated_tokens, 256),
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# The recorder commits a Run whether this succeeds or raises. A failed explore that
|
|
118
|
+
# left no trace would be the one case with nothing to go back to -- a success can be
|
|
119
|
+
# re-derived from its artifacts, a failure cannot be re-derived from anything.
|
|
120
|
+
with RunRecorder(
|
|
121
|
+
handle,
|
|
122
|
+
job={"command": "explore", "model": model, "loader": loader, **params.model_dump()},
|
|
123
|
+
) as run:
|
|
124
|
+
result = driver.run(lambda: LocalSpine(handle), model, params, graph_provider=provider)
|
|
125
|
+
run.add_outputs(result.all)
|
|
126
|
+
|
|
127
|
+
if as_json:
|
|
128
|
+
print(
|
|
129
|
+
json.dumps(
|
|
130
|
+
{
|
|
131
|
+
"run": str(run.committed.artifact_id),
|
|
132
|
+
"fingerprint": result.fingerprint.payload,
|
|
133
|
+
"summary": result.summary.payload,
|
|
134
|
+
"memory": result.memory.payload,
|
|
135
|
+
"artifact_ids": {
|
|
136
|
+
"fingerprint": str(result.fingerprint.artifact_id),
|
|
137
|
+
"graph": str(result.graph.artifact_id),
|
|
138
|
+
"summary": str(result.summary.artifact_id),
|
|
139
|
+
"memory": str(result.memory.artifact_id),
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
indent=2,
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
_render(result)
|
|
148
|
+
out.print(f"[muted]metrik runs show {run.committed.artifact_id.short(8)}[/muted]")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _params_for(implementation: Any, loader: str, requested: dict[str, tuple[Any, Any]]) -> Any:
|
|
152
|
+
"""Build a loader's parameter object from the flags it actually declares.
|
|
153
|
+
|
|
154
|
+
The CLI cannot assume every model_loader takes the Explorer's options. It did, and with a
|
|
155
|
+
single loader installed that was invisible; the second one turned `--loader torch` into a
|
|
156
|
+
raw pydantic ValidationError about extra fields. `Params` is `extra="forbid"` precisely so
|
|
157
|
+
this surfaces rather than being silently swallowed (sdk-contracts.md sec. 4).
|
|
158
|
+
|
|
159
|
+
Unsupported flags are dropped only when they are at their default. A flag the user
|
|
160
|
+
actually typed is never quietly ignored -- reporting "batch size 4" while the loader
|
|
161
|
+
computed nothing of the sort is exactly the sort of confident wrong number the artifact
|
|
162
|
+
rules exist to prevent.
|
|
163
|
+
"""
|
|
164
|
+
model = implementation.params_model
|
|
165
|
+
accepted = set(getattr(model, "model_fields", {}))
|
|
166
|
+
kwargs = {name: value for name, (value, _) in requested.items() if name in accepted}
|
|
167
|
+
|
|
168
|
+
ignored = sorted(
|
|
169
|
+
name
|
|
170
|
+
for name, (value, default) in requested.items()
|
|
171
|
+
if name not in accepted and value != default
|
|
172
|
+
)
|
|
173
|
+
if ignored:
|
|
174
|
+
flags = ", ".join(f"--{name.replace('_', '-')}" for name in ignored)
|
|
175
|
+
raise UserError(
|
|
176
|
+
f"the {loader!r} loader does not accept {flags}",
|
|
177
|
+
remediation=(
|
|
178
|
+
f"Drop the option, or use a loader that supports it -- `metrik plugins show "
|
|
179
|
+
f"{loader}` lists what this one does."
|
|
180
|
+
),
|
|
181
|
+
)
|
|
182
|
+
return model(**kwargs)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _render(result: Any) -> None:
|
|
186
|
+
fingerprint = result.fingerprint.payload
|
|
187
|
+
summary = result.summary.payload
|
|
188
|
+
memory = result.memory.payload
|
|
189
|
+
headline = summary["headline"]
|
|
190
|
+
graph = result.graph.payload
|
|
191
|
+
|
|
192
|
+
facts = Table.grid(padding=(0, 2))
|
|
193
|
+
facts.add_column(style="muted")
|
|
194
|
+
facts.add_column()
|
|
195
|
+
facts.add_row("model", Text(str(fingerprint["uri"])))
|
|
196
|
+
facts.add_row("architecture", Text(str(fingerprint["architecture"] or "unknown")))
|
|
197
|
+
facts.add_row(
|
|
198
|
+
"parameters",
|
|
199
|
+
Text(f"{headline['param_count_total']:,} ({_count(headline['param_count_total'])})"),
|
|
200
|
+
)
|
|
201
|
+
facts.add_row("layers", Text(str(headline["n_layers"])))
|
|
202
|
+
facts.add_row("on disk", Text(_human(headline["size_on_disk_bytes"])))
|
|
203
|
+
facts.add_row("dtypes", Text(", ".join(fingerprint["dtype_on_disk"])))
|
|
204
|
+
facts.add_row("weight digest", Text(str(fingerprint["weight_digest"])[:22], style="hash"))
|
|
205
|
+
out.print(Panel(facts, title="model", title_align="left", border_style="muted"))
|
|
206
|
+
|
|
207
|
+
_render_topology(graph)
|
|
208
|
+
|
|
209
|
+
roles = Table(box=None, pad_edge=False)
|
|
210
|
+
roles.add_column("role", style="type", no_wrap=True)
|
|
211
|
+
roles.add_column("tensors", justify="right", no_wrap=True)
|
|
212
|
+
roles.add_column("parameters", justify="right", no_wrap=True)
|
|
213
|
+
roles.add_column("share", justify="right", no_wrap=True)
|
|
214
|
+
roles.add_column("bytes", justify="right", no_wrap=True)
|
|
215
|
+
for row in summary["by_role"]:
|
|
216
|
+
# A role whose parameters are all tied would otherwise render as "0" across the board,
|
|
217
|
+
# which reads as "this part of the model is empty". It is not -- it shares storage
|
|
218
|
+
# with another tensor, and saying so is the difference between an explanation and an
|
|
219
|
+
# alarming zero.
|
|
220
|
+
tied = row.get("param_count_tied")
|
|
221
|
+
if tied and not row["param_count"]:
|
|
222
|
+
roles.add_row(
|
|
223
|
+
Text(row["role"]),
|
|
224
|
+
Text(str(row["n_tensors"])),
|
|
225
|
+
Text(f"{_count(tied)} tied", style="muted"),
|
|
226
|
+
Text("-", style="muted"),
|
|
227
|
+
Text("shared", style="muted"),
|
|
228
|
+
)
|
|
229
|
+
continue
|
|
230
|
+
roles.add_row(
|
|
231
|
+
Text(row["role"]),
|
|
232
|
+
Text(str(row["n_tensors"])),
|
|
233
|
+
Text(_count(row["param_count"])),
|
|
234
|
+
Text(f"{row['param_fraction']:.1%}"),
|
|
235
|
+
Text(_human(row["bytes"])),
|
|
236
|
+
)
|
|
237
|
+
out.print(Panel(roles, title="parameters by role", title_align="left", border_style="muted"))
|
|
238
|
+
|
|
239
|
+
# Analytic, and labelled as such in the panel title. artifacts.md sec. 5 requires estimated
|
|
240
|
+
# and measured to be visually distinguishable; the theme reserves a colour for each.
|
|
241
|
+
mem = Table.grid(padding=(0, 2))
|
|
242
|
+
mem.add_column(style="muted")
|
|
243
|
+
mem.add_column(justify="right")
|
|
244
|
+
assumptions = memory["assumptions"]
|
|
245
|
+
mem.add_row("weights", Text(_human(memory["weights"]["bytes_total"]), style="analytic"))
|
|
246
|
+
mem.add_row("KV cache", Text(_human(memory["kv_cache"]["bytes_total"]), style="analytic"))
|
|
247
|
+
mem.add_row("", Text(""))
|
|
248
|
+
mem.add_row("min (weights only)", Text(_human(memory["total_bytes"]["min"]), style="analytic"))
|
|
249
|
+
mem.add_row("typical", Text(_human(memory["total_bytes"]["typical"]), style="analytic"))
|
|
250
|
+
mem.add_row("peak (with slack)", Text(_human(memory["total_bytes"]["peak"]), style="analytic"))
|
|
251
|
+
mem.add_row("", Text(""))
|
|
252
|
+
mem.add_row(
|
|
253
|
+
"at",
|
|
254
|
+
Text(
|
|
255
|
+
f"batch {assumptions['batch_size']}, "
|
|
256
|
+
f"{assumptions['sequence_length']}+{assumptions['generated_tokens']} tokens, "
|
|
257
|
+
f"{assumptions['kv_dtype']} KV"
|
|
258
|
+
),
|
|
259
|
+
)
|
|
260
|
+
# Title via Text, not a markup string: "[analytic - not measured]" would be parsed as a
|
|
261
|
+
# style tag -- and `analytic` IS a style in this theme, so it vanished silently rather
|
|
262
|
+
# than erroring. Same trap as the lineage role labels.
|
|
263
|
+
out.print(
|
|
264
|
+
Panel(
|
|
265
|
+
mem,
|
|
266
|
+
title=Text("memory estimate (analytic - not measured)"),
|
|
267
|
+
title_align="left",
|
|
268
|
+
border_style="analytic",
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
if summary["notable"]:
|
|
273
|
+
notes = Table(box=None, pad_edge=False, show_header=False)
|
|
274
|
+
notes.add_column("", no_wrap=True)
|
|
275
|
+
notes.add_column("")
|
|
276
|
+
for note in summary["notable"]:
|
|
277
|
+
marker = {"warn": "[warn]![/warn]", "note": "[muted]*[/muted]"}.get(
|
|
278
|
+
note["severity"], "[muted]i[/muted]"
|
|
279
|
+
)
|
|
280
|
+
notes.add_row(marker, Text(note["message"]))
|
|
281
|
+
out.print(Panel(notes, title="notable", title_align="left", border_style="muted"))
|
|
282
|
+
|
|
283
|
+
# Omissions last but never absent. Every gap in this report is named, with a reason.
|
|
284
|
+
gaps = [
|
|
285
|
+
(artifact.envelope.artifact_type, omission)
|
|
286
|
+
for artifact in result.all
|
|
287
|
+
if artifact.envelope.partial is not None
|
|
288
|
+
for omission in artifact.envelope.partial.omissions
|
|
289
|
+
]
|
|
290
|
+
if gaps:
|
|
291
|
+
table = Table(box=None, pad_edge=False)
|
|
292
|
+
table.add_column("field", style="warn", no_wrap=True)
|
|
293
|
+
table.add_column("why")
|
|
294
|
+
for artifact_type, omission in gaps:
|
|
295
|
+
table.add_row(
|
|
296
|
+
Text(f"{artifact_type.removeprefix('metrik.')}.{omission.field_path}"),
|
|
297
|
+
Text(omission.detail),
|
|
298
|
+
)
|
|
299
|
+
out.print(Panel(table, title="not determined", title_align="left", border_style="warn"))
|
|
300
|
+
|
|
301
|
+
out.print(
|
|
302
|
+
f"[muted]artifacts written to {result.summary.artifact_id.short()} and 3 others[/muted]"
|
|
303
|
+
)
|
|
304
|
+
out.print(f"[muted]metrik store lineage {result.memory.artifact_id.short(8)}[/muted]")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _render_topology(graph: dict[str, Any]) -> None:
|
|
308
|
+
"""The architecture's shape, labelled with where it came from.
|
|
309
|
+
|
|
310
|
+
The panel title says "declared" because that is what it is: config.json is the model
|
|
311
|
+
author's statement about their own architecture, not something Metrik observed. The same
|
|
312
|
+
distinction the memory panel draws between analytic and measured (artifacts.md sec. 5) --
|
|
313
|
+
a reader who cannot tell a declaration from an observation will eventually trust the wrong
|
|
314
|
+
one.
|
|
315
|
+
"""
|
|
316
|
+
topology = graph.get("topology") or {}
|
|
317
|
+
if not topology:
|
|
318
|
+
return
|
|
319
|
+
|
|
320
|
+
rows: list[tuple[str, str]] = []
|
|
321
|
+
if topology.get("n_layers") is not None:
|
|
322
|
+
rows.append(("layers", str(topology["n_layers"])))
|
|
323
|
+
|
|
324
|
+
heads, kv = topology.get("n_heads"), topology.get("n_kv_heads")
|
|
325
|
+
if heads is not None:
|
|
326
|
+
attention = str(topology.get("attention_kind") or "")
|
|
327
|
+
detail = f"{heads}"
|
|
328
|
+
if kv is not None and kv != heads:
|
|
329
|
+
# The ratio is the number that matters: it is the factor by which the KV cache
|
|
330
|
+
# shrinks, and it is why a GQA model fits where an MHA one does not.
|
|
331
|
+
detail += f" -> {kv} kv ({attention}, {heads // kv}x smaller KV)"
|
|
332
|
+
elif attention:
|
|
333
|
+
detail += f" ({attention})"
|
|
334
|
+
rows.append(("heads", detail))
|
|
335
|
+
|
|
336
|
+
if topology.get("head_dim") is not None:
|
|
337
|
+
rows.append(("head dim", str(topology["head_dim"])))
|
|
338
|
+
hidden, inter = topology.get("hidden_size"), topology.get("intermediate_size")
|
|
339
|
+
if hidden is not None:
|
|
340
|
+
rows.append(("hidden / inter", f"{hidden} / {inter}" if inter else str(hidden)))
|
|
341
|
+
if topology.get("vocab_size") is not None:
|
|
342
|
+
rows.append(("vocab", f"{topology['vocab_size']:,}"))
|
|
343
|
+
|
|
344
|
+
kinds = [topology.get("norm_kind"), topology.get("ffn_kind")]
|
|
345
|
+
position = topology.get("position_kind")
|
|
346
|
+
if position and (rope := topology.get("rope")):
|
|
347
|
+
position = f"{position}(theta={rope['theta']:g})"
|
|
348
|
+
kinds.append(position)
|
|
349
|
+
if any(kinds):
|
|
350
|
+
rows.append(("norm / ffn / pos", " . ".join(str(k) for k in kinds if k)))
|
|
351
|
+
|
|
352
|
+
if topology.get("tied_embeddings") is not None:
|
|
353
|
+
rows.append(("tied embeddings", "yes" if topology["tied_embeddings"] else "no"))
|
|
354
|
+
|
|
355
|
+
if not rows:
|
|
356
|
+
return
|
|
357
|
+
|
|
358
|
+
grid = Table.grid(padding=(0, 2))
|
|
359
|
+
grid.add_column(style="muted")
|
|
360
|
+
grid.add_column()
|
|
361
|
+
for label, value in rows:
|
|
362
|
+
grid.add_row(label, Text(value))
|
|
363
|
+
out.print(
|
|
364
|
+
Panel(
|
|
365
|
+
grid,
|
|
366
|
+
title=Text("topology (declared by config.json - not observed)"),
|
|
367
|
+
title_align="left",
|
|
368
|
+
border_style="muted",
|
|
369
|
+
)
|
|
370
|
+
)
|