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 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,7 @@
1
+ """Command groups.
2
+
3
+ One module per group. Each stays a thin shell over the library: parse, call, render. Anything
4
+ worth testing belongs below the CLI, where it can be tested without a subprocess.
5
+ """
6
+
7
+ from __future__ import annotations
@@ -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
+ )