GR-004: bind galleries to the embedder that built them
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>
This commit is contained in:
@@ -40,6 +40,14 @@ struct Config {
|
||||
float detector_conf{0.5f};
|
||||
float detector_nms{0.4f};
|
||||
|
||||
/// TRACES: GR-004 | SR-001
|
||||
// Gallery ↔ embedder binding. A gallery built with a different model than the
|
||||
// one loaded here is a hard error, always. This flag additionally promotes
|
||||
// "cannot prove they match" (unstamped legacy gallery, or a name-only match
|
||||
// because the ONNX could not be hashed) from a loud warning to a hard error.
|
||||
// Also settable via SAE_REQUIRE_GALLERY_STAMP=1. Measurement runs want it on.
|
||||
bool require_gallery_stamp{false}; // --require-gallery-stamp
|
||||
|
||||
// ── Recognition (ArcFace ONNX) ────────────────────────────────────────────
|
||||
std::string arcface_model;
|
||||
std::string arcface_engine; // optional path to a pre-built TRT engine; bypasses ORT
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
/// TRACES: GR-004 | SR-001
|
||||
#include "embedder_stamp.hpp"
|
||||
#include "types.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
// ── SHA-256 (FIPS 180-4) ──────────────────────────────────────────────────────
|
||||
// Self-contained rather than pulled from OpenSSL: the gallery library already
|
||||
// links OpenCV, HDF5, FFmpeg and a GPU backend, and the unit tests deliberately
|
||||
// link none of those crypto stacks. ~80 lines of table-driven code is cheaper
|
||||
// than another find_package that CI has to satisfy on an Intel N100.
|
||||
namespace {
|
||||
|
||||
struct Sha256 {
|
||||
uint32_t h[8] = {0x6a09e667u, 0xbb67ae85u, 0x3c6ef372u, 0xa54ff53au,
|
||||
0x510e527fu, 0x9b05688cu, 0x1f83d9abu, 0x5be0cd19u};
|
||||
uint64_t len = 0;
|
||||
uint8_t buf[64]{};
|
||||
size_t buf_n = 0;
|
||||
|
||||
static uint32_t ror(uint32_t x, int n) { return (x >> n) | (x << (32 - n)); }
|
||||
|
||||
void block(const uint8_t* p) {
|
||||
static const uint32_t k[64] = {
|
||||
0x428a2f98u,0x71374491u,0xb5c0fbcfu,0xe9b5dba5u,0x3956c25bu,0x59f111f1u,
|
||||
0x923f82a4u,0xab1c5ed5u,0xd807aa98u,0x12835b01u,0x243185beu,0x550c7dc3u,
|
||||
0x72be5d74u,0x80deb1feu,0x9bdc06a7u,0xc19bf174u,0xe49b69c1u,0xefbe4786u,
|
||||
0x0fc19dc6u,0x240ca1ccu,0x2de92c6fu,0x4a7484aau,0x5cb0a9dcu,0x76f988dau,
|
||||
0x983e5152u,0xa831c66du,0xb00327c8u,0xbf597fc7u,0xc6e00bf3u,0xd5a79147u,
|
||||
0x06ca6351u,0x14292967u,0x27b70a85u,0x2e1b2138u,0x4d2c6dfcu,0x53380d13u,
|
||||
0x650a7354u,0x766a0abbu,0x81c2c92eu,0x92722c85u,0xa2bfe8a1u,0xa81a664bu,
|
||||
0xc24b8b70u,0xc76c51a3u,0xd192e819u,0xd6990624u,0xf40e3585u,0x106aa070u,
|
||||
0x19a4c116u,0x1e376c08u,0x2748774cu,0x34b0bcb5u,0x391c0cb3u,0x4ed8aa4au,
|
||||
0x5b9cca4fu,0x682e6ff3u,0x748f82eeu,0x78a5636fu,0x84c87814u,0x8cc70208u,
|
||||
0x90befffau,0xa4506cebu,0xbef9a3f7u,0xc67178f2u};
|
||||
uint32_t w[64];
|
||||
for (int i = 0; i < 16; ++i)
|
||||
w[i] = (uint32_t(p[i * 4]) << 24) | (uint32_t(p[i * 4 + 1]) << 16) |
|
||||
(uint32_t(p[i * 4 + 2]) << 8) | uint32_t(p[i * 4 + 3]);
|
||||
for (int i = 16; i < 64; ++i) {
|
||||
uint32_t s0 = ror(w[i - 15], 7) ^ ror(w[i - 15], 18) ^ (w[i - 15] >> 3);
|
||||
uint32_t s1 = ror(w[i - 2], 17) ^ ror(w[i - 2], 19) ^ (w[i - 2] >> 10);
|
||||
w[i] = w[i - 16] + s0 + w[i - 7] + s1;
|
||||
}
|
||||
uint32_t a = h[0], b = h[1], c = h[2], d = h[3];
|
||||
uint32_t e = h[4], f = h[5], g = h[6], hh = h[7];
|
||||
for (int i = 0; i < 64; ++i) {
|
||||
uint32_t S1 = ror(e, 6) ^ ror(e, 11) ^ ror(e, 25);
|
||||
uint32_t ch = (e & f) ^ (~e & g);
|
||||
uint32_t t1 = hh + S1 + ch + k[i] + w[i];
|
||||
uint32_t S0 = ror(a, 2) ^ ror(a, 13) ^ ror(a, 22);
|
||||
uint32_t mj = (a & b) ^ (a & c) ^ (b & c);
|
||||
uint32_t t2 = S0 + mj;
|
||||
hh = g; g = f; f = e; e = d + t1;
|
||||
d = c; c = b; b = a; a = t1 + t2;
|
||||
}
|
||||
h[0] += a; h[1] += b; h[2] += c; h[3] += d;
|
||||
h[4] += e; h[5] += f; h[6] += g; h[7] += hh;
|
||||
}
|
||||
|
||||
void update(const uint8_t* p, size_t n) {
|
||||
len += n;
|
||||
while (n) {
|
||||
size_t take = std::min(n, size_t(64) - buf_n);
|
||||
std::memcpy(buf + buf_n, p, take);
|
||||
buf_n += take; p += take; n -= take;
|
||||
if (buf_n == 64) { block(buf); buf_n = 0; }
|
||||
}
|
||||
}
|
||||
|
||||
std::string hex() {
|
||||
uint64_t bits = len * 8;
|
||||
uint8_t pad = 0x80;
|
||||
update(&pad, 1);
|
||||
uint8_t zero = 0;
|
||||
while (buf_n != 56) update(&zero, 1);
|
||||
uint8_t tail[8];
|
||||
for (int i = 0; i < 8; ++i) tail[i] = uint8_t(bits >> (56 - i * 8));
|
||||
// update() would re-count these into len, but len is already frozen in bits.
|
||||
std::memcpy(buf + buf_n, tail, 8);
|
||||
block(buf);
|
||||
buf_n = 0;
|
||||
|
||||
static const char* d = "0123456789abcdef";
|
||||
std::string out;
|
||||
out.reserve(64);
|
||||
for (int i = 0; i < 8; ++i)
|
||||
for (int s = 28; s >= 0; s -= 4)
|
||||
out += d[(h[i] >> s) & 0xF];
|
||||
return out;
|
||||
}
|
||||
};
|
||||
|
||||
// (path, mtime, size) → digest. Hashing a 250 MB ONNX is cheap but not free, and
|
||||
// the optimizer constructs many networks in one process against the same model.
|
||||
std::mutex g_hash_mu;
|
||||
std::map<std::string, std::string> g_hash_cache;
|
||||
|
||||
std::string short_hash(const std::string& hex) {
|
||||
return hex.size() > 12 ? hex.substr(0, 12) + "…" : hex;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string sha256_hex(const std::string& bytes) {
|
||||
Sha256 s;
|
||||
s.update(reinterpret_cast<const uint8_t*>(bytes.data()), bytes.size());
|
||||
return s.hex();
|
||||
}
|
||||
|
||||
std::string sha256_file_hex(const std::string& path) {
|
||||
if (path.empty()) return "";
|
||||
|
||||
std::error_code ec;
|
||||
auto size = fs::file_size(path, ec);
|
||||
if (ec) return "";
|
||||
auto mtime = fs::last_write_time(path, ec);
|
||||
if (ec) return "";
|
||||
|
||||
std::ostringstream key;
|
||||
key << path << '|' << size << '|'
|
||||
<< mtime.time_since_epoch().count();
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(g_hash_mu);
|
||||
auto it = g_hash_cache.find(key.str());
|
||||
if (it != g_hash_cache.end()) return it->second;
|
||||
}
|
||||
|
||||
std::ifstream f(path, std::ios::binary);
|
||||
if (!f) return "";
|
||||
Sha256 s;
|
||||
std::vector<char> chunk(1 << 20);
|
||||
while (f) {
|
||||
f.read(chunk.data(), static_cast<std::streamsize>(chunk.size()));
|
||||
std::streamsize got = f.gcount();
|
||||
if (got > 0) s.update(reinterpret_cast<const uint8_t*>(chunk.data()),
|
||||
static_cast<size_t>(got));
|
||||
}
|
||||
std::string hex = s.hex();
|
||||
|
||||
std::lock_guard<std::mutex> lk(g_hash_mu);
|
||||
g_hash_cache[key.str()] = hex;
|
||||
return hex;
|
||||
}
|
||||
|
||||
// ── EmbedderStamp ─────────────────────────────────────────────────────────────
|
||||
|
||||
std::string EmbedderStamp::describe() const {
|
||||
std::string name = model_name.empty() ? "<unnamed model>" : model_name;
|
||||
if (model_sha256.empty())
|
||||
return name + " (sha256 unavailable)";
|
||||
return name + " (sha256 " + short_hash(model_sha256) + ")";
|
||||
}
|
||||
|
||||
EmbedderStamp make_embedder_stamp(const std::string& model_path) {
|
||||
EmbedderStamp s;
|
||||
if (model_path.empty()) return s;
|
||||
s.model_name = fs::path(model_path).filename().string();
|
||||
s.model_sha256 = sha256_file_hex(model_path);
|
||||
if (s.model_sha256.empty())
|
||||
std::cerr << "[gallery] cannot hash embedder model " << model_path
|
||||
<< " — model binding falls back to filename only (GR-004)\n";
|
||||
return s;
|
||||
}
|
||||
|
||||
bool require_gallery_stamp_from_env() {
|
||||
const char* v = std::getenv("SAE_REQUIRE_GALLERY_STAMP");
|
||||
return v && *v && std::strcmp(v, "0") != 0;
|
||||
}
|
||||
|
||||
// ── Comparison ────────────────────────────────────────────────────────────────
|
||||
|
||||
StampCheck compare_embedder_stamps(const EmbedderStamp& built_with,
|
||||
const EmbedderStamp& loading_with,
|
||||
const std::string& gallery_desc,
|
||||
const std::string& embedder_desc) {
|
||||
StampCheck out;
|
||||
std::ostringstream m;
|
||||
|
||||
// The gallery predates GR-004 (or was written by a tool that does not stamp).
|
||||
if (built_with.empty()) {
|
||||
out.verdict = StampVerdict::unstamped;
|
||||
m << "gallery '" << gallery_desc << "' carries no embedder stamp (GR-004).\n"
|
||||
<< " gallery was built with : UNKNOWN — this file predates model binding\n"
|
||||
<< " embedder now loaded : " << loading_with.describe()
|
||||
<< " [" << embedder_desc << "]\n"
|
||||
<< " If these are not the same model every similarity from this run is\n"
|
||||
<< " meaningless but will look plausible. Rebuild or re-stamp the gallery\n"
|
||||
<< " (scripts/stamp_gallery.py), or run with SAE_REQUIRE_GALLERY_STAMP=1 to\n"
|
||||
<< " make this a hard error.";
|
||||
out.message = m.str();
|
||||
return out;
|
||||
}
|
||||
|
||||
// Gallery is stamped but we cannot say what is about to embed.
|
||||
if (loading_with.empty()) {
|
||||
out.verdict = StampVerdict::unknown_embedder;
|
||||
m << "cannot identify the embedder being used against gallery '"
|
||||
<< gallery_desc << "' (GR-004).\n"
|
||||
<< " gallery was built with : " << built_with.describe() << "\n"
|
||||
<< " embedder now loaded : UNKNOWN [" << embedder_desc << "]\n"
|
||||
<< " The binding cannot be checked, so it is not being checked.";
|
||||
out.message = m.str();
|
||||
return out;
|
||||
}
|
||||
|
||||
const bool have_both_hashes =
|
||||
!built_with.model_sha256.empty() && !loading_with.model_sha256.empty();
|
||||
|
||||
// Embedding width disagreeing is a mismatch on its own terms — different
|
||||
// spaces entirely, and it will not even be caught by a cosine that "looks fine".
|
||||
if (built_with.embed_dim != loading_with.embed_dim) {
|
||||
out.verdict = StampVerdict::mismatch;
|
||||
m << "gallery/embedder MODEL MISMATCH — refusing to run (GR-004).\n"
|
||||
<< " gallery was built with : " << built_with.describe()
|
||||
<< ", dim=" << built_with.embed_dim << " [" << gallery_desc << "]\n"
|
||||
<< " embedder now loaded : " << loading_with.describe()
|
||||
<< ", dim=" << loading_with.embed_dim << " [" << embedder_desc << "]\n"
|
||||
<< " Embedding dimensions differ; these are not the same space.";
|
||||
out.message = m.str();
|
||||
return out;
|
||||
}
|
||||
|
||||
if (have_both_hashes) {
|
||||
if (built_with.model_sha256 == loading_with.model_sha256) {
|
||||
out.verdict = StampVerdict::match;
|
||||
m << "embedder binding verified: " << built_with.describe();
|
||||
if (built_with.model_name != loading_with.model_name)
|
||||
m << " (gallery recorded it as '" << built_with.model_name
|
||||
<< "', loaded from '" << loading_with.model_name
|
||||
<< "' — same bytes, renamed file)";
|
||||
out.message = m.str();
|
||||
return out;
|
||||
}
|
||||
out.verdict = StampVerdict::mismatch;
|
||||
m << "gallery/embedder MODEL MISMATCH — refusing to run (GR-004).\n"
|
||||
<< " gallery was built with : " << built_with.model_name
|
||||
<< " sha256=" << built_with.model_sha256 << "\n"
|
||||
<< " [" << gallery_desc << "]\n"
|
||||
<< " embedder now loaded : " << loading_with.model_name
|
||||
<< " sha256=" << loading_with.model_sha256 << "\n"
|
||||
<< " [" << embedder_desc << "]\n"
|
||||
<< " 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.";
|
||||
out.message = m.str();
|
||||
return out;
|
||||
}
|
||||
|
||||
// One side has no hash (e.g. a TRT deployment with the .onnx absent). Names
|
||||
// are all we have; agreeing on them is evidence, not proof.
|
||||
if (!built_with.model_name.empty() &&
|
||||
built_with.model_name == loading_with.model_name) {
|
||||
out.verdict = StampVerdict::weak_match;
|
||||
m << "embedder binding UNPROVEN for gallery '" << gallery_desc << "' (GR-004).\n"
|
||||
<< " gallery was built with : " << built_with.describe() << "\n"
|
||||
<< " embedder now loaded : " << loading_with.describe()
|
||||
<< " [" << embedder_desc << "]\n"
|
||||
<< " Filenames agree but at least one SHA-256 is unavailable, so an\n"
|
||||
<< " in-place re-export under the same name would not be detected.";
|
||||
out.message = m.str();
|
||||
return out;
|
||||
}
|
||||
|
||||
out.verdict = StampVerdict::mismatch;
|
||||
m << "gallery/embedder MODEL MISMATCH — refusing to run (GR-004).\n"
|
||||
<< " gallery was built with : " << built_with.describe()
|
||||
<< " [" << gallery_desc << "]\n"
|
||||
<< " embedder now loaded : " << loading_with.describe()
|
||||
<< " [" << embedder_desc << "]\n"
|
||||
<< " 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.";
|
||||
out.message = m.str();
|
||||
return out;
|
||||
}
|
||||
|
||||
void enforce_embedder_stamp(const EmbedderStamp& built_with,
|
||||
const EmbedderStamp& loading_with,
|
||||
const std::string& gallery_desc,
|
||||
const std::string& embedder_desc,
|
||||
bool require_stamp) {
|
||||
const bool strict = require_stamp || require_gallery_stamp_from_env();
|
||||
StampCheck chk = compare_embedder_stamps(built_with, loading_with,
|
||||
gallery_desc, embedder_desc);
|
||||
|
||||
if (chk.fatal(strict)) {
|
||||
if (chk.verdict != StampVerdict::mismatch)
|
||||
throw std::runtime_error(chk.message +
|
||||
"\n (fatal because SAE_REQUIRE_GALLERY_STAMP / --require-gallery-stamp is set)");
|
||||
throw std::runtime_error(chk.message);
|
||||
}
|
||||
|
||||
if (chk.verdict == StampVerdict::match) {
|
||||
std::cerr << "[gallery] " << chk.message << "\n";
|
||||
} else {
|
||||
std::cerr << "\n[gallery] ***** WARNING (GR-004) *****\n"
|
||||
<< chk.message << "\n"
|
||||
<< "[gallery] ****************************\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
void verify_gallery_embedder(const ActorGallery& gallery,
|
||||
const std::string& gallery_path,
|
||||
const std::string& arcface_model_path,
|
||||
bool require_stamp) {
|
||||
enforce_embedder_stamp(gallery.embedder,
|
||||
make_embedder_stamp(arcface_model_path),
|
||||
gallery_path,
|
||||
arcface_model_path.empty() ? "no --arcface given"
|
||||
: arcface_model_path,
|
||||
require_stamp);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
#pragma once
|
||||
/// TRACES: GR-004 | SR-001
|
||||
//
|
||||
// Gallery ↔ embedder binding.
|
||||
//
|
||||
// A gallery is only valid for the embedder that built it. Cosine similarities
|
||||
// between embeddings from two different models are meaningless but *look*
|
||||
// plausible — nothing crashes, nothing is obviously wrong, and every number
|
||||
// measured downstream is quietly garbage. So the embedder's identity is stamped
|
||||
// into the gallery at build time and checked by every consumer at load time.
|
||||
//
|
||||
// ── What identifies an embedder ───────────────────────────────────────────────
|
||||
// Two fields, carried together:
|
||||
//
|
||||
// model_name basename of the model file, e.g. "LVFace-B_Glint360K.onnx"
|
||||
// model_sha256 hex SHA-256 of that file's bytes
|
||||
//
|
||||
// The hash is what *decides*; the name is what a human *reads*. Neither alone is
|
||||
// enough:
|
||||
//
|
||||
// • A name alone is a promise, not a fact. Models get re-exported, re-quantised
|
||||
// and overwritten in place under an unchanged filename — which is precisely
|
||||
// the case where the weights differ and nothing else does. A name-only stamp
|
||||
// is blind to exactly the failure it exists to catch.
|
||||
// • A hash alone is correct but unreadable: "expected 3f2a… got 9c1b…" tells an
|
||||
// operator nothing about what to do next.
|
||||
//
|
||||
// SHA-256 over the file bytes is derived from the artefact rather than asserted
|
||||
// about it, is stable across machines and filesystems, and needs no registry to
|
||||
// be kept up to date. Cost is ~0.1 s for a 250 MB ONNX, paid once per process
|
||||
// (results are memoised on path+mtime+size), which is noise next to model load.
|
||||
//
|
||||
// ── Degraded and legacy cases ─────────────────────────────────────────────────
|
||||
// A TRT-backend deployment may run from a prebuilt .engine with the source .onnx
|
||||
// absent, so the hash cannot be computed. Then the name is compared alone and the
|
||||
// result is reported as a *weak* match — believed, not proven.
|
||||
//
|
||||
// Galleries built before GR-004 carry no stamp at all. They warn loudly rather
|
||||
// than fail, because the state is unknown rather than known-bad, and because
|
||||
// hard-failing every pre-existing gallery would make the check something people
|
||||
// route around rather than trust. Set require_stamp (or SAE_REQUIRE_GALLERY_STAMP=1)
|
||||
// to promote "unknown" to a hard error — that is the mode measurement work runs in.
|
||||
//
|
||||
// A *mismatch* is always fatal, in every mode, with no bypass.
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
struct EmbedderStamp {
|
||||
std::string model_name; // basename of the model file
|
||||
std::string model_sha256; // lowercase hex SHA-256 of the file's bytes ("" = unavailable)
|
||||
int32_t embed_dim{512};
|
||||
|
||||
bool empty() const { return model_name.empty() && model_sha256.empty(); }
|
||||
|
||||
// "LVFace-B_Glint360K.onnx (sha256 3f2a1c4d…)" — for error messages.
|
||||
std::string describe() const;
|
||||
};
|
||||
|
||||
// Identify the model at `model_path`. Missing/unreadable file → name filled from
|
||||
// the path, hash left empty (the weak-match path). Empty path → empty stamp.
|
||||
EmbedderStamp make_embedder_stamp(const std::string& model_path);
|
||||
|
||||
enum class StampVerdict {
|
||||
match, // hashes agree — binding proven
|
||||
weak_match, // names agree, no hash on one side — believed, unproven
|
||||
unstamped, // gallery predates GR-004 / was written without a stamp
|
||||
unknown_embedder, // gallery is stamped but the loaded embedder can't be identified
|
||||
mismatch, // proven different models — always fatal
|
||||
};
|
||||
|
||||
struct StampCheck {
|
||||
StampVerdict verdict{StampVerdict::match};
|
||||
std::string message; // human-readable, names BOTH sides
|
||||
|
||||
// A mismatch is fatal unconditionally. The three "cannot prove it" verdicts
|
||||
// are fatal only in strict mode.
|
||||
bool fatal(bool require_stamp) const {
|
||||
return verdict == StampVerdict::mismatch ||
|
||||
(require_stamp && verdict != StampVerdict::match);
|
||||
}
|
||||
};
|
||||
|
||||
// Pure comparison — no file I/O, no model loading. This is the unit under test.
|
||||
// `gallery_desc`/`embedder_desc` are only used to make the message locatable
|
||||
// (a gallery path, a dump path, "the embedder being loaded", …).
|
||||
StampCheck compare_embedder_stamps(const EmbedderStamp& built_with,
|
||||
const EmbedderStamp& loading_with,
|
||||
const std::string& gallery_desc = "gallery",
|
||||
const std::string& embedder_desc = "embedder");
|
||||
|
||||
// Apply the comparison: throw std::runtime_error on a fatal verdict, otherwise
|
||||
// log to stderr. `require_stamp` is OR-ed with SAE_REQUIRE_GALLERY_STAMP.
|
||||
void enforce_embedder_stamp(const EmbedderStamp& built_with,
|
||||
const EmbedderStamp& loading_with,
|
||||
const std::string& gallery_desc,
|
||||
const std::string& embedder_desc,
|
||||
bool require_stamp);
|
||||
|
||||
// Convenience for the common consumer shape: "I loaded this gallery and I am
|
||||
// about to embed with this model file." Hashes the model, then enforces.
|
||||
struct ActorGallery;
|
||||
void verify_gallery_embedder(const ActorGallery& gallery,
|
||||
const std::string& gallery_path,
|
||||
const std::string& arcface_model_path,
|
||||
bool require_stamp);
|
||||
|
||||
// SAE_REQUIRE_GALLERY_STAMP=1 → treat an unprovable binding as fatal.
|
||||
bool require_gallery_stamp_from_env();
|
||||
|
||||
// Lowercase hex SHA-256. Exposed so a test can pin the digest against the
|
||||
// published vectors, which is what guarantees the C++ and Python (hashlib)
|
||||
// stamps of the same file agree.
|
||||
std::string sha256_hex(const std::string& bytes);
|
||||
std::string sha256_file_hex(const std::string& path); // "" if unreadable
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "gallery_builder.hpp"
|
||||
#include "config.hpp"
|
||||
#include "embedder_stamp.hpp"
|
||||
#include "face_utils.hpp"
|
||||
#include "inference/face_detector.hpp"
|
||||
#include "inference/face_embedder.hpp"
|
||||
@@ -41,6 +42,12 @@ ActorGallery build_gallery(const BuildConfig& cfg) {
|
||||
|
||||
ActorGallery gallery;
|
||||
|
||||
/// TRACES: GR-004 | SR-001
|
||||
// Stamp before the first embedding exists, so there is no window in which a
|
||||
// gallery holds vectors without recording what produced them.
|
||||
gallery.embedder = make_embedder_stamp(cfg.arcface_model);
|
||||
std::cerr << "[build_gallery] embedder: " << gallery.embedder.describe() << "\n";
|
||||
|
||||
for (const auto& actor_dir : fs::directory_iterator(cfg.gallery_root)) {
|
||||
if (!actor_dir.is_directory()) continue;
|
||||
|
||||
|
||||
@@ -79,6 +79,21 @@ static ActorGallery load_gallery_hdf5(const std::string& path) {
|
||||
gallery.actors.push_back(std::move(actor));
|
||||
}
|
||||
|
||||
/// TRACES: GR-004 | SR-001
|
||||
// Absent /embedder group == a gallery written before model binding existed.
|
||||
// It stays readable; verify_gallery_embedder() decides what that means.
|
||||
if (file.nameExists("embedder")) {
|
||||
H5::Group eg = file.openGroup("embedder");
|
||||
H5::StrType str(H5::PredType::C_S1, H5T_VARIABLE);
|
||||
if (eg.attrExists("model_name"))
|
||||
eg.openAttribute("model_name").read(str, gallery.embedder.model_name);
|
||||
if (eg.attrExists("model_sha256"))
|
||||
eg.openAttribute("model_sha256").read(str, gallery.embedder.model_sha256);
|
||||
if (eg.attrExists("embed_dim"))
|
||||
eg.openAttribute("embed_dim").read(H5::PredType::NATIVE_INT32,
|
||||
&gallery.embedder.embed_dim);
|
||||
}
|
||||
|
||||
if (file.nameExists("calibration")) {
|
||||
H5::Group cal = file.openGroup("calibration");
|
||||
cal.openAttribute("a").read(H5::PredType::NATIVE_FLOAT, &gallery.calib_a);
|
||||
@@ -149,6 +164,20 @@ static void save_gallery_hdf5(const std::string& path, const ActorGallery& galle
|
||||
write_str_dataset(file, "name", name);
|
||||
write_str_dataset(file, "source_images", src_images);
|
||||
|
||||
/// TRACES: GR-004 | SR-001
|
||||
// Bind the file to the embedder that produced its vectors. Written only when
|
||||
// known — an empty stamp must round-trip as "unstamped", not as a stamp
|
||||
// claiming an unnamed model.
|
||||
if (!gallery.embedder.empty()) {
|
||||
H5::Group eg = file.createGroup("embedder");
|
||||
H5::DataSpace scalar(H5S_SCALAR);
|
||||
H5::StrType str(H5::PredType::C_S1, H5T_VARIABLE);
|
||||
eg.createAttribute("model_name", str, scalar).write(str, gallery.embedder.model_name);
|
||||
eg.createAttribute("model_sha256", str, scalar).write(str, gallery.embedder.model_sha256);
|
||||
eg.createAttribute("embed_dim", H5::PredType::NATIVE_INT32, scalar)
|
||||
.write(H5::PredType::NATIVE_INT32, &gallery.embedder.embed_dim);
|
||||
}
|
||||
|
||||
if (gallery.calib_hash != 0) {
|
||||
H5::Group cal = file.createGroup("calibration");
|
||||
H5::DataSpace scalar(H5S_SCALAR);
|
||||
@@ -186,6 +215,17 @@ ActorGallery load_gallery(const std::string& path) {
|
||||
<< std::chrono::duration<double>(t1 - t0).count() << "s\n";
|
||||
|
||||
ActorGallery gallery;
|
||||
|
||||
/// TRACES: GR-004 | SR-001
|
||||
// Optional top-level "embedder" object, matching the HDF5 /embedder group.
|
||||
// Written by the JSON-era helper scripts; absent in anything older.
|
||||
if (j.contains("embedder") && j.at("embedder").is_object()) {
|
||||
const auto& je = j.at("embedder");
|
||||
gallery.embedder.model_name = je.value("model_name", "");
|
||||
gallery.embedder.model_sha256 = je.value("model_sha256", "");
|
||||
gallery.embedder.embed_dim = je.value("embed_dim", 512);
|
||||
}
|
||||
|
||||
for (const auto& ja : j.at("actors")) {
|
||||
ActorGallery::Actor actor;
|
||||
actor.imdb_id = ja.value("imdb_id", "");
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
// /count int32 [A] number of refs for actor a
|
||||
// /imdb_id /tmdb_id /jellyfin_id /name : variable-length string [A]
|
||||
// /source_images : variable-length string [N], parallel to /embeddings rows
|
||||
// /embedder/model_name : scalar var-len string attr — embedder file basename
|
||||
// /embedder/model_sha256 : scalar var-len string attr — SHA-256 of that file
|
||||
// /embedder/embed_dim : scalar int32 attr
|
||||
// The GR-004 model binding. Absent group == unstamped
|
||||
// (pre-GR-004 file); see gallery/embedder_stamp.hpp.
|
||||
// /calibration/a, /b : scalar float32 attrs — Platt-sigmoid P(match|sim) fit
|
||||
// /calibration/valid : scalar int8 attr (0/1)
|
||||
// /calibration/hash : scalar uint64 attr — hash of the embeddings the fit
|
||||
@@ -19,6 +24,7 @@
|
||||
//
|
||||
// Legacy JSON format (read-only):
|
||||
// {
|
||||
// "embedder": {"model_name": "...", "model_sha256": "...", "embed_dim": 512},
|
||||
// "actors": [
|
||||
// {
|
||||
// "imdb_id": "nm0000093", // optional, "" if unknown
|
||||
|
||||
+28
-2
@@ -15,6 +15,7 @@
|
||||
|
||||
#include "types.hpp"
|
||||
#include "config.hpp"
|
||||
#include "gallery/embedder_stamp.hpp"
|
||||
#include "gallery/gallery_store.hpp"
|
||||
#include "nodes/face_tracker_node.hpp"
|
||||
#include "nodes/identity_matcher_node.hpp"
|
||||
@@ -163,6 +164,9 @@ static Config config_from_dict(nb::dict d) {
|
||||
getd("anneal_sec", cfg.anneal_sec);
|
||||
// gallery expansion (usually off for sweeps; expose so it can be toggled)
|
||||
if (d.contains("expand_gallery")) cfg.expand_gallery = nb::cast<bool>(d["expand_gallery"]);
|
||||
/// TRACES: GR-004 | SR-001
|
||||
if (d.contains("require_gallery_stamp"))
|
||||
cfg.require_gallery_stamp = nb::cast<bool>(d["require_gallery_stamp"]);
|
||||
return cfg;
|
||||
}
|
||||
|
||||
@@ -210,8 +214,17 @@ NB_MODULE(sae_kpn, m) {
|
||||
net.add(std::move(name), std::move(node));
|
||||
}, "net"_a, "name"_a, "config"_a, "capacity"_a = 16);
|
||||
|
||||
/// TRACES: GR-004 | SR-001
|
||||
// embedder_model / embedder_sha256 identify whatever produced the embeddings
|
||||
// that will be fed in. In a replay those come from the dump's own stamp (see
|
||||
// scripts/optimizer/SCHEMA.md), because there is no live embedder in the
|
||||
// network — the dump *is* the embedder as far as this gallery is concerned.
|
||||
// Passing neither leaves the binding unverifiable, which warns loudly and is
|
||||
// fatal under SAE_REQUIRE_GALLERY_STAMP.
|
||||
m.def("add_identity_matcher", [](Net& net, std::string name, std::string gallery_path,
|
||||
nb::dict cfg_dict, std::size_t cap) {
|
||||
nb::dict cfg_dict, std::size_t cap,
|
||||
std::string embedder_model,
|
||||
std::string embedder_sha256) {
|
||||
Config cfg = config_from_dict(cfg_dict);
|
||||
cfg.gallery_path = gallery_path; // needed to persist refreshed calibration back
|
||||
// Cache loaded galleries by path so a threshold sweep (many networks, same
|
||||
@@ -222,11 +235,24 @@ NB_MODULE(sae_kpn, m) {
|
||||
if (it == cache.end())
|
||||
it = cache.emplace(gallery_path,
|
||||
std::make_shared<ActorGallery>(load_gallery(gallery_path))).first;
|
||||
|
||||
// Checked on every construction, not only on the cache miss: the same
|
||||
// process may replay several dumps against one cached gallery.
|
||||
EmbedderStamp feeding;
|
||||
feeding.model_name = std::move(embedder_model);
|
||||
feeding.model_sha256 = std::move(embedder_sha256);
|
||||
enforce_embedder_stamp(it->second->embedder, feeding, gallery_path,
|
||||
feeding.model_name.empty()
|
||||
? "embeddings fed into this network"
|
||||
: feeding.model_name,
|
||||
cfg.require_gallery_stamp);
|
||||
|
||||
auto node = std::make_shared<kpn::ObjectVariantNodeWrapper<
|
||||
IdentityMatcherFunc, SaeVariant, kpn::in<"tracked">, kpn::out<"matched">>>(
|
||||
cap, *it->second, cfg);
|
||||
net.add(std::move(name), std::move(node));
|
||||
}, "net"_a, "name"_a, "gallery"_a, "config"_a, "capacity"_a = 16);
|
||||
}, "net"_a, "name"_a, "gallery"_a, "config"_a, "capacity"_a = 16,
|
||||
"embedder_model"_a = "", "embedder_sha256"_a = "");
|
||||
|
||||
m.def("add_scene_tracker", [](Net& net, std::string name, nb::dict cfg_dict, std::size_t cap) {
|
||||
Config cfg = config_from_dict(cfg_dict);
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
|
||||
#include "config.hpp"
|
||||
#include "types.hpp"
|
||||
#include "gallery/embedder_stamp.hpp"
|
||||
#include "gallery/gallery_store.hpp"
|
||||
#include "nodes/frame_source_node.hpp"
|
||||
#include "nodes/camera_position_change_detector_node.hpp"
|
||||
@@ -114,6 +115,7 @@ static Config parse_args(int argc, char** argv) {
|
||||
else if (arg("--detector")) cfg.detector_model = next();
|
||||
else if (arg("--detector-engine")) cfg.detector_engine = next();
|
||||
else if (arg("--arcface")) cfg.arcface_model = next();
|
||||
else if (arg("--require-gallery-stamp")) cfg.require_gallery_stamp = true;
|
||||
else if (arg("--arcface-engine")) cfg.arcface_engine = next();
|
||||
else if (arg("--conf")) cfg.detector_conf = std::stof(next());
|
||||
else if (arg("--max-faces")) cfg.max_faces = std::stoi(next());
|
||||
@@ -167,6 +169,11 @@ int main(int argc, char** argv) {
|
||||
ActorGallery gallery;
|
||||
try {
|
||||
gallery = load_gallery(cfg.gallery_path);
|
||||
/// TRACES: GR-004 | SR-001
|
||||
// Hard startup error before a single frame is decoded: a gallery built
|
||||
// with another embedder yields plausible-looking, meaningless matches.
|
||||
verify_gallery_embedder(gallery, cfg.gallery_path, cfg.arcface_model,
|
||||
cfg.require_gallery_stamp);
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << "Gallery error: " << e.what() << "\n";
|
||||
return 1;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
#include "types.hpp"
|
||||
#include "config.hpp"
|
||||
#include "gallery/embedder_stamp.hpp"
|
||||
|
||||
#include <H5Cpp.h>
|
||||
|
||||
@@ -25,7 +26,13 @@ struct EmbeddingDumpFunc {
|
||||
: path_(cfg.dump_embeddings_path), movie_(cfg.movie_path),
|
||||
sample_fps_(cfg.sample_fps), done_(done)
|
||||
{
|
||||
std::cerr << "[embedding_dump] writing " << path_ << "\n";
|
||||
/// TRACES: GR-004 | SR-001
|
||||
// A dump is a bag of embeddings with no model attached, replayed against a
|
||||
// gallery hours or weeks later — the same silent cross-model hazard as the
|
||||
// gallery itself, so it carries the same stamp.
|
||||
stamp_ = make_embedder_stamp(cfg.arcface_model);
|
||||
std::cerr << "[embedding_dump] writing " << path_
|
||||
<< " embedder: " << stamp_.describe() << "\n";
|
||||
}
|
||||
|
||||
void operator()(EmbeddedSceneFrame ef) {
|
||||
@@ -91,6 +98,9 @@ private:
|
||||
H5::StrType str(H5::PredType::C_S1, H5T_VARIABLE);
|
||||
auto mv = file.createAttribute("movie", str, scalar);
|
||||
mv.write(str, movie_);
|
||||
/// TRACES: GR-004 | SR-001
|
||||
file.createAttribute("embedder_model", str, scalar).write(str, stamp_.model_name);
|
||||
file.createAttribute("embedder_sha256", str, scalar).write(str, stamp_.model_sha256);
|
||||
|
||||
H5::Group frames = file.createGroup("frames");
|
||||
write_vec(frames, "timestamp_sec", ts_, H5::PredType::NATIVE_DOUBLE);
|
||||
@@ -110,7 +120,8 @@ private:
|
||||
<< conf_.size() << " faces → " << path_ << "\n";
|
||||
}
|
||||
|
||||
std::string path_, movie_;
|
||||
std::string path_, movie_;
|
||||
EmbedderStamp stamp_;
|
||||
float sample_fps_;
|
||||
std::atomic<bool>& done_;
|
||||
std::atomic<bool> written_{false};
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
#include "config.hpp"
|
||||
#include "types.hpp"
|
||||
#include "gallery/embedder_stamp.hpp"
|
||||
#include "gallery/gallery_store.hpp"
|
||||
#include "nodes/frame_source_node.hpp"
|
||||
#include "nodes/camera_position_change_detector_node.hpp"
|
||||
@@ -76,6 +77,7 @@ static Config parse_args(int argc, char** argv) {
|
||||
else if (arg("--detector-engine")) cfg.detector_engine = next();
|
||||
else if (arg("--arcface")) cfg.arcface_model = next();
|
||||
else if (arg("--arcface-engine")) cfg.arcface_engine = next();
|
||||
else if (arg("--require-gallery-stamp")) cfg.require_gallery_stamp = true;
|
||||
else if (arg("--conf")) cfg.detector_conf = std::stof(next());
|
||||
else if (arg("--max-faces")) cfg.max_faces = std::stoi(next());
|
||||
else if (arg("--min-face-px")) cfg.min_face_px = std::stof(next());
|
||||
@@ -126,7 +128,12 @@ int main(int argc, char** argv) {
|
||||
}
|
||||
|
||||
ActorGallery gallery;
|
||||
try { gallery = load_gallery(cfg.gallery_path); }
|
||||
try {
|
||||
gallery = load_gallery(cfg.gallery_path);
|
||||
/// TRACES: GR-004 | SR-001
|
||||
verify_gallery_embedder(gallery, cfg.gallery_path, cfg.arcface_model,
|
||||
cfg.require_gallery_stamp);
|
||||
}
|
||||
catch (const std::exception& e) {
|
||||
std::cerr << "Gallery error: " << e.what() << "\n";
|
||||
return 1;
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
#include <opencv2/core.hpp>
|
||||
|
||||
#include "gallery/embedder_stamp.hpp"
|
||||
|
||||
// ── Embedding ─────────────────────────────────────────────────────────────────
|
||||
// 512-dim L2-normalised ArcFace embedding
|
||||
using Embedding = std::array<float, 512>;
|
||||
@@ -136,6 +138,11 @@ struct ActorGallery {
|
||||
};
|
||||
std::vector<Actor> actors;
|
||||
|
||||
/// TRACES: GR-004 | SR-001
|
||||
// Which embedder produced every embedding above. Empty == the file predates
|
||||
// model binding; see gallery/embedder_stamp.hpp for what is checked and why.
|
||||
EmbedderStamp embedder;
|
||||
|
||||
// Cached Platt-sigmoid calibration (see gallery/gallery_calibration.hpp),
|
||||
// stored alongside the gallery in HDF5 so it never needs recomputing
|
||||
// unless the reference embeddings actually change. calib_valid=false and
|
||||
|
||||
Reference in New Issue
Block a user