Add determinism golden index
- SHA
15b524b009882f1a9987832462467a38feb6b34f- Parents
-
54e7e6a - Tree
b77dab1
15b524b
15b524b009882f1a9987832462467a38feb6b34f54e7e6a
b77dab1| Status | File | + | - |
|---|---|---|---|
| A |
.determinism/lock.json
|
5 | 0 |
| M |
docs/determinism.md
|
8 | 3 |
| M |
scripts/regen-determinism-golden.py
|
17 | 6 |
| M |
src/dlm/base_models/license.py
|
1 | 1 |
| M |
src/dlm/lock/__init__.py
|
34 | 6 |
| M |
src/dlm/lock/errors.py
|
18 | 0 |
| A |
src/dlm/lock/golden_index.py
|
136 | 0 |
| A |
tests/unit/lock/test_golden_index.py
|
128 | 0 |
.determinism/lock.jsonadded@@ -0,0 +1,5 @@ | |||
| 1 | +{ | ||
| 2 | + "goldens": [], | ||
| 3 | + "lock_version": 1, | ||
| 4 | + "updated_at": "2026-04-22T04:25:32" | ||
| 5 | +} | ||
docs/determinism.mdmodified@@ -19,7 +19,8 @@ training produces a byte-identical `adapter_model.safetensors`. | |||
| 19 | 19 | ||
| 20 | Proved by `tests/integration/lock/test_determinism_golden.py`, which | 20 | Proved by `tests/integration/lock/test_determinism_golden.py`, which |
| 21 | runs two fresh training cycles on the tiny model and asserts the | 21 | runs two fresh training cycles on the tiny model and asserts the |
| 22 | -adapter SHAs match. | 22 | +adapter SHAs match. Approved tuple goldens are tracked at the repo |
| 23 | +level in `.determinism/lock.json`. | ||
| 23 | 24 | ||
| 24 | ## What's in `dlm.lock` | 25 | ## What's in `dlm.lock` |
| 25 | 26 | ||
@@ -120,10 +121,14 @@ The script: | |||
| 120 | 2. Runs the tiny-model training twice; confirms the two SHAs match. | 121 | 2. Runs the tiny-model training twice; confirms the two SHAs match. |
| 121 | 3. Writes `tests/golden/determinism/tuple-<hash>.json` keyed by a | 122 | 3. Writes `tests/golden/determinism/tuple-<hash>.json` keyed by a |
| 122 | SHA-256 of the sorted version tuple + platform. | 123 | SHA-256 of the sorted version tuple + platform. |
| 124 | +4. Upserts `.determinism/lock.json` with the tuple path, adapter SHA, | ||
| 125 | + platform, and pinned versions. | ||
| 123 | 126 | ||
| 124 | Each tuple gets its own golden; the tuple file is keyed by content so | 127 | Each tuple gets its own golden; the tuple file is keyed by content so |
| 125 | -running on a new platform simply writes a new golden file. The | 128 | +running on a new platform simply writes a new golden file. The repo-level |
| 126 | -reviewer checks in the new golden alongside the dep bump. | 129 | +index keeps the checked-in set explicit and avoids overloading the |
| 130 | +per-store `dlm.lock` name with a second meaning. The reviewer checks in | ||
| 131 | +the tuple file and the index update alongside the dep bump. | ||
| 127 | 132 | ||
| 128 | ## Non-goals | 133 | ## Non-goals |
| 129 | 134 | ||
scripts/regen-determinism-golden.pymodified@@ -17,8 +17,9 @@ Flow: | |||
| 17 | - `regenerated_at` — UTC timestamp | 17 | - `regenerated_at` — UTC timestamp |
| 18 | - `dlm_sha256` — hash of the synthetic training doc (reproducible | 18 | - `dlm_sha256` — hash of the synthetic training doc (reproducible |
| 19 | across runs when the factory's ULID seed is pinned) | 19 | across runs when the factory's ULID seed is pinned) |
| 20 | -5. Compare against the prior golden (if one existed) and print a diff. | 20 | +5. Upsert `.determinism/lock.json` with the checked-in tuple metadata. |
| 21 | -6. Exit non-zero unless `--approve` is passed. The default is | 21 | +6. Compare against the prior golden (if one existed) and print a diff. |
| 22 | +7. Exit non-zero unless `--approve` is passed. The default is | ||
| 22 | dry-run-and-report so a stray script invocation doesn't silently | 23 | dry-run-and-report so a stray script invocation doesn't silently |
| 23 | overwrite a baseline. | 24 | overwrite a baseline. |
| 24 | 25 | ||
@@ -26,10 +27,9 @@ Usage: | |||
| 26 | uv run python scripts/regen-determinism-golden.py # dry run | 27 | uv run python scripts/regen-determinism-golden.py # dry run |
| 27 | uv run python scripts/regen-determinism-golden.py --approve # write | 28 | uv run python scripts/regen-determinism-golden.py --approve # write |
| 28 | 29 | ||
| 29 | -The matching root-level `dlm.lock` (distinct from the per-store | 30 | +The matching repo-level `.determinism/lock.json` records which tuples |
| 30 | -`dlm.lock`) records which tuples have a checked-in golden. CI computes | 31 | +have a checked-in golden. It is distinct from the per-store |
| 31 | -the current golden and fails iff that lock asserts a tuple has a | 32 | +`dlm.lock`, which captures one training run's determinism contract. |
| 32 | -golden but the on-disk file differs (catches silent drift on dep bump). | ||
| 33 | """ | 33 | """ |
| 34 | 34 | ||
| 35 | from __future__ import annotations | 35 | from __future__ import annotations |
@@ -140,9 +140,12 @@ def main() -> int: | |||
| 140 | 140 | ||
| 141 | import tempfile | 141 | import tempfile |
| 142 | 142 | ||
| 143 | + from dlm.lock.golden_index import GOLDEN_INDEX_RELATIVE_PATH, upsert_golden_index | ||
| 144 | + | ||
| 143 | versions = _current_versions() | 145 | versions = _current_versions() |
| 144 | filename = _tuple_filename(versions) | 146 | filename = _tuple_filename(versions) |
| 145 | target = _GOLDEN_DIR / filename | 147 | target = _GOLDEN_DIR / filename |
| 148 | + golden_relpath = target.relative_to(_REPO_ROOT).as_posix() | ||
| 146 | prior = None | 149 | prior = None |
| 147 | if target.is_file(): | 150 | if target.is_file(): |
| 148 | try: | 151 | try: |
@@ -192,7 +195,15 @@ def main() -> int: | |||
| 192 | 195 | ||
| 193 | _GOLDEN_DIR.mkdir(parents=True, exist_ok=True) | 196 | _GOLDEN_DIR.mkdir(parents=True, exist_ok=True) |
| 194 | target.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") | 197 | target.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") |
| 198 | + upsert_golden_index( | ||
| 199 | + _REPO_ROOT, | ||
| 200 | + golden_relpath=golden_relpath, | ||
| 201 | + adapter_sha256=sha_a, | ||
| 202 | + platform=payload["platform"], | ||
| 203 | + pinned_versions=versions, | ||
| 204 | + ) | ||
| 195 | print(f"[wrote] {target.relative_to(_REPO_ROOT)}") | 205 | print(f"[wrote] {target.relative_to(_REPO_ROOT)}") |
| 206 | + print(f"[wrote] {GOLDEN_INDEX_RELATIVE_PATH}") | ||
| 196 | return 0 | 207 | return 0 |
| 197 | 208 | ||
| 198 | 209 | ||
src/dlm/base_models/license.pymodified@@ -11,7 +11,7 @@ an `accept_license` flag against the spec. | |||
| 11 | - `manifest.json.license_acceptance`: the per-store durable record; | 11 | - `manifest.json.license_acceptance`: the per-store durable record; |
| 12 | read on every subsequent `dlm train` to verify the acceptance | 12 | read on every subsequent `dlm train` to verify the acceptance |
| 13 | fingerprint is still present. | 13 | fingerprint is still present. |
| 14 | -- Repo-level `dlm.lock.license_acceptance`: the determinism-contract | 14 | +- Per-store `dlm.lock.license_acceptance`: the determinism-contract |
| 15 | mirror; divergence between the two triggers a lock re-check. | 15 | mirror; divergence between the two triggers a lock re-check. |
| 16 | 16 | ||
| 17 | The interactive prompt in `dlm init` lives in the CLI layer; this | 17 | The interactive prompt in `dlm init` lives in the CLI layer; this |
src/dlm/lock/__init__.pymodified@@ -1,10 +1,11 @@ | |||
| 1 | """Per-store `dlm.lock` — determinism contract for one `.dlm`. | 1 | """Per-store `dlm.lock` — determinism contract for one `.dlm`. |
| 2 | 2 | ||
| 3 | -Separate from the repo-level `uv.lock` (tool-dep pins) and from the | 3 | +Separate from the repo-level `uv.lock` (tool-dep pins), from the |
| 4 | -`manifest.json` (training run narrative). The store-level `dlm.lock` | 4 | +repo-level determinism-golden index at `.determinism/lock.json`, and |
| 5 | -pins the tuple `(torch, transformers, peft, trl, bitsandbytes, | 5 | +from `manifest.json` (training run narrative). The store-level |
| 6 | -accelerate, llama.cpp tag, cuda/rocm, hardware_tier, seed, | 6 | +`dlm.lock` pins the tuple `(torch, transformers, peft, trl, |
| 7 | -determinism_flags, determinism_class)` and carries: | 7 | +bitsandbytes, accelerate, llama.cpp tag, cuda/rocm, hardware_tier, |
| 8 | +seed, determinism_flags, determinism_class)` and carries: | ||
| 8 | 9 | ||
| 9 | - the hash of the `.dlm` source at the time the lock was written | 10 | - the hash of the `.dlm` source at the time the lock was written |
| 10 | - the base-model revision + content hash | 11 | - the base-model revision + content hash |
@@ -20,7 +21,24 @@ table in `policy.py`. | |||
| 20 | from __future__ import annotations | 21 | from __future__ import annotations |
| 21 | 22 | ||
| 22 | from dlm.lock.builder import build_lock, hardware_tier_from_backend, hash_dlm_file | 23 | from dlm.lock.builder import build_lock, hardware_tier_from_backend, hash_dlm_file |
| 23 | -from dlm.lock.errors import LockError, LockSchemaError, LockValidationError, LockWriteError | 24 | +from dlm.lock.errors import ( |
| 25 | + GoldenIndexSchemaError, | ||
| 26 | + GoldenIndexWriteError, | ||
| 27 | + LockError, | ||
| 28 | + LockSchemaError, | ||
| 29 | + LockValidationError, | ||
| 30 | + LockWriteError, | ||
| 31 | +) | ||
| 32 | +from dlm.lock.golden_index import ( | ||
| 33 | + CURRENT_GOLDEN_INDEX_VERSION, | ||
| 34 | + GOLDEN_INDEX_RELATIVE_PATH, | ||
| 35 | + DeterminismGoldenEntry, | ||
| 36 | + DeterminismGoldenIndex, | ||
| 37 | + golden_index_path, | ||
| 38 | + load_golden_index, | ||
| 39 | + upsert_golden_index, | ||
| 40 | + write_golden_index, | ||
| 41 | +) | ||
| 24 | from dlm.lock.policy import Severity, classify_mismatches | 42 | from dlm.lock.policy import Severity, classify_mismatches |
| 25 | from dlm.lock.schema import CURRENT_LOCK_VERSION, LOCK_FILENAME, DlmLock | 43 | from dlm.lock.schema import CURRENT_LOCK_VERSION, LOCK_FILENAME, DlmLock |
| 26 | from dlm.lock.validator import LockDecision, LockMode, validate_lock | 44 | from dlm.lock.validator import LockDecision, LockMode, validate_lock |
@@ -28,6 +46,12 @@ from dlm.lock.writer import load_lock, lock_path, write_lock | |||
| 28 | 46 | ||
| 29 | __all__ = [ | 47 | __all__ = [ |
| 30 | "CURRENT_LOCK_VERSION", | 48 | "CURRENT_LOCK_VERSION", |
| 49 | + "CURRENT_GOLDEN_INDEX_VERSION", | ||
| 50 | + "GOLDEN_INDEX_RELATIVE_PATH", | ||
| 51 | + "DeterminismGoldenEntry", | ||
| 52 | + "DeterminismGoldenIndex", | ||
| 53 | + "GoldenIndexSchemaError", | ||
| 54 | + "GoldenIndexWriteError", | ||
| 31 | "LOCK_FILENAME", | 55 | "LOCK_FILENAME", |
| 32 | "DlmLock", | 56 | "DlmLock", |
| 33 | "LockDecision", | 57 | "LockDecision", |
@@ -40,9 +64,13 @@ __all__ = [ | |||
| 40 | "build_lock", | 64 | "build_lock", |
| 41 | "classify_mismatches", | 65 | "classify_mismatches", |
| 42 | "hardware_tier_from_backend", | 66 | "hardware_tier_from_backend", |
| 67 | + "golden_index_path", | ||
| 43 | "hash_dlm_file", | 68 | "hash_dlm_file", |
| 44 | "load_lock", | 69 | "load_lock", |
| 70 | + "load_golden_index", | ||
| 45 | "lock_path", | 71 | "lock_path", |
| 72 | + "upsert_golden_index", | ||
| 46 | "validate_lock", | 73 | "validate_lock", |
| 74 | + "write_golden_index", | ||
| 47 | "write_lock", | 75 | "write_lock", |
| 48 | ] | 76 | ] |
src/dlm/lock/errors.pymodified@@ -51,3 +51,21 @@ class LockValidationError(LockError): | |||
| 51 | self.reasons = list(reasons) | 51 | self.reasons = list(reasons) |
| 52 | joined = "; ".join(reasons) | 52 | joined = "; ".join(reasons) |
| 53 | super().__init__(f"{path}: lock validation failed ({joined})") | 53 | super().__init__(f"{path}: lock validation failed ({joined})") |
| 54 | + | ||
| 55 | + | ||
| 56 | +class GoldenIndexSchemaError(LockError): | ||
| 57 | + """Repo-level determinism-golden index is unreadable or schema-invalid.""" | ||
| 58 | + | ||
| 59 | + def __init__(self, path: Path, reason: str) -> None: | ||
| 60 | + self.path = path | ||
| 61 | + self.reason = reason | ||
| 62 | + super().__init__(f"{path}: {reason}") | ||
| 63 | + | ||
| 64 | + | ||
| 65 | +class GoldenIndexWriteError(LockError): | ||
| 66 | + """Programmer error on the repo-level determinism-golden index write path.""" | ||
| 67 | + | ||
| 68 | + def __init__(self, *, path: Path, reason: str) -> None: | ||
| 69 | + self.path = path | ||
| 70 | + self.reason = reason | ||
| 71 | + super().__init__(f"{path}: write refused: {reason}") | ||
src/dlm/lock/golden_index.pyadded@@ -0,0 +1,136 @@ | |||
| 1 | +"""Repo-level index of checked-in determinism goldens. | ||
| 2 | + | ||
| 3 | +Separate from the per-store `dlm.lock`: this file tracks which | ||
| 4 | +runtime tuples have an approved golden under `tests/golden/determinism/`. | ||
| 5 | +The canonical path is `.determinism/lock.json`. | ||
| 6 | +""" | ||
| 7 | + | ||
| 8 | +from __future__ import annotations | ||
| 9 | + | ||
| 10 | +import json | ||
| 11 | +from collections.abc import Mapping | ||
| 12 | +from datetime import UTC, datetime | ||
| 13 | +from pathlib import Path | ||
| 14 | +from typing import Final | ||
| 15 | + | ||
| 16 | +from pydantic import BaseModel, ConfigDict, Field | ||
| 17 | + | ||
| 18 | +from dlm.io.atomic import write_text | ||
| 19 | +from dlm.lock.errors import GoldenIndexSchemaError, GoldenIndexWriteError | ||
| 20 | + | ||
| 21 | +GOLDEN_INDEX_RELATIVE_PATH: Final[str] = ".determinism/lock.json" | ||
| 22 | +CURRENT_GOLDEN_INDEX_VERSION: Final[int] = 1 | ||
| 23 | + | ||
| 24 | + | ||
| 25 | +class DeterminismGoldenEntry(BaseModel): | ||
| 26 | + """One approved tuple golden tracked at repo scope.""" | ||
| 27 | + | ||
| 28 | + model_config = ConfigDict(extra="forbid", frozen=True) | ||
| 29 | + | ||
| 30 | + golden_relpath: str = Field( | ||
| 31 | + ..., | ||
| 32 | + pattern=r"^tests/golden/determinism/tuple-[0-9a-f]{16}\.json$", | ||
| 33 | + ) | ||
| 34 | + adapter_sha256: str = Field(..., pattern=r"^[0-9a-f]{64}$") | ||
| 35 | + platform: str = Field(..., min_length=1) | ||
| 36 | + pinned_versions: dict[str, str] = Field(default_factory=dict) | ||
| 37 | + | ||
| 38 | + | ||
| 39 | +class DeterminismGoldenIndex(BaseModel): | ||
| 40 | + """Checked-in set of approved determinism goldens.""" | ||
| 41 | + | ||
| 42 | + model_config = ConfigDict(extra="forbid", frozen=True) | ||
| 43 | + | ||
| 44 | + lock_version: int = Field(CURRENT_GOLDEN_INDEX_VERSION, ge=1) | ||
| 45 | + updated_at: datetime | ||
| 46 | + goldens: tuple[DeterminismGoldenEntry, ...] = () | ||
| 47 | + | ||
| 48 | + | ||
| 49 | +def golden_index_path(repo_root: Path) -> Path: | ||
| 50 | + """Return `<repo_root>/.determinism/lock.json`.""" | ||
| 51 | + | ||
| 52 | + return repo_root / GOLDEN_INDEX_RELATIVE_PATH | ||
| 53 | + | ||
| 54 | + | ||
| 55 | +def write_golden_index(repo_root: Path, index: DeterminismGoldenIndex) -> Path: | ||
| 56 | + """Atomically persist the repo-level determinism-golden index.""" | ||
| 57 | + | ||
| 58 | + target = golden_index_path(repo_root) | ||
| 59 | + if index.lock_version != CURRENT_GOLDEN_INDEX_VERSION: | ||
| 60 | + raise GoldenIndexWriteError( | ||
| 61 | + path=target, | ||
| 62 | + reason=( | ||
| 63 | + f"lock_version={index.lock_version!r} != writer's " | ||
| 64 | + f"CURRENT_GOLDEN_INDEX_VERSION={CURRENT_GOLDEN_INDEX_VERSION}" | ||
| 65 | + ), | ||
| 66 | + ) | ||
| 67 | + target.parent.mkdir(parents=True, exist_ok=True) | ||
| 68 | + payload = index.model_dump(mode="json") | ||
| 69 | + text = json.dumps(payload, indent=2, sort_keys=True) + "\n" | ||
| 70 | + write_text(target, text) | ||
| 71 | + return target | ||
| 72 | + | ||
| 73 | + | ||
| 74 | +def load_golden_index(repo_root: Path) -> DeterminismGoldenIndex | None: | ||
| 75 | + """Read `.determinism/lock.json`, returning `None` when absent.""" | ||
| 76 | + | ||
| 77 | + path = golden_index_path(repo_root) | ||
| 78 | + if not path.is_file(): | ||
| 79 | + return None | ||
| 80 | + | ||
| 81 | + try: | ||
| 82 | + raw = path.read_text(encoding="utf-8") | ||
| 83 | + except OSError as exc: | ||
| 84 | + raise GoldenIndexSchemaError(path, f"unreadable: {exc}") from exc | ||
| 85 | + | ||
| 86 | + try: | ||
| 87 | + payload = json.loads(raw) | ||
| 88 | + except json.JSONDecodeError as exc: | ||
| 89 | + raise GoldenIndexSchemaError(path, f"invalid JSON: {exc}") from exc | ||
| 90 | + | ||
| 91 | + if not isinstance(payload, dict): | ||
| 92 | + raise GoldenIndexSchemaError( | ||
| 93 | + path, | ||
| 94 | + f"top-level JSON must be an object, got {type(payload).__name__}", | ||
| 95 | + ) | ||
| 96 | + | ||
| 97 | + version = payload.get("lock_version") | ||
| 98 | + if version != CURRENT_GOLDEN_INDEX_VERSION: | ||
| 99 | + raise GoldenIndexSchemaError( | ||
| 100 | + path, | ||
| 101 | + f"unsupported lock_version {version!r} (reader expects {CURRENT_GOLDEN_INDEX_VERSION})", | ||
| 102 | + ) | ||
| 103 | + | ||
| 104 | + try: | ||
| 105 | + return DeterminismGoldenIndex.model_validate(payload) | ||
| 106 | + except Exception as exc: | ||
| 107 | + raise GoldenIndexSchemaError(path, f"schema validation: {exc}") from exc | ||
| 108 | + | ||
| 109 | + | ||
| 110 | +def upsert_golden_index( | ||
| 111 | + repo_root: Path, | ||
| 112 | + *, | ||
| 113 | + golden_relpath: str, | ||
| 114 | + adapter_sha256: str, | ||
| 115 | + platform: str, | ||
| 116 | + pinned_versions: Mapping[str, str], | ||
| 117 | +) -> Path: | ||
| 118 | + """Insert or replace one tuple golden in `.determinism/lock.json`.""" | ||
| 119 | + | ||
| 120 | + current = load_golden_index(repo_root) | ||
| 121 | + entries = {} if current is None else {entry.golden_relpath: entry for entry in current.goldens} | ||
| 122 | + entries[golden_relpath] = DeterminismGoldenEntry( | ||
| 123 | + golden_relpath=golden_relpath, | ||
| 124 | + adapter_sha256=adapter_sha256, | ||
| 125 | + platform=platform, | ||
| 126 | + pinned_versions=dict(sorted(pinned_versions.items())), | ||
| 127 | + ) | ||
| 128 | + updated = DeterminismGoldenIndex( | ||
| 129 | + updated_at=_utcnow(), | ||
| 130 | + goldens=tuple(sorted(entries.values(), key=lambda entry: entry.golden_relpath)), | ||
| 131 | + ) | ||
| 132 | + return write_golden_index(repo_root, updated) | ||
| 133 | + | ||
| 134 | + | ||
| 135 | +def _utcnow() -> datetime: | ||
| 136 | + return datetime.now(UTC).replace(tzinfo=None, microsecond=0) | ||
tests/unit/lock/test_golden_index.pyadded@@ -0,0 +1,128 @@ | |||
| 1 | +"""Repo-level determinism-golden index I/O.""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +from datetime import UTC, datetime | ||
| 6 | +from pathlib import Path | ||
| 7 | + | ||
| 8 | +import pytest | ||
| 9 | + | ||
| 10 | +from dlm.lock.errors import GoldenIndexSchemaError | ||
| 11 | +from dlm.lock.golden_index import ( | ||
| 12 | + GOLDEN_INDEX_RELATIVE_PATH, | ||
| 13 | + DeterminismGoldenEntry, | ||
| 14 | + DeterminismGoldenIndex, | ||
| 15 | + golden_index_path, | ||
| 16 | + load_golden_index, | ||
| 17 | + upsert_golden_index, | ||
| 18 | + write_golden_index, | ||
| 19 | +) | ||
| 20 | + | ||
| 21 | + | ||
| 22 | +def _index(*entries: DeterminismGoldenEntry) -> DeterminismGoldenIndex: | ||
| 23 | + return DeterminismGoldenIndex( | ||
| 24 | + updated_at=datetime(2026, 4, 22, 4, 25, 32, tzinfo=UTC), | ||
| 25 | + goldens=entries, | ||
| 26 | + ) | ||
| 27 | + | ||
| 28 | + | ||
| 29 | +def _entry( | ||
| 30 | + *, | ||
| 31 | + golden_relpath: str = "tests/golden/determinism/tuple-0123456789abcdef.json", | ||
| 32 | + adapter_sha256: str = "a" * 64, | ||
| 33 | + platform: str = "darwin-arm64", | ||
| 34 | +) -> DeterminismGoldenEntry: | ||
| 35 | + return DeterminismGoldenEntry( | ||
| 36 | + golden_relpath=golden_relpath, | ||
| 37 | + adapter_sha256=adapter_sha256, | ||
| 38 | + platform=platform, | ||
| 39 | + pinned_versions={"peft": "0.14.0", "torch": "2.5.1"}, | ||
| 40 | + ) | ||
| 41 | + | ||
| 42 | + | ||
| 43 | +class TestGoldenIndexPath: | ||
| 44 | + def test_returns_repo_relative_path(self, tmp_path: Path) -> None: | ||
| 45 | + assert golden_index_path(tmp_path) == tmp_path / GOLDEN_INDEX_RELATIVE_PATH | ||
| 46 | + | ||
| 47 | + | ||
| 48 | +class TestWriteGoldenIndex: | ||
| 49 | + def test_writes_readable_json(self, tmp_path: Path) -> None: | ||
| 50 | + written = write_golden_index(tmp_path, _index(_entry())) | ||
| 51 | + assert written.is_file() | ||
| 52 | + text = written.read_text(encoding="utf-8") | ||
| 53 | + assert text.endswith("\n") | ||
| 54 | + assert text.index('"golden_relpath"') < text.index('"platform"') | ||
| 55 | + | ||
| 56 | + def test_round_trip_equal(self, tmp_path: Path) -> None: | ||
| 57 | + original = _index(_entry()) | ||
| 58 | + write_golden_index(tmp_path, original) | ||
| 59 | + loaded = load_golden_index(tmp_path) | ||
| 60 | + assert loaded == original | ||
| 61 | + | ||
| 62 | + | ||
| 63 | +class TestLoadGoldenIndex: | ||
| 64 | + def test_missing_file_returns_none(self, tmp_path: Path) -> None: | ||
| 65 | + assert load_golden_index(tmp_path) is None | ||
| 66 | + | ||
| 67 | + def test_invalid_json_raises(self, tmp_path: Path) -> None: | ||
| 68 | + golden_index_path(tmp_path).parent.mkdir(parents=True) | ||
| 69 | + golden_index_path(tmp_path).write_text("{not valid", encoding="utf-8") | ||
| 70 | + with pytest.raises(GoldenIndexSchemaError, match="invalid JSON"): | ||
| 71 | + load_golden_index(tmp_path) | ||
| 72 | + | ||
| 73 | + def test_non_object_top_level_raises(self, tmp_path: Path) -> None: | ||
| 74 | + golden_index_path(tmp_path).parent.mkdir(parents=True) | ||
| 75 | + golden_index_path(tmp_path).write_text("[]", encoding="utf-8") | ||
| 76 | + with pytest.raises(GoldenIndexSchemaError, match="must be an object"): | ||
| 77 | + load_golden_index(tmp_path) | ||
| 78 | + | ||
| 79 | + def test_newer_version_is_rejected(self, tmp_path: Path) -> None: | ||
| 80 | + golden_index_path(tmp_path).parent.mkdir(parents=True) | ||
| 81 | + golden_index_path(tmp_path).write_text('{"lock_version": 99}', encoding="utf-8") | ||
| 82 | + with pytest.raises(GoldenIndexSchemaError, match="unsupported lock_version"): | ||
| 83 | + load_golden_index(tmp_path) | ||
| 84 | + | ||
| 85 | + | ||
| 86 | +class TestUpsertGoldenIndex: | ||
| 87 | + def test_creates_index_when_absent(self, tmp_path: Path) -> None: | ||
| 88 | + upsert_golden_index( | ||
| 89 | + tmp_path, | ||
| 90 | + golden_relpath="tests/golden/determinism/tuple-0123456789abcdef.json", | ||
| 91 | + adapter_sha256="a" * 64, | ||
| 92 | + platform="darwin-arm64", | ||
| 93 | + pinned_versions={"torch": "2.5.1", "peft": "0.14.0"}, | ||
| 94 | + ) | ||
| 95 | + loaded = load_golden_index(tmp_path) | ||
| 96 | + assert loaded is not None | ||
| 97 | + assert [entry.golden_relpath for entry in loaded.goldens] == [ | ||
| 98 | + "tests/golden/determinism/tuple-0123456789abcdef.json" | ||
| 99 | + ] | ||
| 100 | + | ||
| 101 | + def test_overwrites_existing_entry_and_sorts(self, tmp_path: Path) -> None: | ||
| 102 | + write_golden_index( | ||
| 103 | + tmp_path, | ||
| 104 | + _index( | ||
| 105 | + _entry(golden_relpath="tests/golden/determinism/tuple-ffffffffffffffff.json"), | ||
| 106 | + _entry( | ||
| 107 | + golden_relpath="tests/golden/determinism/tuple-aaaaaaaaaaaaaaaa.json", | ||
| 108 | + adapter_sha256="b" * 64, | ||
| 109 | + ), | ||
| 110 | + ), | ||
| 111 | + ) | ||
| 112 | + | ||
| 113 | + upsert_golden_index( | ||
| 114 | + tmp_path, | ||
| 115 | + golden_relpath="tests/golden/determinism/tuple-ffffffffffffffff.json", | ||
| 116 | + adapter_sha256="c" * 64, | ||
| 117 | + platform="linux-x86_64", | ||
| 118 | + pinned_versions={"torch": "2.6.0"}, | ||
| 119 | + ) | ||
| 120 | + | ||
| 121 | + loaded = load_golden_index(tmp_path) | ||
| 122 | + assert loaded is not None | ||
| 123 | + assert [entry.golden_relpath for entry in loaded.goldens] == [ | ||
| 124 | + "tests/golden/determinism/tuple-aaaaaaaaaaaaaaaa.json", | ||
| 125 | + "tests/golden/determinism/tuple-ffffffffffffffff.json", | ||
| 126 | + ] | ||
| 127 | + assert loaded.goldens[1].adapter_sha256 == "c" * 64 | ||
| 128 | + assert loaded.goldens[1].platform == "linux-x86_64" | ||