metrik-core 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.
Files changed (33) hide show
  1. metrik_core-0.1.0/.gitignore +89 -0
  2. metrik_core-0.1.0/PKG-INFO +68 -0
  3. metrik_core-0.1.0/README.md +46 -0
  4. metrik_core-0.1.0/pyproject.toml +43 -0
  5. metrik_core-0.1.0/src/metrik/core/__init__.py +239 -0
  6. metrik_core-0.1.0/src/metrik/core/artifacts/__init__.py +38 -0
  7. metrik_core-0.1.0/src/metrik/core/artifacts/artifact.py +177 -0
  8. metrik_core-0.1.0/src/metrik/core/artifacts/envelope.py +241 -0
  9. metrik_core-0.1.0/src/metrik/core/bench.py +335 -0
  10. metrik_core-0.1.0/src/metrik/core/env.py +45 -0
  11. metrik_core-0.1.0/src/metrik/core/errors.py +125 -0
  12. metrik_core-0.1.0/src/metrik/core/hashing.py +407 -0
  13. metrik_core-0.1.0/src/metrik/core/ir.py +431 -0
  14. metrik_core-0.1.0/src/metrik/core/lineage.py +206 -0
  15. metrik_core-0.1.0/src/metrik/core/plugins.py +381 -0
  16. metrik_core-0.1.0/src/metrik/core/py.typed +0 -0
  17. metrik_core-0.1.0/src/metrik/core/runs.py +300 -0
  18. metrik_core-0.1.0/src/metrik/core/store/__init__.py +14 -0
  19. metrik_core-0.1.0/src/metrik/core/store/_fs.py +102 -0
  20. metrik_core-0.1.0/src/metrik/core/store/artifacts.py +380 -0
  21. metrik_core-0.1.0/src/metrik/core/store/blobs.py +130 -0
  22. metrik_core-0.1.0/src/metrik/core/viz.py +353 -0
  23. metrik_core-0.1.0/tests/test_ascii_messages.py +67 -0
  24. metrik_core-0.1.0/tests/test_bench.py +195 -0
  25. metrik_core-0.1.0/tests/test_canonical.py +297 -0
  26. metrik_core-0.1.0/tests/test_canonical_properties.py +134 -0
  27. metrik_core-0.1.0/tests/test_envelope.py +174 -0
  28. metrik_core-0.1.0/tests/test_ir.py +270 -0
  29. metrik_core-0.1.0/tests/test_lineage.py +267 -0
  30. metrik_core-0.1.0/tests/test_plugins.py +316 -0
  31. metrik_core-0.1.0/tests/test_runs.py +287 -0
  32. metrik_core-0.1.0/tests/test_store.py +369 -0
  33. metrik_core-0.1.0/tests/test_viz.py +257 -0
@@ -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,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: metrik-core
3
+ Version: 0.1.0
4
+ Summary: Metrik core — artifact envelope, canonical serialization, content addressing.
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
+ Keywords: machine-learning,provenance,quantization,reproducibility
11
+ Classifier: Development Status :: 2 - Pre-Alpha
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
17
+ Requires-Python: >=3.11
18
+ Requires-Dist: blake3>=1.0
19
+ Requires-Dist: packaging>=23.0
20
+ Requires-Dist: pydantic>=2.7
21
+ Description-Content-Type: text/markdown
22
+
23
+ # metrik-core
24
+
25
+ The vocabulary every other Metrik package agrees on: the artifact envelope, canonical
26
+ serialization, and content addressing.
27
+
28
+ **This package defines vocabulary, not behaviour** (`plan.md` §4). It has no ML dependencies —
29
+ no torch, no transformers — which is enforced in CI by `import-linter`, not by convention. That
30
+ constraint is what lets the artifact layer be installed and reasoned about without a multi-gigabyte
31
+ ML stack.
32
+
33
+ Not published yet. Phase 2 of [the roadmap](../../ROADMAP.md).
34
+
35
+ ## What is here so far
36
+
37
+ | Module | Purpose |
38
+ |---|---|
39
+ | `metrik.core.hashing` | Canonical JSON, `Digest`, `content_hash`, `derivation_hash` |
40
+ | `metrik.core.errors` | The error taxonomy plugins raise from |
41
+ | `metrik.core.artifacts` | The envelope every artifact carries |
42
+
43
+ ## The two hashes
44
+
45
+ The subtlest part of the design, and getting it wrong makes both caching and reproducibility
46
+ silently unreliable. There are two questions, so there are two hashes:
47
+
48
+ | Question | Answered by |
49
+ |---|---|
50
+ | *Is this the same result as that?* | `content_hash` — over the payload only |
51
+ | *Have I already computed this, so can I skip the work?* | `derivation_hash` — over how it was asked for |
52
+
53
+ A single hash cannot do both. Caching needs a key computable **before** running, so it must cover
54
+ inputs and parameters rather than results. Dedup and citation need a key covering **only** the
55
+ value, so two runs producing identical numbers are recognised as identical even if asked for
56
+ differently.
57
+
58
+ Because both are recorded, the store can *check* a producer's determinism claim rather than
59
+ believe it: same `derivation_hash`, different `content_hash`, and a declared class of
60
+ `deterministic` means the declaration is false.
61
+
62
+ ## Specification
63
+
64
+ The full contract is [`docs/planning/artifacts.md`](../../docs/planning/artifacts.md). This
65
+ package implements §2 (envelope), §3 (hashing and canonicalization), and §4 (blobs).
66
+
67
+ Canonicalization rules are normative and tested as properties, not examples — see
68
+ `tests/test_canonical_properties.py`.
@@ -0,0 +1,46 @@
1
+ # metrik-core
2
+
3
+ The vocabulary every other Metrik package agrees on: the artifact envelope, canonical
4
+ serialization, and content addressing.
5
+
6
+ **This package defines vocabulary, not behaviour** (`plan.md` §4). It has no ML dependencies —
7
+ no torch, no transformers — which is enforced in CI by `import-linter`, not by convention. That
8
+ constraint is what lets the artifact layer be installed and reasoned about without a multi-gigabyte
9
+ ML stack.
10
+
11
+ Not published yet. Phase 2 of [the roadmap](../../ROADMAP.md).
12
+
13
+ ## What is here so far
14
+
15
+ | Module | Purpose |
16
+ |---|---|
17
+ | `metrik.core.hashing` | Canonical JSON, `Digest`, `content_hash`, `derivation_hash` |
18
+ | `metrik.core.errors` | The error taxonomy plugins raise from |
19
+ | `metrik.core.artifacts` | The envelope every artifact carries |
20
+
21
+ ## The two hashes
22
+
23
+ The subtlest part of the design, and getting it wrong makes both caching and reproducibility
24
+ silently unreliable. There are two questions, so there are two hashes:
25
+
26
+ | Question | Answered by |
27
+ |---|---|
28
+ | *Is this the same result as that?* | `content_hash` — over the payload only |
29
+ | *Have I already computed this, so can I skip the work?* | `derivation_hash` — over how it was asked for |
30
+
31
+ A single hash cannot do both. Caching needs a key computable **before** running, so it must cover
32
+ inputs and parameters rather than results. Dedup and citation need a key covering **only** the
33
+ value, so two runs producing identical numbers are recognised as identical even if asked for
34
+ differently.
35
+
36
+ Because both are recorded, the store can *check* a producer's determinism claim rather than
37
+ believe it: same `derivation_hash`, different `content_hash`, and a declared class of
38
+ `deterministic` means the declaration is false.
39
+
40
+ ## Specification
41
+
42
+ The full contract is [`docs/planning/artifacts.md`](../../docs/planning/artifacts.md). This
43
+ package implements §2 (envelope), §3 (hashing and canonicalization), and §4 (blobs).
44
+
45
+ Canonicalization rules are normative and tested as properties, not examples — see
46
+ `tests/test_canonical_properties.py`.
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "metrik-core"
7
+ version = "0.1.0"
8
+ description = "Metrik core — artifact envelope, canonical serialization, content addressing."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "Apache-2.0"
12
+ authors = [{ name = "The Metrik Authors" }]
13
+ keywords = ["machine-learning", "quantization", "provenance", "reproducibility"]
14
+ classifiers = [
15
+ "Development Status :: 2 - Pre-Alpha",
16
+ "Intended Audience :: Science/Research",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
21
+ ]
22
+
23
+ # Deliberately tiny. metrik-core defines vocabulary, not behaviour (plan.md §4), and
24
+ # must be installable without a multi-GB ML stack:
25
+ # - NO torch, NO transformers — enforced by .importlinter (ADR 007).
26
+ # - blake3 is a compiled dependency, taken knowingly: buy native performance as a
27
+ # dependency rather than as a component (ADR 001, ADR 023).
28
+ dependencies = [
29
+ "pydantic>=2.7",
30
+ "blake3>=1.0",
31
+ # Semver range parsing for plugin API compatibility. Pure Python, no transitive deps,
32
+ # already present in every build environment -- the alternative is hand-rolling version
33
+ # comparison, which is a well-known way to be subtly wrong about pre-releases.
34
+ "packaging>=23.0",
35
+ ]
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/Asmodeus14/Metrik"
39
+ Repository = "https://github.com/Asmodeus14/Metrik"
40
+ Issues = "https://github.com/Asmodeus14/Metrik/issues"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["src/metrik"]
@@ -0,0 +1,239 @@
1
+ """Metrik core — the vocabulary every other package agrees on.
2
+
3
+ This package defines vocabulary, not behaviour (``plan.md`` §4). It has **no ML
4
+ dependencies** — no torch, no transformers — which is enforced in CI by ``import-linter``
5
+ rather than by convention, and is what lets the artifact layer be installed and reasoned
6
+ about without a multi-gigabyte stack.
7
+
8
+ Specification: ``docs/planning/artifacts.md``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from metrik.core.artifacts.artifact import MAX_PAYLOAD_BYTES, Artifact, build_artifact
14
+ from metrik.core.artifacts.envelope import (
15
+ ROOT_ARTIFACT_TYPES,
16
+ BlobRef,
17
+ DeterminismClass,
18
+ Envelope,
19
+ InputRef,
20
+ Omission,
21
+ PartialInfo,
22
+ ProducerRef,
23
+ Provenance,
24
+ ReasonCode,
25
+ )
26
+ from metrik.core.bench import (
27
+ BaselineComparison,
28
+ BenchResult,
29
+ ContaminationCheck,
30
+ CostRecord,
31
+ GenerationSpec,
32
+ HarnessName,
33
+ HarnessRef,
34
+ Isolation,
35
+ Metric,
36
+ SandboxAttestation,
37
+ SandboxLimits,
38
+ TaskRef,
39
+ )
40
+ from metrik.core.errors import (
41
+ BackendError,
42
+ CancelledError,
43
+ FailureKind,
44
+ InsufficientResourcesError,
45
+ LineageError,
46
+ MetrikError,
47
+ UnsupportedError,
48
+ UserError,
49
+ ValidationError,
50
+ )
51
+ from metrik.core.hashing import (
52
+ CanonicalizationError,
53
+ Digest,
54
+ HashAlgo,
55
+ canonical_bytes,
56
+ canonical_json,
57
+ content_hash,
58
+ derivation_hash,
59
+ hash_bytes,
60
+ hash_stream,
61
+ )
62
+ from metrik.core.ir import (
63
+ IR_VERSION,
64
+ AttentionKind,
65
+ Block,
66
+ Edge,
67
+ ExtractionInfo,
68
+ ExtractionMethod,
69
+ FfnKind,
70
+ Fidelity,
71
+ ModelGraph,
72
+ MoEInfo,
73
+ Node,
74
+ NodeKind,
75
+ NormKind,
76
+ PositionKind,
77
+ RoleTag,
78
+ RopeInfo,
79
+ TensorDecl,
80
+ TensorKind,
81
+ Topology,
82
+ op_node_id,
83
+ )
84
+ from metrik.core.lineage import Lineage, LineageEdge, lineage_of
85
+ from metrik.core.plugins import PluginManifest, PluginProblem, PluginRef, PluginRegistry
86
+ from metrik.core.runs import (
87
+ RUN_ARTIFACT_TYPE,
88
+ CacheEvent,
89
+ FailureInfo,
90
+ RunRecorder,
91
+ RunStatus,
92
+ Step,
93
+ StepStatus,
94
+ )
95
+ from metrik.core.store import ArtifactStore, BlobStore, DeterminismViolationWarning
96
+ from metrik.core.viz import (
97
+ APPROVED_COLOR_SCHEMES,
98
+ MAX_INLINE_POINTS,
99
+ VIZ_SPEC_VERSION,
100
+ A11yBlock,
101
+ ChannelType,
102
+ Downsample,
103
+ EncodingDef,
104
+ Fallbacks,
105
+ Interaction,
106
+ InteractionKind,
107
+ ProvenanceBlock,
108
+ RenderHints,
109
+ ScaleDef,
110
+ TableSpec,
111
+ TransformKind,
112
+ TransformStep,
113
+ ViewKind,
114
+ VizData,
115
+ VizSpec,
116
+ )
117
+
118
+
119
+ def _distribution_version(name: str) -> str:
120
+ from importlib.metadata import PackageNotFoundError, version
121
+
122
+ try:
123
+ return version(name)
124
+ except PackageNotFoundError: # pragma: no cover - only in an uninstalled source tree
125
+ return "0+unknown"
126
+
127
+
128
+ #: Read from the installed distribution rather than hardcoded.
129
+ #:
130
+ #: Both existed for a while and drifted: a 0.1.0 build reported 0.0.1 from `metrik --version`,
131
+ #: found by installing the actual wheel rather than by any test. A version string is exactly
132
+ #: the kind of duplicate that stays wrong quietly, because nothing imports it in anger.
133
+ #:
134
+ #: The fallback covers a source checkout with nothing installed, where there is no metadata to
135
+ #: read and no released version to name.
136
+ __version__ = _distribution_version("metrik-core")
137
+
138
+ __all__ = [
139
+ "APPROVED_COLOR_SCHEMES",
140
+ "IR_VERSION",
141
+ "MAX_INLINE_POINTS",
142
+ "MAX_PAYLOAD_BYTES",
143
+ "ROOT_ARTIFACT_TYPES",
144
+ "RUN_ARTIFACT_TYPE",
145
+ "VIZ_SPEC_VERSION",
146
+ "A11yBlock",
147
+ "Artifact",
148
+ "ArtifactStore",
149
+ "AttentionKind",
150
+ "BackendError",
151
+ "BaselineComparison",
152
+ "BenchResult",
153
+ "BlobRef",
154
+ "BlobStore",
155
+ "Block",
156
+ "CacheEvent",
157
+ "CancelledError",
158
+ "CanonicalizationError",
159
+ "ChannelType",
160
+ "ContaminationCheck",
161
+ "CostRecord",
162
+ "DeterminismClass",
163
+ "DeterminismViolationWarning",
164
+ "Digest",
165
+ "Downsample",
166
+ "Edge",
167
+ "EncodingDef",
168
+ "Envelope",
169
+ "ExtractionInfo",
170
+ "ExtractionMethod",
171
+ "FailureInfo",
172
+ "FailureKind",
173
+ "Fallbacks",
174
+ "FfnKind",
175
+ "Fidelity",
176
+ "GenerationSpec",
177
+ "HarnessName",
178
+ "HarnessRef",
179
+ "HashAlgo",
180
+ "InputRef",
181
+ "InsufficientResourcesError",
182
+ "Interaction",
183
+ "InteractionKind",
184
+ "Isolation",
185
+ "Lineage",
186
+ "LineageEdge",
187
+ "LineageError",
188
+ "Metric",
189
+ "MetrikError",
190
+ "MoEInfo",
191
+ "ModelGraph",
192
+ "Node",
193
+ "NodeKind",
194
+ "NormKind",
195
+ "Omission",
196
+ "PartialInfo",
197
+ "PluginManifest",
198
+ "PluginProblem",
199
+ "PluginRef",
200
+ "PluginRegistry",
201
+ "PositionKind",
202
+ "ProducerRef",
203
+ "Provenance",
204
+ "ProvenanceBlock",
205
+ "ReasonCode",
206
+ "RenderHints",
207
+ "RoleTag",
208
+ "RopeInfo",
209
+ "RunRecorder",
210
+ "RunStatus",
211
+ "SandboxAttestation",
212
+ "SandboxLimits",
213
+ "ScaleDef",
214
+ "Step",
215
+ "StepStatus",
216
+ "TableSpec",
217
+ "TaskRef",
218
+ "TensorDecl",
219
+ "TensorKind",
220
+ "Topology",
221
+ "TransformKind",
222
+ "TransformStep",
223
+ "UnsupportedError",
224
+ "UserError",
225
+ "ValidationError",
226
+ "ViewKind",
227
+ "VizData",
228
+ "VizSpec",
229
+ "__version__",
230
+ "build_artifact",
231
+ "canonical_bytes",
232
+ "canonical_json",
233
+ "content_hash",
234
+ "derivation_hash",
235
+ "hash_bytes",
236
+ "hash_stream",
237
+ "lineage_of",
238
+ "op_node_id",
239
+ ]
@@ -0,0 +1,38 @@
1
+ """Artifact schemas.
2
+
3
+ The envelope lives here. Concrete artifact types (``ModelFingerprint``, ``ModelGraph``,
4
+ ``ProfileRecord``, …) arrive alongside the producers that emit them; the catalogue is
5
+ specified in ``docs/planning/artifacts.md`` §6.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from metrik.core.artifacts.artifact import MAX_PAYLOAD_BYTES, Artifact, build_artifact
11
+ from metrik.core.artifacts.envelope import (
12
+ ROOT_ARTIFACT_TYPES,
13
+ BlobRef,
14
+ DeterminismClass,
15
+ Envelope,
16
+ InputRef,
17
+ Omission,
18
+ PartialInfo,
19
+ ProducerRef,
20
+ Provenance,
21
+ ReasonCode,
22
+ )
23
+
24
+ __all__ = [
25
+ "MAX_PAYLOAD_BYTES",
26
+ "ROOT_ARTIFACT_TYPES",
27
+ "Artifact",
28
+ "BlobRef",
29
+ "DeterminismClass",
30
+ "Envelope",
31
+ "InputRef",
32
+ "Omission",
33
+ "PartialInfo",
34
+ "ProducerRef",
35
+ "Provenance",
36
+ "ReasonCode",
37
+ "build_artifact",
38
+ ]
@@ -0,0 +1,177 @@
1
+ """An artifact: envelope plus payload.
2
+
3
+ The envelope is described in :mod:`metrik.core.artifacts.envelope`. This module is the pair,
4
+ plus the one constructor producers should use — :func:`build_artifact`, which computes
5
+ ``content_hash`` from the payload rather than accepting it.
6
+
7
+ That is deliberate. A producer that can *pass in* a content hash can pass in a wrong one, and
8
+ a wrong content hash is undetectable by inspection and fatal to every citation that quotes it.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from datetime import UTC, datetime
15
+ from typing import Any
16
+
17
+ from pydantic import BaseModel, ConfigDict, field_validator, model_validator
18
+
19
+ from metrik.core.artifacts.envelope import (
20
+ DeterminismClass,
21
+ Envelope,
22
+ InputRef,
23
+ PartialInfo,
24
+ ProducerRef,
25
+ )
26
+ from metrik.core.hashing import (
27
+ Digest,
28
+ canonical_bytes,
29
+ canonical_json,
30
+ content_hash,
31
+ derivation_hash,
32
+ )
33
+
34
+ __all__ = ["MAX_PAYLOAD_BYTES", "Artifact", "build_artifact"]
35
+
36
+ #: ``artifacts.md`` §4. A payload you cannot open in an editor is one nobody will review, and
37
+ #: unreviewed artifacts are how a schema quietly starts carrying per-tensor arrays that scale
38
+ #: with model size. Anything that scales goes to a blob and the payload keeps aggregates.
39
+ MAX_PAYLOAD_BYTES = 1024 * 1024
40
+
41
+
42
+ class Artifact(BaseModel):
43
+ """A stored fact: what was computed, and everything needed to trace it."""
44
+
45
+ model_config = ConfigDict(frozen=True, extra="forbid")
46
+
47
+ envelope: Envelope
48
+ payload: dict[str, Any]
49
+
50
+ @field_validator("payload", mode="before")
51
+ @classmethod
52
+ def _canonicalize(cls, value: Any) -> Any:
53
+ """Replace the payload with its canonical form at construction.
54
+
55
+ Without this, an artifact does not survive a round trip through its own store. Rule 6
56
+ omits null fields on write (``artifacts.md`` §3.1), so ``{"license_id": None}`` is
57
+ stored as ``{}`` and read back with the key *missing* -- and a consumer written against
58
+ the schema gets ``KeyError`` where it expected ``None``. The hashes agree either way,
59
+ which is exactly what makes the discrepancy easy to miss.
60
+
61
+ Normalizing here means ``artifact.payload`` is always precisely what is on disk.
62
+ Canonicalization is idempotent (asserted as a property test), so doing it early cannot
63
+ change any hash.
64
+ """
65
+ if isinstance(value, dict):
66
+ return json.loads(canonical_json(value))
67
+ return value
68
+
69
+ @model_validator(mode="after")
70
+ def _check_self_consistent(self) -> Artifact:
71
+ actual = content_hash(self.payload, algo=self.envelope.content_hash.algo)
72
+ if actual != self.envelope.content_hash:
73
+ raise ValueError(
74
+ f"content_hash does not match payload: envelope claims "
75
+ f"{self.envelope.content_hash.short()}, payload hashes to {actual.short()}"
76
+ )
77
+ size = len(canonical_bytes(self.payload))
78
+ if size > MAX_PAYLOAD_BYTES:
79
+ raise ValueError(
80
+ f"payload is {size} bytes, over the {MAX_PAYLOAD_BYTES}-byte limit "
81
+ f"(artifacts.md sec. 4). Move the field that scales with model size into a blob "
82
+ f"and keep aggregates in the payload."
83
+ )
84
+ return self
85
+
86
+ @property
87
+ def artifact_id(self) -> Digest:
88
+ """The storage key: a digest over the envelope, **excluding ``created_at``**.
89
+
90
+ Not a third semantic hash. ``content_hash`` and ``derivation_hash`` answer the two
91
+ questions in ``artifacts.md`` §3; this one answers neither, it just names a file.
92
+
93
+ It has to exist because neither of the other two can serve as a primary key:
94
+
95
+ * ``content_hash`` cannot, because two producers can compute the same value by
96
+ different routes, and keying on it would silently discard the second one's
97
+ provenance — the exact information the spine exists to keep.
98
+ * ``derivation_hash`` cannot, because ``artifacts.md`` §3.3 requires the store to
99
+ hold two artifacts that share one and *differ* in ``content_hash``. That is how a
100
+ producer falsely claiming ``deterministic`` gets caught. Keying on it would
101
+ overwrite the evidence.
102
+
103
+ Git makes the same split for the same reason: a commit is not its tree.
104
+
105
+ Why ``created_at`` is excluded
106
+ ------------------------------
107
+
108
+ It records when we *noticed* a fact, not the fact. Including it meant re-running a
109
+ deterministic producer wrote a brand-new record every time, identical in every respect
110
+ that matters. Two real costs: the store grew without bound on repeated runs, and --
111
+ because ``InputRef`` resolves by ``content_hash`` -- each duplicate added another
112
+ candidate parent, so lineage went ambiguous on the second run and N-way ambiguous
113
+ after N. ``metrik explore`` run twice already showed it.
114
+
115
+ Everything distinguishing two genuine facts is still in the key: producer, version,
116
+ derivation, content, ``env``, ``labels``, ``partial``. Only the clock is dropped.
117
+ """
118
+ envelope = self.envelope.model_dump(exclude={"created_at"})
119
+ return content_hash(envelope, algo=self.envelope.content_hash.algo)
120
+
121
+
122
+ def build_artifact(
123
+ *,
124
+ artifact_type: str,
125
+ schema_version: int,
126
+ payload: dict[str, Any],
127
+ determinism: DeterminismClass,
128
+ produced_by: ProducerRef,
129
+ inputs: tuple[InputRef, ...] = (),
130
+ params: Any = None,
131
+ seed: int | None = None,
132
+ device_class: str | None = None,
133
+ env: dict[str, object] | None = None,
134
+ labels: dict[str, str] | None = None,
135
+ supersedes: Digest | None = None,
136
+ partial: PartialInfo | None = None,
137
+ created_at: datetime | None = None,
138
+ ) -> Artifact:
139
+ """Assemble an artifact, computing both hashes from the inputs given.
140
+
141
+ Neither hash is a parameter, so neither can be wrong. ``derivation_hash`` is built from
142
+ the same ``params``/``inputs``/``seed`` the producer actually used, which is what makes a
143
+ later cache probe on those values meaningful.
144
+
145
+ Args:
146
+ params: The producer's typed parameter object. Untyped ``**kwargs`` have no canonical
147
+ form and therefore cannot be cached correctly (``sdk-contracts.md`` §4).
148
+ device_class: Pass only for producers declaring themselves device-sensitive.
149
+ Including it elsewhere fragments the cache for nothing.
150
+ """
151
+ ch = content_hash(payload)
152
+ dh = derivation_hash(
153
+ artifact_type=artifact_type,
154
+ schema_version=schema_version,
155
+ producer_name=produced_by.name,
156
+ producer_version=produced_by.version,
157
+ api_version=produced_by.api_version,
158
+ params=params,
159
+ inputs=[(ref.role, ref.content_hash) for ref in inputs],
160
+ seed=seed,
161
+ device_class=device_class,
162
+ )
163
+ envelope = Envelope(
164
+ artifact_type=artifact_type,
165
+ schema_version=schema_version,
166
+ content_hash=ch,
167
+ derivation_hash=dh,
168
+ determinism=determinism,
169
+ created_at=created_at or datetime.now(UTC),
170
+ produced_by=produced_by,
171
+ inputs=inputs,
172
+ env=env,
173
+ labels=labels or {},
174
+ supersedes=supersedes,
175
+ partial=partial,
176
+ )
177
+ return Artifact(envelope=envelope, payload=payload)