ledgerworks 0.2.0__py3-none-any.whl

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,71 @@
1
+ """ledgerworks: run, record, verify and trace pipeline artifacts.
2
+
3
+ Every output of a pipeline is registered as an immutable artifact directory
4
+ with a ``meta.json`` sidecar (identity, hyperparameters, inputs, model, one
5
+ record per execution with its git commit, file hashes) and a SQLite index
6
+ that can always be rebuilt from those sidecars.
7
+
8
+ from ledgerworks import Ledger
9
+
10
+ ledger = Ledger("/data/ledger")
11
+ source = ledger.latest("documents")
12
+
13
+ with ledger.run(kind="document_embeddings", pipeline="my-project",
14
+ pipeline_version="v0.1.0", sources=[source],
15
+ verifier="document_embeddings") as run:
16
+ ... # write outputs into run.out_dir
17
+
18
+ Optional pieces: ``ledgerworks.jobs`` (resumable long runs), ``ledgerworks.periods``
19
+ and ``ledgerworks.layout`` (calendar partitions), ``ledgerworks.relink`` and
20
+ ``ledgerworks.equivalence`` (re-register outputs under new inputs, checked;
21
+ needs the ``relink`` extra).
22
+ """
23
+
24
+ from ledgerworks.artifact import (
25
+ Artifact,
26
+ ModelCard,
27
+ RunMeta,
28
+ RunRecord,
29
+ utc_now_iso,
30
+ )
31
+ from ledgerworks.ledger import DEFAULT_LAYER, VALID_LAYERS, Ledger, LedgerError, RunHandle
32
+ from ledgerworks.meta import git_commit, hash_file, read_meta, run_environment, write_sidecars
33
+ from ledgerworks.verify import (
34
+ Finding,
35
+ Severity,
36
+ VerifyReport,
37
+ discover_verifiers,
38
+ verify,
39
+ )
40
+
41
+ __version__ = "0.2.0"
42
+
43
+ # Names from the pre-release package, kept so older code keeps importing.
44
+ DatalakeIndex = Ledger
45
+ DatalakeError = LedgerError
46
+
47
+ __all__ = [
48
+ "Artifact",
49
+ "DEFAULT_LAYER",
50
+ "DatalakeError",
51
+ "DatalakeIndex",
52
+ "Finding",
53
+ "Ledger",
54
+ "LedgerError",
55
+ "ModelCard",
56
+ "RunHandle",
57
+ "RunMeta",
58
+ "RunRecord",
59
+ "Severity",
60
+ "VALID_LAYERS",
61
+ "VerifyReport",
62
+ "__version__",
63
+ "discover_verifiers",
64
+ "git_commit",
65
+ "run_environment",
66
+ "hash_file",
67
+ "read_meta",
68
+ "utc_now_iso",
69
+ "verify",
70
+ "write_sidecars",
71
+ ]
@@ -0,0 +1,396 @@
1
+ """Ledger artifact metadata types.
2
+
3
+ Four dataclasses:
4
+
5
+ ModelCard -- describes a model used to produce an artifact. For models on
6
+ the HuggingFace Hub, prefer pulling the Hub's own metadata;
7
+ this class is the fallback for models whose weights live
8
+ locally and cannot be published.
9
+
10
+ RunRecord -- one execution that contributed to an artifact. Carries its
11
+ own git commit, timestamps, and the list of outputs it wrote.
12
+ An artifact may have several: a completed artifact that later
13
+ gains more data appends a new RunRecord rather than mutating
14
+ history, so each execution's code version stays auditable.
15
+
16
+ RunMeta -- the artifact as a whole: identity (kind, hyperparams, model),
17
+ lineage sources, a verifier reference, and the list of
18
+ RunRecords. Serialised to meta.json.
19
+
20
+ Artifact -- a registered, on-disk artifact: RunMeta plus its resolved path
21
+ and per-file hashes. Returned by Ledger lookups.
22
+
23
+ Identity
24
+ --------
25
+ An artifact's ID is a human-readable slug, deterministic given its identity
26
+ fields (kind, model, pipeline_version, hyperparams, creation date):
27
+
28
+ {kind}__{model_id}-{model_version}__{pipeline_version}__{param_slug}__{YYYYMMDDTHHMM}
29
+
30
+ Slugs sort sensibly, are greppable, and survive being read out loud. They are
31
+ NOT content hashes: two artifacts with identical parameters created in
32
+ different minutes get different IDs. Content identity lives in the file hashes.
33
+ Artifacts created before 2026-10-04 keep their date-only suffix ({YYYYMMDD}):
34
+ the minute applies to records carrying ``id_minute`` (every new one), so no
35
+ existing id changes.
36
+
37
+ The folder group (``{layer}/{group}/{kind}/{id}``, e.g. the data source an
38
+ artifact derives from) is NOT part of the ID: an artifact can be moved
39
+ between groups without changing identity.
40
+
41
+ Completeness
42
+ ------------
43
+ An artifact is complete when its last RunRecord has partial=False. Any
44
+ artifact whose last record is still partial is a crashed or in-progress run:
45
+ downstream code must ignore it, and verification flags it.
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import re
51
+ from dataclasses import asdict, dataclass, field
52
+ from datetime import datetime, timezone
53
+ from pathlib import Path
54
+ from typing import Any
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Slug construction
58
+ # ---------------------------------------------------------------------------
59
+
60
+ _SLUG_SAFE = re.compile(r"[^A-Za-z0-9._-]+")
61
+ _SEP = "__"
62
+
63
+ _MAX_PARAM_VALUE_CHARS = 24
64
+ _MAX_PARAM_SLUG_CHARS = 96
65
+ _DIGEST_CHARS = 8
66
+
67
+
68
+ def slugify(value: str) -> str:
69
+ """Reduce an arbitrary string to a slug-safe component."""
70
+ cleaned = _SLUG_SAFE.sub("-", str(value)).strip("-")
71
+ return cleaned or "none"
72
+
73
+
74
+ def _short_digest(text: str) -> str:
75
+ """Stable short digest for slug use (process-independent)."""
76
+ import hashlib
77
+
78
+ return hashlib.blake2b(text.encode(), digest_size=_DIGEST_CHARS // 2).hexdigest()
79
+
80
+
81
+ def _format_param_value(value: Any) -> str:
82
+ """Render a hyperparameter value compactly for a slug."""
83
+ if isinstance(value, bool):
84
+ return "true" if value else "false"
85
+ if isinstance(value, float):
86
+ return f"{value:g}"
87
+ return slugify(value)
88
+
89
+
90
+ def param_slug(hyperparams: dict[str, Any]) -> str:
91
+ """Deterministic, length-bounded slug for a hyperparameter dict.
92
+
93
+ Keys are sorted so construction does not depend on insertion order. Values
94
+ too long to inline are digested; if the whole slug is still too long it
95
+ collapses to one digest of the canonical dict. Both fallbacks stay
96
+ deterministic. Full values are always recorded in meta.json regardless.
97
+ """
98
+ if not hyperparams:
99
+ return ""
100
+
101
+ parts: list[str] = []
102
+ for key, value in sorted(hyperparams.items()):
103
+ rendered = _format_param_value(value)
104
+ if len(rendered) > _MAX_PARAM_VALUE_CHARS:
105
+ rendered = _short_digest(f"{key}={value!r}")
106
+ parts.append(f"{slugify(key)}{rendered}")
107
+
108
+ slug = "_".join(parts)
109
+ if len(slug) > _MAX_PARAM_SLUG_CHARS:
110
+ canonical = repr(sorted(hyperparams.items()))
111
+ slug = f"params{_short_digest(canonical)}"
112
+ return slug
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Time helper
117
+ # ---------------------------------------------------------------------------
118
+
119
+
120
+ def utc_now_iso() -> str:
121
+ """Current UTC time as ISO8601 with second precision."""
122
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # ModelCard
127
+ # ---------------------------------------------------------------------------
128
+
129
+
130
+ @dataclass
131
+ class ModelCard:
132
+ """Describes a model used to produce an artifact.
133
+
134
+ ``backend`` names the inference engine that ran the model (``local``,
135
+ ``tei``, ``embedx``) and ``serving`` holds everything that engine reported
136
+ about the model it served (a TEI ``/info`` snapshot, an embedx model
137
+ entry, the local device and dtype) plus the load-time compatibility-check
138
+ results. Both are ``None`` on cards written before they existed.
139
+ """
140
+
141
+ model_id: str
142
+ version: str
143
+ repo: str
144
+ weights_public: bool
145
+ weights_sha256: str | None = None
146
+ commit: str | None = None
147
+ architecture: str | None = None
148
+ dim: int | None = None
149
+ pooling: str | None = None
150
+ trained_on: str | None = None
151
+ notes: str = ""
152
+ backend: str | None = None
153
+ serving: dict[str, Any] | None = None
154
+
155
+ _NO_SHA_WARNING = (
156
+ "[warning: private weights with no weights_sha256; "
157
+ "third parties cannot verify weight identity]"
158
+ )
159
+
160
+ def __post_init__(self) -> None:
161
+ # Idempotent: from_dict re-runs this on every load, so the warning must
162
+ # not be appended again when the stored notes already carry it.
163
+ if (
164
+ not self.weights_public
165
+ and not self.weights_sha256
166
+ and self._NO_SHA_WARNING not in self.notes
167
+ ):
168
+ self.notes = ((self.notes + " " if self.notes else "") + self._NO_SHA_WARNING).strip()
169
+
170
+ @property
171
+ def slug(self) -> str:
172
+ return f"{slugify(self.model_id)}-{slugify(self.version)}"
173
+
174
+ def to_dict(self) -> dict[str, Any]:
175
+ return asdict(self)
176
+
177
+ @classmethod
178
+ def from_dict(cls, data: dict[str, Any]) -> ModelCard:
179
+ known = {f for f in cls.__dataclass_fields__}
180
+ return cls(**{k: v for k, v in data.items() if k in known})
181
+
182
+
183
+ # ---------------------------------------------------------------------------
184
+ # RunRecord
185
+ # ---------------------------------------------------------------------------
186
+
187
+
188
+ @dataclass
189
+ class RunRecord:
190
+ """One execution that contributed outputs to an artifact.
191
+
192
+ `produced` lists the output file names this execution wrote (just this
193
+ run's contribution, not the whole artifact). The union of produced lists
194
+ across all records is what a verifier checks against the declared scope.
195
+
196
+ `partial` is True from the moment the record is created until the execution
197
+ completes cleanly. A record left partial is a crashed run.
198
+
199
+ `env` describes where the execution ran (see `meta.run_environment`); it is
200
+ empty on records written before it existed and on relinked artifacts.
201
+ """
202
+
203
+ run_start: str
204
+ pipeline_version: str
205
+ pipeline_commit: str | None = None
206
+ run_end: str | None = None
207
+ partial: bool = True
208
+ produced: list[str] = field(default_factory=list)
209
+ notes: str = ""
210
+ env: dict[str, Any] = field(default_factory=dict)
211
+
212
+ def to_dict(self) -> dict[str, Any]:
213
+ return asdict(self)
214
+
215
+ @classmethod
216
+ def from_dict(cls, data: dict[str, Any]) -> RunRecord:
217
+ known = {f for f in cls.__dataclass_fields__}
218
+ return cls(**{k: v for k, v in data.items() if k in known})
219
+
220
+
221
+ # ---------------------------------------------------------------------------
222
+ # RunMeta
223
+ # ---------------------------------------------------------------------------
224
+
225
+
226
+ @dataclass
227
+ class RunMeta:
228
+ """Reproducibility record for one artifact across all its executions."""
229
+
230
+ kind: str
231
+ pipeline: str
232
+ pipeline_version: str
233
+ pipeline_repo: str | None = None
234
+ hyperparams: dict[str, Any] = field(default_factory=dict)
235
+ sources: list[str] = field(default_factory=list)
236
+ model_card: ModelCard | None = None
237
+ verifier: str | None = None # entry-point name for content verification
238
+ runs: list[RunRecord] = field(default_factory=list)
239
+ deprecated: bool = False
240
+ deprecation_reason: str | None = None
241
+ notes: str = ""
242
+ # Frozen at creation so the artifact_id stays stable across executions.
243
+ created: str = ""
244
+ # Folder the artifact lives under, {layer}/{group}/{kind}/{id} (None: {layer}/{kind}/{id}).
245
+ # Not part of the identity: moving an artifact never changes its id.
246
+ group: str | None = None
247
+ # Set on an artifact registered by Ledger.relink: {"from": old id, "replace":
248
+ # {old parent: new parent}, "checks": [check results], "commit": git commit}.
249
+ relink: dict[str, Any] | None = None
250
+ # New artifacts carry the creation minute in their id (YYYYMMDDTHHMM); records
251
+ # written before this field existed keep the date-only id they were registered under.
252
+ id_minute: bool = False
253
+
254
+ def __post_init__(self) -> None:
255
+ if not self.created:
256
+ self.created = utc_now_iso()
257
+ self.id_minute = True
258
+
259
+ # -- aggregate state ---------------------------------------------------
260
+
261
+ @property
262
+ def partial(self) -> bool:
263
+ """An artifact is complete only when its last run finished cleanly."""
264
+ if not self.runs:
265
+ return True
266
+ return self.runs[-1].partial
267
+
268
+ @property
269
+ def run_start(self) -> str:
270
+ """Start of the first execution."""
271
+ return self.runs[0].run_start if self.runs else self.created
272
+
273
+ @property
274
+ def run_end(self) -> str | None:
275
+ """End of the last execution, or None if still partial."""
276
+ if not self.runs:
277
+ return None
278
+ return self.runs[-1].run_end
279
+
280
+ @property
281
+ def pipeline_commit(self) -> str | None:
282
+ """Commit of the last execution."""
283
+ return self.runs[-1].pipeline_commit if self.runs else None
284
+
285
+ @property
286
+ def produced(self) -> list[str]:
287
+ """Union of all outputs across every execution, sorted and deduped."""
288
+ seen: set[str] = set()
289
+ for record in self.runs:
290
+ seen.update(record.produced)
291
+ return sorted(seen)
292
+
293
+ # -- identity ----------------------------------------------------------
294
+
295
+ @property
296
+ def artifact_id(self) -> str:
297
+ parts: list[str] = [slugify(self.kind)]
298
+ if self.model_card is not None:
299
+ parts.append(self.model_card.slug)
300
+ parts.append(slugify(self.pipeline_version))
301
+ params = param_slug(self.hyperparams)
302
+ if params:
303
+ parts.append(params)
304
+ stamp = self.created[:16] if self.id_minute else self.created[:10]
305
+ parts.append(stamp.replace("-", "").replace(":", ""))
306
+ return _SEP.join(parts)
307
+
308
+ # -- serialisation -----------------------------------------------------
309
+
310
+ def to_dict(self) -> dict[str, Any]:
311
+ return {
312
+ "kind": self.kind,
313
+ "pipeline": self.pipeline,
314
+ "pipeline_version": self.pipeline_version,
315
+ "pipeline_repo": self.pipeline_repo,
316
+ "hyperparams": dict(self.hyperparams),
317
+ "sources": list(self.sources),
318
+ "model_card": self.model_card.to_dict() if self.model_card else None,
319
+ "verifier": self.verifier,
320
+ "runs": [r.to_dict() for r in self.runs],
321
+ "deprecated": self.deprecated,
322
+ "deprecation_reason": self.deprecation_reason,
323
+ "notes": self.notes,
324
+ "created": self.created,
325
+ "group": self.group,
326
+ "relink": dict(self.relink) if self.relink is not None else None,
327
+ **({"id_minute": True} if self.id_minute else {}),
328
+ # Denormalised aggregates for human/tool convenience (never read back).
329
+ "partial": self.partial,
330
+ "run_start": self.run_start,
331
+ "run_end": self.run_end,
332
+ "pipeline_commit": self.pipeline_commit,
333
+ }
334
+
335
+ @classmethod
336
+ def from_dict(cls, data: dict[str, Any]) -> RunMeta:
337
+ payload = dict(data)
338
+ card = payload.pop("model_card", None)
339
+ runs_raw = payload.pop("runs", [])
340
+ for derived in ("partial", "run_start", "run_end", "pipeline_commit"):
341
+ payload.pop(derived, None)
342
+ known = {f for f in cls.__dataclass_fields__}
343
+ filtered = {k: v for k, v in payload.items() if k in known}
344
+ return cls(
345
+ **filtered,
346
+ model_card=ModelCard.from_dict(card) if card else None,
347
+ runs=[RunRecord.from_dict(r) for r in runs_raw],
348
+ )
349
+
350
+
351
+ # ---------------------------------------------------------------------------
352
+ # Artifact
353
+ # ---------------------------------------------------------------------------
354
+
355
+
356
+ @dataclass
357
+ class Artifact:
358
+ """A registered artifact on disk: its metadata, path, and file hashes."""
359
+
360
+ artifact_id: str
361
+ layer: str # 'raw' | 'derived' | 'output'
362
+ path: Path
363
+ meta: RunMeta
364
+ file_hashes: dict[str, str] = field(default_factory=dict)
365
+
366
+ @property
367
+ def kind(self) -> str:
368
+ return self.meta.kind
369
+
370
+ @property
371
+ def partial(self) -> bool:
372
+ return self.meta.partial
373
+
374
+ @property
375
+ def deprecated(self) -> bool:
376
+ return self.meta.deprecated
377
+
378
+ @property
379
+ def group(self) -> str | None:
380
+ return self.meta.group
381
+
382
+ def glob(self, pattern: str = "*.parquet") -> str:
383
+ """A glob string ready to hand to DuckDB's read_parquet()."""
384
+ return str(self.path / pattern)
385
+
386
+ def files(self, pattern: str = "*.parquet") -> list[Path]:
387
+ return sorted(self.path.glob(pattern))
388
+
389
+ def __repr__(self) -> str:
390
+ flags = []
391
+ if self.partial:
392
+ flags.append("PARTIAL")
393
+ if self.deprecated:
394
+ flags.append("DEPRECATED")
395
+ suffix = f" [{','.join(flags)}]" if flags else ""
396
+ return f"<Artifact {self.artifact_id}{suffix}>"