Story S2-03 — Content-addressed score cache (get / put / gc)¶
Step: Step 2 — Build harness internals: loader, cache, audit chain extension, canary + cost-tag shims
Status: HARDENED
Effort: M
Depends on: S1-02
ADRs honored: Phase 0 ADR-0001 (0001-cache-content-hash-algorithm.md — single chokepoint for BLAKE3 + SHA-256), Phase 0 ADR-0011 (directory-permissions model — 0700 dirs / 0600 files), Phase 6.5 ADR-0005 (cassette_canary_pin participates in cache-key composition)
Validation notes (phase-story-validator, 2026-05-26)¶
Hardened from the Ready draft after four parallel critics (Coverage, Test-Quality, Consistency, Design-Patterns). Full report: _validation/S2-03-content-addressed-cache.md.
Highlights of what changed:
- Dead lift removed.
bytes_hashstub deleted from "Files to touch" and Outline —codegenie.hashing.content_hash_bytesalready exists (Phase 0 S2-03); the cache must call it, not edithashing.py. os.rename→os.replaceeverywhere. Matches Phase 0cache/store.py:138precedent (Rule 11; cross-platform-safe; overwrites atomically when target exists).- Filename indecision resolved. On-disk filename is
<cache_dir>/<64-hex>.json(hex only; theblake3:prefix lives only inside theCacheKeystring, never on disk). Hedge deleted from Outline §3. - Newtype + smart constructor.
CacheKey = NewType("CacheKey", str)incodegenie/types/identifiers.py;compose_cache_key(inputs: CacheKeyInputs) -> CacheKey(frozenCacheKeyInputsdataclass) replaces the six-kwarg signature. CLAUDE.md "Never rawstrfor domain IDs" + "Extension by addition — no silent edits" applied at the function-arg boundary (adding a future input fails type-check at every call site, loudly). - AC additions for boundary conditions the runner will hit on day one:
putcreatescache_dir(and parents) if missing;geton a missingcache_dirreturnsNonewithout warning;gcon a missing or emptycache_dirreturns0and never raises;gcboundary tests atretain_days * 86400second granularity;gcskips both.lockand*.tmporphans; overwrite (put(k, v2)afterput(k, v1)) is atomic andgetthen returnsv2;OSErrorfromos.replacepropagates (never swallowed) and leaves the prior<key>.jsonbyte-identical. - Fail-loud disciplines as ACs. File-mode
0o600is asserted post-puton both<key>.jsonand.lock(not just<key>.tmp);putre-chmods afteros.replaceto defeat CI restore-time umask flattening (Phase 0 ADR-0011 + Phase 0cache/store.py:380). - Mutation-resistance rewrites of the TDD plan.
- Lock test (was: "two threads write valid JSON") rewritten to probe
flockstate directly withLOCK_EX | LOCK_NBfrom a sibling fd — the previous test passes even ifflockis deleted (becauseBenchScoreJSON fits underPIPE_BUF). - Atomicity test (was: patch
os.renameand assert suffixes) rewritten to observe filesystem state at the moment ofos.replace— the previous test passes for a degeneratereplace(path, path). compose_cache_keytests (were:...placeholders) rewritten with six disjoint role-encoding values and a positional-swap parametrize overitertools.combinations(range(6), 2)— the previous tests cannot catch a(case_digest, sut_digest)swap mutation.- Corrupt-then-recover round-trip added (catches the "short-circuit if file exists" mutation).
structlog.testing.capture_logs()replacescaplogand asserts bothcache_keyandpathkwargs (matches Phase 0 cache-store test convention).- Property test on
compose_cache_keydeterminism over random shapes added (AC mandates Hypothesis; previous TDD plan was a fixed-input call). - ADR-0005 scoped-invalidation test added: rotating case A's pin must not change case B's key.
compose_cache_keyinput contract documented. Pure bytes-to-hex; does not validate. Inputs must not contain\x1f(the unit separator); callers (S2-02 loader, Runner) own input-shape validation. Arity-byte omitted intentionally (kw-only signature pins arity at exactly six) — divergence fromhashing.identity_hashdocumented in Notes.- Architectural divergence surfaced, not silently chosen. Two intentional Phase-0 divergences are now explicit: (a) free-function module surface vs the arch class diagram's
class Cache; (b)fcntl.flockvs Phase 0'sO_APPEND+PIPE_BUFatomicity (BenchScore JSON exceeds 4 KB). See Notes for implementer. - Goal sentence widened to include
compose_cache_key(consistency with__all__and AC list).
Context¶
The runner's per-case cache makes lower_bound_95 computation cheap on warm reruns: a 10-case cold run is ≤12 min; the warm rerun must be ≤8 s (High-level-impl.md §Step 5 done criterion). The cache is content-addressed under BLAKE3(case_digest || sut_digest || rubric_digest || cassette_corpus_digest || harness_version || cassette_canary_pin) (phase-arch-design.md §Component design — cache.py). The two load-bearing disciplines are (a) atomic writes — <hex>.tmp then os.replace to <hex>.json so a mid-write crash leaves the previous value intact (phase-arch-design.md §Edge cases #16; os.replace per Phase 0 cache/store.py:138 precedent — cross-platform-safe and overwrites atomically when target exists); and (b) corrupt-on-read is a miss, not a failure — a truncated cache file emits a structlog warning and re-executes the case, never poisoning the run.
References — where to look¶
- Architecture:
../phase-arch-design.md §Component design — src/codegenie/eval/cache.py— public interface, cache-key composition,fcntl.flockdiscipline, GC by mtime../phase-arch-design.md §Edge cases #16— corrupt cache file → miss../phase-arch-design.md §Edge cases #17+§Process view (Concurrency note)—fcntl.flockserializes writers within one host../phase-arch-design.md §Non-goals #9, #10— per-host cache only, no remote/shared; nightly single-host cadence../phase-arch-design.md §Property tests— cache-key determinism and uniqueness invariants- Phase ADRs:
../ADRs/0005-cassette-canary-seed-parameterization.md §Consequences—cassette_canary_pinis part of cache-key composition so a curator who rotates a pin invalidates only that case's cache entry- Source design:
../final-design.md §Components → cache.py— original key-composition spec- Existing code:
src/codegenie/eval/models.py(S1-02) —BenchScorePydantic;frozen=True,extra="forbid"; the cache's value typesrc/codegenie/hashing.py(Phase 0 S2-03) —content_hash_bytes(b: bytes) -> "blake3:<hex>"already exists at line ~85; reuse it. Do not importblake3directly (ADR-0001 chokepoint). Do not add abytes_hashhelper —content_hash_bytesis the right primitive.src/codegenie/cache/store.py— Phase 0 atomic-write precedent. Mirror its shape:_atomic_write_bytesusesos.replace(notos.rename), pid+secrets.token_hex(4)tmp-suffix discipline,os.write+os.fsync+os.close+os.replacesequence, and a post-writeos.chmodto defeat CIactions/cacheumask flattening (Phase 0 ADR-0011 +cache/store.py:380).src/codegenie/types/identifiers.py— newtype home. This story addsCacheKey = NewType("CacheKey", str)here (mirroringProbeId,IndexName).
Goal¶
codegenie.eval.cache exposes compose_cache_key, get, put, gc with content-addressed keys; put is atomic (os.replace) under fcntl.flock; get is lock-free and treats corrupt files as miss with a structured warning; gc evicts entries older than retain_days by mtime and skips both .lock and *.tmp orphans.
Acceptance criteria¶
Typed surface¶
- [ ] Newtype + smart constructor.
codegenie/types/identifiers.pyexportsCacheKey = NewType("CacheKey", str)(mirrorsProbeId,IndexName). The only public way to construct aCacheKeyiscompose_cache_key(...); module re-exportsCacheKeyfromcodegenie.eval.cache. - [ ] Cache-key inputs aggregate.
compose_cache_keytakes a singleCacheKeyInputsargument:@dataclass(frozen=True, slots=True) class CacheKeyInputs(case_digest: str, sut_digest: str, rubric_digest: str, cassette_corpus_digest: str, harness_version: str, cassette_canary_pin: str). Adding a future input is a loud structural change (all call sites fail type-check until updated), not a silent positional argument addition — Extension-by-addition + Open/Closed at the function-arg boundary. - [ ] Module API:
compose_cache_key(inputs: CacheKeyInputs) -> CacheKey;get(cache_key: CacheKey, cache_dir: Path) -> BenchScore | None;put(cache_key: CacheKey, score: BenchScore, cache_dir: Path) -> None;gc(cache_dir: Path, retain_days: int = 90) -> int(returns count of*.jsonentries evicted; does NOT count.tmporphans toward the return value). - [ ]
__all__equals exactly("CacheKey", "CacheKeyInputs", "compose_cache_key", "get", "put", "gc"); no other public names (asserted byassert cache.__all__ == ...in a fence-style test).
Composer (compose_cache_key) semantics¶
- [ ] Output shape. Returns
CacheKeywhose string value matches^blake3:[0-9a-f]{64}$(prefix + 64 lowercase hex). - [ ] Composition. Concatenates the six
CacheKeyInputsfields in the order declared on the dataclass with\x1f(ASCII unit-separator) between them, UTF-8 encoded, then routed throughcodegenie.hashing.content_hash_bytes.cache.pyMUST NOTimport blake3(asserted by an AST-scan fence test intests/unit/eval/test_cache.py::test_no_direct_blake3_import). - [ ] Determinism (Hypothesis property test). For any
CacheKeyInputsvalue, repeated calls return byte-identicalCacheKey. Usehypothesis.strategies.text(min_size=0, max_size=128, alphabet=...)excluding\x1ffor each field;--no-covpermitted for ad-hoc subset runs. - [ ] Per-field uniqueness. Parametrized test (one row per field name) flips that one field and asserts
compose_cache_key(modified) != compose_cache_key(base). Base values are six disjoint role-encoding strings (e.g.,"case-d-AAA","sut-d-BBB", …) so any field can be uniquely identified in its slot. - [ ] Positional-swap resistance. Parametrized test over
itertools.combinations(range(6), 2)swaps every pair of input values viadataclasses.replace; asserts the resultingCacheKeydiffers from the base. Catches a mutant where, e.g.,compose_cache_keybuilds the join from(sut_digest, case_digest, ...)instead of(case_digest, sut_digest, ...). - [ ] ADR-0005 scoped invalidation. Test: rotating case A's
cassette_canary_pinchanges A'sCacheKeybut does NOT change case B'sCacheKey(B's pin is the only one that affects B's key). Encodes the per-case scoping consequence of ADR-0005, distinct from the bare per-field uniqueness check. - [ ] Input contract — pure bytes-to-hex.
compose_cache_keydoes not validate input shape or content; empty strings and arbitrary-length values hash to a validCacheKey. Inputs MUST NOT contain\x1f(the unit-separator) — caller responsibility (the S2-02 loader validates*_digestshape; the Runner validatesharness_versionandcassette_canary_pin). Document this contract in the function docstring. - [ ] Keyword-only at call site.
compose_cache_key(some_kwargs)— positionalstrarguments are aTypeError(signature iscompose_cache_key(inputs: CacheKeyInputs), so this is automatic but assert it in a test for explicitness).
get semantics¶
- [ ] Round-trip.
put(k, score, dir); assert get(k, dir) == scorefor a freshly constructedBenchScorewith every field populated. Pydantic equality holds (frozen models compare by field values). - [ ] Missing-
<hex>.jsonis a miss.getreturnsNonewithout warning. - [ ] Missing
cache_diris a miss.get(k, /nonexistent/path)returnsNonewithout raising, without warning. - [ ] Corrupt-file-on-read is a miss. If
<cache_dir>/<hex>.jsonexists butBenchScore.model_validate_jsonraises (pydantic.ValidationErrorORjson.JSONDecodeError), returnNoneand emitstructlog.warn("cache.corrupt_entry", cache_key=<key>, path=<absolute path>). Do NOT raise; do NOT delete the file (operators may want to inspect). Event MUST contain bothcache_keyandpathkwargs (asserted bystructlog.testing.capture_logs()— NOTcaplog, which loses structlog kwargs under the project's processor chain). - [ ] Corrupt-then-recover. After
getreturnsNoneon a corrupt<hex>.json, a subsequentput(k, v, dir)overwrites the corrupt file and the nextget(k, dir) == v. Catches a mutant that short-circuitsputif the destination exists. - [ ] Reader-during-writer safety. While a writer holds
LOCK_EXand is mid-os.replace, a concurrentget(k, dir)returns either the prior<hex>.jsonvalue (if any) orNone— never raises, never returns a torn read. Verified by a thread-based test that interleavesgetcalls with amonkeypatch-slowedos.replace.
put semantics¶
- [ ] Atomicity at the syscall level.
putwrites to<cache_dir>/<hex>.<pid>.<6-char-secrets-hex>.tmp(mirrors Phase 0cache/store.pypid+token tmp-suffix to defeat any future cross-process tmp collision), thenos.fsync(fd),os.close(fd), thenos.replace(tmp, <cache_dir>/<hex>.json). Verified by spying onos.replace: at the moment of replace, a.tmpfile matching the per-writer pattern exists, and either no<hex>.jsonexists (cold) or the previous<hex>.jsonis byte-identical to its pre-putcontent (recovery is the next AC). - [ ] Crash-during-write preserves prior value. Parametrized over four crash points (
tmp_create_fails,tmp_write_fails,fsync_fails,replace_fails— each viamonkeypatch.setattr(..., side_effect=OSError)):put(k, v1, dir)first, capturev1_bytes = (cache_dir / f"{hex}.json").read_bytes(), thenput(k, v2, dir)raisesOSError, and finally(cache_dir / f"{hex}.json").read_bytes() == v1_bytes(byte-identical, untouched).OSErrorpropagates — never swallowed. - [ ] Overwrite is atomic.
put(k, v2, dir)afterput(k, v1, dir)succeeds (noFileExistsError); subsequentget(k, dir) == v2.os.replacesemantics (vsos.rename) carry the overwrite guarantee — this AC pins it as a contract, not an accident. - [ ] Lock discipline — direct probe.
putacquiresfcntl.flock(LOCK_EX)on<cache_dir>/.lock(sentinel file, created with mode0o600) via a@contextlib.contextmanagerhelper_cache_write_lock(cache_dir) -> Iterator[None]; releases on success or exception. Verified by a test that pausesputmid-write (slowos.replace) and asserts a concurrentfcntl.flock(fd, LOCK_EX | LOCK_NB)on.lockfrom a sibling fd raisesBlockingIOError. The previous "two threads write valid JSON" test is not sufficient —BenchScoreJSON fits underPIPE_BUF=4096, so even with the lock removed kernel atomicity foros.writewould let it pass. - [ ]
getdoes NOT take the lock. Asserted:get(k, dir)succeeds while another thread holdsLOCK_EXon.lock(returns whatever was on disk before the writer started, orNone). - [ ]
cache_dirauto-creation.putcreatescache_dir(and parents) viamkdir(parents=True, exist_ok=True)if missing; round-trip test runs againsttmp_path / "never-created" / "cache". Without this, the Runner crashes on the firstputof a fresh checkout. - [ ] File modes are
0o600post-put.stat.S_IMODE((cache_dir / f"{hex}.json").stat().st_mode) == 0o600ANDstat.S_IMODE((cache_dir / ".lock").stat().st_mode) == 0o600.putre-os.chmods<hex>.jsonAFTERos.replaceto defeat CI restore-time umask flattening (Phase 0 ADR-0011 +cache/store.py:380precedent). - [ ] Directory mode is
0o700post-put. Whenputcreatescache_dir, the directory ischmod 0o700. Mirrors Phase 0_ensure_dir. - [ ] Mode constants. Module declares
_FILE_MODE: Final[int] = 0o600and_DIR_MODE: Final[int] = 0o700— no inline0o600/0o700literals in function bodies.
gc semantics¶
- [ ] Mixed-age counting. Given N entries with K older than
retain_days * 86400seconds and N-K younger,gc(cache_dir, retain_days)returns exactlyK, exactly the K old entries areunlink'd, exactly the N-K young entries remain readable viaget. Verified by a multi-entry test with explicitos.utime(path, (target_mtime, target_mtime))mtime forging. - [ ] Boundary behavior at
retain_days * 86400seconds. Parametrized test at four mtime offsets —(-91*86400, 1),(-90*86400 - 1, 1),(-90*86400 + 1, 0),(-89*86400, 0)— asserts the documented comparison operator (use<, not<=; entries written exactlyretain_days * 86400seconds ago are retained). - [ ]
retain_days=0.gc(cache_dir, retain_days=0)evicts every*.jsonentry whosemtime < time.time()(effectively all of them). - [ ] Skip
.locksentinel. Touch.lock, force its mtime to epoch zero, callgc(cache_dir, retain_days=1), assert.lockstill exists. Catches the mutantPath.glob("*")(which would unlink.lockand break every subsequentput). - [ ] Skip
*.tmporphans.*.tmpfiles from a crashed priorputare NOT counted toward the return value and are NOT unlinked bygc(an in-flightputin another process may still need its.tmp).gconly touches*.jsonfiles. (Reaping.tmporphans is deferred to a future story when the threat is empirical; for now the safer default is leave-alone — flagged in Notes for future revisit.) - [ ] Missing or empty
cache_dir.gc(/nonexistent/path, retain_days=90) == 0and does not raise;gc(cache_dir_with_only_lock, retain_days=90) == 0and does not raise. - [ ] Hypothesis property test — round-trip survives GC. Generate a list of
(cache_key, score, mtime_offset_seconds)tuples;putall;gc(retain_days=R); assertget(k) == scorefor exactly the entries whosemtime_offset_seconds > -R*86400.
Cross-cutting¶
- [ ] No
import blake3incache.py(AST-scan test). - [ ] No
import hashlibincache.py(BLAKE3 chokepoint iscodegenie.hashingonly). - [ ] No
os.renameincache.py(useos.replace; AST-scan test, matches Phase 0cache/store.pydiscipline). - [ ] TDD red test exists, committed, green.
- [ ]
ruff format,ruff check,mypy --strictclean. - [ ] Coverage on
src/codegenie/eval/cache.py≥ 95% line, ≥ 90% branch.
Implementation outline¶
- Add the newtype in
src/codegenie/types/identifiers.py:CacheKey = NewType("CacheKey", str)(one line; mirrors the existingProbeId,IndexNamerows). Re-export fromcodegenie.eval.cache. - Create
src/codegenie/eval/cache.pywith module-level constants_FILE_MODE: Final[int] = 0o600,_DIR_MODE: Final[int] = 0o700,_UNIT_SEP: Final[bytes] = b"\x1f". Module__all__ = ("CacheKey", "CacheKeyInputs", "compose_cache_key", "get", "put", "gc"). CacheKeyInputsdataclass —@dataclass(frozen=True, slots=True) class CacheKeyInputswith the sixstrfields in the declared order:case_digest,sut_digest,rubric_digest,cassette_corpus_digest,harness_version,cassette_canary_pin.compose_cache_key(inputs: CacheKeyInputs) -> CacheKey— read fields in dataclass order, UTF-8 encode each, join with_UNIT_SEP, route throughcodegenie.hashing.content_hash_bytes(joined_bytes), wrap the resulting"blake3:<hex>"inCacheKey(...). No validation of inputs. Do notimport blake3. Do not add abytes_hashhelper tohashing.py—content_hash_bytesalready exists.get(cache_key, cache_dir)—- Resolve
hex_part = cache_key.removeprefix("blake3:"),path = cache_dir / f"{hex_part}.json". - If
cache_dirorpathis missing → returnNonewithout warning. try: return BenchScore.model_validate_json(path.read_bytes()); onpydantic.ValidationError | json.JSONDecodeError→_log.warn("cache.corrupt_entry", cache_key=cache_key, path=str(path.resolve()))and returnNone. Do notunlink.- Do NOT take the lock.
_cache_write_lock(cache_dir: Path) -> Iterator[None]—@contextlib.contextmanager-decorated; ensures<cache_dir>/.lockexists (mode_FILE_MODE), opens itr,fcntl.flock(fh, LOCK_EX), yields, thenfcntl.flock(fh, LOCK_UN)in afinally.put(cache_key, score, cache_dir)—cache_dir.mkdir(parents=True, exist_ok=True);os.chmod(cache_dir, _DIR_MODE).hex_part = cache_key.removeprefix("blake3:");target = cache_dir / f"{hex_part}.json";tmp = cache_dir / f"{hex_part}.{os.getpid()}.{secrets.token_hex(4)}.tmp".with _cache_write_lock(cache_dir):data = score.model_dump_json().encode("utf-8").fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, _FILE_MODE);try: os.write(fd, data); os.fsync(fd) finally: os.close(fd).os.replace(tmp, target).os.chmod(target, _FILE_MODE)— re-assert afteros.replace(defeats CI umask flattening per Phase 0 ADR-0011).
OSErrorpropagates; never swallowed.gc(cache_dir, retain_days=90) -> int—- If
cache_dirdoes not exist → return0. cutoff = time.time() - retain_days * 86400.- Walk
cache_dir.glob("*.json")(this naturally excludes.lockand*.tmp). - For each
p: ifp.stat().st_mtime < cutoff,p.unlink(),evicted += 1, emit_log.info("cache.eviction", cache_dir=str(cache_dir), path=str(p)). - Return
evicted. - Tests in
tests/unit/eval/test_cache.py(see TDD plan).
TDD plan — red / green / refactor¶
Red¶
Test file: tests/unit/eval/test_cache.py. All tests must be mutation-resistant — for each, name the wrong implementation the test catches.
# ---- fixtures and helpers ----------------------------------------------------
KEY_A_HEX = "a" * 64
KEY_A = CacheKey(f"blake3:{KEY_A_HEX}")
KEY_B = CacheKey(f"blake3:{'b' * 64}")
_DISTINCT_INPUTS = CacheKeyInputs(
case_digest="case-d-AAA",
sut_digest="sut-d-BBB",
rubric_digest="rubric-d-CCC",
cassette_corpus_digest="corpus-d-DDD",
harness_version="harness-EEE",
cassette_canary_pin="canary-FFF",
)
def _score() -> BenchScore:
return BenchScore(
passed=True, score=1.0, breakdown={}, failure_modes=(),
cost_usd=0.0, wall_clock_ms=10,
)
# ---- get / put round-trip ---------------------------------------------------
def test_round_trip_get_returns_put_value(tmp_path):
cache.put(KEY_A, _score(), tmp_path)
assert cache.get(KEY_A, tmp_path) == _score()
def test_round_trip_creates_cache_dir_if_missing(tmp_path):
"""Catches: `put` assumes cache_dir exists; Runner crashes on fresh checkout."""
fresh = tmp_path / "never" / "created" / "cache"
cache.put(KEY_A, _score(), fresh)
assert cache.get(KEY_A, fresh) == _score()
def test_get_missing_returns_none(tmp_path):
assert cache.get(KEY_B, tmp_path) is None
def test_get_missing_cache_dir_returns_none_without_warning(tmp_path):
"""Catches: `get` raises FileNotFoundError on missing parent."""
with structlog.testing.capture_logs() as captured:
assert cache.get(KEY_A, tmp_path / "nonexistent") is None
assert [e for e in captured if "cache.corrupt_entry" in e.get("event", "")] == []
# ---- corrupt-on-read --------------------------------------------------------
def test_get_corrupt_returns_none_and_emits_structured_warning(tmp_path):
"""Catches: warn event missing kwargs; or impl re-raises; or impl unlinks the file."""
p = tmp_path / f"{KEY_A_HEX}.json"
p.write_text("{not valid json")
with structlog.testing.capture_logs() as captured:
assert cache.get(KEY_A, tmp_path) is None
events = [e for e in captured if e.get("event") == "cache.corrupt_entry"]
assert len(events) == 1, events
assert events[0]["cache_key"] == KEY_A
assert Path(events[0]["path"]) == p.resolve()
assert events[0]["log_level"] == "warning"
assert p.exists(), "corrupt file MUST NOT be deleted (operators inspect)"
def test_corrupt_then_put_recovers(tmp_path):
"""Catches: `put` short-circuits if destination exists — corrupt entry becomes permanent."""
p = tmp_path / f"{KEY_A_HEX}.json"
p.write_text("{garbage")
assert cache.get(KEY_A, tmp_path) is None
cache.put(KEY_A, _score(), tmp_path)
assert cache.get(KEY_A, tmp_path) == _score()
# ---- atomicity --------------------------------------------------------------
def test_put_uses_pid_token_tmp_then_os_replace(tmp_path, monkeypatch):
"""Catches: `replace(path, path)` self-rename; or `os.rename` used instead of `os.replace`."""
seen_dirs: list[set[str]] = []
real_replace = os.replace
def spy(src, dst):
seen_dirs.append({p.name for p in tmp_path.iterdir()})
return real_replace(src, dst)
monkeypatch.setattr(os, "replace", spy)
cache.put(KEY_A, _score(), tmp_path)
# At the moment of replace, a pid+token .tmp existed
target_name = f"{KEY_A_HEX}.json"
assert any(
n.endswith(".tmp") and n.startswith(f"{KEY_A_HEX}.{os.getpid()}.") and target_name not in s
for s in seen_dirs for n in s
), seen_dirs
assert (tmp_path / target_name).exists()
@pytest.mark.parametrize("victim", ["os.write", "os.fsync", "os.replace"])
def test_previous_value_preserved_across_any_crash_point(tmp_path, monkeypatch, victim):
"""Catches: impl truncates target before tmp write; or swallows OSError; or rewrites in place."""
cache.put(KEY_A, _score(), tmp_path)
target = tmp_path / f"{KEY_A_HEX}.json"
v1_bytes = target.read_bytes()
# Patch the named primitive to raise OSError mid-write of v2.
module, name = victim.rsplit(".", 1)
monkeypatch.setattr(module, name, mock.Mock(side_effect=OSError("simulated")))
v2 = _score().model_copy(update={"score": 0.5})
with pytest.raises(OSError):
cache.put(KEY_A, v2, tmp_path)
assert target.read_bytes() == v1_bytes, f"v1 mutated by crash at {victim}"
def test_put_overwrite_is_atomic_no_file_exists_error(tmp_path):
"""Catches: impl uses `os.rename` (Windows) or `os.O_EXCL` — raises on existing target."""
cache.put(KEY_A, _score(), tmp_path)
v2 = _score().model_copy(update={"score": 0.42})
cache.put(KEY_A, v2, tmp_path) # must not raise FileExistsError
assert cache.get(KEY_A, tmp_path) == v2
# ---- reader-during-writer safety ------------------------------------------
def test_get_does_not_take_lock_concurrent_with_writer(tmp_path):
"""Catches: `get` accidentally `flock`s — defeats the lock-free read design."""
cache.put(KEY_A, _score(), tmp_path)
lock_path = tmp_path / ".lock"
with open(lock_path, "r") as fh:
fcntl.flock(fh, fcntl.LOCK_EX)
try:
assert cache.get(KEY_A, tmp_path) == _score() # must NOT block
finally:
fcntl.flock(fh, fcntl.LOCK_UN)
def test_get_returns_prior_value_or_none_during_writer(tmp_path, monkeypatch):
"""Catches: `get` returns torn read mid-replace."""
cache.put(KEY_A, _score(), tmp_path)
barrier = threading.Event()
proceed = threading.Event()
real_replace = os.replace
def slow_replace(src, dst):
barrier.set(); proceed.wait(5)
return real_replace(src, dst)
monkeypatch.setattr(os, "replace", slow_replace)
v2 = _score().model_copy(update={"score": 0.5})
writer = threading.Thread(target=cache.put, args=(KEY_A, v2, tmp_path))
writer.start()
barrier.wait(5)
got = cache.get(KEY_A, tmp_path)
proceed.set(); writer.join()
assert got in (_score(), v2, None) # prior value or post-replace, never torn
# ---- lock discipline (direct probe, not byte-pattern proxy) ----------------
def test_put_holds_exclusive_lock_during_write(tmp_path, monkeypatch):
"""Catches: `fcntl.flock` removed — BenchScore JSON < PIPE_BUF, byte test would pass."""
barrier = threading.Event()
proceed = threading.Event()
real_replace = os.replace
def slow_replace(src, dst):
barrier.set(); proceed.wait(5)
return real_replace(src, dst)
monkeypatch.setattr(os, "replace", slow_replace)
writer = threading.Thread(target=cache.put, args=(KEY_A, _score(), tmp_path))
writer.start()
barrier.wait(5)
# While the writer holds LOCK_EX on .lock, a sibling fd's non-blocking
# LOCK_EX MUST raise BlockingIOError.
with open(tmp_path / ".lock", "r") as fh:
with pytest.raises(BlockingIOError):
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
proceed.set(); writer.join()
# ---- file modes (Phase 0 ADR-0011) -----------------------------------------
def test_put_writes_files_with_mode_0600(tmp_path):
cache.put(KEY_A, _score(), tmp_path)
blob = tmp_path / f"{KEY_A_HEX}.json"
lock = tmp_path / ".lock"
assert stat.S_IMODE(blob.stat().st_mode) == 0o600, oct(blob.stat().st_mode)
assert stat.S_IMODE(lock.stat().st_mode) == 0o600, oct(lock.stat().st_mode)
def test_put_creates_cache_dir_with_mode_0700(tmp_path):
fresh = tmp_path / "fresh"
cache.put(KEY_A, _score(), fresh)
assert stat.S_IMODE(fresh.stat().st_mode) == 0o700, oct(fresh.stat().st_mode)
# ---- GC --------------------------------------------------------------------
def test_gc_evicts_old_returns_exact_count(tmp_path):
"""Catches: returns `len(all_files)` instead of evicted count."""
cache.put(KEY_A, _score(), tmp_path)
cache.put(KEY_B, _score(), tmp_path)
p_old = tmp_path / f"{KEY_A_HEX}.json"
old_mtime = time.time() - 100 * 86400
os.utime(p_old, (old_mtime, old_mtime))
assert cache.gc(tmp_path, retain_days=90) == 1
assert not p_old.exists()
assert cache.get(KEY_B, tmp_path) == _score()
@pytest.mark.parametrize("offset_seconds,expected_evicted", [
(-91 * 86400, 1),
(-90 * 86400 - 1, 1),
(-90 * 86400 + 1, 0),
(-89 * 86400, 0),
])
def test_gc_retain_days_boundary(tmp_path, offset_seconds, expected_evicted):
"""Catches: `<` vs `<=` off-by-one at the retain_days * 86400 second boundary."""
cache.put(KEY_A, _score(), tmp_path)
p = tmp_path / f"{KEY_A_HEX}.json"
mtime = time.time() + offset_seconds
os.utime(p, (mtime, mtime))
assert cache.gc(tmp_path, retain_days=90) == expected_evicted
def test_gc_does_not_evict_lock_file(tmp_path):
"""Catches: `Path.glob('*')` instead of `Path.glob('*.json')` — would unlink .lock."""
(tmp_path / ".lock").touch()
os.utime(tmp_path / ".lock", (0, 0))
cache.gc(tmp_path, retain_days=1)
assert (tmp_path / ".lock").exists()
def test_gc_does_not_evict_tmp_orphans(tmp_path):
"""Catches: `gc` unlinks a `.tmp` from a concurrent in-flight `put`."""
tmp_orphan = tmp_path / f"{KEY_A_HEX}.99999.deadbe.tmp"
tmp_orphan.touch()
os.utime(tmp_orphan, (0, 0))
assert cache.gc(tmp_path, retain_days=1) == 0
assert tmp_orphan.exists()
def test_gc_returns_zero_on_missing_cache_dir(tmp_path):
assert cache.gc(tmp_path / "nonexistent", retain_days=90) == 0
def test_gc_returns_zero_on_empty_cache_dir(tmp_path):
assert cache.gc(tmp_path, retain_days=90) == 0
# ---- compose_cache_key -----------------------------------------------------
def test_compose_cache_key_shape():
k = cache.compose_cache_key(_DISTINCT_INPUTS)
assert isinstance(k, str) # CacheKey is a NewType over str
assert k.startswith("blake3:")
assert len(k) == len("blake3:") + 64
assert re.fullmatch(r"blake3:[0-9a-f]{64}", k)
def test_compose_cache_key_determinism_distinct_inputs():
"""Catches: impl reads dataclass fields in non-deterministic order."""
k1 = cache.compose_cache_key(_DISTINCT_INPUTS)
k2 = cache.compose_cache_key(_DISTINCT_INPUTS)
assert k1 == k2
@given(st.builds(
CacheKeyInputs,
case_digest=st.text(alphabet=st.characters(blacklist_characters="\x1f"), min_size=0, max_size=128),
sut_digest=st.text(alphabet=st.characters(blacklist_characters="\x1f"), min_size=0, max_size=128),
rubric_digest=st.text(alphabet=st.characters(blacklist_characters="\x1f"), min_size=0, max_size=128),
cassette_corpus_digest=st.text(alphabet=st.characters(blacklist_characters="\x1f"), min_size=0, max_size=128),
harness_version=st.text(alphabet=st.characters(blacklist_characters="\x1f"), min_size=0, max_size=64),
cassette_canary_pin=st.text(alphabet=st.characters(blacklist_characters="\x1f"), min_size=0, max_size=64),
))
def test_compose_cache_key_determinism_property(inputs):
"""Hypothesis: determinism across arbitrary input shapes (per AC)."""
assert cache.compose_cache_key(inputs) == cache.compose_cache_key(inputs)
@pytest.mark.parametrize("varying", [
"case_digest", "sut_digest", "rubric_digest",
"cassette_corpus_digest", "harness_version", "cassette_canary_pin",
])
def test_compose_cache_key_per_field_uniqueness(varying):
"""Catches: impl ignores or hard-codes one of the six fields."""
modified = dataclasses.replace(_DISTINCT_INPUTS, **{varying: "MODIFIED"})
assert cache.compose_cache_key(modified) != cache.compose_cache_key(_DISTINCT_INPUTS)
@pytest.mark.parametrize("i,j", list(itertools.combinations(range(6), 2)))
def test_compose_cache_key_resists_positional_swap(i, j):
"""Catches: impl builds the join from fields in the wrong order."""
field_names = list(dataclasses.fields(CacheKeyInputs))
values = [getattr(_DISTINCT_INPUTS, f.name) for f in field_names]
values[i], values[j] = values[j], values[i]
swapped = CacheKeyInputs(**{f.name: v for f, v in zip(field_names, values)})
assert cache.compose_cache_key(swapped) != cache.compose_cache_key(_DISTINCT_INPUTS)
def test_compose_cache_key_canary_rotation_does_not_affect_sibling(tmp_path):
"""ADR-0005 scoped invalidation: rotating case A's pin must NOT change case B's key."""
a_v1 = cache.compose_cache_key(dataclasses.replace(_DISTINCT_INPUTS, case_digest="A", cassette_canary_pin="pin-A-v1"))
b_v1 = cache.compose_cache_key(dataclasses.replace(_DISTINCT_INPUTS, case_digest="B", cassette_canary_pin="pin-B-v1"))
a_v2 = cache.compose_cache_key(dataclasses.replace(_DISTINCT_INPUTS, case_digest="A", cassette_canary_pin="pin-A-v2")) # rotated
b_after = cache.compose_cache_key(dataclasses.replace(_DISTINCT_INPUTS, case_digest="B", cassette_canary_pin="pin-B-v1"))
assert a_v2 != a_v1, "A's pin rotation must change A's key"
assert b_after == b_v1, "A's pin rotation must NOT change B's key"
# ---- module surface fences -------------------------------------------------
def test_module_all_is_exact():
"""Catches: impl exports extra surface area (e.g., evict, has_key) — locks contract."""
assert cache.__all__ == ("CacheKey", "CacheKeyInputs", "compose_cache_key", "get", "put", "gc")
def test_no_direct_blake3_import():
"""Catches: impl bypasses Phase 0 ADR-0001 chokepoint."""
src = (Path("src") / "codegenie" / "eval" / "cache.py").read_text()
tree = ast.parse(src)
for node in ast.walk(tree):
if isinstance(node, ast.Import):
assert all(a.name != "blake3" for a in node.names)
if isinstance(node, ast.ImportFrom):
assert node.module != "blake3"
def test_no_hashlib_import():
"""Catches: impl uses SHA-256 directly instead of routing through codegenie.hashing."""
src = (Path("src") / "codegenie" / "eval" / "cache.py").read_text()
tree = ast.parse(src)
for node in ast.walk(tree):
if isinstance(node, ast.Import):
assert all(a.name != "hashlib" for a in node.names)
def test_no_os_rename_use():
"""Catches: impl uses os.rename (Windows-unsafe) instead of os.replace."""
src = (Path("src") / "codegenie" / "eval" / "cache.py").read_text()
assert "os.rename" not in src, "use os.replace (Phase 0 cache/store.py precedent)"
Green¶
Smallest impl: §Implementation outline; ~110 lines including the CacheKeyInputs dataclass, the _cache_write_lock context manager, and the module-level constants. The implementer's job is to make every test above pass with the minimum code that satisfies the AC list — no speculative surface (no evict, no has, no keys()).
Refactor¶
- Keep
_cache_write_lockas the only fcntl callsite — type asIterator[None]. - Consider extracting
_atomic_write_bytes(path, data, mode)if Phase-3 reuse arrives — for now it stays private (rule of three not met; Phase 0cache/store.py:_atomic_write_bytesis the first site, this is the second). - Document the
.locksentinel as part of the directory contract — never lives outsidecache_dir;gcnever touches it. - Document the
*.tmporphan policy (leave alone, deferred reaping) so future readers see the intentional non-action.
Files to touch¶
| Path | Why |
|---|---|
src/codegenie/eval/cache.py |
New module — CacheKey, CacheKeyInputs, compose_cache_key, get, put, gc |
src/codegenie/types/identifiers.py |
Add CacheKey = NewType("CacheKey", str) (one row; mirrors ProbeId, IndexName) |
tests/unit/eval/test_cache.py |
Red tests covering all ACs |
(Do not touch src/codegenie/hashing.py — content_hash_bytes already exists; reuse it.)
Out of scope¶
- Cache-key composition at runtime — the runner (S3-01) constructs
CacheKeyInputsfrom the per-run digests and callscompose_cache_key; this story only exposes the function. - GC scheduling — the runner (S3-02 end-of-run) invokes
gc(retain_days=90); cron-style scheduling is out. - Cross-host cache sharing — explicit non-goal (
phase-arch-design.md §Non-goals #9). - Cache-key index / manifest — the filesystem
<hex>.jsonlisting IS the index; no separate manifest. .tmporphan reaping — current contract:gcleaves*.tmpalone (an in-flightputin another process may still need its.tmp). If.tmpaccumulation becomes empirical on long-running CI hosts, a separate story sweeps the policy.- Cache-backend Strategy/DIP seam — pure YAGNI per Non-goals #9. If a future phase needs a non-filesystem backend, the right shape is
class CacheBackend(Protocol)withread/write/list/delete; the current module becomes the defaultFilesystemBackend. Do not introduce now. - Probe-module
_WARNING_IDSdiscipline — convention is scoped to probes (CLAUDE.md). Phase 0cache/store.pydoes not declare_WARNING_IDS; this cache mirrors that. If a later story formalizes warning-ID discipline for non-probe modules, sweep both caches together.
Notes for the implementer¶
Hard constraints (load-bearing)¶
- Reuse Phase 0's
content_hash_bytes.src/codegenie/hashing.py:85already exposescontent_hash_bytes(b: bytes) -> "blake3:<hex>"— it was added in Phase 0 S2-03 specifically for this kind of use. Do not addbytes_hash; do not edithashing.py. os.replace, notos.rename. Phase 0cache/store.py:138usesos.replace(cross-platform-safe; overwrites atomically when target exists). Match that — Rule 11 (codebase conventions) + Rule 7 (don't average two patterns; pick the more tested).- Pid+token
.tmpsuffix. Mirror Phase 0's_atomic_write_bytes(<target>.<pid>.<secrets.token_hex(4)>.tmp) — even thoughfcntl.flockserializes single-host writers today, the disambiguation costs ~one line and forecloses a class of cross-process.tmpcollisions you don't want to think about ever again. - Re-
chmodafteros.replace. Mode bits on the destination afteros.replacecarry from the tmp file, but CIactions/cacherestore flattens modes to umask defaults — Phase 0cache/store.py:380re-asserts modes per-putfor exactly this reason. \x1fis the established separator. Phase 0'sidentity_hash(hashing.py:74) uses\x1f— the eval cache reuses it. Do not invent a new convention.
Intentional Phase-0 divergences (surface, don't hide)¶
- Module-level free functions vs
class Cache. The arch class diagram (phase-arch-design.md §Logical view, line 177) showsclass Cache. The component spec (phase-arch-design.md §src/codegenie/eval/cache.py, line 606) definesdef get(...),def put(...),def gc(...)at the module level — i.e., the arch component spec wins, and free functions are the prescribed surface. Rule 2 (simplicity first) supports this. Revisit if the Runner accumulates ≥ 3 cache-touching call sites that would benefit from constructor-time invariants — promote toclass Cache(cache_dir: Path)then, with one-shot_ensure_dir+_reapply_modesat__init__(Phase 0 precedent). fcntl.flockvs Phase 0'sO_APPEND+PIPE_BUFatomicity. Phase 0cache/store.pydeliberately uses no flock — it leans onO_APPENDatomicity for records ≤PIPE_BUF=4096B. This cache usesfcntl.flockbecauseBenchScoreserialized JSON can exceed 4 KB oncebreakdownkeys +failure_modespopulate. Divergence is intentional; document in the module docstring.- Free-function module emits structlog events but does not declare
_WARNING_IDS. Phase 0cache/store.pydoes the same. Convention is currently probe-scoped.
Design-pattern decisions baked into the ACs¶
CacheKeynewtype + smart constructor. Per CLAUDE.md "Never rawstrfor domain IDs". The only public way to construct aCacheKeyiscompose_cache_key(...)— passing a hand-rolled string toget/putrequires an explicitCacheKey(my_str)cast at the call site, which mypy-strict surfaces in code review.CacheKeyInputsfrozen dataclass. Aggregates the six inputs so adding a future input (Phase 13+'s hypotheticalmodel_pin) is a loud structural change — every call site fails type-check until updated. Closed-for-modification at the function-arg boundary; open for extension via a new dataclass field + ADR amendment. Six kwargs would be silently extensible (forgotten call sites keep compiling).- Arity-byte omitted (vs Phase 0's
identity_hash). The kw-only single-argumentCacheKeyInputssignature pins arity at exactly six;(parts_n, parts_m)-shape boundary-shift collisions are unreachable by API. Documented; not load-bearing once the input contract bans\x1f.
Operational notes¶
- Input contract: no
\x1fin field values.compose_cache_keyis pure bytes-to-hex; it does NOT validate. Callers (S2-02 loader for*_digest; Runner forharness_versionandcassette_canary_pin) own input-shape validation. A\x1fsmuggled into a field would silently shift the join boundary — the AC documents the prohibition; future caller stories should validate. fcntl.flockis POSIX-only. Phase 6.5 runs on Linux/macOS (the operator laptop substrate). Windows support is out-of-roadmap; if it ever arrives, the lock primitive is the swap-out point (msvcrt.lockingon Windows).BenchScoreequality.frozen=TruePydantic v2 →==compares by field values;cache.put(k, v); cache.get(k) == vis meaningful as written.structlogcapture in tests. Usestructlog.testing.capture_logs(), NOTcaplog—caplogdrops structlog kwargs under the project's processor chain. Phase 0tests/unit/test_cache_store.pyis the precedent (six call sites).- Coverage gate.
pyproject.tomlhas--cov-fail-under=85; when running this test file alone, append--no-covper project convention (CLAUDE.md "running a narrow subset can falsely fail the coverage gate").