A gallery is only valid for the embedder that produced its vectors. Cosine
similarities across models are meaningless but *look* plausible, so the mistake
is silent and every measurement taken afterwards is suspect. Stamp the embedder
identity into the gallery at build; verify it at every load.
The stamp is the model file's basename plus the SHA-256 of its bytes (plus
embed_dim). The hash decides, the name explains. A name alone is a promise
rather than a fact — models get re-exported and overwritten in place under an
unchanged filename, which is exactly the case where the weights differ and
nothing else does. A hash alone is correct but unactionable in an error message.
SHA-256 is derived from the artefact, needs no registry kept current, and costs
~0.1s for a 250MB ONNX, memoised per process.
Mismatch is a hard error in every mode, with no bypass, naming both sides.
Unstamped legacy galleries warn loudly and proceed: unknown is not known-bad,
and hard-failing every pre-existing gallery would turn the check into something
people disable rather than trust. --require-gallery-stamp (or
SAE_REQUIRE_GALLERY_STAMP=1, which propagates to subprocesses) promotes that to
a hard error — the mode measurement work should run in. scripts/stamp_gallery.py
re-binds an existing gallery with no re-embedding, so "warn" is a cheap state to
leave rather than a permanent one.
Embedding dumps carry the same stamp: a replay has no live embedder, so the dump
is the embedder as far as the gallery is concerned. Derived galleries inherit
their source's stamp; --merge and the JSON gallery merge check before writing,
since one file holding two embedding spaces cannot be untangled afterwards.
Verified in: scene_analyze, scene_preview, the sae_kpn matcher binding,
replay.py, optimize.py (once per film at startup, before the first evaluation),
movienet_eval.py and both merge paths.
Stamp logic lives in src/gallery/embedder_stamp.{hpp,cpp} and its Python twin
scripts/sae_stamp.py, kept dependency-light so replay subprocesses do not pay
sae_gallery's requests/Pillow import to ask whether two models match.
Tests: 12 new cases in test_gallery_store.cpp covering the comparison logic,
both round trips, and the SHA-256 vectors that guarantee the C++ and hashlib
stamps agree. No ONNX or GPU required.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
218 lines
9.8 KiB
Python
218 lines
9.8 KiB
Python
"""Gallery ↔ embedder model binding (GR-004).
|
|
|
|
TRACES: GR-004 | SR-001
|
|
|
|
Python twin of src/gallery/embedder_stamp.{hpp,cpp}; the two implement the same
|
|
comparison rules and must stay in agreement. Kept as its own module — rather than
|
|
folded into sae_gallery — because scripts/optimizer/replay.py imports it once per
|
|
replay subprocess, thousands of times in a DE sweep, and must not pay for
|
|
sae_gallery's requests/Pillow imports to ask "were these made by the same model?".
|
|
Dependencies here are hashlib, json and h5py, all of which a replay already loads.
|
|
|
|
A gallery is only valid for the embedder that built it: cosine similarities across
|
|
models are meaningless but look plausible, so the mistake is silent and every
|
|
measurement taken afterwards is suspect. Identity = model filename + SHA-256 of
|
|
the model file. The hash decides (a model re-exported in place keeps its name but
|
|
not its bytes); the name is what makes the error readable. See
|
|
src/gallery/embedder_stamp.hpp for the full rationale.
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import h5py
|
|
|
|
|
|
def _as_str(v) -> str:
|
|
return v.decode() if isinstance(v, bytes) else ("" if v is None else str(v))
|
|
|
|
|
|
_STAMP_CACHE: dict = {}
|
|
|
|
|
|
class EmbedderMismatch(RuntimeError):
|
|
"""Gallery was built with a different embedder than the one about to be used."""
|
|
|
|
|
|
def sha256_file(path) -> str:
|
|
"""Lowercase hex SHA-256 of a file's bytes; "" if it cannot be read."""
|
|
path = Path(path)
|
|
try:
|
|
st = path.stat()
|
|
except OSError:
|
|
return ""
|
|
key = (str(path), st.st_mtime_ns, st.st_size)
|
|
if key in _STAMP_CACHE:
|
|
return _STAMP_CACHE[key]
|
|
h = hashlib.sha256()
|
|
try:
|
|
with open(path, "rb") as f:
|
|
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
h.update(chunk)
|
|
except OSError:
|
|
return ""
|
|
_STAMP_CACHE[key] = h.hexdigest()
|
|
return _STAMP_CACHE[key]
|
|
|
|
|
|
def embedder_stamp(model_path, embed_dim: int = 512) -> dict:
|
|
"""Identify an embedder model file → {"model_name", "model_sha256", "embed_dim"}.
|
|
|
|
A model file that is absent (e.g. a TRT deployment running from a prebuilt
|
|
.engine) yields a name-only stamp: still comparable, just not provable."""
|
|
if not model_path:
|
|
return {"model_name": "", "model_sha256": "", "embed_dim": embed_dim}
|
|
sha = sha256_file(model_path)
|
|
if not sha:
|
|
print(f"[gallery] cannot hash embedder model {model_path} — model binding "
|
|
f"falls back to filename only (GR-004)", file=sys.stderr)
|
|
return {"model_name": Path(model_path).name, "model_sha256": sha,
|
|
"embed_dim": embed_dim}
|
|
|
|
|
|
def _stamp_empty(s) -> bool:
|
|
return not s or (not s.get("model_name") and not s.get("model_sha256"))
|
|
|
|
|
|
def describe_stamp(s) -> str:
|
|
if _stamp_empty(s):
|
|
return "UNKNOWN"
|
|
name = s.get("model_name") or "<unnamed model>"
|
|
sha = s.get("model_sha256") or ""
|
|
return f"{name} (sha256 {sha[:12]}…)" if sha else f"{name} (sha256 unavailable)"
|
|
|
|
|
|
def require_gallery_stamp_from_env() -> bool:
|
|
"""SAE_REQUIRE_GALLERY_STAMP=1 → an unprovable binding is fatal, not a warning."""
|
|
return os.environ.get("SAE_REQUIRE_GALLERY_STAMP", "0") not in ("", "0")
|
|
|
|
|
|
def check_embedder_stamp(built_with: dict | None, loading_with: dict | None,
|
|
gallery_desc: str = "gallery",
|
|
embedder_desc: str = "embedder") -> tuple[str, str]:
|
|
"""Pure comparison. Returns (verdict, message); verdict is one of
|
|
match / weak_match / unstamped / unknown_embedder / mismatch.
|
|
|
|
Same rules as compare_embedder_stamps() in src/gallery/embedder_stamp.cpp."""
|
|
if _stamp_empty(built_with):
|
|
return "unstamped", (
|
|
f"gallery '{gallery_desc}' carries no embedder stamp (GR-004).\n"
|
|
f" gallery was built with : UNKNOWN — this file predates model binding\n"
|
|
f" embedder now loaded : {describe_stamp(loading_with)} [{embedder_desc}]\n"
|
|
f" If these are not the same model every similarity from this run is\n"
|
|
f" meaningless but will look plausible. Rebuild or re-stamp the gallery\n"
|
|
f" (scripts/stamp_gallery.py), or run with SAE_REQUIRE_GALLERY_STAMP=1 to\n"
|
|
f" make this a hard error.")
|
|
|
|
if _stamp_empty(loading_with):
|
|
return "unknown_embedder", (
|
|
f"cannot identify the embedder being used against gallery "
|
|
f"'{gallery_desc}' (GR-004).\n"
|
|
f" gallery was built with : {describe_stamp(built_with)}\n"
|
|
f" embedder now loaded : UNKNOWN [{embedder_desc}]\n"
|
|
f" The binding cannot be checked, so it is not being checked.")
|
|
|
|
mismatch_tail = (
|
|
" Cosine similarities between embeddings from different models are\n"
|
|
" meaningless but look plausible. Rebuild the gallery with the loaded\n"
|
|
" model, or point the embedder at the model the gallery was built with.")
|
|
|
|
if int(built_with.get("embed_dim", 512)) != int(loading_with.get("embed_dim", 512)):
|
|
return "mismatch", (
|
|
f"gallery/embedder MODEL MISMATCH — refusing to run (GR-004).\n"
|
|
f" gallery was built with : {describe_stamp(built_with)}, "
|
|
f"dim={built_with.get('embed_dim')} [{gallery_desc}]\n"
|
|
f" embedder now loaded : {describe_stamp(loading_with)}, "
|
|
f"dim={loading_with.get('embed_dim')} [{embedder_desc}]\n"
|
|
f" Embedding dimensions differ; these are not the same space.")
|
|
|
|
a, b = built_with.get("model_sha256", ""), loading_with.get("model_sha256", "")
|
|
if a and b:
|
|
if a == b:
|
|
note = ""
|
|
if built_with.get("model_name") != loading_with.get("model_name"):
|
|
note = (f" (gallery recorded it as '{built_with.get('model_name')}', "
|
|
f"loaded from '{loading_with.get('model_name')}' — "
|
|
f"same bytes, renamed file)")
|
|
return "match", f"embedder binding verified: {describe_stamp(built_with)}{note}"
|
|
return "mismatch", (
|
|
f"gallery/embedder MODEL MISMATCH — refusing to run (GR-004).\n"
|
|
f" gallery was built with : {built_with.get('model_name')} sha256={a}\n"
|
|
f" [{gallery_desc}]\n"
|
|
f" embedder now loaded : {loading_with.get('model_name')} sha256={b}\n"
|
|
f" [{embedder_desc}]\n" + mismatch_tail)
|
|
|
|
if built_with.get("model_name") and \
|
|
built_with.get("model_name") == loading_with.get("model_name"):
|
|
return "weak_match", (
|
|
f"embedder binding UNPROVEN for gallery '{gallery_desc}' (GR-004).\n"
|
|
f" gallery was built with : {describe_stamp(built_with)}\n"
|
|
f" embedder now loaded : {describe_stamp(loading_with)} [{embedder_desc}]\n"
|
|
f" Filenames agree but at least one SHA-256 is unavailable, so an\n"
|
|
f" in-place re-export under the same name would not be detected.")
|
|
|
|
return "mismatch", (
|
|
f"gallery/embedder MODEL MISMATCH — refusing to run (GR-004).\n"
|
|
f" gallery was built with : {describe_stamp(built_with)} [{gallery_desc}]\n"
|
|
f" embedder now loaded : {describe_stamp(loading_with)} [{embedder_desc}]\n"
|
|
+ mismatch_tail)
|
|
|
|
|
|
def enforce_embedder_stamp(built_with, loading_with, gallery_desc, embedder_desc,
|
|
require_stamp: bool = False) -> str:
|
|
"""Apply check_embedder_stamp: raise EmbedderMismatch when fatal, else warn.
|
|
|
|
A mismatch is fatal unconditionally — there is no bypass, because a mismatch is
|
|
a known-wrong state, not an unknown one. The three "cannot prove it" verdicts
|
|
warn loudly and become fatal under require_stamp / SAE_REQUIRE_GALLERY_STAMP."""
|
|
strict = require_stamp or require_gallery_stamp_from_env()
|
|
verdict, msg = check_embedder_stamp(built_with, loading_with,
|
|
gallery_desc, embedder_desc)
|
|
if verdict == "mismatch":
|
|
raise EmbedderMismatch(msg)
|
|
if strict and verdict != "match":
|
|
raise EmbedderMismatch(
|
|
msg + "\n (fatal because SAE_REQUIRE_GALLERY_STAMP is set)")
|
|
if verdict == "match":
|
|
print(f"[gallery] {msg}", file=sys.stderr)
|
|
else:
|
|
print(f"\n[gallery] ***** WARNING (GR-004) *****\n{msg}\n"
|
|
f"[gallery] ****************************\n", file=sys.stderr)
|
|
return verdict
|
|
|
|
|
|
def read_gallery_stamp(path) -> dict | None:
|
|
"""The embedder stamp recorded in a gallery file, or None if unstamped.
|
|
|
|
Handles both the HDF5 /embedder group and the legacy JSON "embedder" object."""
|
|
path = Path(path)
|
|
if path.suffix in (".h5", ".hdf5"):
|
|
with h5py.File(path, "r") as f:
|
|
if "embedder" not in f:
|
|
return None
|
|
a = f["embedder"].attrs
|
|
return {"model_name": _as_str(a.get("model_name", "")),
|
|
"model_sha256": _as_str(a.get("model_sha256", "")),
|
|
"embed_dim": int(a.get("embed_dim", 512))}
|
|
data = json.loads(path.read_text())
|
|
return data.get("embedder") or None
|
|
|
|
|
|
def verify_gallery_stamp(gallery_path, model_path=None, *, stamp=None,
|
|
embedder_desc: str | None = None,
|
|
require_stamp: bool = False) -> str:
|
|
"""Load a gallery's stamp and check it against a model file (or an explicit
|
|
stamp, e.g. one read off an embedding dump). Raises EmbedderMismatch."""
|
|
loading = stamp if stamp is not None else embedder_stamp(model_path)
|
|
return enforce_embedder_stamp(read_gallery_stamp(gallery_path), loading,
|
|
str(gallery_path),
|
|
embedder_desc or str(model_path or "unknown"),
|
|
require_stamp)
|
|
|
|
|
|
def _as_str(v) -> str:
|
|
return v.decode() if isinstance(v, bytes) else ("" if v is None else str(v))
|