metrik-explorer 0.1.0__tar.gz

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.
@@ -0,0 +1,89 @@
1
+ # Metrik — .gitignore
2
+ #
3
+ # NOTE: the previous version of this file contained `docs/*`, `.gitignore`, and `.claude`,
4
+ # which excluded the entire documentation tree — i.e. every Phase 0/1 deliverable — from
5
+ # version control. Replaced deliberately. docs/ is the project's primary output right now.
6
+
7
+ # --- Build output that must never be committed ---
8
+ # ADR 016: diagram sources live in docs/diagrams/*.mmd; rendered images are build output.
9
+ docs/static/diagrams/
10
+ # ADR 015: generated reference docs are committed on purpose (drift detection), so they
11
+ # are NOT ignored here — see docs/reference/.
12
+
13
+ # --- Python ---
14
+ __pycache__/
15
+ *.py[cod]
16
+ *.so
17
+ .Python
18
+ build/
19
+ dist/
20
+ *.egg-info/
21
+ .eggs/
22
+ .venv/
23
+ venv/
24
+ env/
25
+
26
+ # Tooling caches
27
+ .pytest_cache/
28
+ .mypy_cache/
29
+ .ruff_cache/
30
+ .hypothesis/
31
+ .coverage
32
+ .coverage.*
33
+ htmlcov/
34
+ coverage.xml
35
+ .benchmarks/
36
+ .tox/
37
+ .nox/
38
+
39
+ # uv (ADR 013) — uv.lock IS committed; the cache is not.
40
+ .uv/
41
+
42
+ # --- Node / web (ADR 009) ---
43
+ node_modules/
44
+ .next/
45
+ out/
46
+ .turbo/
47
+ *.tsbuildinfo
48
+ .pnpm-store/
49
+
50
+ # --- Tauri (ADR 010) ---
51
+ apps/desktop/src-tauri/target/
52
+ apps/desktop/src-tauri/gen/
53
+
54
+ # --- Docs site (ADR 015) ---
55
+ docs/.docusaurus/
56
+ docs/build/
57
+
58
+ # --- Metrik runtime data ---
59
+ # The artifact store and blob store (ADR 006). Content-addressed, machine-generated,
60
+ # gigabytes. Never in git.
61
+ .metrik/
62
+ *.metrik-store/
63
+ # Local config may hold API keys (ADR 018).
64
+ metrik.local.toml
65
+ .env
66
+ .env.*
67
+ !.env.example
68
+
69
+ # Model weights and checkpoints — always too large, often license-encumbered (plan.md §5.7).
70
+ *.safetensors
71
+ *.gguf
72
+ *.onnx
73
+ *.pt
74
+ *.pth
75
+ *.bin
76
+ # ...except the tiny-random fixtures, which are committed on purpose (ADR 017).
77
+ !tests/fixtures/**
78
+
79
+ # --- Editors and OS ---
80
+ .vscode/
81
+ !.vscode/extensions.json
82
+ .idea/
83
+ *.swp
84
+ .DS_Store
85
+ Thumbs.db
86
+ desktop.ini
87
+
88
+ # --- Local agent/tooling settings ---
89
+ .claude/settings.local.json
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.4
2
+ Name: metrik-explorer
3
+ Version: 0.1.0
4
+ Summary: Metrik Model Explorer — architecture, parameter, and memory analysis without a forward pass.
5
+ Project-URL: Homepage, https://github.com/Asmodeus14/Metrik
6
+ Project-URL: Repository, https://github.com/Asmodeus14/Metrik
7
+ Project-URL: Issues, https://github.com/Asmodeus14/Metrik/issues
8
+ Author: The Metrik Authors
9
+ License-Expression: Apache-2.0
10
+ Classifier: Development Status :: 2 - Pre-Alpha
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
13
+ Requires-Python: >=3.11
14
+ Requires-Dist: metrik-sdk==0.1.0
15
+ Description-Content-Type: text/markdown
16
+
17
+ # metrik-explorer
18
+
19
+ Architecture, parameter, and memory analysis — **without a forward pass, without torch, and
20
+ without a GPU**.
21
+
22
+ ```console
23
+ $ metrik explore ./models/TinyLlama-1.1B
24
+ ```
25
+
26
+ Reads safetensors headers (a length-prefixed JSON blob) and `config.json`. **No tensor is ever
27
+ deserialized**, no torch is imported, and no forward pass happens.
28
+
29
+ One honest caveat: `ModelFingerprint.weight_digest` is a merkle over the weight files, because
30
+ identity has to be content-based — a benchmark result attached to a *name* rather than a digest
31
+ is unfalsifiable ([`artifacts.md`](../../docs/planning/artifacts.md) §6.1). So the command does
32
+ stream every weight byte through BLAKE3 once. It is I/O-bound, not compute-bound, and nothing
33
+ is held in memory, but it is not free on a 140 GB directory. Header parsing alone *is* free,
34
+ and that is what the structural analysis uses.
35
+
36
+ Emits four artifacts:
37
+
38
+ | Artifact | What it carries |
39
+ |---|---|
40
+ | `ModelFingerprint` | identity: merkle over weight files, config digest, param split |
41
+ | `ModelGraph` | one node per tensor — **no edges**, declared as an omission |
42
+ | `GraphSummary` | role/dtype/depth breakdown plus evidenced observations |
43
+ | `MemoryEstimate` | analytic weights + KV cache, with stated assumptions |
44
+
45
+ ## What it deliberately does not do
46
+
47
+ - **No `ParamStats`.** Per-tensor statistics need the weight data and a blob format that is
48
+ still an open question ([`artifacts.md`](../../docs/planning/artifacts.md) §8, question 1).
49
+ - **No graph edges.** Headers describe storage, not dataflow. A topology needs torch and a
50
+ forward pass, which is a different loader behind the same contract.
51
+ - **No measured numbers.** `MemoryEstimate` is analytic by type and can never contain one
52
+ ([`artifacts.md`](../../docs/planning/artifacts.md) §5).
53
+
54
+ Role classification is name-based pattern matching, so `GraphSummary.classification_confidence`
55
+ always reports what fraction of parameters it could not classify. A confident breakdown of a
56
+ model the heuristic did not understand is worse than no breakdown.
@@ -0,0 +1,40 @@
1
+ # metrik-explorer
2
+
3
+ Architecture, parameter, and memory analysis — **without a forward pass, without torch, and
4
+ without a GPU**.
5
+
6
+ ```console
7
+ $ metrik explore ./models/TinyLlama-1.1B
8
+ ```
9
+
10
+ Reads safetensors headers (a length-prefixed JSON blob) and `config.json`. **No tensor is ever
11
+ deserialized**, no torch is imported, and no forward pass happens.
12
+
13
+ One honest caveat: `ModelFingerprint.weight_digest` is a merkle over the weight files, because
14
+ identity has to be content-based — a benchmark result attached to a *name* rather than a digest
15
+ is unfalsifiable ([`artifacts.md`](../../docs/planning/artifacts.md) §6.1). So the command does
16
+ stream every weight byte through BLAKE3 once. It is I/O-bound, not compute-bound, and nothing
17
+ is held in memory, but it is not free on a 140 GB directory. Header parsing alone *is* free,
18
+ and that is what the structural analysis uses.
19
+
20
+ Emits four artifacts:
21
+
22
+ | Artifact | What it carries |
23
+ |---|---|
24
+ | `ModelFingerprint` | identity: merkle over weight files, config digest, param split |
25
+ | `ModelGraph` | one node per tensor — **no edges**, declared as an omission |
26
+ | `GraphSummary` | role/dtype/depth breakdown plus evidenced observations |
27
+ | `MemoryEstimate` | analytic weights + KV cache, with stated assumptions |
28
+
29
+ ## What it deliberately does not do
30
+
31
+ - **No `ParamStats`.** Per-tensor statistics need the weight data and a blob format that is
32
+ still an open question ([`artifacts.md`](../../docs/planning/artifacts.md) §8, question 1).
33
+ - **No graph edges.** Headers describe storage, not dataflow. A topology needs torch and a
34
+ forward pass, which is a different loader behind the same contract.
35
+ - **No measured numbers.** `MemoryEstimate` is analytic by type and can never contain one
36
+ ([`artifacts.md`](../../docs/planning/artifacts.md) §5).
37
+
38
+ Role classification is name-based pattern matching, so `GraphSummary.classification_confidence`
39
+ always reports what fraction of parameters it could not classify. A confident breakdown of a
40
+ model the heuristic did not understand is worse than no breakdown.
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "metrik-explorer"
7
+ version = "0.1.0"
8
+ description = "Metrik Model Explorer — architecture, parameter, and memory analysis without a forward pass."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "Apache-2.0"
12
+ authors = [{ name = "The Metrik Authors" }]
13
+ classifiers = [
14
+ "Development Status :: 2 - Pre-Alpha",
15
+ "Intended Audience :: Science/Research",
16
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
17
+ ]
18
+
19
+ # metrik-sdk ONLY. Not metrik-core: the Explorer is a plugin with no privileged access, and
20
+ # the `sdk-dogfooding` contract in .importlinter fails the build if that stops being true.
21
+ #
22
+ # No torch and no safetensors library either. A safetensors header is a length-prefixed JSON
23
+ # blob, so the whole metadata path is stdlib -- which is what lets `metrik explore` run on a
24
+ # laptop with no ML stack installed, and makes it honest that no forward pass occurred.
25
+ dependencies = [
26
+ "metrik-sdk==0.1.0",
27
+ ]
28
+
29
+ # The Explorer is discovered exactly the way a third-party plugin is: an entry point plus a
30
+ # manifest shipped as package data. It gets no privileged path into the registry, which is
31
+ # how we find out in Phase 2 whether plugin discovery actually works (plan.md Verification #2).
32
+ [project.entry-points."metrik.loaders"]
33
+ explorer = "metrik.explorer:Explorer"
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/Asmodeus14/Metrik"
37
+ Repository = "https://github.com/Asmodeus14/Metrik"
38
+ Issues = "https://github.com/Asmodeus14/Metrik/issues"
39
+
40
+ [tool.uv.sources]
41
+ metrik-sdk = { workspace = true }
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/metrik"]
@@ -0,0 +1,44 @@
1
+ """Model Explorer — architecture, parameter, and memory analysis with no forward pass.
2
+
3
+ ``plan.md`` §5.1. Reads safetensors headers and ``config.json`` and nothing else, which is why
4
+ it needs neither torch nor a GPU, and why the claim "no forward pass occurred" is checkable
5
+ rather than asserted.
6
+
7
+ Imports ``metrik.sdk`` and nothing else from Metrik. That is the dogfooding contract
8
+ (``sdk-contracts.md`` §1): the built-in Explorer is a plugin with no privileged access, so if
9
+ it ever needs something the SDK does not expose, the SDK is broken and gets fixed.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from metrik.explorer.producer import INFO, ExploreParams, Explorer
15
+ from metrik.explorer.run import ExploreResult, explore
16
+
17
+
18
+ def _distribution_version(name: str) -> str:
19
+ from importlib.metadata import PackageNotFoundError, version
20
+
21
+ try:
22
+ return version(name)
23
+ except PackageNotFoundError: # pragma: no cover - only in an uninstalled source tree
24
+ return "0+unknown"
25
+
26
+
27
+ #: Read from the installed distribution rather than hardcoded.
28
+ #:
29
+ #: Both existed for a while and drifted: a 0.1.0 build reported 0.0.1 from `metrik --version`,
30
+ #: found by installing the actual wheel rather than by any test. A version string is exactly
31
+ #: the kind of duplicate that stays wrong quietly, because nothing imports it in anger.
32
+ #:
33
+ #: The fallback covers a source checkout with nothing installed, where there is no metadata to
34
+ #: read and no released version to name.
35
+ __version__ = _distribution_version("metrik-explorer")
36
+
37
+ __all__ = [
38
+ "INFO",
39
+ "ExploreParams",
40
+ "ExploreResult",
41
+ "Explorer",
42
+ "__version__",
43
+ "explore",
44
+ ]
@@ -0,0 +1,20 @@
1
+ {
2
+ "explorer": {
3
+ "name": "explorer",
4
+ "kind": "model_loader",
5
+ "version": "0.0.1",
6
+ "metrik_api": ">=1.0,<2.0",
7
+ "summary": "Architecture, parameter, and memory analysis from safetensors headers. No forward pass.",
8
+ "homepage": "https://github.com/Asmodeus14/Metrik",
9
+ "license": "Apache-2.0",
10
+ "requires_device": [],
11
+ "requires_gradients": false,
12
+ "requires_calibration": false,
13
+ "requires_container": false,
14
+ "determinism": "deterministic",
15
+ "device_sensitive": false,
16
+ "writes_files": false,
17
+ "needs_network": false,
18
+ "executes_untrusted_code": false
19
+ }
20
+ }
@@ -0,0 +1,132 @@
1
+ """Locating and digesting the files that make up a model on disk."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from metrik.explorer.safetensors import SafetensorsHeader, read_header
11
+ from metrik.sdk import Digest, HashAlgo, UserError, content_hash, hash_stream
12
+
13
+ __all__ = ["ModelDir", "config_digest_of", "digest_file", "discover", "merkle_of"]
14
+
15
+ WEIGHT_SUFFIXES = (".safetensors",)
16
+ #: Formats we can see but not yet read. Named explicitly so the error can say which one.
17
+ UNSUPPORTED_SUFFIXES = {
18
+ ".bin": "PyTorch pickle",
19
+ ".pt": "PyTorch pickle",
20
+ ".pth": "PyTorch pickle",
21
+ ".gguf": "GGUF",
22
+ ".onnx": "ONNX",
23
+ }
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ModelDir:
28
+ """A model directory, resolved and read."""
29
+
30
+ root: Path
31
+ headers: tuple[SafetensorsHeader, ...]
32
+ config: dict[str, Any]
33
+ config_path: Path | None
34
+ tokenizer_path: Path | None
35
+
36
+ @property
37
+ def weight_files(self) -> tuple[Path, ...]:
38
+ return tuple(h.path for h in self.headers)
39
+
40
+ def source_stamp(self) -> tuple[tuple[str, int, int], ...]:
41
+ """Cheap identity of the bytes on disk: (name, size, mtime_ns) per file, sorted.
42
+
43
+ Deliberately not a content digest. This feeds ``derivation_hash``, which must be
44
+ computable *before* running -- digesting gigabytes to decide whether to skip digesting
45
+ gigabytes is not a cache.
46
+ """
47
+ paths = [*self.weight_files]
48
+ if self.config_path:
49
+ paths.append(self.config_path)
50
+ return tuple(sorted((p.name, p.stat().st_size, p.stat().st_mtime_ns) for p in paths))
51
+
52
+
53
+ def discover(uri: str) -> ModelDir:
54
+ """Resolve a local path to a model directory and read every safetensors header.
55
+
56
+ Only local paths in v0.1. Hugging Face resolution is a separate loader behind the same
57
+ contract; keeping it out means this path has no network dependency and no cache semantics
58
+ to reason about.
59
+ """
60
+ root = Path(uri).expanduser()
61
+ files: tuple[Path, ...]
62
+ if root.is_file() and root.suffix in WEIGHT_SUFFIXES:
63
+ root, files = root.parent, (root,)
64
+ elif root.is_dir():
65
+ files = tuple(sorted(p for p in root.iterdir() if p.suffix in WEIGHT_SUFFIXES))
66
+ else:
67
+ raise UserError(
68
+ f"{uri} is not a file or directory",
69
+ remediation="Pass a path to a model directory containing .safetensors files.",
70
+ )
71
+
72
+ if not files:
73
+ found = {p.suffix for p in root.iterdir() if p.is_file()} & set(UNSUPPORTED_SUFFIXES)
74
+ if found:
75
+ names = ", ".join(sorted(UNSUPPORTED_SUFFIXES[s] for s in found))
76
+ raise UserError(
77
+ f"{root} contains {names} weights, which this loader cannot read",
78
+ remediation=(
79
+ "Convert to safetensors, or wait for the format-specific loader. Metrik "
80
+ "reads safetensors headers without torch, which is why it is the one "
81
+ "supported format in v0.1."
82
+ ),
83
+ )
84
+ raise UserError(
85
+ f"no .safetensors files in {root}",
86
+ remediation="Check the path points at a model directory.",
87
+ )
88
+
89
+ config_path = root / "config.json"
90
+ config: dict[str, Any] = {}
91
+ if config_path.is_file():
92
+ try:
93
+ config = json.loads(config_path.read_text(encoding="utf-8"))
94
+ except (OSError, json.JSONDecodeError) as exc:
95
+ raise UserError(
96
+ f"config.json in {root} is not readable JSON: {exc}",
97
+ remediation="Re-download the model, or remove the file to analyse weights only.",
98
+ ) from exc
99
+
100
+ tokenizer = root / "tokenizer.json"
101
+ return ModelDir(
102
+ root=root,
103
+ headers=tuple(read_header(p) for p in files),
104
+ config=config,
105
+ config_path=config_path if config_path.is_file() else None,
106
+ tokenizer_path=tokenizer if tokenizer.is_file() else None,
107
+ )
108
+
109
+
110
+ def digest_file(path: Path) -> Digest:
111
+ with path.open("rb") as handle:
112
+ return hash_stream(handle)
113
+
114
+
115
+ def merkle_of(digests: list[tuple[str, Digest]]) -> Digest:
116
+ """Combine per-file digests into one identity.
117
+
118
+ Sorted by path so the result does not depend on directory iteration order, and computed
119
+ over the *canonical form* of the (name, digest) pairs so it inherits the canonicalization
120
+ rules rather than inventing a second, subtly different, serialization.
121
+ """
122
+ return content_hash([{"path": name, "digest": str(digest)} for name, digest in sorted(digests)])
123
+
124
+
125
+ def config_digest_of(config: dict[str, Any]) -> Digest:
126
+ """Hash a parsed config.
127
+
128
+ Over the parsed object rather than the raw bytes, so that reformatting ``config.json``
129
+ (whitespace, key order) does not invalidate every cached analysis -- while a real change
130
+ to a value still does.
131
+ """
132
+ return content_hash(config, algo=HashAlgo.BLAKE3)