metrik-sdk 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,35 @@
1
+ Metadata-Version: 2.4
2
+ Name: metrik-sdk
3
+ Version: 0.1.0
4
+ Summary: Metrik plugin SDK — the public contracts every producer and consumer is written against.
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 :: Developers
12
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
13
+ Requires-Python: >=3.11
14
+ Requires-Dist: metrik-core==0.1.0
15
+ Requires-Dist: pydantic>=2.7
16
+ Description-Content-Type: text/markdown
17
+
18
+ # metrik-sdk
19
+
20
+ The public contracts. Every first-party module is written against this package and nothing
21
+ else — if a built-in producer needs something the SDK does not expose, that is a **bug in the
22
+ SDK**, and the fix is to expose it publicly rather than to reach through into `metrik.core`.
23
+
24
+ That rule is enforced in CI by the `sdk-dogfooding` contract in
25
+ [`.importlinter`](../../.importlinter), from Phase 2 — seven phases before the SDK is
26
+ nominally "delivered". It is how the SDK gets to be real instead of aspirational.
27
+
28
+ See [`sdk-contracts.md`](../../docs/planning/sdk-contracts.md) for the full specification.
29
+
30
+ ## Status
31
+
32
+ Phase 2. Of the ten contracts in the spec, `ModelLoader` and `Producer` exist because the
33
+ Explorer needs them. The rest arrive with the modules that implement them; adding a contract
34
+ before there is an implementation to check it against is how SDKs end up with methods nobody
35
+ can call.
@@ -0,0 +1,18 @@
1
+ # metrik-sdk
2
+
3
+ The public contracts. Every first-party module is written against this package and nothing
4
+ else — if a built-in producer needs something the SDK does not expose, that is a **bug in the
5
+ SDK**, and the fix is to expose it publicly rather than to reach through into `metrik.core`.
6
+
7
+ That rule is enforced in CI by the `sdk-dogfooding` contract in
8
+ [`.importlinter`](../../.importlinter), from Phase 2 — seven phases before the SDK is
9
+ nominally "delivered". It is how the SDK gets to be real instead of aspirational.
10
+
11
+ See [`sdk-contracts.md`](../../docs/planning/sdk-contracts.md) for the full specification.
12
+
13
+ ## Status
14
+
15
+ Phase 2. Of the ten contracts in the spec, `ModelLoader` and `Producer` exist because the
16
+ Explorer needs them. The rest arrive with the modules that implement them; adding a contract
17
+ before there is an implementation to check it against is how SDKs end up with methods nobody
18
+ can call.
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "metrik-sdk"
7
+ version = "0.1.0"
8
+ description = "Metrik plugin SDK — the public contracts every producer and consumer is written against."
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 :: Developers",
16
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
17
+ ]
18
+
19
+ # Separately versioned from metrik-core on purpose (sdk-contracts.md §7): a plugin author
20
+ # pins the SDK, not the engine. NO torch here either -- implementing an interface must not
21
+ # cost a multi-GB dependency.
22
+ dependencies = [
23
+ "metrik-core==0.1.0",
24
+ "pydantic>=2.7",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/Asmodeus14/Metrik"
29
+ Repository = "https://github.com/Asmodeus14/Metrik"
30
+ Issues = "https://github.com/Asmodeus14/Metrik/issues"
31
+
32
+ [tool.uv.sources]
33
+ metrik-core = { workspace = true }
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/metrik"]
@@ -0,0 +1,225 @@
1
+ """The Metrik plugin SDK — the only Metrik package a producer or consumer imports.
2
+
3
+ Everything a module needs is re-exported here, including the parts that originate in
4
+ ``metrik.core``. That is the point: the `sdk-dogfooding` contract in ``.importlinter`` forbids
5
+ first-party modules from importing ``metrik.core`` at all, so anything missing from this
6
+ namespace is a **bug in the SDK** and gets fixed by exporting it, never by reaching through.
7
+
8
+ Because the built-in Explorer is written against this and nothing else, the SDK is exercised
9
+ from Phase 2 rather than being designed in the abstract and discovered to be wrong in Phase 9.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from metrik.core.artifacts import (
15
+ Artifact,
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
+ MetrikError,
46
+ UnsupportedError,
47
+ UserError,
48
+ ValidationError,
49
+ )
50
+ from metrik.core.hashing import (
51
+ Digest,
52
+ HashAlgo,
53
+ canonical_bytes,
54
+ content_hash,
55
+ hash_bytes,
56
+ hash_stream,
57
+ )
58
+ from metrik.core.ir import (
59
+ IR_VERSION,
60
+ AttentionKind,
61
+ Block,
62
+ Edge,
63
+ ExtractionInfo,
64
+ ExtractionMethod,
65
+ FfnKind,
66
+ Fidelity,
67
+ ModelGraph,
68
+ MoEInfo,
69
+ Node,
70
+ NodeKind,
71
+ NormKind,
72
+ PositionKind,
73
+ RoleTag,
74
+ RopeInfo,
75
+ TensorDecl,
76
+ TensorKind,
77
+ Topology,
78
+ op_node_id,
79
+ )
80
+ from metrik.core.plugins import CONTRACT_GROUPS, PluginManifest
81
+ from metrik.core.viz import (
82
+ APPROVED_COLOR_SCHEMES,
83
+ MAX_INLINE_POINTS,
84
+ VIZ_SPEC_VERSION,
85
+ A11yBlock,
86
+ ChannelType,
87
+ Downsample,
88
+ EncodingDef,
89
+ Fallbacks,
90
+ Interaction,
91
+ InteractionKind,
92
+ ProvenanceBlock,
93
+ RenderHints,
94
+ ScaleDef,
95
+ TableSpec,
96
+ TransformKind,
97
+ TransformStep,
98
+ ViewKind,
99
+ VizData,
100
+ VizSpec,
101
+ )
102
+ from metrik.sdk.contracts import (
103
+ Emission,
104
+ ModelLoader,
105
+ Params,
106
+ Producer,
107
+ ProducerInfo,
108
+ Spine,
109
+ )
110
+ from metrik.sdk.runner import LocalSpine, emit
111
+
112
+ #: The SDK's own version, which is what a plugin declares compatibility against
113
+ #: (``sdk-contracts.md`` §7). Deliberately not tied to ``metrik-core``'s version: the engine
114
+ #: may move without the contract moving, and a plugin author pins the contract.
115
+ API_VERSION = "1.0.0"
116
+
117
+
118
+ def _distribution_version(name: str) -> str:
119
+ from importlib.metadata import PackageNotFoundError, version
120
+
121
+ try:
122
+ return version(name)
123
+ except PackageNotFoundError: # pragma: no cover - only in an uninstalled source tree
124
+ return "0+unknown"
125
+
126
+
127
+ #: Read from the installed distribution rather than hardcoded.
128
+ #:
129
+ #: Both existed for a while and drifted: a 0.1.0 build reported 0.0.1 from `metrik --version`,
130
+ #: found by installing the actual wheel rather than by any test. A version string is exactly
131
+ #: the kind of duplicate that stays wrong quietly, because nothing imports it in anger.
132
+ #:
133
+ #: The fallback covers a source checkout with nothing installed, where there is no metadata to
134
+ #: read and no released version to name.
135
+ __version__ = _distribution_version("metrik-sdk")
136
+
137
+ __all__ = [
138
+ "API_VERSION",
139
+ "APPROVED_COLOR_SCHEMES",
140
+ "CONTRACT_GROUPS",
141
+ "IR_VERSION",
142
+ "MAX_INLINE_POINTS",
143
+ "VIZ_SPEC_VERSION",
144
+ "A11yBlock",
145
+ "Artifact",
146
+ "AttentionKind",
147
+ "BackendError",
148
+ "BaselineComparison",
149
+ "BenchResult",
150
+ "BlobRef",
151
+ "Block",
152
+ "CancelledError",
153
+ "ChannelType",
154
+ "ContaminationCheck",
155
+ "CostRecord",
156
+ "DeterminismClass",
157
+ "Digest",
158
+ "Downsample",
159
+ "Edge",
160
+ "Emission",
161
+ "EncodingDef",
162
+ "Envelope",
163
+ "ExtractionInfo",
164
+ "ExtractionMethod",
165
+ "FailureKind",
166
+ "Fallbacks",
167
+ "FfnKind",
168
+ "Fidelity",
169
+ "GenerationSpec",
170
+ "HarnessName",
171
+ "HarnessRef",
172
+ "HashAlgo",
173
+ "InputRef",
174
+ "InsufficientResourcesError",
175
+ "Interaction",
176
+ "InteractionKind",
177
+ "Isolation",
178
+ "LocalSpine",
179
+ "Metric",
180
+ "MetrikError",
181
+ "MoEInfo",
182
+ "ModelGraph",
183
+ "ModelLoader",
184
+ "Node",
185
+ "NodeKind",
186
+ "NormKind",
187
+ "Omission",
188
+ "Params",
189
+ "PartialInfo",
190
+ "PluginManifest",
191
+ "PositionKind",
192
+ "Producer",
193
+ "ProducerInfo",
194
+ "ProducerRef",
195
+ "Provenance",
196
+ "ProvenanceBlock",
197
+ "ReasonCode",
198
+ "RenderHints",
199
+ "RoleTag",
200
+ "RopeInfo",
201
+ "SandboxAttestation",
202
+ "SandboxLimits",
203
+ "ScaleDef",
204
+ "Spine",
205
+ "TableSpec",
206
+ "TaskRef",
207
+ "TensorDecl",
208
+ "TensorKind",
209
+ "Topology",
210
+ "TransformKind",
211
+ "TransformStep",
212
+ "UnsupportedError",
213
+ "UserError",
214
+ "ValidationError",
215
+ "ViewKind",
216
+ "VizData",
217
+ "VizSpec",
218
+ "__version__",
219
+ "canonical_bytes",
220
+ "content_hash",
221
+ "emit",
222
+ "hash_bytes",
223
+ "hash_stream",
224
+ "op_node_id",
225
+ ]
@@ -0,0 +1,139 @@
1
+ """The producer contract and the spine handle.
2
+
3
+ ``sdk-contracts.md`` §4 and §5. Two rules shape everything here:
4
+
5
+ **Parameters are typed, always.** ``derivation_hash`` -- the cache key -- is computed over the
6
+ parameter object, so a producer accepting ``**kwargs`` cannot be cached correctly: two callers
7
+ passing logically identical arguments would miss each other's cache, and a parameter that
8
+ silently changes behaviour would produce a false cache *hit*.
9
+
10
+ **Producers do not write.** A producer returns an :class:`Emission`; the spine builds the
11
+ envelope and stores it. A producer that could write would be a producer that could store an
12
+ artifact with no lineage, and then the graph is a suggestion again.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Mapping
18
+ from pathlib import Path
19
+ from typing import Any, BinaryIO, Protocol, runtime_checkable
20
+
21
+ from pydantic import BaseModel, ConfigDict
22
+
23
+ from metrik.core.artifacts import Artifact, BlobRef, DeterminismClass, PartialInfo
24
+ from metrik.core.hashing import Digest
25
+
26
+ __all__ = [
27
+ "Emission",
28
+ "ModelLoader",
29
+ "Params",
30
+ "Producer",
31
+ "ProducerInfo",
32
+ "Spine",
33
+ ]
34
+
35
+
36
+ class Params(BaseModel):
37
+ """Base for a producer's declared parameters.
38
+
39
+ Frozen and ``extra="forbid"`` so the object has one canonical form. An unknown key must be
40
+ an error rather than an ignored field: silently dropping ``n_sample`` (for ``n_samples``)
41
+ would produce a *valid* cache key for the wrong computation, and the result would look
42
+ correct forever.
43
+ """
44
+
45
+ model_config = ConfigDict(frozen=True, extra="forbid")
46
+
47
+
48
+ class ProducerInfo(BaseModel):
49
+ """Identity of a producer, as it will appear in every artifact it emits."""
50
+
51
+ model_config = ConfigDict(frozen=True, extra="forbid")
52
+
53
+ name: str
54
+ version: str
55
+ #: SDK version this producer was written against. The registry refuses to load a plugin
56
+ #: whose declared range excludes the running version (``sdk-contracts.md`` §7).
57
+ api_version: str
58
+ #: "builtin" for in-tree modules, "plugin" for third-party. Recorded so that a traceback
59
+ #: from someone else's probe is never rendered as a Metrik bug.
60
+ kind: str = "plugin"
61
+
62
+
63
+ class Emission(BaseModel):
64
+ """What a producer returns: a payload and a claim about it.
65
+
66
+ Note what is *absent*: no hashes, no timestamps, no envelope, no path. The producer states
67
+ what it computed and what it read; the spine decides how that is identified and stored.
68
+ """
69
+
70
+ model_config = ConfigDict(frozen=True, extra="forbid")
71
+
72
+ artifact_type: str
73
+ schema_version: int
74
+ payload: dict[str, Any]
75
+ determinism: DeterminismClass
76
+ #: Roles mapped to the ``content_hash`` of each input actually used. Checked against what
77
+ #: the spine observed the producer resolve -- under-declaring is detectable, not merely
78
+ #: discouraged (``artifacts.md`` §2.2).
79
+ inputs: Mapping[str, Digest] = {}
80
+ #: Present when the producer finished with gaps. A field it could not determine is null
81
+ #: *and* named here -- never 0, never interpolated (``artifacts.md`` §2.3).
82
+ partial: PartialInfo | None = None
83
+ #: Required whenever ``determinism`` is not ``DETERMINISTIC``.
84
+ env: dict[str, object] | None = None
85
+
86
+
87
+ @runtime_checkable
88
+ class Spine(Protocol):
89
+ """A producer's entire connection to the platform.
90
+
91
+ Producers do not open the store, do not touch the database, and do not import each other.
92
+ ``require()`` is how one producer gets another's output: it resolves cache-or-compute
93
+ through the spine, which is what keeps the DAG acyclic and every intermediate reusable
94
+ (``plan.md`` §3.4).
95
+ """
96
+
97
+ def get(self, digest: Digest) -> Artifact: ...
98
+
99
+ def open_blob(self, ref: BlobRef) -> BinaryIO: ...
100
+
101
+ def put_blob(self, data: BinaryIO | bytes, *, media_type: str) -> BlobRef: ...
102
+
103
+ def scratch_dir(self) -> Path: ...
104
+
105
+ def progress(self, fraction: float, message: str = "") -> None: ...
106
+
107
+ def check_cancelled(self) -> None: ...
108
+
109
+ def log(self, level: str, message: str, **fields: object) -> None: ...
110
+
111
+
112
+ @runtime_checkable
113
+ class Producer(Protocol):
114
+ """Anything that reads artifacts and emits one.
115
+
116
+ The base of every contract in ``sdk-contracts.md`` §8. Specific contracts narrow what goes
117
+ in and what comes out; the shape is always the same.
118
+ """
119
+
120
+ info: ProducerInfo
121
+
122
+ def produce(self, spine: Spine, params: Params) -> Emission: ...
123
+
124
+
125
+ @runtime_checkable
126
+ class ModelLoader(Protocol):
127
+ """URI -> ``ModelFingerprint``. ``sdk-contracts.md`` §8, contract 1.
128
+
129
+ A loader is the only component permitted to touch model files on disk, which is why it is
130
+ also where ``trust_remote_code`` is decided and recorded.
131
+ """
132
+
133
+ info: ProducerInfo
134
+
135
+ def can_load(self, uri: str) -> bool:
136
+ """Cheap check. Must not read the model, must not hit the network."""
137
+ ...
138
+
139
+ def load(self, spine: Spine, uri: str, params: Params) -> Emission: ...
File without changes
@@ -0,0 +1,158 @@
1
+ """Running a producer, and writing what it returns.
2
+
3
+ This is the half of ``sdk-contracts.md`` §5 that makes lineage a guarantee: the spine handed
4
+ to a producer *observes every resolution it performs*, and the write compares that observation
5
+ against what the producer declared. A producer cannot forget to record lineage and cannot
6
+ under-declare it, because the check does not depend on the producer's cooperation.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import tempfile
12
+ from collections.abc import Mapping
13
+ from pathlib import Path
14
+ from typing import Any, BinaryIO
15
+
16
+ from metrik.core.artifacts import Artifact, BlobRef, InputRef, ProducerRef, build_artifact
17
+ from metrik.core.errors import LineageError
18
+ from metrik.core.hashing import Digest
19
+ from metrik.core.store import ArtifactStore
20
+ from metrik.sdk.contracts import Emission, Params, ProducerInfo
21
+
22
+ __all__ = ["LocalSpine", "emit"]
23
+
24
+
25
+ class LocalSpine:
26
+ """In-process spine over a local store.
27
+
28
+ The direct, single-process implementation. A remote spine speaking to ``metrikd`` arrives
29
+ in Phase 10 and satisfies the same protocol, which is the point of the protocol.
30
+ """
31
+
32
+ def __init__(self, store: ArtifactStore, *, scratch: Path | None = None) -> None:
33
+ self._store = store
34
+ self._scratch = scratch
35
+ #: Every artifact this producer resolved, in resolution order. The producer cannot see
36
+ #: or clear this, which is precisely what makes it evidence rather than a self-report.
37
+ self._resolved: dict[Digest, Artifact] = {}
38
+ self._cancelled = False
39
+
40
+ # -- Spine protocol ------------------------------------------------------------------
41
+
42
+ def get(self, digest: Digest) -> Artifact:
43
+ artifact = self._store.get(digest)
44
+ self._resolved[artifact.envelope.content_hash] = artifact
45
+ return artifact
46
+
47
+ def open_blob(self, ref: BlobRef) -> BinaryIO:
48
+ return self._store.blobs.open(ref.digest)
49
+
50
+ def put_blob(self, data: BinaryIO | bytes, *, media_type: str) -> BlobRef:
51
+ if isinstance(data, bytes):
52
+ digest = self._store.blobs.put_bytes(data)
53
+ size = len(data)
54
+ else:
55
+ digest = self._store.blobs.put_stream(data)
56
+ size = self._store.blobs.path_of(digest).stat().st_size
57
+ return BlobRef(digest=digest, size_bytes=size, media_type=media_type)
58
+
59
+ def scratch_dir(self) -> Path:
60
+ if self._scratch is None:
61
+ self._scratch = Path(tempfile.mkdtemp(prefix="metrik-scratch-"))
62
+ self._scratch.mkdir(parents=True, exist_ok=True)
63
+ return self._scratch
64
+
65
+ def progress(self, fraction: float, message: str = "") -> None:
66
+ """Best-effort and unversioned. Nothing downstream may depend on it."""
67
+
68
+ def check_cancelled(self) -> None:
69
+ if self._cancelled:
70
+ from metrik.core.errors import CancelledError
71
+
72
+ raise CancelledError("run cancelled")
73
+
74
+ def log(self, level: str, message: str, **fields: object) -> None:
75
+ """Deliberately inert until the run registry exists (Phase 2 remainder)."""
76
+
77
+ # -- Writing -------------------------------------------------------------------------
78
+
79
+ @property
80
+ def resolved(self) -> Mapping[Digest, Artifact]:
81
+ return dict(self._resolved)
82
+
83
+ def commit(
84
+ self, emission: Emission, info: ProducerInfo, params: Params | None = None
85
+ ) -> Artifact:
86
+ """Turn an :class:`Emission` into a stored artifact.
87
+
88
+ Raises:
89
+ LineageError: if the producer declared an input it never resolved, or resolved one
90
+ it did not declare. Both directions matter. Under-declaring hides a dependency,
91
+ which is the failure ``artifacts.md`` §2.2 exists to prevent. Over-declaring is
92
+ subtler and just as bad: it claims a derivation that did not happen, so a user
93
+ tracing the number is told it depends on something it does not.
94
+ """
95
+ declared = dict(emission.inputs)
96
+ self._check_declaration_matches_reality(declared, emission.artifact_type)
97
+
98
+ inputs = tuple(
99
+ InputRef(
100
+ role=role,
101
+ content_hash=digest,
102
+ artifact_type=self._resolved[digest].envelope.artifact_type,
103
+ )
104
+ for role, digest in sorted(declared.items())
105
+ )
106
+ artifact = build_artifact(
107
+ artifact_type=emission.artifact_type,
108
+ schema_version=emission.schema_version,
109
+ payload=emission.payload,
110
+ determinism=emission.determinism,
111
+ produced_by=ProducerRef(
112
+ kind=info.kind, name=info.name, version=info.version, api_version=info.api_version
113
+ ),
114
+ inputs=inputs,
115
+ params=params.model_dump() if params is not None else None,
116
+ env=emission.env,
117
+ partial=emission.partial,
118
+ )
119
+ self._store.put(artifact)
120
+ return artifact
121
+
122
+ def _check_declaration_matches_reality(
123
+ self, declared: dict[str, Digest], artifact_type: str
124
+ ) -> None:
125
+ declared_hashes = set(declared.values())
126
+ observed = set(self._resolved)
127
+
128
+ missing = declared_hashes - observed
129
+ if missing:
130
+ roles = sorted(r for r, d in declared.items() if d in missing)
131
+ raise LineageError(
132
+ f"{artifact_type} declares input(s) {roles} that were never resolved through "
133
+ f"the spine",
134
+ remediation="Resolve inputs with spine.get(), or drop them from the declaration.",
135
+ )
136
+
137
+ undeclared = observed - declared_hashes
138
+ if undeclared:
139
+ types = sorted(self._resolved[d].envelope.artifact_type for d in undeclared)
140
+ raise LineageError(
141
+ f"{artifact_type} read {types} through the spine but did not declare them as "
142
+ f"inputs",
143
+ remediation=(
144
+ "Add them to Emission.inputs. Lineage completeness is checked, not trusted "
145
+ "(artifacts.md sec. 2.2)."
146
+ ),
147
+ )
148
+
149
+
150
+ def emit(
151
+ store: ArtifactStore,
152
+ info: ProducerInfo,
153
+ build: Any,
154
+ params: Params | None = None,
155
+ ) -> Artifact:
156
+ """Run ``build(spine)`` and commit whatever it emits. A convenience for callers and tests."""
157
+ spine = LocalSpine(store)
158
+ return spine.commit(build(spine), info, params)