eval-toolkit 0.27.1__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,622 @@
1
+ """Run manifest: NeurIPS-aligned reproducibility sidecar.
2
+
3
+ A :class:`RunManifest` is the metadata sidecar for a single run, written to
4
+ ``<run_dir>/manifest.json`` next to the existing ``results.json`` /
5
+ ``results_full.json`` produced by :func:`eval_toolkit.harness.write_run_result`.
6
+
7
+ Aligned with the [NeurIPS Reproducibility Checklist](https://neurips.cc/public/guides/PaperChecklist):
8
+ captures git provenance (with dirty-flag), code versions, seeds, data hashes,
9
+ config hash, environment fingerprint, GPU info, wall-clock time, and any
10
+ :class:`~eval_toolkit.leakage.LeakageReport` produced inline by the harness.
11
+
12
+ Per-object versions of any :class:`~eval_toolkit.leakage.Versioned`
13
+ implementation (Scorer, LeakageCheck, Splitter, ThresholdSelector,
14
+ DatasetLoader) are auto-captured into ``versioned_objects`` so cross-version
15
+ metric comparisons can be invalidated explicitly. Mirrors the
16
+ ``lm-evaluation-harness`` task ``VERSION`` field pattern.
17
+
18
+ Pure / IO split mirrors :func:`eval_toolkit.harness.evaluate` /
19
+ :func:`eval_toolkit.harness.write_run_result` — :func:`build_manifest` is
20
+ deterministic and side-effect-free; :func:`write_manifest` is the sole IO sink.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import contextlib
26
+ import datetime as _dt
27
+ import hashlib
28
+ import json
29
+ import platform
30
+ import subprocess
31
+ import sys
32
+ from collections.abc import Mapping, Sequence
33
+ from dataclasses import asdict, dataclass, field
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ from eval_toolkit._version import __version__ as eval_toolkit_version
38
+ from eval_toolkit.artifacts import PredictionArtifactRef, sanitize_for_json, write_json_strict
39
+ from eval_toolkit.provenance import FileHash, capture_git_sha, compute_file_hash
40
+
41
+ __all__ = [
42
+ "MANIFEST_SCHEMA_VERSION",
43
+ "RunManifest",
44
+ "SourceRoleRecord",
45
+ "build_manifest",
46
+ "gpu_info",
47
+ "validate_source_roles",
48
+ "write_manifest",
49
+ ]
50
+
51
+ MANIFEST_SCHEMA_VERSION = "v3"
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class SourceRoleRecord:
56
+ """Generic source-role metadata for data-quality and evidence audits.
57
+
58
+ Roles are intentionally user-defined. Recommended values include
59
+ ``train``, ``calibration``, ``locked_eval``, ``external_diagnostic``,
60
+ and ``excluded``.
61
+ """
62
+
63
+ source: str
64
+ role: str
65
+ n_rows: int | None = None
66
+ notes: str = ""
67
+ metadata: dict[str, object] = field(default_factory=dict)
68
+
69
+ def to_dict(self) -> dict[str, object]:
70
+ """JSON-serializable representation with ``None`` fields omitted."""
71
+ out: dict[str, object] = {
72
+ "source": self.source,
73
+ "role": self.role,
74
+ "notes": self.notes,
75
+ "metadata": self.metadata,
76
+ }
77
+ if self.n_rows is not None:
78
+ out["n_rows"] = self.n_rows
79
+ return out
80
+
81
+
82
+ @dataclass(frozen=True, slots=True)
83
+ class RunManifest:
84
+ """Reproducibility metadata sidecar for a single run.
85
+
86
+ Pure value type. JSON-serializable via :meth:`to_dict`. Written to
87
+ ``manifest.json`` next to ``results.json`` by :func:`write_manifest`.
88
+
89
+ Parameters
90
+ ----------
91
+ run_id : str
92
+ Caller-supplied run identifier (timestamp / UUID).
93
+ captured_at : str
94
+ ISO-8601 UTC timestamp captured at :func:`build_manifest` call time
95
+ (``"YYYY-MM-DDTHH:MM:SSZ"``). v2 field — distinguishes wall-clock
96
+ capture from caller-meaningful ``run_id``.
97
+ git_sha : str or None
98
+ Repo HEAD commit SHA. ``None`` if not in a git repo or git unavailable.
99
+ dirty_flag : bool
100
+ True iff the working tree had uncommitted changes when the run was
101
+ captured. NeurIPS-checklist concern: a "clean" replay needs no dirty
102
+ bits.
103
+ code_versions : dict[str, str]
104
+ ``{package_name: version_string}`` for the toolkit and any caller-
105
+ declared key dependencies (e.g. transformers, torch, sklearn).
106
+ data_revisions : dict[str, str]
107
+ ``{logical_name: revision_string}`` for input dataset and model
108
+ revisions. v2 field — extracts dataset/model provenance out of
109
+ ``code_versions`` (where it sat awkwardly with prefixes like
110
+ ``hf_dataset:`` and ``hf_model:`` in v1 consumers).
111
+ metadata : dict[str, str]
112
+ ``{label_name: label_value}`` for caller-supplied run-time labels
113
+ (CLI arguments, environment selectors, etc.). v2 field — replaces
114
+ the v1 pattern of stuffing labels into ``code_versions`` under a
115
+ ``meta:`` prefix.
116
+ seeds : dict[str, int]
117
+ Per-source seeds (``"global"``, ``"bootstrap"``, ``"torch"``, etc.)
118
+ as captured by the caller.
119
+ data_hashes : dict[str, str]
120
+ ``{filename: "sha256:..."}`` for every artifact the run consumed.
121
+ Populated from :func:`eval_toolkit.provenance.file_sha256`.
122
+ config_hash : str
123
+ SHA-256 of the canonical-JSON eval config (sorted keys, no whitespace).
124
+ env : dict[str, str]
125
+ Environment fingerprint: Python version, platform, key dep versions.
126
+ gpu_info : dict[str, object]
127
+ GPU model / count / memory captured via ``nvidia-smi`` (empty dict if
128
+ unavailable). NeurIPS compute-resources field. v2 tightens
129
+ ``count`` to ``int`` and ``memory_gb`` to ``float``; ``name``
130
+ remains ``str``.
131
+ cuda_version : str or None
132
+ CUDA toolkit / driver version (empty if no CUDA).
133
+ wall_clock_seconds : float or None
134
+ Caller-supplied total run duration in seconds.
135
+ versioned_objects : dict[str, str]
136
+ ``{object_name: version_string}`` auto-captured for every
137
+ :class:`~eval_toolkit.leakage.Versioned` Tier-2 implementation in
138
+ the run (scorers, checks, splitters, etc.). Used to invalidate
139
+ cross-version metric comparisons.
140
+ leakage_report : dict[str, object] or None
141
+ Serialized :class:`~eval_toolkit.leakage.LeakageReport` produced
142
+ inline by the harness, or ``None`` if no checks ran.
143
+ source_roles : list[dict[str, object]]
144
+ Optional generic source-role records for data-quality and evidence
145
+ audits. Roles are caller-defined; the toolkit only validates shape.
146
+ guardrails : list[str]
147
+ Optional predeclared guardrails for claim/evidence discipline.
148
+ prediction_artifacts : list[dict[str, object]]
149
+ Optional references to retained prediction artifacts.
150
+ schema_version : str
151
+ Manifest schema version. ``"v2"`` starting v0.14.0; ``"v1"`` for
152
+ manifests written by v0.7.0–v0.13.x.
153
+ """
154
+
155
+ run_id: str
156
+ captured_at: str = ""
157
+ git_sha: str | None = None
158
+ dirty_flag: bool = False
159
+ code_versions: dict[str, str] = field(default_factory=dict)
160
+ data_revisions: dict[str, str] = field(default_factory=dict)
161
+ metadata: dict[str, str] = field(default_factory=dict)
162
+ seeds: dict[str, int] = field(default_factory=dict)
163
+ data_hashes: dict[str, str] = field(default_factory=dict)
164
+ config_hash: str = ""
165
+ env: dict[str, str] = field(default_factory=dict)
166
+ gpu_info: dict[str, object] = field(default_factory=dict)
167
+ cuda_version: str | None = None
168
+ wall_clock_seconds: float | None = None
169
+ versioned_objects: dict[str, str] = field(default_factory=dict)
170
+ leakage_report: dict[str, object] | None = None
171
+ source_roles: list[dict[str, object]] = field(default_factory=list)
172
+ guardrails: list[object] = field(default_factory=list)
173
+ prediction_artifacts: list[dict[str, object]] = field(default_factory=list)
174
+ contamination_flags: dict[str, str] = field(default_factory=dict)
175
+ schema_version: str = MANIFEST_SCHEMA_VERSION
176
+
177
+ def to_dict(self) -> dict[str, object]:
178
+ """JSON-serializable representation.
179
+
180
+ Raises
181
+ ------
182
+ TypeError
183
+ If JSON-sanitization returns a non-mapping payload (defensive;
184
+ ``sanitize_for_json`` normally preserves dict shape).
185
+ """
186
+ out = sanitize_for_json(asdict(self))
187
+ if not isinstance(out, dict):
188
+ raise TypeError("RunManifest.to_dict expected a mapping payload")
189
+ return out
190
+
191
+
192
+ def _hash_canonical_json(payload: Mapping[str, Any]) -> str:
193
+ """SHA-256 over the JSON-canonical encoding of ``payload``."""
194
+ blob = json.dumps(
195
+ sanitize_for_json(dict(payload)),
196
+ sort_keys=True,
197
+ separators=(",", ":"),
198
+ allow_nan=False,
199
+ )
200
+ return f"sha256:{hashlib.sha256(blob.encode()).hexdigest()}"
201
+
202
+
203
+ def _is_git_dirty(repo_root: Path | str | None = None) -> bool:
204
+ """True iff ``git status --porcelain`` shows uncommitted changes.
205
+
206
+ Returns ``False`` if git is unavailable or not in a repo.
207
+ """
208
+ cwd = str(repo_root) if repo_root is not None else None
209
+ try:
210
+ result = subprocess.run(
211
+ ["git", "status", "--porcelain"],
212
+ cwd=cwd,
213
+ capture_output=True,
214
+ text=True,
215
+ check=False,
216
+ timeout=5,
217
+ )
218
+ except (FileNotFoundError, subprocess.TimeoutExpired):
219
+ return False
220
+ return bool(result.stdout.strip())
221
+
222
+
223
+ def gpu_info() -> tuple[dict[str, object], str | None]:
224
+ """Capture ``(gpu_info_dict, cuda_version)`` via ``nvidia-smi``.
225
+
226
+ Returns ``({}, None)`` if ``nvidia-smi`` is unavailable, fails, or
227
+ times out. Never raises — meant to be called unconditionally from
228
+ :func:`build_manifest` with graceful fallback for CPU-only environments.
229
+
230
+ Returns
231
+ -------
232
+ tuple[dict[str, object], str | None]
233
+ First element: ``{"name": "...", "count": 1, "memory_gb": 40.0}``
234
+ or ``{}`` if no GPU. ``count`` is ``int`` and ``memory_gb`` is
235
+ ``float`` since v0.14.0 (v0.13.x and earlier returned strings).
236
+ Second element: CUDA driver version string, or ``None``.
237
+ """
238
+ try:
239
+ result = subprocess.run(
240
+ [
241
+ "nvidia-smi",
242
+ "--query-gpu=name,memory.total,driver_version",
243
+ "--format=csv,noheader,nounits",
244
+ ],
245
+ capture_output=True,
246
+ text=True,
247
+ check=False,
248
+ timeout=5,
249
+ )
250
+ except (FileNotFoundError, subprocess.TimeoutExpired):
251
+ return {}, None
252
+ if result.returncode != 0 or not result.stdout.strip():
253
+ return {}, None
254
+ rows = [r.strip() for r in result.stdout.strip().splitlines() if r.strip()]
255
+ if not rows:
256
+ return {}, None
257
+ # Each row: "Tesla A100, 40960, 535.86.10"
258
+ first_parts = [p.strip() for p in rows[0].split(",")]
259
+ if len(first_parts) < 3:
260
+ return {}, None
261
+ name = first_parts[0]
262
+ memory_mb = first_parts[1]
263
+ driver_version = first_parts[2]
264
+ info: dict[str, object] = {"name": name, "count": len(rows)}
265
+ with contextlib.suppress(ValueError):
266
+ info["memory_gb"] = round(int(memory_mb) / 1024, 1)
267
+ return info, driver_version
268
+
269
+
270
+ def _capture_env() -> dict[str, str]:
271
+ """Snapshot Python + platform + key library versions."""
272
+ env: dict[str, str] = {
273
+ "python": sys.version.split()[0],
274
+ "platform": platform.platform(),
275
+ "machine": platform.machine(),
276
+ "eval_toolkit": eval_toolkit_version,
277
+ }
278
+ # Optional libraries: only record if importable.
279
+ for lib in ("numpy", "scipy", "sklearn", "pandas", "torch", "transformers"):
280
+ try:
281
+ mod = __import__(lib)
282
+ env[lib] = str(getattr(mod, "__version__", "unknown"))
283
+ except ImportError:
284
+ continue
285
+ return env
286
+
287
+
288
+ def _collect_versioned(objects: Sequence[object] | Mapping[str, object]) -> dict[str, str]:
289
+ """Auto-capture ``version`` from any object that exposes it.
290
+
291
+ Accepts a sequence or a mapping. For sequences the key is the object's
292
+ class name; for mappings the user-supplied key is used.
293
+ """
294
+ out: dict[str, str] = {}
295
+ items = (
296
+ objects.items()
297
+ if isinstance(objects, Mapping)
298
+ else ((type(o).__name__, o) for o in objects)
299
+ )
300
+ for key, obj in items:
301
+ version = getattr(obj, "version", None)
302
+ if isinstance(version, str):
303
+ out[key] = version
304
+ return out
305
+
306
+
307
+ def _source_role_to_dict(record: SourceRoleRecord | Mapping[str, object]) -> dict[str, object]:
308
+ """Normalize a source-role record to a plain dict."""
309
+ if isinstance(record, SourceRoleRecord):
310
+ return record.to_dict()
311
+ return dict(record)
312
+
313
+
314
+ def validate_source_roles(
315
+ source_roles: Sequence[SourceRoleRecord | Mapping[str, object]],
316
+ *,
317
+ required_roles: Sequence[str] = (),
318
+ ) -> list[str]:
319
+ """Return validation errors for generic source-role records.
320
+
321
+ Validation is deliberately about shape, not taxonomy. Consumers choose
322
+ role names that fit their domain.
323
+
324
+ Uniqueness contract (relaxed in v0.14.2, closing F4.5): each
325
+ ``(source, role)`` pair must be unique. The same upstream ``source``
326
+ may appear multiple times as long as each occurrence has a distinct
327
+ ``role`` (e.g. the same dataset feeding both a training pool and an
328
+ OOD diagnostic slice). Prior versions required ``source`` to be
329
+ globally unique, which forced callers to synthesize per-slice source
330
+ names like ``notinject_train_neg_pool`` and ``notinject_ood_fpr_only``
331
+ just to satisfy the validator.
332
+ """
333
+ errors: list[str] = []
334
+ seen_pairs: set[tuple[str, str]] = set()
335
+ roles_seen: set[str] = set()
336
+ for idx, record in enumerate(source_roles):
337
+ row = _source_role_to_dict(record)
338
+ prefix = f"source_roles[{idx}]"
339
+ source = row.get("source")
340
+ role = row.get("role")
341
+ source_ok = isinstance(source, str) and source.strip()
342
+ role_ok = isinstance(role, str) and role.strip()
343
+ if not source_ok:
344
+ errors.append(f"{prefix}: source must be a non-empty string")
345
+ if not role_ok:
346
+ errors.append(f"{prefix}: role must be a non-empty string")
347
+ if source_ok and role_ok:
348
+ assert isinstance(source, str)
349
+ assert isinstance(role, str)
350
+ pair = (source, role)
351
+ if pair in seen_pairs:
352
+ errors.append(f"{prefix}: duplicate (source, role) pair " f"({source!r}, {role!r})")
353
+ else:
354
+ seen_pairs.add(pair)
355
+ roles_seen.add(role)
356
+ n_rows = row.get("n_rows")
357
+ if n_rows is not None and (
358
+ not isinstance(n_rows, int) or isinstance(n_rows, bool) or n_rows < 0
359
+ ):
360
+ errors.append(f"{prefix}: n_rows must be a non-negative integer when present")
361
+ notes = row.get("notes")
362
+ if notes is not None and not isinstance(notes, str):
363
+ errors.append(f"{prefix}: notes must be a string when present")
364
+ metadata = row.get("metadata")
365
+ if metadata is not None and not isinstance(metadata, Mapping):
366
+ errors.append(f"{prefix}: metadata must be an object when present")
367
+
368
+ missing = sorted(set(required_roles) - roles_seen)
369
+ if missing:
370
+ errors.append(f"missing required source role(s): {missing}")
371
+ return errors
372
+
373
+
374
+ def _utc_now_iso8601() -> str:
375
+ """Return current UTC time as ``YYYY-MM-DDTHH:MM:SSZ`` (1-second resolution)."""
376
+ return _dt.datetime.now(_dt.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
377
+
378
+
379
+ _VALID_CONTAMINATION_FLAGS: frozenset[str] = frozenset(
380
+ {"verified_disjoint", "suspected_contamination", "vendor_black_box", "unknown"}
381
+ )
382
+
383
+
384
+ def build_manifest(
385
+ *,
386
+ run_id: str,
387
+ config: Mapping[str, Any],
388
+ data_files: Mapping[str, Path | str] | None = None,
389
+ seeds: Mapping[str, int] | None = None,
390
+ extra_code_versions: Mapping[str, str] | None = None,
391
+ data_revisions: Mapping[str, str] | None = None,
392
+ metadata: Mapping[str, str] | None = None,
393
+ versioned: Sequence[object] | Mapping[str, object] | None = None,
394
+ leakage_report: object | None = None,
395
+ source_roles: Sequence[SourceRoleRecord | Mapping[str, object]] | None = None,
396
+ required_source_roles: Sequence[str] = (),
397
+ guardrails: Sequence[str | Mapping[str, object]] | None = None,
398
+ prediction_artifacts: Sequence[PredictionArtifactRef | Mapping[str, object]] | None = None,
399
+ contamination_flags: Mapping[str, str] | None = None,
400
+ wall_clock_seconds: float | None = None,
401
+ repo_root: Path | str | None = None,
402
+ git_sha: str | None = None,
403
+ ) -> RunManifest:
404
+ """Pure builder: assemble a :class:`RunManifest` from components.
405
+
406
+ Parameters
407
+ ----------
408
+ run_id : str
409
+ Caller-supplied run identifier.
410
+ config : Mapping
411
+ Eval config; SHA-256 of its canonical-JSON encoding becomes
412
+ :attr:`RunManifest.config_hash`.
413
+ data_files : Mapping[str, Path | str] or None
414
+ ``{logical_name: filesystem_path}`` for every input artifact.
415
+ Each path is hashed via :func:`provenance.file_sha256` and the
416
+ results land in :attr:`RunManifest.data_hashes`.
417
+ seeds : Mapping[str, int] or None
418
+ ``{source: seed}`` (``"global"``, ``"bootstrap"``, ``"torch"``).
419
+ extra_code_versions : Mapping[str, str] or None
420
+ Caller-declared package versions to record alongside the auto-
421
+ captured env (e.g. for in-repo subpackages with no PyPI version).
422
+ data_revisions : Mapping[str, str] or None
423
+ v2 — dataset / model revisions, keyed by logical name. Recommended
424
+ key prefixes: ``"hf_dataset:<name>"`` and ``"hf_model:<name>"``.
425
+ metadata : Mapping[str, str] or None
426
+ v2 — caller-supplied run-time labels (CLI arguments, environment
427
+ selectors, etc.). Use this for non-semantic provenance that does
428
+ not belong in ``config`` (which feeds ``config_hash``).
429
+ versioned : sequence of objects or Mapping[str, object] or None
430
+ Tier-2 implementations whose ``version`` attribute should be
431
+ captured. Mapping keys override the default class-name keying.
432
+ leakage_report : object or None
433
+ Mapping or object with ``to_dict()``, serialized into
434
+ :attr:`RunManifest.leakage_report`.
435
+ source_roles : sequence of SourceRoleRecord or mapping, optional
436
+ Generic source-role records. Recommended role names are documented in
437
+ :class:`SourceRoleRecord`, but not enforced.
438
+ required_source_roles : sequence of str, optional
439
+ Roles that must appear in ``source_roles`` if source-role metadata is
440
+ supplied.
441
+ guardrails : sequence of str, optional
442
+ Predeclared guardrails for claim/evidence discipline.
443
+ prediction_artifacts : sequence, optional
444
+ Manifest references to retained prediction artifacts.
445
+ wall_clock_seconds : float or None
446
+ repo_root : Path or str or None
447
+ Where to run ``git status`` for the dirty-flag check.
448
+ git_sha : str or None, optional
449
+ Explicit git SHA override. When provided, used as
450
+ :attr:`RunManifest.git_sha` directly (callers that have already
451
+ captured the SHA — e.g. from a CI environment variable on a pod
452
+ with no ``.git/`` directory — skip the
453
+ :func:`~eval_toolkit.provenance.capture_git_sha` fallback). When
454
+ ``None`` (default), :func:`capture_git_sha` is used to auto-detect.
455
+
456
+ Returns
457
+ -------
458
+ RunManifest
459
+ With ``captured_at`` auto-populated to the current UTC time (ISO-8601,
460
+ 1-second resolution).
461
+
462
+ Raises
463
+ ------
464
+ ValueError
465
+ If ``source_roles`` is malformed (missing required roles, invalid
466
+ record shape), if any ``guardrails`` entry is an empty string or
467
+ empty mapping, or if any ``contamination_flags`` value is not in
468
+ the manifest.v3 enum
469
+ ``{"verified_disjoint", "suspected_contamination",
470
+ "vendor_black_box", "unknown"}``.
471
+
472
+ Examples
473
+ --------
474
+ >>> from eval_toolkit.manifest import build_manifest
475
+ >>> m = build_manifest(run_id="demo", config={"k": 5})
476
+ >>> m.run_id, m.schema_version
477
+ ('demo', 'v3')
478
+ """
479
+ data_hashes: dict[str, str] = {}
480
+ if data_files:
481
+ for logical_name, path in data_files.items():
482
+ # Manifest format prefixes hashes with their algorithm for
483
+ # self-describing JSON (matches Croissant's distribution.sha256).
484
+ # v0.15.0: sentinel-typed compute_file_hash replaces the
485
+ # str | None file_sha256; pattern-match clarifies the miss path.
486
+ digest = compute_file_hash(path)
487
+ if isinstance(digest, FileHash):
488
+ data_hashes[logical_name] = f"sha256:{digest.sha256}"
489
+ else:
490
+ data_hashes[logical_name] = ""
491
+
492
+ code_versions: dict[str, str] = {"eval_toolkit": eval_toolkit_version}
493
+ if extra_code_versions:
494
+ code_versions.update(dict(extra_code_versions))
495
+
496
+ source_role_dicts = (
497
+ [_source_role_to_dict(record) for record in source_roles] if source_roles else []
498
+ )
499
+ source_role_errors = validate_source_roles(
500
+ source_role_dicts,
501
+ required_roles=required_source_roles,
502
+ )
503
+ if source_role_errors:
504
+ joined = "; ".join(source_role_errors)
505
+ raise ValueError(f"invalid source_roles: {joined}")
506
+
507
+ guardrail_list: list[object] = list(guardrails) if guardrails else []
508
+ bad_guardrails: list[object] = []
509
+ for g in guardrail_list:
510
+ if isinstance(g, str):
511
+ if not g.strip():
512
+ bad_guardrails.append(g)
513
+ elif isinstance(g, Mapping):
514
+ if not g:
515
+ bad_guardrails.append(g)
516
+ else:
517
+ bad_guardrails.append(g)
518
+ if bad_guardrails:
519
+ raise ValueError("guardrails must be non-empty strings or non-empty objects")
520
+
521
+ contamination_flag_dict: dict[str, str] = (
522
+ dict(contamination_flags) if contamination_flags else {}
523
+ )
524
+ invalid_flags = {
525
+ scorer: flag
526
+ for scorer, flag in contamination_flag_dict.items()
527
+ if flag not in _VALID_CONTAMINATION_FLAGS
528
+ }
529
+ if invalid_flags:
530
+ raise ValueError(
531
+ f"invalid contamination_flags values: {invalid_flags}; "
532
+ f"expected one of {sorted(_VALID_CONTAMINATION_FLAGS)}"
533
+ )
534
+
535
+ prediction_artifact_dicts = (
536
+ [_prediction_artifact_to_dict(record) for record in prediction_artifacts]
537
+ if prediction_artifacts
538
+ else []
539
+ )
540
+
541
+ g_info, cuda = gpu_info()
542
+
543
+ return RunManifest(
544
+ run_id=run_id,
545
+ captured_at=_utc_now_iso8601(),
546
+ git_sha=git_sha if git_sha is not None else capture_git_sha(repo_root),
547
+ dirty_flag=_is_git_dirty(repo_root),
548
+ code_versions=code_versions,
549
+ data_revisions=dict(data_revisions) if data_revisions else {},
550
+ metadata=dict(metadata) if metadata else {},
551
+ seeds=dict(seeds) if seeds else {},
552
+ data_hashes=data_hashes,
553
+ config_hash=_hash_canonical_json(dict(config)),
554
+ env=_capture_env(),
555
+ gpu_info=g_info,
556
+ cuda_version=cuda,
557
+ wall_clock_seconds=wall_clock_seconds,
558
+ versioned_objects=_collect_versioned(versioned) if versioned else {},
559
+ leakage_report=_object_to_dict(leakage_report) if leakage_report is not None else None,
560
+ source_roles=source_role_dicts,
561
+ guardrails=guardrail_list,
562
+ prediction_artifacts=prediction_artifact_dicts,
563
+ contamination_flags=contamination_flag_dict,
564
+ schema_version=MANIFEST_SCHEMA_VERSION,
565
+ )
566
+
567
+
568
+ def write_manifest(manifest: RunManifest, run_dir: Path | str) -> Path:
569
+ """Write ``manifest.json`` to ``run_dir``. Sole IO sink.
570
+
571
+ Parameters
572
+ ----------
573
+ manifest : RunManifest
574
+ run_dir : Path or str
575
+ Destination directory; created if it doesn't exist.
576
+
577
+ Returns
578
+ -------
579
+ Path
580
+ Path to the written ``manifest.json``.
581
+ """
582
+ out_dir = Path(run_dir)
583
+ out_dir.mkdir(parents=True, exist_ok=True)
584
+ out_path = out_dir / "manifest.json"
585
+ return write_json_strict(manifest.to_dict(), out_path)
586
+
587
+
588
+ def _object_to_dict(obj: object) -> dict[str, object]:
589
+ """Normalize mapping or ``to_dict()`` object to a strict-JSON-safe dict."""
590
+ if isinstance(obj, Mapping):
591
+ out = sanitize_for_json(dict(obj))
592
+ else:
593
+ to_dict = getattr(obj, "to_dict", None)
594
+ if not callable(to_dict):
595
+ raise TypeError(f"expected mapping or object with to_dict(), got {type(obj).__name__}")
596
+ out = sanitize_for_json(to_dict())
597
+ if not isinstance(out, dict):
598
+ raise TypeError(f"expected mapping payload, got {type(out).__name__}")
599
+ return out
600
+
601
+
602
+ def _prediction_artifact_to_dict(
603
+ artifact: PredictionArtifactRef | Mapping[str, object],
604
+ ) -> dict[str, object]:
605
+ """Normalize a prediction artifact reference to a plain dict."""
606
+ if isinstance(artifact, PredictionArtifactRef):
607
+ return artifact.to_dict()
608
+ out = sanitize_for_json(dict(artifact))
609
+ if not isinstance(out, dict):
610
+ raise TypeError("prediction artifact must normalize to a mapping")
611
+ if not isinstance(out.get("uri"), str) or not out.get("uri"):
612
+ raise ValueError("prediction artifact must include a non-empty uri")
613
+ if not isinstance(out.get("media_type"), str) or not out.get("media_type"):
614
+ raise ValueError("prediction artifact must include a non-empty media_type")
615
+ columns = out.get("columns")
616
+ if not isinstance(columns, Mapping):
617
+ raise ValueError("prediction artifact must include a columns object")
618
+ if not isinstance(columns.get("label"), str) or not columns.get("label"):
619
+ raise ValueError("prediction artifact columns must include label")
620
+ if not isinstance(columns.get("score"), str) or not columns.get("score"):
621
+ raise ValueError("prediction artifact columns must include score")
622
+ return out