Source code for phenotypic.sdk_._verification_cache

"""The verification cache -- one tier in memory, one tier on disk.

Audit **S1** -- the finding spec §9.1 responds to -- proposed a *process-level*
cache keyed on the marker file's stat tuple. §9.1 escalated that to a file on
disk. Decision **D-B** (OPEN-QUESTIONS) took it back, and **U-11 reversed D-B**
on measured evidence: at N=6,657 on a cold node a deep pass is 1403 s and the
stat sweep the cache enables is ~37 s, because the cost is per-file *latency*
(a cold ``open+read`` is ~10x a cold ``stat``) rather than bytes.

So there are two tiers, and they answer different questions:

* **Tier 1, in process** (:data:`_CACHE`). Every cadence the audit measured is
  a repeated call inside ONE long-lived process -- the observer's 2 s tick, the
  viewer's 5-10 s poll, ``OutputRoot.discover``'s double read,
  ``OutputMutationGuard``'s double read. This tier makes the *second* call
  cheap.
* **Tier 2, on disk** (``.phenotypic/verification_cache.json``). Read when tier
  1 is cold, written after a pass that deep-verified something. This tier makes
  the *first call in a new process* cheap.

INVARIANT (INV-VERDICT) -- **nothing may improve a verdict except a successful
deep verification.** No function here returns a verdict to a caller that has
not deep-verified. :func:`entry_is_still_current` answers exactly one question:
*may a previously deep-verified result stand?* The caller supplied the verdict
from its own deep pass, and a ``True`` here merely licenses skipping that pass
next time. A stale, replaced or forged entry therefore degrades to today's
behaviour and never past it.

**The on-disk tier is a tenth tracked file in a change that removes nine, and
it is a cache -- not a tenth evidence source.** P7 Task 6's register lists it
under *cache*, never under tracked state. **Nothing branches on it and no
verdict is derived from it.** That is the whole of what keeps it a cache: it
can cause a re-verification, and it can cause a re-verification to be skipped,
and it can cause nothing else. Delete the file and every answer this package
gives is unchanged; only the time taken to give it moves.

**The on-disk tier weakens the sentence INV-VERDICT's safety argument rests
on, and inheriting the in-process wording would misrepresent it.** *"An entry
only lets a previously deep-verified result stand"* means, in tier 1, *by this
process, minutes ago* -- the process that wrote the entry is the process
reading it, running the code you are reading now. On disk it means **by some
process**:

* possibly an **older build** whose deep-verification rules differed. The
  payload records what was checked, never how, so
  ``VERIFICATION_CACHE_VERSION`` is the only signal available and it is only as
  good as the discipline of bumping it -- which is why
  :data:`~phenotypic.sdk_._io_constants.VERIFICATION_CACHE_VERSION` says to
  bump on a *rules* change and not only on a shape change.
* possibly one **still mid-write**. The write is a temp file plus
  ``os.replace`` (:func:`~phenotypic.sdk_._atomic_io.atomic_write_json`), so a
  reader sees the whole old file or the whole new one and never a torn one --
  but "the whole new one" can still be a file whose writer then died before
  finishing the run it describes.
* possibly **another user**, or the same user under a different build, sharing
  the output tree.

Writing to ``.phenotypic/verification_cache.json`` needs the same permission as
writing ``processing_state.json`` beside it, so the tier opens no new *access*.
What it changes is the *consequence* of that access: forging
``processing_state.json`` changes what a run claims to have accepted, and can
be detected by verifying it; forging this file changes whether anything was
checked at all, and a forgery whose stat tuples happen to be current is
indistinguishable from a real entry. The degradation story survives in full --
every doubt still falls through to ``deep`` -- but the guarantee is materially
weaker than tier 1's, and a reader is entitled to know which one they have.

``ctime_ns`` is deliberately absent from the stat tuple (audit **S3**): it
moves on ``chmod``, ownership change, hardlink and ``rsync -a``, all routine on
a shared filesystem, and ``size`` + ``mtime_ns`` already covers every write the
publication contract makes.

**And ``(size, mtime_ns)`` is sufficient, not merely convenient.** The
theoretical hole is a same-size rewrite landing inside one ``mtime_ns``
tick. Its precondition does not occur here: no path rewrites a tracked
artifact *within* a pass, and the run output's filesystem resolves finely
enough to separate consecutive writes anyway -- GPFS measured at ~81 us,
with 0 of 200 back-to-back same-size writes sharing an ``mtime_ns``.

The condition under which that stops being true is worth naming, because
it is an environment change rather than a code change: **an output tree on
a coarser filesystem.** Node-local scratch measured ~1 ms and 181 of 200
collisions. Run output may never live there -- it is per-job and invisible
to other nodes (see the scratch rule) -- so the measured case is the one
that ships. If that ever changes, this pair needs a content check, not a
third stat field.

**CONTRACT FOR CALLERS: fence a store by its root ``zarr.json``, never by the
store directory.** ``stat_tuples`` names **regular files only**;
:func:`entry_is_still_current` fails closed on anything else, so a caller that
records a store *directory* gets a permanent cache miss rather than a wrong
answer. This is not fussiness about types. A store is marker-bound as a
directory (``_cli_completion._artifact_descriptor``), but a directory's
``st_size`` is a filesystem detail and its ``mtime_ns`` tracks only its own
entries -- rewriting ``tables/measurements/table.parquet`` inside a promoted
store leaves the store root's ``mtime_ns`` untouched. Fencing on the directory
would therefore license re-use of a verdict for contents that changed, which is
spec §0's *"a valid root does not imply unchanged contents"* arriving through
the cache instead of through the root. The root ``zarr.json`` is the file the
marker descriptor already digests, and it is the right thing to stat.

**Bounded by the fence, not by a policy** (CAN-28). ``LocalRunner._instances``,
``_terminal_job_cache`` (``_cli/_dashboard/_manifest_builder.py:73``) and
``_LAST_DUMPED`` (``_gui/builder/_point_picker.py:549``) are three unbounded
module globals this codebase already carries as audit findings (§5, S22, S23),
so a fourth is not acceptable. But an LRU would be a *second* mechanism on top
of one that already bounds this: entries are identity-fenced, so only those
under the current identity for the current output can ever be used. Replacing
each output's map wholesale on an identity change is therefore both tighter and
simpler than evicting -- the bound is "images in the runs currently being asked
about", and it follows from the fence instead of being layered on top of it.

**No lock.** Entries are published by a single ``dict`` assignment of an
immutable ``(identity_digest, states)`` pair whose inner map is a private copy
that is never mutated afterwards, so a concurrent reader either sees the whole
old pair or the whole new one. That is what makes this safe under Dash's
threaded server without serialising the GUI's poll.

**No lock on disk either, and for a stronger reason.** Concurrent writers are
last-wins via ``atomic_write_json``, which is safe *precisely because the file
is never authoritative*: the worst a lost write can do is cost a later process
a deep pass it would otherwise have skipped. Taking ``flock`` here -- per
output, on every resolve, on the filesystem whose cross-node semantics
``sdk_/_file_locking.py:101`` already flags as the weaker POSIX option -- would
buy nothing a wrong answer could come from.

**Two rules the on-disk tier follows so that it stays a cache:**

1. **It never creates the directory it lives in.** ``persist_states`` writes
   only when ``.phenotypic/`` already exists, so resolving the state of a tree
   this package has never written to leaves that tree byte-for-byte alone. A
   reader that creates directories is not a reader.
2. **Every failure is silent and degrades to ``deep``.** A read-only output, a
   full disk, a truncated or forged or unparseable file, a file from another
   identity -- each is a cache miss, never an exception and never a log line
   the user has to act on. Spec §9.1: *a tree the user cannot write must not
   become a tree the user cannot read.*

``clear_machine_state`` deletes the file, and that is deliberate rather than
incidental. The rule deciding it -- and why an expensive cache is exactly the
thing a restart must **not** carry across its own fence -- is stated once, at
``_io_constants._PRESERVED_ON_RESTART``. Do not restate it here; a membership
rule with two homes is a membership rule that will disagree with itself.

:func:`clear_verification_cache` clears **tier 1 only**. That is the same
asymmetry, from the other side: it is a reader-side memory reset, it touches no
file, and a caller that wants both tiers gone wants ``clear_machine_state``.
"""

from __future__ import annotations

import json
import os
import stat
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from types import MappingProxyType
from typing import get_args

from ._atomic_io import atomic_write_json
from ._io_constants import (
    VERIFICATION_CACHE_VERSION,
    phenotypic_cache_dir,
    verification_cache_path,
)

# NOT from ._run_state -- that direction is the cycle the split prevents.
from ._state_types import ImageState, Verdict

#: output_dir (resolved, as str) -> (identity_digest, {work_id: entry}).
#:
#: One slot per output, replaced wholesale. See the module docstring on why
#: there is no eviction policy and no lock.
_CACHE: dict[str, tuple[str, dict[str, "CachedVerification"]]] = {}


@dataclass(frozen=True)
class CachedVerification:
    """One image's deep-verified state plus the stat tuples that fence it.

    Attributes:
        state: The WHOLE :class:`~phenotypic.sdk_._state_types.ImageState`
            (CAN-14), not just a verdict. ``RunState.images`` needs ``stages``
            per image, so an entry carrying only ``(work_id, verdict)`` would
            force the shallow path to re-read every record JSON -- the ~10^4
            marker-reads half of audit §4's cost, left in place. Caching the
            state is what makes spec §9.2's claim true rather than half-true.
        stat_tuples: Run-root-relative POSIX path -> ``(size, mtime_ns)`` for
            every **regular file** the verdict was derived from. Relative, so
            an entry survives the tree being reached through a second mount,
            exactly as marker artifact descriptors do (ledger FLOW-3).
    """

    state: ImageState
    stat_tuples: Mapping[str, tuple[int, int]]


def _cache_key(output_dir: Path) -> str:
    """Return the canonical per-output cache key.

    Resolving collapses ``a``, ``a/``, ``a/../a`` and any symlinked route to
    the same slot, so two callers naming one output cannot end up with two
    entries that each think they are the only one. A path that cannot be
    resolved (a symlink loop) falls back to an absolute path rather than
    raising: a cache may never turn a readable output into an error.
    """
    try:
        return str(Path(output_dir).resolve())
    except OSError:
        return os.path.abspath(str(output_dir))


def cached_states(
    output_dir: Path, identity_digest: str
) -> Mapping[str, CachedVerification] | None:
    """Return this output's entries under ``identity_digest``, or ``None``.

    There is no partial trust: an identity that does not match **exactly**
    yields ``None`` for the whole output, never a filtered subset. Rule 1 of
    Task 3.

    The returned mapping is read-only. A caller adding freshly verified images
    builds its own ``dict`` and hands it to :func:`remember_states`; handing
    out the live map would let a consumer forge an entry in place, which is
    precisely the failure INV-VERDICT exists to prevent.

    Args:
        output_dir: Run output root.
        identity_digest: :meth:`RunIdentity.digest` for the run being resolved.

    Returns:
        A read-only ``work_id -> CachedVerification`` mapping, or ``None`` when
        this output has no entry or its entry was minted under a different
        identity.
    """
    entry = _CACHE.get(_cache_key(output_dir))
    if entry is None:
        return None
    stored_digest, states = entry
    if stored_digest != identity_digest:
        return None
    return MappingProxyType(states)


def remember_states(
    output_dir: Path,
    identity_digest: str,
    entries: Mapping[str, CachedVerification],
) -> None:
    """Replace this output's entries wholesale under ``identity_digest``.

    Replacement, not merge (rule 3). An identity change discards the previous
    map rather than accumulating beside it, which is what bounds the cache
    without an eviction path to get wrong (CAN-28).

    Args:
        output_dir: Run output root.
        identity_digest: :meth:`RunIdentity.digest` the entries were verified
            under.
        entries: The complete ``work_id -> CachedVerification`` map for this
            output. Copied, so a caller mutating its own dict afterwards
            cannot reach into the cache.
    """
    _CACHE[_cache_key(output_dir)] = (identity_digest, dict(entries))


def entry_is_still_current(
    output_dir: Path, entry: CachedVerification
) -> bool:
    """Return whether every file ``entry`` was verified from is unchanged.

    **This is not a verdict.** A ``True`` says only that a deep pass the caller
    already performed may stand; the caller supplies the verdict itself. Rule 2
    of Task 3: an empty ``stat_tuples`` map, a missing file, an ``OSError`` or
    any changed ``(size, mtime_ns)`` all return ``False``, and this function
    never raises.

    An empty map is ``False`` on purpose -- ``all()`` over nothing is ``True``,
    which would make a stat-tuple-less entry the strongest entry in the cache
    rather than the weakest.

    A **directory** is also ``False``. A store is marker-bound as a directory
    (``_cli_completion._artifact_descriptor``), but a directory's ``st_size``
    is a filesystem detail and its ``mtime_ns`` tracks only its own entries --
    rewriting ``tables/measurements/table.parquet`` inside a store leaves the
    store root's mtime untouched. A caller fencing a store must therefore name
    its root ``zarr.json``, the same file the marker descriptor digests, not
    the store directory.

    Args:
        output_dir: Run output root the entry's paths are relative to.
        entry: A previously remembered verification.

    Returns:
        ``True`` only when every named path is a regular file whose size and
        ``mtime_ns`` are unchanged.
    """
    if not entry.stat_tuples:
        return False
    root = Path(output_dir)
    for relative, expected in entry.stat_tuples.items():
        try:
            info = (root / relative).stat()
        except OSError:
            return False
        if not stat.S_ISREG(info.st_mode):
            return False
        # ctime_ns is absent by design -- see the module docstring (audit S3).
        if (info.st_size, info.st_mtime_ns) != tuple(expected):
            return False
    return True


# ---------------------------------------------------------------------------
# Tier 2: the on-disk cache (spec §9.1, U-11)
# ---------------------------------------------------------------------------

#: Document keys. Spelled once, because a reader and a writer disagreeing
#: about a key name is a permanent, silent cache miss -- the failure mode this
#: tier is least able to notice, since a miss is also its correct behaviour.
_SCHEMA_VERSION_KEY = "schema_version"
_IDENTITY_KEY = "identity_digest"
_ENTRIES_KEY = "entries"

#: The three legal ``ImageState.verdict`` values, taken from the type rather
#: than respelled. A forged file naming a fourth is refused.
_VERDICTS: frozenset[str] = frozenset(get_args(Verdict))


def _entry_to_json(entry: "CachedVerification") -> dict[str, object]:
    """Render one entry as JSON, minus its ``work_id``.

    ``work_id`` is the map key and is not repeated inside the value, so the
    document cannot disagree with itself about which image an entry describes
    -- the reader reconstructs it from the key.
    """
    state = entry.state
    return {
        "dataset": state.dataset,
        "image_stem": state.image_stem,
        "stages": {
            name: dict(body) for name, body in state.stages.items()
        },
        "verdict": state.verdict,
        "reason": state.reason,
        "stat_tuples": {
            relative: [size, mtime_ns]
            for relative, (size, mtime_ns) in entry.stat_tuples.items()
        },
    }


def _entry_from_json(
    work_id: str, payload: object
) -> "CachedVerification | None":
    """Rebuild one entry, or ``None`` if anything about it is off.

    Every field is type-checked rather than trusted. This is not defensive
    style for its own sake: the file may have been written by an older build,
    a half-finished run or another user (see the module docstring), so the
    reader's job is to decide whether it is looking at a document this build
    wrote, and a wrong type is the cheapest evidence that it is not.

    Deliberately **fails closed on ``bool``**. ``True`` is an ``int`` in
    Python, so ``[true, true]`` would otherwise deserialize to the perfectly
    plausible stat tuple ``(1, 1)``.
    """
    if not isinstance(payload, dict):
        return None
    dataset = payload.get("dataset")
    image_stem = payload.get("image_stem")
    verdict = payload.get("verdict")
    raw_reason = payload.get("reason")
    stages = payload.get("stages")
    raw_tuples = payload.get("stat_tuples")
    if not isinstance(dataset, str) or not isinstance(image_stem, str):
        return None
    if verdict not in _VERDICTS:
        return None
    # Spelled as a widening rather than a guard so the narrowed type is
    # `str | None` here and not `object`; the guard form types fine to a
    # reader and not to mypy.
    reason: str | None = raw_reason if isinstance(raw_reason, str) else None
    if raw_reason is not None and reason is None:
        return None
    if not isinstance(stages, dict) or not all(
        isinstance(name, str) and isinstance(body, dict)
        for name, body in stages.items()
    ):
        return None
    if not isinstance(raw_tuples, dict):
        return None
    tuples: dict[str, tuple[int, int]] = {}
    for relative, pair in raw_tuples.items():
        if not isinstance(relative, str):
            return None
        if not isinstance(pair, list) or len(pair) != 2:
            return None
        size, mtime_ns = pair
        if not _is_plain_int(size) or not _is_plain_int(mtime_ns):
            return None
        tuples[relative] = (size, mtime_ns)
    return CachedVerification(
        state=ImageState(
            work_id=work_id,
            dataset=dataset,
            image_stem=image_stem,
            stages=stages,
            verdict=verdict,  # type: ignore[arg-type]
            reason=reason,
        ),
        stat_tuples=tuples,
    )


def _is_plain_int(value: object) -> bool:
    """Return whether ``value`` is an ``int`` that is not a ``bool``."""
    return isinstance(value, int) and not isinstance(value, bool)


def load_persisted_states(
    output_dir: Path, identity_digest: str
) -> Mapping[str, CachedVerification] | None:
    """Return tier 2's entries under ``identity_digest``, or ``None``.

    Spec §9.1 names six ways this must yield nothing, and every one of them is
    a path along which a wrong ``complete`` could otherwise be manufactured
    from a file this build did not write. Five are here -- **file missing**,
    **unreadable**, **unparseable**, **recorded identity != current**, and (via
    the whole-document refusal below) a structurally wrong **entry**; the
    sixth, **entry absent**, is the caller finding no key for a ``work_id``,
    and the seventh thing that must fall through -- a **moved stat tuple** --
    is :func:`entry_is_still_current`'s, on the entries this returns.

    **No partial trust, exactly as in tier 1.** One malformed entry discards
    the whole document rather than the entry: a file this build cannot fully
    account for was written by something else, and reading the half of it that
    happens to parse is a decision to trust that something else.

    Args:
        output_dir: Run output root.
        identity_digest: :meth:`RunIdentity.digest` for the run being resolved.

    Returns:
        A read-only ``work_id -> CachedVerification`` mapping, or ``None``.
        **Never raises.**
    """
    try:
        raw = verification_cache_path(Path(output_dir)).read_bytes()
    except OSError:
        return None
    try:
        # UnicodeDecodeError is a ValueError, so undecodable bytes and
        # malformed JSON are one case here, as they are one case to a caller.
        document = json.loads(raw)
    except ValueError:
        return None
    if not isinstance(document, dict):
        return None
    # `_is_plain_int` and not a bare `!=`: `True == 1` in Python, so a
    # document whose schema_version is `true` would satisfy a plain
    # comparison against version 1. This module already fails closed on
    # bool in three other places; the omission here was inconsistent
    # rather than reasoned.
    version = document.get(_SCHEMA_VERSION_KEY)
    if not _is_plain_int(version) or version != VERIFICATION_CACHE_VERSION:
        return None
    if document.get(_IDENTITY_KEY) != identity_digest:
        return None
    entries = document.get(_ENTRIES_KEY)
    if not isinstance(entries, dict):
        return None
    loaded: dict[str, CachedVerification] = {}
    for work_id, payload in entries.items():
        if not isinstance(work_id, str):
            return None
        rebuilt = _entry_from_json(work_id, payload)
        if rebuilt is None:
            return None
        loaded[work_id] = rebuilt
    return MappingProxyType(loaded)


def persist_states(
    output_dir: Path,
    identity_digest: str,
    entries: Mapping[str, CachedVerification],
) -> bool:
    """Write tier 2 for this output, best-effort. **Never raises.**

    Two rules from the module docstring are implemented here rather than
    merely described:

    1. **It never creates ``.phenotypic/``.** Resolving the state of a tree
       this package has never written to must leave that tree byte-for-byte
       alone, and ``atomic_write_json`` would otherwise ``mkdir`` the parent.
    2. **A failed write is a return value, not an exception.** A read-only
       output degrades to a deep pass on the next cold start, which is the
       behaviour that shipped before this tier existed.

    The catch covers serialization as well as I/O, and the payload is built
    inside it for that reason. ``stages`` is an **open** map (spec §6.1) whose
    values this module never constrains, so a stage body that is not
    JSON-serializable is a ``TypeError`` from ``json.dumps`` and one that is
    not a mapping is a ``ValueError`` from ``dict()``. Every value reaching
    here today came from marker JSON and is safe; "never raises" is a promise
    about the contract, not about today's callers.

    Entries with no ``stat_tuples`` are **omitted**. They are permanently
    non-current by :func:`entry_is_still_current`'s first rule, so persisting
    them would grow the file by every unverified image while licensing
    nothing.

    Args:
        output_dir: Run output root.
        identity_digest: :meth:`RunIdentity.digest` the entries were verified
            under.
        entries: The complete ``work_id -> CachedVerification`` map.

    Returns:
        ``True`` when the file was written. ``False`` for every reason it was
        not -- no machine-state directory, a read-only tree, a full disk. No
        caller should branch on a verdict because of it; the value exists so a
        test can tell "declined" from "raised".
    """
    root = Path(output_dir)
    if not phenotypic_cache_dir(root).is_dir():
        return False
    try:
        payload: dict[str, object] = {
            _SCHEMA_VERSION_KEY: VERIFICATION_CACHE_VERSION,
            _IDENTITY_KEY: identity_digest,
            _ENTRIES_KEY: {
                work_id: _entry_to_json(entry)
                for work_id, entry in entries.items()
                if entry.stat_tuples
            },
        }
        atomic_write_json(verification_cache_path(root), payload)
    except (OSError, TypeError, ValueError):
        return False
    return True


def warm_states(
    output_dir: Path, identity_digest: str
) -> Mapping[str, CachedVerification] | None:
    """Return the warmest entries: tier 1, else tier 2, else ``None``.

    The tiers are ordered by *cost*, not by trust -- a tier-2 entry licenses
    exactly what a tier-1 entry licenses and no more, because the caller runs
    :func:`entry_is_still_current` over whichever it gets. What the order buys
    is that a long-lived process pays the JSON read once, on its first cold
    call, and never again.

    Args:
        output_dir: Run output root.
        identity_digest: :meth:`RunIdentity.digest` for the run being resolved.

    Returns:
        A read-only ``work_id -> CachedVerification`` mapping, or ``None`` when
        neither tier holds entries for this output under this identity.
    """
    in_process = cached_states(output_dir, identity_digest)
    if in_process is not None:
        return in_process
    return load_persisted_states(output_dir, identity_digest)


[docs] def clear_verification_cache(output_dir: Path | None = None) -> None: """Drop one output's **tier-1** entries, or every output's. Rule 4 of Task 3. **Nothing in ``src/`` calls the scoped form.** An earlier draft of this docstring said P2 wired it to ``clear_machine_state``; it does not, and the paragraph below -- which says the two are separate and why -- is the accurate one. The two sentences contradicted each other three lines apart. Left unwired deliberately. Adding the call is a behaviour change nobody has ruled on, and it is not needed: ``clear_machine_state`` deletes the on-disk tier by deleting the directory, and tier 1 is per-process memory that a restarting process does not have. **Tier 1 only, and deliberately.** This function touches no file: it is a reader-side memory reset, and that is what lets ``_run_state`` re-export it without exporting a writer (INV-LAYER). ``clear_machine_state`` is what removes the on-disk tier, by removing every child of ``.phenotypic/``. A caller that clears only this one and then resolves shallowly will still be served from disk -- which is correct, since nothing about the run changed, but it is not what "clear the cache" sounds like. Args: output_dir: The output to forget. ``None`` forgets all of them, which is what a test fixture wants and what a process teardown wants; it is never what a run's own state reset wants. """ if output_dir is None: _CACHE.clear() return _CACHE.pop(_cache_key(output_dir), None)
def tracked_output_count() -> int: """Return how many outputs currently hold entries. Test-only introspection. It exists so that ``test_an_identity_change_replaces_the_output_entry_wholesale`` can assert the *absence* of accumulation, which is the property CAN-28 turns into the bound -- and which is invisible from :func:`cached_states` alone, since a stale identity and an evicted output both read as ``None``. """ return len(_CACHE)