metrik-torch 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,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: metrik-torch
3
+ Version: 0.1.0
4
+ Summary: Metrik torch loader — observed ModelGraph extraction from a real nn.Module tree.
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
+ Provides-Extra: torch
16
+ Requires-Dist: torch>=2.2; extra == 'torch'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # metrik-torch
20
+
21
+ Observed `ModelGraph` extraction from a real `nn.Module` tree.
22
+
23
+ ```console
24
+ $ pip install "metrik-torch[torch]"
25
+ $ metrik explore ./models/TinyLlama-1.1B --loader torch
26
+ ```
27
+
28
+ The counterpart to `metrik-explorer`, which reads safetensors headers and `config.json` and
29
+ never imports torch. This package walks the actual module tree, so module classes, parameter
30
+ ownership, and **weight tying** are observed facts rather than claims from a config file.
31
+
32
+ Tying in particular: an untied `lm_head` has exactly the same shape as the embedding it does
33
+ not share, so shape is not evidence. Here it is established by storage identity.
34
+
35
+ ## What it still does not know
36
+
37
+ Dataflow. `nn.Module` records containment, not what feeds what, so `edges` is `None` and
38
+ `fidelity` is `structural`. Recovering real edges needs tracing, which breaks on the dynamic
39
+ control flow most model implementations have. A consumer that needs edges checks `fidelity`
40
+ and refuses rather than reading a containment tree as a dataflow graph.
41
+
42
+ No forward pass, no device transfer, no weight value read — module structure and parameter
43
+ metadata only.
44
+
45
+ ## Why torch is an optional extra
46
+
47
+ `uv sync --all-packages --dev` stays torch-free so the CI matrix keeps running in about a
48
+ minute. One dedicated job installs the extra and exercises extraction. See
49
+ [ADR 007](../../docs/planning/adr/007-ml-substrate.md).
@@ -0,0 +1,31 @@
1
+ # metrik-torch
2
+
3
+ Observed `ModelGraph` extraction from a real `nn.Module` tree.
4
+
5
+ ```console
6
+ $ pip install "metrik-torch[torch]"
7
+ $ metrik explore ./models/TinyLlama-1.1B --loader torch
8
+ ```
9
+
10
+ The counterpart to `metrik-explorer`, which reads safetensors headers and `config.json` and
11
+ never imports torch. This package walks the actual module tree, so module classes, parameter
12
+ ownership, and **weight tying** are observed facts rather than claims from a config file.
13
+
14
+ Tying in particular: an untied `lm_head` has exactly the same shape as the embedding it does
15
+ not share, so shape is not evidence. Here it is established by storage identity.
16
+
17
+ ## What it still does not know
18
+
19
+ Dataflow. `nn.Module` records containment, not what feeds what, so `edges` is `None` and
20
+ `fidelity` is `structural`. Recovering real edges needs tracing, which breaks on the dynamic
21
+ control flow most model implementations have. A consumer that needs edges checks `fidelity`
22
+ and refuses rather than reading a containment tree as a dataflow graph.
23
+
24
+ No forward pass, no device transfer, no weight value read — module structure and parameter
25
+ metadata only.
26
+
27
+ ## Why torch is an optional extra
28
+
29
+ `uv sync --all-packages --dev` stays torch-free so the CI matrix keeps running in about a
30
+ minute. One dedicated job installs the extra and exercises extraction. See
31
+ [ADR 007](../../docs/planning/adr/007-ml-substrate.md).
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "metrik-torch"
7
+ version = "0.1.0"
8
+ description = "Metrik torch loader — observed ModelGraph extraction from a real nn.Module tree."
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, same as every other first-party producer: no privileged access, enforced by
20
+ # the `sdk-dogfooding` contract.
21
+ #
22
+ # torch is deliberately NOT here. It is an optional extra, so `uv sync --all-packages --dev`
23
+ # stays torch-free and the seven-way CI matrix keeps running in about a minute. A single
24
+ # dedicated job installs the extra and exercises extraction. The alternative -- torch as a hard
25
+ # dependency -- would put a multi-hundred-megabyte download in front of every contributor and
26
+ # every matrix job, to test one code path on one OS's worth of behaviour.
27
+ dependencies = [
28
+ "metrik-sdk==0.1.0",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ # CPU is enough: this extracts structure, it never runs a forward pass. Anything that needs an
33
+ # accelerator belongs in the profiler, behind the GPU tier.
34
+ torch = ["torch>=2.2"]
35
+
36
+ # Discovered through the registry like any third-party plugin, so `metrik explore --loader
37
+ # torch` never imports this package by name.
38
+ [project.entry-points."metrik.loaders"]
39
+ torch = "metrik.torch:TorchLoader"
40
+
41
+ [project.urls]
42
+ Homepage = "https://github.com/Asmodeus14/Metrik"
43
+ Repository = "https://github.com/Asmodeus14/Metrik"
44
+ Issues = "https://github.com/Asmodeus14/Metrik/issues"
45
+
46
+ [tool.uv.sources]
47
+ metrik-sdk = { workspace = true }
48
+
49
+ [tool.hatch.build.targets.wheel]
50
+ packages = ["src/metrik"]
@@ -0,0 +1,23 @@
1
+ """Metrik torch loader -- observed ModelGraph extraction from a real ``nn.Module`` tree.
2
+
3
+ ``torch`` is an optional extra. Importing this package without it raises immediately with a
4
+ remediation rather than failing later inside extraction, because "No module named 'torch'"
5
+ surfacing from three frames deep in a loader reads as a Metrik bug rather than a missing
6
+ install.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from metrik.torch.extract import ROOT_NODE_ID, extract
12
+ from metrik.torch.loader import INFO, TorchLoader, TorchLoaderParams
13
+ from metrik.torch.roles import role_of_module, tensor_kind_of
14
+
15
+ __all__ = [
16
+ "INFO",
17
+ "ROOT_NODE_ID",
18
+ "TorchLoader",
19
+ "TorchLoaderParams",
20
+ "extract",
21
+ "role_of_module",
22
+ "tensor_kind_of",
23
+ ]
@@ -0,0 +1,273 @@
1
+ """Extracting a :class:`~metrik.sdk.ModelGraph` from a real ``nn.Module`` tree.
2
+
3
+ ``artifacts.md`` §6.2, ``method: torch_module_tree``. This is the *observed* counterpart to the
4
+ Explorer's ``config_only`` path: the module tree is walked, so module classes, parameter
5
+ ownership, and weight tying are facts about the object rather than claims from a config file.
6
+
7
+ What it still does **not** know is dataflow. ``nn.Module`` records containment -- which module
8
+ holds which -- and nothing about what feeds what. Recovering that needs tracing, which breaks
9
+ on the dynamic control flow most model implementations have. So ``fidelity`` stays
10
+ ``structural`` and ``edges`` stays ``None``; a consumer that needs real edges checks the field
11
+ and refuses rather than reading a containment tree as a dataflow graph.
12
+
13
+ Nothing here runs a forward pass, moves a tensor to a device, or reads a weight value. The
14
+ module tree and parameter *metadata* are all that is touched, so extraction is fast and cannot
15
+ accidentally become a benchmark.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import TYPE_CHECKING, Any
21
+
22
+ from metrik.sdk import (
23
+ IR_VERSION,
24
+ Block,
25
+ ExtractionInfo,
26
+ ExtractionMethod,
27
+ Fidelity,
28
+ ModelGraph,
29
+ Node,
30
+ NodeKind,
31
+ TensorDecl,
32
+ TensorKind,
33
+ Topology,
34
+ )
35
+ from metrik.torch.roles import role_of_module, tensor_kind_of
36
+
37
+ if TYPE_CHECKING: # pragma: no cover - torch is an optional extra
38
+ import torch.nn as nn
39
+
40
+ __all__ = ["ROOT_NODE_ID", "extract"]
41
+
42
+ #: The root module has no name in ``named_modules()``, and an empty ``node_id`` is not legal.
43
+ #: Angle brackets cannot occur in a Python attribute path, so this cannot collide with a real
44
+ #: module -- including the very common case of a root whose child is itself called ``model``.
45
+ ROOT_NODE_ID = "<model>"
46
+
47
+ #: torch dtype repr -> the safetensors spelling, so a graph from this loader and one from the
48
+ #: header reader use one vocabulary. An unlisted dtype passes through as its torch name rather
49
+ #: than being coerced into a wrong one.
50
+ _DTYPE_NAMES = {
51
+ "torch.float32": "F32",
52
+ "torch.float16": "F16",
53
+ "torch.bfloat16": "BF16",
54
+ "torch.float64": "F64",
55
+ "torch.int8": "I8",
56
+ "torch.uint8": "U8",
57
+ "torch.int32": "I32",
58
+ "torch.int64": "I64",
59
+ "torch.bool": "BOOL",
60
+ }
61
+
62
+
63
+ def _dtype_name(dtype: Any) -> str:
64
+ return _DTYPE_NAMES.get(str(dtype), str(dtype))
65
+
66
+
67
+ def _qualified(name: str) -> str:
68
+ return name or ROOT_NODE_ID
69
+
70
+
71
+ def extract(
72
+ model: nn.Module,
73
+ *,
74
+ loader_name: str,
75
+ loader_version: str,
76
+ config: dict[str, Any] | None = None,
77
+ topology: Topology | None = None,
78
+ ) -> ModelGraph:
79
+ """Walk ``model`` and build the graph.
80
+
81
+ Args:
82
+ topology: Declared topology, usually read from ``config.json``. Merged *under* what
83
+ the module tree observes: an observed value always wins, because the config is a
84
+ claim and the tree is the object. ``tied_embeddings`` is the case that matters --
85
+ configs get it wrong, and here it is checked by identity.
86
+ """
87
+ nodes: list[Node] = []
88
+ tensors: list[TensorDecl] = []
89
+ unsupported: set[str] = set()
90
+
91
+ # Storage identity -> the first name that referred to it. Two parameters that are the same
92
+ # object are tied, and this is *observed*: no config key, no shape heuristic. Shape
93
+ # equality is not evidence -- an untied lm_head has the same shape as the embedding it does
94
+ # not share -- so identity is the only sound test, and torch gives it to us directly.
95
+ seen_storage: dict[int, str] = {}
96
+
97
+ for name, module in model.named_modules():
98
+ node_id = _qualified(name)
99
+ role = role_of_module(name, type(module).__name__)
100
+ if role.value == "other" and _has_own_parameters(module):
101
+ # Only report a miss for modules that actually own weights. Containers like
102
+ # `ModuleList` are legitimately unclassifiable and listing them would drown the
103
+ # signal that matters: a *parameterised* module we failed to understand.
104
+ unsupported.add(type(module).__name__)
105
+
106
+ own = _own_tensors(module)
107
+ for tensor_name, tensor, is_buffer in own:
108
+ full = f"{name}.{tensor_name}" if name else tensor_name
109
+ key = _storage_key(tensor)
110
+ tied_to = seen_storage.get(key)
111
+ if tied_to is None:
112
+ seen_storage[key] = full
113
+ tensors.append(
114
+ TensorDecl(
115
+ name=full,
116
+ shape=tuple(tensor.shape),
117
+ dtype=_dtype_name(tensor.dtype),
118
+ numel=tensor.numel(),
119
+ # element_size() rather than a dtype table: it is right for every dtype
120
+ # torch supports, including ones added after this was written.
121
+ bytes_on_disk=tensor.numel() * tensor.element_size(),
122
+ kind=TensorKind.BUFFER if is_buffer else tensor_kind_of(full, role),
123
+ owner_node=node_id,
124
+ tied_to=tied_to,
125
+ )
126
+ )
127
+
128
+ nodes.append(
129
+ Node(
130
+ node_id=node_id,
131
+ qualified_name=node_id,
132
+ kind=NodeKind.MODULE,
133
+ role=role,
134
+ module_class=type(module).__name__,
135
+ layer_index=_layer_index(name),
136
+ param_tensors=tuple(sorted(f"{name}.{n}" if name else n for n, _, _ in own)),
137
+ )
138
+ )
139
+
140
+ nodes.sort(key=lambda n: n.node_id)
141
+ tensors.sort(key=lambda t: t.name)
142
+ blocks = _blocks_of(model)
143
+
144
+ return ModelGraph(
145
+ ir_version=IR_VERSION,
146
+ nodes=tuple(nodes),
147
+ # Containment is not dataflow. See the module docstring.
148
+ edges=None,
149
+ tensors=tuple(tensors),
150
+ blocks=tuple(blocks),
151
+ topology=_merge_topology(topology, tensors, blocks, config),
152
+ extraction=ExtractionInfo(
153
+ method=ExtractionMethod.TORCH_MODULE_TREE,
154
+ fidelity=Fidelity.STRUCTURAL,
155
+ loader_name=loader_name,
156
+ loader_version=loader_version,
157
+ unsupported=tuple(sorted(unsupported)),
158
+ ),
159
+ )
160
+
161
+
162
+ def _storage_key(tensor: Any) -> int:
163
+ """Identify the underlying storage.
164
+
165
+ ``data_ptr()`` rather than ``id(tensor)``: tying shares *storage*, and two Parameter
166
+ objects can wrap one allocation. A meta-device or otherwise unallocated tensor reports 0,
167
+ which would make every such tensor look tied to every other, so those fall back to object
168
+ identity and are simply never reported as tied.
169
+ """
170
+ pointer = int(tensor.data_ptr())
171
+ return pointer if pointer else -id(tensor)
172
+
173
+
174
+ def _has_own_parameters(module: nn.Module) -> bool:
175
+ return any(True for _ in module.parameters(recurse=False))
176
+
177
+
178
+ def _own_tensors(module: nn.Module) -> list[tuple[str, Any, bool]]:
179
+ """Parameters and buffers declared on this module, not on its children.
180
+
181
+ ``recurse=False`` is what makes ``owner_node`` meaningful: with recursion every ancestor
182
+ would claim every descendant's weights, and parameter counts per node would multiply by
183
+ tree depth.
184
+ """
185
+ out: list[tuple[str, Any, bool]] = [
186
+ (n, p, False) for n, p in module.named_parameters(recurse=False)
187
+ ]
188
+ out += [(n, b, True) for n, b in module.named_buffers(recurse=False)]
189
+ return out
190
+
191
+
192
+ def _layer_index(name: str) -> int | None:
193
+ """The decoder layer this module sits in, if any.
194
+
195
+ Read from the qualified name, which is where torch puts ``ModuleList`` indices. Modules
196
+ outside the repeating stack get ``None`` rather than 0 -- collapsing them into layer 0
197
+ makes the first layer look anomalously large in every depth profile.
198
+ """
199
+ for part in name.split("."):
200
+ if part.isdigit():
201
+ return int(part)
202
+ return None
203
+
204
+
205
+ def _blocks_of(model: nn.Module) -> list[Block]:
206
+ """Group the repeating stack.
207
+
208
+ Every ``ModuleList`` is a repeat group, keyed by its own qualified name. That is what lets
209
+ 32 identical decoder layers be recognised as one structure rather than 32 unrelated ones,
210
+ which is what makes per-layer plans and visualizations comparable across model depths.
211
+ """
212
+ import torch.nn as nn_
213
+
214
+ blocks: list[Block] = []
215
+ for name, module in model.named_modules():
216
+ if not isinstance(module, nn_.ModuleList) or not len(module):
217
+ continue
218
+ for index, child in enumerate(module):
219
+ child_name = f"{name}.{index}" if name else str(index)
220
+ blocks.append(
221
+ Block(
222
+ index=index,
223
+ block_class=type(child).__name__,
224
+ repeat_group_id=name or ROOT_NODE_ID,
225
+ node_ids=tuple(
226
+ sorted(
227
+ f"{child_name}.{sub}" if sub else child_name
228
+ for sub, _ in child.named_modules()
229
+ )
230
+ ),
231
+ )
232
+ )
233
+ return blocks
234
+
235
+
236
+ def _merge_topology(
237
+ declared: Topology | None,
238
+ tensors: list[TensorDecl],
239
+ blocks: list[Block],
240
+ config: dict[str, Any] | None,
241
+ ) -> Topology:
242
+ """Combine what was declared with what was observed, observation winning.
243
+
244
+ Only two fields are genuinely observable from a module tree without tracing, and both are
245
+ worth overriding the config for:
246
+
247
+ ``n_layers`` -- counted from the repeating stack. A config can disagree with the weights it
248
+ ships, and the weights are what will be loaded.
249
+
250
+ ``tied_embeddings`` -- established by storage identity above. This is the field configs
251
+ most often get wrong, and getting it wrong changes the parameter count by the size of the
252
+ entire embedding matrix.
253
+ """
254
+ base = declared.model_dump() if declared is not None else {}
255
+
256
+ observed_layers = len({b.repeat_group_id for b in blocks}) and max(
257
+ (
258
+ len([b for b in blocks if b.repeat_group_id == g])
259
+ for g in {b.repeat_group_id for b in blocks}
260
+ ),
261
+ default=0,
262
+ )
263
+ if observed_layers:
264
+ base["n_layers"] = observed_layers
265
+
266
+ base["tied_embeddings"] = any(t.tied_to is not None for t in tensors)
267
+
268
+ if config and base.get("vocab_size") is None:
269
+ vocab = config.get("vocab_size")
270
+ if isinstance(vocab, int) and not isinstance(vocab, bool):
271
+ base["vocab_size"] = vocab
272
+
273
+ return Topology(**base)
@@ -0,0 +1,211 @@
1
+ """The ``ModelLoader`` plugin the registry resolves for ``--loader torch``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from metrik.sdk import (
10
+ DeterminismClass,
11
+ Emission,
12
+ Omission,
13
+ Params,
14
+ PartialInfo,
15
+ ProducerInfo,
16
+ ReasonCode,
17
+ UnsupportedError,
18
+ UserError,
19
+ )
20
+
21
+ __all__ = ["INFO", "TorchLoader", "TorchLoaderParams"]
22
+
23
+ INFO = ProducerInfo(name="torch", version="0.0.1", api_version="1.0.0", kind="builtin")
24
+
25
+
26
+ class TorchLoaderParams(Params):
27
+ """Everything that changes what is extracted.
28
+
29
+ ``trust_remote_code`` is here and defaults off because loading a model with custom code
30
+ executes the model author's Python. It is recorded in the artifact, so a graph extracted
31
+ with it on is distinguishable from one extracted without.
32
+ """
33
+
34
+ uri: str = ""
35
+ trust_remote_code: bool = False
36
+ #: Instantiate on the meta device: shapes and structure without allocating weights. On by
37
+ #: default because extraction never reads a weight value, so materialising gigabytes to
38
+ #: inspect the tree is pure cost. Turn it off only to inspect a model already in memory.
39
+ meta_device: bool = True
40
+
41
+
42
+ def _require_torch() -> Any:
43
+ """Import torch, or fail with something a user can act on.
44
+
45
+ A bare ImportError three frames into extraction reads as a Metrik bug. This names the
46
+ install that fixes it.
47
+ """
48
+ try:
49
+ import torch
50
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
51
+ raise UnsupportedError(
52
+ "the torch loader needs PyTorch, which is not installed",
53
+ remediation=(
54
+ "Install it with `pip install 'metrik-torch[torch]'`, or use the header-only "
55
+ "loader with `metrik explore --loader explorer`."
56
+ ),
57
+ ) from exc
58
+ return torch
59
+
60
+
61
+ class TorchLoader:
62
+ """Builds a ModelGraph by walking a real module tree."""
63
+
64
+ info = INFO
65
+ params_model = TorchLoaderParams
66
+
67
+ def can_load(self, uri: str) -> bool:
68
+ path = Path(uri).expanduser()
69
+ return (path / "config.json").is_file() if path.is_dir() else False
70
+
71
+ def graph_of_module(self, module: Any, *, config: dict[str, Any] | None = None) -> Any:
72
+ """Extract from an already-instantiated module.
73
+
74
+ The seam that makes this testable without transformers or a checkpoint: a plain
75
+ ``nn.Module`` built in a test exercises exactly the code a real model does.
76
+ """
77
+ from metrik.torch.extract import extract
78
+
79
+ return extract(
80
+ module,
81
+ loader_name=INFO.name,
82
+ loader_version=INFO.version,
83
+ config=config,
84
+ topology=_declared_topology(config or {}),
85
+ )
86
+
87
+ def load(self, uri: str, params: TorchLoaderParams | None = None) -> Any:
88
+ """Instantiate the model from a directory and extract its graph."""
89
+ params = params or TorchLoaderParams()
90
+ torch = _require_torch()
91
+ path = Path(uri).expanduser()
92
+ config_path = path / "config.json"
93
+ if not config_path.is_file():
94
+ raise UserError(
95
+ f"{uri} has no config.json, so there is nothing to instantiate",
96
+ remediation=(
97
+ "Point at a model directory, or use `--loader explorer` to analyse "
98
+ "weights without instantiating anything."
99
+ ),
100
+ )
101
+ config = json.loads(config_path.read_text(encoding="utf-8"))
102
+
103
+ try:
104
+ from transformers import AutoConfig, AutoModelForCausalLM
105
+ except ImportError as exc:
106
+ raise UnsupportedError(
107
+ "instantiating a model from a directory needs transformers",
108
+ remediation=(
109
+ "Install it with `pip install transformers`, or use "
110
+ "`metrik explore --loader explorer` for a header-only analysis."
111
+ ),
112
+ ) from exc
113
+
114
+ auto_config = AutoConfig.from_pretrained(
115
+ str(path), trust_remote_code=params.trust_remote_code
116
+ )
117
+ if params.meta_device:
118
+ # Structure only. No weights are allocated, which is what makes inspecting a 70B
119
+ # model on a laptop possible -- and is safe precisely because extraction never
120
+ # reads a value.
121
+ with torch.device("meta"):
122
+ # transformers ships no annotations for from_config, so these two calls are
123
+ # untyped in a strict context. Ignored narrowly rather than by relaxing the
124
+ # whole module: everything else here should stay checked.
125
+ model = AutoModelForCausalLM.from_config( # type: ignore[no-untyped-call]
126
+ auto_config, trust_remote_code=params.trust_remote_code
127
+ )
128
+ else:
129
+ model = AutoModelForCausalLM.from_config( # type: ignore[no-untyped-call]
130
+ auto_config, trust_remote_code=params.trust_remote_code
131
+ )
132
+ return self.graph_of_module(model, config=config)
133
+
134
+ def provide_graph(self, spine: Any, uri: str, fingerprint_id: Any) -> Emission:
135
+ """The graph step, in the shape the explore chain expects.
136
+
137
+ Presence of this method is what marks a loader as able to supply a graph to someone
138
+ else's pipeline, so the CLI never has to know how any particular loader is built.
139
+
140
+ Resolves the fingerprint through the spine rather than being handed it, because the
141
+ spine records what a producer reads and checks it against what the producer declares.
142
+ Being passed the artifact directly would leave nothing observed and make that check
143
+ vacuous (``sdk-contracts.md`` §5).
144
+ """
145
+ fingerprint = spine.get(fingerprint_id)
146
+ graph = self.load(uri)
147
+ return self.emission(graph, fingerprint.envelope.content_hash)
148
+
149
+ def emission(self, graph: Any, fingerprint_hash: Any) -> Emission:
150
+ """Wrap an extracted graph as an artifact emission.
151
+
152
+ The omissions are not optional decoration. ``edges`` is null here, and a null field
153
+ with no recorded reason is indistinguishable from a bug (``artifacts.md`` §2.3) --
154
+ it also vanished from the CLI's "not determined" panel, so the gap became invisible
155
+ precisely where a reader would look for it. Anything a producer cannot determine is
156
+ named, with a reason, every time.
157
+ """
158
+ payload = graph.model_dump(mode="json", exclude_none=True)
159
+ payload["fingerprint"] = str(fingerprint_hash)
160
+
161
+ omissions = [
162
+ Omission(
163
+ field_path="edges",
164
+ reason_code=ReasonCode.MISSING_CAPABILITY,
165
+ detail=("nn.Module records containment, not dataflow, so no edges were observed"),
166
+ remediation=(
167
+ "Dataflow needs tracing, which is not implemented and fails on the "
168
+ "dynamic control flow most model implementations have."
169
+ ),
170
+ )
171
+ ]
172
+ if graph.extraction.unsupported:
173
+ named = ", ".join(graph.extraction.unsupported)
174
+ omissions.append(
175
+ Omission(
176
+ field_path="nodes[].role",
177
+ reason_code=ReasonCode.MISSING_CAPABILITY,
178
+ detail=(
179
+ f"{len(graph.extraction.unsupported)} parameterised module class(es) "
180
+ f"were not classified: {named}"
181
+ ),
182
+ remediation=(
183
+ "Those nodes are tagged `other`; check "
184
+ "GraphSummary.classification_confidence before using this for a "
185
+ "cross-architecture claim."
186
+ ),
187
+ )
188
+ )
189
+
190
+ return Emission(
191
+ artifact_type="metrik.ModelGraph",
192
+ schema_version=1,
193
+ payload=payload,
194
+ determinism=DeterminismClass.DETERMINISTIC,
195
+ inputs={"fingerprint": fingerprint_hash},
196
+ partial=PartialInfo(complete=False, omissions=tuple(omissions)),
197
+ )
198
+
199
+
200
+ def _declared_topology(config: dict[str, Any]) -> Any:
201
+ """Reuse the Explorer's config reader when it is installed.
202
+
203
+ Imported lazily and optionally rather than depended on: duplicating the alias tables would
204
+ guarantee they drift, but a hard dependency would make the torch loader unusable without
205
+ the header-only one. Absent it, the module tree still supplies what it observes.
206
+ """
207
+ try:
208
+ from metrik.explorer.topology import topology_of
209
+ except ImportError: # pragma: no cover - explorer is installed in this workspace
210
+ return None
211
+ return topology_of(config)
@@ -0,0 +1,20 @@
1
+ {
2
+ "torch": {
3
+ "name": "torch",
4
+ "kind": "model_loader",
5
+ "version": "0.0.1",
6
+ "metrik_api": ">=1.0,<2.0",
7
+ "summary": "Observed ModelGraph extraction from a real nn.Module tree. Structural fidelity: no dataflow edges.",
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": true
19
+ }
20
+ }
File without changes
@@ -0,0 +1,90 @@
1
+ """Mapping a module to its canonical :class:`~metrik.sdk.RoleTag`.
2
+
3
+ Classified from the **qualified name**, not the module class. Almost every projection in a
4
+ transformer is an ``nn.Linear``, so the class says nothing; the name says which one it is. The
5
+ class is still recorded on the node, and is what identifies an unclassifiable *parameterised*
6
+ module in ``ExtractionInfo.unsupported``.
7
+
8
+ This is pattern matching on naming conventions, which is a heuristic and will eventually
9
+ misclassify something. That is why nothing here guesses: a name that matches nothing is
10
+ ``OTHER``, its class is reported as unsupported, and the share of parameters landing in
11
+ ``OTHER`` is a confidence signal a consumer can act on (``artifacts.md`` §6.2).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from typing import Final
18
+
19
+ from metrik.sdk import RoleTag, TensorKind
20
+
21
+ __all__ = ["role_of_module", "tensor_kind_of"]
22
+
23
+ #: Matched in order, first hit wins. Order is load-bearing in two places: the fused patterns
24
+ #: must precede the single-projection ones, or ``qkv_proj`` is read as ``q_proj``; and the
25
+ #: expert patterns must precede the plain MLP ones, or every expert is filed as a dense MLP
26
+ #: and a MoE model's parameter breakdown becomes meaningless.
27
+ _PATTERNS: Final[tuple[tuple[re.Pattern[str], RoleTag], ...]] = tuple(
28
+ (re.compile(pattern), role)
29
+ for pattern, role in (
30
+ # MoE first.
31
+ (r"(^|\.)(gate|router)$", RoleTag.MOE_ROUTER),
32
+ (r"experts?\.\d+\.(w1|gate_proj)$", RoleTag.MOE_EXPERT_GATE),
33
+ (r"experts?\.\d+\.(w3|up_proj)$", RoleTag.MOE_EXPERT_UP),
34
+ (r"experts?\.\d+\.(w2|down_proj)$", RoleTag.MOE_EXPERT_DOWN),
35
+ (r"shared_expert", RoleTag.MOE_SHARED),
36
+ # Fused before single.
37
+ (r"(qkv_proj|c_attn|Wqkv|in_proj)$", RoleTag.ATTN_QKV_FUSED),
38
+ (r"gate_up_proj$", RoleTag.MLP_FUSED),
39
+ # Attention projections.
40
+ (r"(q_proj|query|wq)$", RoleTag.ATTN_Q),
41
+ (r"(k_proj|key|wk)$", RoleTag.ATTN_K),
42
+ (r"(v_proj|value|wv)$", RoleTag.ATTN_V),
43
+ (r"(o_proj|out_proj|dense|wo|c_proj)$", RoleTag.ATTN_O),
44
+ # MLP.
45
+ (r"gate_proj$", RoleTag.MLP_GATE),
46
+ (r"(up_proj|fc1|c_fc|w1)$", RoleTag.MLP_UP),
47
+ (r"(down_proj|fc2|w2)$", RoleTag.MLP_DOWN),
48
+ # Norms. `input_layernorm` guards the attention block, `post_attention_layernorm` the
49
+ # MLP -- naming that reads backwards until you notice it is named for what precedes it.
50
+ (r"(input_layernorm|ln_1|attention_norm|pre_attn_norm)$", RoleTag.ATTN_NORM),
51
+ (r"(post_attention_layernorm|ln_2|ffn_norm|pre_mlp_norm)$", RoleTag.MLP_NORM),
52
+ # Final norm: anchored to the top level, so a per-layer `model.layers.3.norm` cannot
53
+ # match it.
54
+ (r"^(model\.norm|transformer\.ln_f|norm|ln_f)$", RoleTag.NORM_FINAL),
55
+ # Embeddings and head.
56
+ (r"(embed_tokens|wte|tok_embeddings|word_embeddings)$", RoleTag.EMBED_TOKENS),
57
+ (r"(embed_positions|wpe|position_embeddings)$", RoleTag.EMBED_POS),
58
+ (r"(lm_head|output|score)$", RoleTag.LM_HEAD),
59
+ )
60
+ )
61
+
62
+ #: Module classes that hold parameters but carry no role of their own -- reporting them as
63
+ #: unsupported would be noise, since there is nothing to classify.
64
+ _CONTAINERS: Final[frozenset[str]] = frozenset({"ModuleList", "Sequential", "ModuleDict"})
65
+
66
+
67
+ def role_of_module(qualified_name: str, module_class: str) -> RoleTag:
68
+ """Classify one module. Returns ``OTHER`` rather than guessing."""
69
+ if not qualified_name or module_class in _CONTAINERS:
70
+ return RoleTag.OTHER
71
+ for pattern, role in _PATTERNS:
72
+ if pattern.search(qualified_name):
73
+ return role
74
+ return RoleTag.OTHER
75
+
76
+
77
+ def tensor_kind_of(tensor_name: str, role: RoleTag) -> TensorKind:
78
+ """Classify a parameter from its name and its owner's role.
79
+
80
+ Checked in this order deliberately: a norm's ``bias`` is a bias, not a norm scale, and a
81
+ norm's ``weight`` is a scale rather than a weight matrix. Getting that backwards puts
82
+ per-tensor statistics for a scalar scale in the same bucket as a projection matrix.
83
+ """
84
+ if tensor_name.endswith(".bias") or tensor_name == "bias":
85
+ return TensorKind.BIAS
86
+ if role in (RoleTag.ATTN_NORM, RoleTag.MLP_NORM, RoleTag.NORM_FINAL):
87
+ return TensorKind.NORM_SCALE
88
+ if role in (RoleTag.EMBED_TOKENS, RoleTag.EMBED_POS):
89
+ return TensorKind.EMBEDDING
90
+ return TensorKind.WEIGHT
@@ -0,0 +1,332 @@
1
+ """Extraction against a hand-built module tree.
2
+
3
+ No transformers, no checkpoint, no download. A plain ``nn.Module`` shaped like a decoder-only
4
+ transformer exercises exactly the code a real model does, and keeps this runnable in the one
5
+ CI job that installs torch.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ import pytest
13
+
14
+ pytest.importorskip("torch", reason="metrik-torch[torch] is not installed")
15
+
16
+ import torch
17
+ from torch import nn
18
+
19
+ from metrik.sdk import ExtractionMethod, Fidelity, RoleTag, TensorKind
20
+ from metrik.torch import ROOT_NODE_ID, TorchLoader, extract
21
+
22
+ pytestmark = pytest.mark.unit
23
+
24
+ HIDDEN, HEADS, KV_HEADS, INTER, VOCAB, LAYERS = 64, 4, 2, 176, 512, 3
25
+
26
+
27
+ class Attention(nn.Module):
28
+ def __init__(self) -> None:
29
+ super().__init__()
30
+ head_dim = HIDDEN // HEADS
31
+ self.q_proj = nn.Linear(HIDDEN, HEADS * head_dim, bias=False)
32
+ self.k_proj = nn.Linear(HIDDEN, KV_HEADS * head_dim, bias=False)
33
+ self.v_proj = nn.Linear(HIDDEN, KV_HEADS * head_dim, bias=False)
34
+ self.o_proj = nn.Linear(HEADS * head_dim, HIDDEN, bias=False)
35
+
36
+
37
+ class Mlp(nn.Module):
38
+ def __init__(self) -> None:
39
+ super().__init__()
40
+ self.gate_proj = nn.Linear(HIDDEN, INTER, bias=False)
41
+ self.up_proj = nn.Linear(HIDDEN, INTER, bias=False)
42
+ self.down_proj = nn.Linear(INTER, HIDDEN, bias=False)
43
+
44
+
45
+ class DecoderLayer(nn.Module):
46
+ def __init__(self) -> None:
47
+ super().__init__()
48
+ self.self_attn = Attention()
49
+ self.mlp = Mlp()
50
+ self.input_layernorm = nn.LayerNorm(HIDDEN)
51
+ self.post_attention_layernorm = nn.LayerNorm(HIDDEN)
52
+
53
+
54
+ class Inner(nn.Module):
55
+ def __init__(self) -> None:
56
+ super().__init__()
57
+ self.embed_tokens = nn.Embedding(VOCAB, HIDDEN)
58
+ self.layers = nn.ModuleList(DecoderLayer() for _ in range(LAYERS))
59
+ self.norm = nn.LayerNorm(HIDDEN)
60
+
61
+
62
+ class TinyCausalLM(nn.Module):
63
+ def __init__(self, *, tie: bool = False) -> None:
64
+ super().__init__()
65
+ self.model = Inner()
66
+ self.lm_head = nn.Linear(HIDDEN, VOCAB, bias=False)
67
+ if tie:
68
+ # Exactly how transformers ties: the same Parameter object under two names.
69
+ self.lm_head.weight = self.model.embed_tokens.weight
70
+
71
+
72
+ @pytest.fixture
73
+ def graph() -> object:
74
+ return extract(TinyCausalLM(), loader_name="torch", loader_version="0.0.1")
75
+
76
+
77
+ def _node(graph: object, node_id: str) -> object:
78
+ return next(n for n in graph.nodes if n.node_id == node_id) # type: ignore[attr-defined]
79
+
80
+
81
+ def _tensor(graph: object, name: str) -> object:
82
+ return next(t for t in graph.tensors if t.name == name) # type: ignore[attr-defined]
83
+
84
+
85
+ # -- structure ------------------------------------------------------------------------------
86
+
87
+
88
+ def test_node_ids_are_qualified_names(graph: object) -> None:
89
+ ids = {n.node_id for n in graph.nodes} # type: ignore[attr-defined]
90
+ assert "model.layers.1.self_attn.q_proj" in ids
91
+ assert "model.embed_tokens" in ids
92
+ # The root has no name in named_modules(), and an empty id is not legal.
93
+ assert ROOT_NODE_ID in ids
94
+
95
+
96
+ def test_the_root_id_cannot_collide_with_a_real_module(graph: object) -> None:
97
+ """A root whose child is itself called `model` is the common case, so the sentinel uses
98
+ characters that cannot occur in a Python attribute path."""
99
+ assert "model" in {n.node_id for n in graph.nodes} # type: ignore[attr-defined]
100
+ assert ROOT_NODE_ID != "model"
101
+
102
+
103
+ def test_module_classes_are_recorded(graph: object) -> None:
104
+ """The thing the header-only loader fundamentally cannot know."""
105
+ assert _node(graph, "model.layers.0.self_attn.q_proj").module_class == "Linear" # type: ignore[attr-defined]
106
+ assert _node(graph, "model.layers.0").module_class == "DecoderLayer" # type: ignore[attr-defined]
107
+
108
+
109
+ def test_parameters_are_owned_by_the_module_that_declares_them(graph: object) -> None:
110
+ """`recurse=False` is what makes owner_node meaningful: with recursion every ancestor
111
+ would claim every descendant's weights and per-node counts would multiply by depth."""
112
+ assert _tensor(graph, "model.layers.0.self_attn.q_proj.weight").owner_node == ( # type: ignore[attr-defined]
113
+ "model.layers.0.self_attn.q_proj"
114
+ )
115
+ assert _node(graph, "model.layers.0").param_tensors == () # type: ignore[attr-defined]
116
+
117
+
118
+ def test_layer_index_is_none_outside_the_stack(graph: object) -> None:
119
+ """0 would make the first layer look anomalously large in every depth profile."""
120
+ assert _node(graph, "model.layers.2.mlp.up_proj").layer_index == 2 # type: ignore[attr-defined]
121
+ assert _node(graph, "model.embed_tokens").layer_index is None # type: ignore[attr-defined]
122
+
123
+
124
+ # -- roles ----------------------------------------------------------------------------------
125
+
126
+
127
+ @pytest.mark.parametrize(
128
+ ("node_id", "role"),
129
+ [
130
+ ("model.layers.0.self_attn.q_proj", RoleTag.ATTN_Q),
131
+ ("model.layers.0.self_attn.k_proj", RoleTag.ATTN_K),
132
+ ("model.layers.0.self_attn.v_proj", RoleTag.ATTN_V),
133
+ ("model.layers.0.self_attn.o_proj", RoleTag.ATTN_O),
134
+ ("model.layers.0.mlp.gate_proj", RoleTag.MLP_GATE),
135
+ ("model.layers.0.mlp.down_proj", RoleTag.MLP_DOWN),
136
+ ("model.layers.0.input_layernorm", RoleTag.ATTN_NORM),
137
+ ("model.layers.0.post_attention_layernorm", RoleTag.MLP_NORM),
138
+ ("model.embed_tokens", RoleTag.EMBED_TOKENS),
139
+ ("model.norm", RoleTag.NORM_FINAL),
140
+ ("lm_head", RoleTag.LM_HEAD),
141
+ ],
142
+ )
143
+ def test_roles(graph: object, node_id: str, role: RoleTag) -> None:
144
+ assert _node(graph, node_id).role is role # type: ignore[attr-defined]
145
+
146
+
147
+ def test_a_per_layer_norm_is_not_the_final_norm(graph: object) -> None:
148
+ """`model.norm` is anchored, so a hypothetical `model.layers.3.norm` cannot match it --
149
+ which would otherwise put a per-layer norm in the final-norm role."""
150
+ assert _node(graph, "model.norm").role is RoleTag.NORM_FINAL # type: ignore[attr-defined]
151
+ assert _node(graph, "model.layers.0.input_layernorm").role is not RoleTag.NORM_FINAL # type: ignore[attr-defined]
152
+
153
+
154
+ def test_containers_are_not_reported_as_unsupported(graph: object) -> None:
155
+ """A ModuleList is legitimately unclassifiable. Listing it would drown the signal that
156
+ matters: a *parameterised* module we failed to understand."""
157
+ assert "ModuleList" not in graph.extraction.unsupported # type: ignore[attr-defined]
158
+
159
+
160
+ def test_an_unrecognised_parameterised_module_is_reported() -> None:
161
+ class Mystery(nn.Module):
162
+ def __init__(self) -> None:
163
+ super().__init__()
164
+ self.weird_thing = nn.Linear(4, 4)
165
+
166
+ result = extract(Mystery(), loader_name="torch", loader_version="0.0.1")
167
+ assert "Linear" in result.extraction.unsupported
168
+ assert _node(result, "weird_thing").role is RoleTag.OTHER # type: ignore[attr-defined]
169
+
170
+
171
+ # -- weight tying, observed rather than declared ---------------------------------------------
172
+
173
+
174
+ def test_tying_is_detected_by_storage_identity() -> None:
175
+ """The headline improvement over the header-only loader, which cannot see this at all:
176
+ an untied lm_head has exactly the same shape as the embedding it does not share."""
177
+ graph = extract(TinyCausalLM(tie=True), loader_name="torch", loader_version="0.0.1")
178
+ head = _tensor(graph, "lm_head.weight")
179
+ assert head.tied_to == "model.embed_tokens.weight" # type: ignore[attr-defined]
180
+ assert graph.topology.tied_embeddings is True
181
+
182
+
183
+ def test_an_untied_model_reports_no_tying(graph: object) -> None:
184
+ assert _tensor(graph, "lm_head.weight").tied_to is None # type: ignore[attr-defined]
185
+ assert graph.topology.tied_embeddings is False # type: ignore[attr-defined]
186
+
187
+
188
+ def test_a_tied_weight_is_counted_once() -> None:
189
+ untied = extract(TinyCausalLM(), loader_name="torch", loader_version="0.0.1")
190
+ tied = extract(TinyCausalLM(tie=True), loader_name="torch", loader_version="0.0.1")
191
+ assert untied.param_count - tied.param_count == VOCAB * HIDDEN
192
+
193
+
194
+ # -- blocks ---------------------------------------------------------------------------------
195
+
196
+
197
+ def test_the_decoder_stack_is_one_repeat_group(graph: object) -> None:
198
+ """What lets N identical layers be recognised as one structure rather than N unrelated
199
+ ones, which is what makes per-layer plans comparable across model depths."""
200
+ groups = {b.repeat_group_id for b in graph.blocks} # type: ignore[attr-defined]
201
+ assert groups == {"model.layers"}
202
+ assert len(graph.blocks) == LAYERS # type: ignore[attr-defined]
203
+ assert {b.block_class for b in graph.blocks} == {"DecoderLayer"} # type: ignore[attr-defined]
204
+
205
+
206
+ def test_layer_count_is_observed_from_the_stack(graph: object) -> None:
207
+ assert graph.topology.n_layers == LAYERS # type: ignore[attr-defined]
208
+
209
+
210
+ # -- fidelity -------------------------------------------------------------------------------
211
+
212
+
213
+ def test_extraction_is_structural_with_no_edges(graph: object) -> None:
214
+ """nn.Module records containment, not dataflow. Claiming edges here would be fabricating
215
+ them, and the IR rejects the combination outright."""
216
+ assert graph.extraction.method is ExtractionMethod.TORCH_MODULE_TREE # type: ignore[attr-defined]
217
+ assert graph.extraction.fidelity is Fidelity.STRUCTURAL # type: ignore[attr-defined]
218
+ assert graph.edges is None # type: ignore[attr-defined]
219
+
220
+
221
+ def test_tensor_kinds_distinguish_a_norm_scale_from_a_weight(graph: object) -> None:
222
+ assert _tensor(graph, "model.layers.0.input_layernorm.weight").kind is TensorKind.NORM_SCALE # type: ignore[attr-defined]
223
+ assert _tensor(graph, "model.layers.0.input_layernorm.bias").kind is TensorKind.BIAS # type: ignore[attr-defined]
224
+ assert _tensor(graph, "model.embed_tokens.weight").kind is TensorKind.EMBEDDING # type: ignore[attr-defined]
225
+ assert _tensor(graph, "model.layers.0.mlp.up_proj.weight").kind is TensorKind.WEIGHT # type: ignore[attr-defined]
226
+
227
+
228
+ # -- determinism ----------------------------------------------------------------------------
229
+
230
+
231
+ def test_two_extractions_of_one_model_are_identical() -> None:
232
+ """Nodes and tensors are sorted precisely so this holds: module traversal order is not
233
+ stable across transformers versions, and an unsorted graph would hash differently for an
234
+ unchanged model."""
235
+ model = TinyCausalLM()
236
+ first = extract(model, loader_name="torch", loader_version="0.0.1")
237
+ second = extract(model, loader_name="torch", loader_version="0.0.1")
238
+ assert first.model_dump_json() == second.model_dump_json()
239
+
240
+
241
+ def test_extraction_never_reads_a_weight_value() -> None:
242
+ """Proved on the meta device, where the tensors have shapes but no storage at all. If
243
+ extraction touched a value this would raise; succeeding means only metadata was read --
244
+ which is what makes inspecting a 70B model on a laptop possible.
245
+ """
246
+ with torch.device("meta"):
247
+ model = TinyCausalLM(tie=True)
248
+ graph = extract(model, loader_name="torch", loader_version="0.0.1")
249
+ assert graph.param_count > 0
250
+ assert _node(graph, "model.layers.0.self_attn.q_proj").module_class == "Linear" # type: ignore[attr-defined]
251
+
252
+
253
+ def test_tying_survives_the_meta_device() -> None:
254
+ """The loader instantiates on `meta` by default, and every meta tensor reports
255
+ `data_ptr() == 0` -- so the obvious storage test would silently stop detecting tying on
256
+ the one path users actually take. It holds because tying shares the same Parameter object,
257
+ so identity still separates them, and the meta and real counts must agree exactly.
258
+ """
259
+ with torch.device("meta"):
260
+ meta = extract(TinyCausalLM(tie=True), loader_name="torch", loader_version="0.0.1")
261
+ real = extract(TinyCausalLM(tie=True), loader_name="torch", loader_version="0.0.1")
262
+
263
+ assert _tensor(meta, "lm_head.weight").tied_to == "model.embed_tokens.weight" # type: ignore[attr-defined]
264
+ assert meta.topology.tied_embeddings is True
265
+ assert meta.param_count == real.param_count
266
+
267
+
268
+ def test_meta_tensors_are_not_all_reported_as_tied_to_each_other() -> None:
269
+ """Every meta tensor reports data_ptr() == 0. Keying tying on that alone would make the
270
+ entire model look like one shared weight, and the parameter count collapse to a single
271
+ tensor -- a wrong number that looks like a very efficient model.
272
+ """
273
+ with torch.device("meta"):
274
+ model = TinyCausalLM()
275
+ graph = extract(model, loader_name="torch", loader_version="0.0.1")
276
+ tied = [t for t in graph.tensors if t.tied_to is not None]
277
+ assert tied == []
278
+
279
+
280
+ # -- the loader contract ----------------------------------------------------------------------
281
+
282
+
283
+ def test_the_loader_extracts_from_an_instantiated_module() -> None:
284
+ graph = TorchLoader().graph_of_module(TinyCausalLM(), config={"vocab_size": VOCAB})
285
+ assert graph.topology.vocab_size == VOCAB
286
+ assert graph.extraction.loader_name == "torch"
287
+
288
+
289
+ def test_can_load_requires_a_directory_with_a_config(tmp_path: Path) -> None:
290
+ loader = TorchLoader()
291
+ assert not loader.can_load(str(tmp_path))
292
+ (tmp_path / "config.json").write_text("{}", encoding="utf-8")
293
+ assert loader.can_load(str(tmp_path))
294
+
295
+
296
+ # -- honesty ---------------------------------------------------------------------------------
297
+
298
+
299
+ def test_the_emission_declares_its_missing_edges() -> None:
300
+ """A null field with no recorded reason is indistinguishable from a bug.
301
+
302
+ Caught by running it: the graph had `edges: None` and no omission, so it silently dropped
303
+ out of the CLI's "not determined" panel -- disappearing from exactly the place a reader
304
+ looks to find out what was not determined.
305
+ """
306
+ from metrik.core.hashing import Digest
307
+
308
+ loader = TorchLoader()
309
+ graph = loader.graph_of_module(TinyCausalLM())
310
+ emission = loader.emission(graph, Digest.parse("b3:" + "11" * 32))
311
+
312
+ assert emission.partial is not None
313
+ assert not emission.partial.complete
314
+ paths = {o.field_path for o in emission.partial.omissions}
315
+ assert "edges" in paths
316
+
317
+
318
+ def test_unclassified_modules_are_surfaced_as_an_omission() -> None:
319
+ """A graph where a parameterised module went unrecognised should say so, because the
320
+ share of parameters tagged `other` is what a consumer checks before making a
321
+ cross-architecture claim."""
322
+ from metrik.core.hashing import Digest
323
+
324
+ class Mystery(nn.Module):
325
+ def __init__(self) -> None:
326
+ super().__init__()
327
+ self.weird_thing = nn.Linear(4, 4)
328
+
329
+ loader = TorchLoader()
330
+ emission = loader.emission(loader.graph_of_module(Mystery()), Digest.parse("b3:" + "22" * 32))
331
+ assert emission.partial is not None
332
+ assert "nodes[].role" in {o.field_path for o in emission.partial.omissions}