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.
- metrik_sdk-0.1.0/.gitignore +89 -0
- metrik_sdk-0.1.0/PKG-INFO +35 -0
- metrik_sdk-0.1.0/README.md +18 -0
- metrik_sdk-0.1.0/pyproject.toml +36 -0
- metrik_sdk-0.1.0/src/metrik/sdk/__init__.py +225 -0
- metrik_sdk-0.1.0/src/metrik/sdk/contracts.py +139 -0
- metrik_sdk-0.1.0/src/metrik/sdk/py.typed +0 -0
- metrik_sdk-0.1.0/src/metrik/sdk/runner.py +158 -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,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)
|