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.
- eval_toolkit/__init__.py +238 -0
- eval_toolkit/__main__.py +156 -0
- eval_toolkit/_version.py +5 -0
- eval_toolkit/analysis.py +196 -0
- eval_toolkit/artifacts.py +376 -0
- eval_toolkit/bootstrap.py +1344 -0
- eval_toolkit/calibration.py +1143 -0
- eval_toolkit/claims.py +670 -0
- eval_toolkit/config.py +112 -0
- eval_toolkit/docs.py +305 -0
- eval_toolkit/evidence.py +90 -0
- eval_toolkit/harness.py +1193 -0
- eval_toolkit/leakage.py +1052 -0
- eval_toolkit/loaders.py +424 -0
- eval_toolkit/manifest.py +622 -0
- eval_toolkit/metrics.py +1720 -0
- eval_toolkit/operating_points.py +192 -0
- eval_toolkit/paths.py +125 -0
- eval_toolkit/plotting.py +991 -0
- eval_toolkit/protocols.py +98 -0
- eval_toolkit/provenance.py +255 -0
- eval_toolkit/py.typed +0 -0
- eval_toolkit/schemas/manifest.v1.json +155 -0
- eval_toolkit/schemas/manifest.v2.json +186 -0
- eval_toolkit/schemas/manifest.v3.json +186 -0
- eval_toolkit/schemas/results.v1.json +87 -0
- eval_toolkit/schemas/results_full.v1.json +83 -0
- eval_toolkit/seeds.py +119 -0
- eval_toolkit/splits.py +520 -0
- eval_toolkit/text_dedup.py +1403 -0
- eval_toolkit/thresholds.py +819 -0
- eval_toolkit-0.27.1.dist-info/METADATA +314 -0
- eval_toolkit-0.27.1.dist-info/RECORD +36 -0
- eval_toolkit-0.27.1.dist-info/WHEEL +4 -0
- eval_toolkit-0.27.1.dist-info/entry_points.txt +2 -0
- eval_toolkit-0.27.1.dist-info/licenses/LICENSE +21 -0
eval_toolkit/manifest.py
ADDED
|
@@ -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
|