// Unit tests for gallery (de)serialisation: HDF5 round-trip fidelity (the only // format save_gallery writes), legacy JSON read back-compat (optional field // defaults, the legacy "jellyfin_person_id" fallback), and the GR-004 embedder // stamp. GPU-free, model-free — the stamp tests exercise the comparison logic // with synthetic stamps and never load an ONNX, so they run on CI's Intel N100. #include #include "gallery/embedder_stamp.hpp" #include "gallery/gallery_store.hpp" #include "types.hpp" #include #include #include #include #include namespace { struct TempFile { std::string path; explicit TempFile(const std::string& name) : path(std::string(std::tmpnam(nullptr)) + name) {} ~TempFile() { std::remove(path.c_str()); } }; Embedding make_embedding(float base) { Embedding e{}; for (int i = 0; i < 512; ++i) e[i] = base + i * 1e-4f; return e; } // A stamp built by hand — no ONNX is read, so these tests never need a model. EmbedderStamp stamp(const std::string& name, const std::string& sha, int32_t dim = 512) { EmbedderStamp s; s.model_name = name; s.model_sha256 = sha; s.embed_dim = dim; return s; } const std::string kShaA(64, 'a'); const std::string kShaB(64, 'b'); bool mentions(const std::string& haystack, const std::string& needle) { return haystack.find(needle) != std::string::npos; } } // namespace TEST_CASE("gallery save/load round-trips actors and embeddings", "[gallery]") { ActorGallery g; ActorGallery::Actor a; a.imdb_id = "nm0000093"; a.tmdb_id = "287"; a.jellyfin_id = "guid-abc"; a.name = "Brad Pitt"; a.source_images = {"img1.jpg", "img2.jpg"}; a.embeddings = {make_embedding(0.1f), make_embedding(-0.2f)}; g.actors.push_back(a); ActorGallery::Actor b; b.name = "Edward Norton"; b.embeddings = {make_embedding(0.5f)}; g.actors.push_back(b); // save_gallery always writes HDF5 (see gallery_store.cpp); use a .h5 path // directly rather than relying on the "wrong extension gets rewritten" // fallback, which is a compatibility shim, not the intended usage. TempFile tf("gallery.h5"); save_gallery(tf.path, g); ActorGallery loaded = load_gallery(tf.path); REQUIRE(loaded.actors.size() == 2); const auto& la = loaded.actors[0]; CHECK(la.imdb_id == "nm0000093"); CHECK(la.tmdb_id == "287"); CHECK(la.jellyfin_id == "guid-abc"); CHECK(la.name == "Brad Pitt"); CHECK(la.source_images == std::vector{"img1.jpg", "img2.jpg"}); REQUIRE(la.embeddings.size() == 2); for (int i = 0; i < 512; ++i) { CHECK(la.embeddings[0][i] == a.embeddings[0][i]); CHECK(la.embeddings[1][i] == a.embeddings[1][i]); } CHECK(loaded.actors[1].name == "Edward Norton"); CHECK(loaded.actors[1].embeddings.size() == 1); } TEST_CASE("gallery save/load round-trips calibration", "[gallery]") { ActorGallery g; ActorGallery::Actor a; a.name = "Solo Actor"; a.embeddings = {make_embedding(0.1f)}; g.actors.push_back(a); g.calib_a = 12.5f; g.calib_b = -3.25f; g.calib_valid = true; g.calib_hash = 0xDEADBEEFULL; TempFile tf("gallery_calib.h5"); save_gallery(tf.path, g); ActorGallery loaded = load_gallery(tf.path); CHECK(loaded.calib_a == g.calib_a); CHECK(loaded.calib_b == g.calib_b); CHECK(loaded.calib_valid == g.calib_valid); CHECK(loaded.calib_hash == g.calib_hash); } TEST_CASE("gallery with no calibration loads with calib_hash 0", "[gallery]") { ActorGallery g; ActorGallery::Actor a; a.name = "No Calibration"; a.embeddings = {make_embedding(0.f)}; g.actors.push_back(a); // calib_hash left at its default (0) — save_gallery skips writing the // /calibration group entirely when there's nothing to persist yet. TempFile tf("gallery_nocalib.h5"); save_gallery(tf.path, g); ActorGallery loaded = load_gallery(tf.path); CHECK(loaded.calib_hash == 0); CHECK_FALSE(loaded.calib_valid); } TEST_CASE("load_gallery defaults optional string fields to empty", "[gallery]") { // Minimal actor: only the required name + embeddings. nlohmann::json j; j["actors"] = nlohmann::json::array(); nlohmann::json ja; ja["name"] = "Minimal"; ja["embeddings"] = nlohmann::json::array(); ja["embeddings"].push_back(std::vector(512, 0.25f)); j["actors"].push_back(ja); TempFile tf("gallery_min.json"); { std::ofstream out(tf.path); out << j.dump(); } ActorGallery g = load_gallery(tf.path); REQUIRE(g.actors.size() == 1); CHECK(g.actors[0].name == "Minimal"); CHECK(g.actors[0].imdb_id.empty()); CHECK(g.actors[0].tmdb_id.empty()); CHECK(g.actors[0].jellyfin_id.empty()); CHECK(g.actors[0].source_images.empty()); REQUIRE(g.actors[0].embeddings.size() == 1); CHECK(g.actors[0].embeddings[0][0] == 0.25f); } TEST_CASE("load_gallery reads the legacy jellyfin_person_id key", "[gallery]") { nlohmann::json j; j["actors"] = nlohmann::json::array(); nlohmann::json ja; ja["name"] = "Legacy"; ja["jellyfin_person_id"] = "old-guid"; // pre-rename key ja["embeddings"] = nlohmann::json::array(); ja["embeddings"].push_back(std::vector(512, 0.f)); j["actors"].push_back(ja); TempFile tf("gallery_legacy.json"); { std::ofstream out(tf.path); out << j.dump(); } ActorGallery g = load_gallery(tf.path); REQUIRE(g.actors.size() == 1); CHECK(g.actors[0].jellyfin_id == "old-guid"); } TEST_CASE("load_gallery throws on a missing file", "[gallery]") { CHECK_THROWS(load_gallery("/nonexistent/path/gallery.json")); } // ── GR-004: gallery ↔ embedder binding ─────────────────────────────────────── // Verification plan row GR-004/T1: "Mismatched embedder → hard startup error; // error names both sides." The comparison is a pure function over two stamps, so // none of this needs a GPU, an ONNX, or even a file. /// TRACES: GR-004 | SR-001 TEST_CASE("gallery save/load round-trips the embedder stamp", "[gallery][GR-004]") { ActorGallery g; ActorGallery::Actor a; a.name = "Stamped Actor"; a.embeddings = {make_embedding(0.3f)}; g.actors.push_back(a); g.embedder = stamp("LVFace-B_Glint360K.onnx", kShaA); TempFile tf("gallery_stamped.h5"); save_gallery(tf.path, g); ActorGallery loaded = load_gallery(tf.path); CHECK(loaded.embedder.model_name == "LVFace-B_Glint360K.onnx"); CHECK(loaded.embedder.model_sha256 == kShaA); CHECK(loaded.embedder.embed_dim == 512); CHECK_FALSE(loaded.embedder.empty()); } /// TRACES: GR-004 | SR-001 TEST_CASE("a gallery written without a stamp loads as unstamped", "[gallery][GR-004]") { // The back-compat case: pre-GR-004 files have no /embedder group at all. The // absence must survive the round trip as an absence — a stamp naming no model // would read as "checked and fine" to every consumer. ActorGallery g; ActorGallery::Actor a; a.name = "Legacy Actor"; a.embeddings = {make_embedding(0.f)}; g.actors.push_back(a); TempFile tf("gallery_unstamped.h5"); save_gallery(tf.path, g); ActorGallery loaded = load_gallery(tf.path); CHECK(loaded.embedder.empty()); } /// TRACES: GR-004 | SR-001 TEST_CASE("legacy JSON galleries carry an optional embedder stamp", "[gallery][GR-004]") { nlohmann::json j; j["embedder"] = {{"model_name", "arcface_w600k_r50.onnx"}, {"model_sha256", kShaB}, {"embed_dim", 512}}; j["actors"] = nlohmann::json::array(); nlohmann::json ja; ja["name"] = "JSON Actor"; ja["embeddings"] = nlohmann::json::array(); ja["embeddings"].push_back(std::vector(512, 0.1f)); j["actors"].push_back(ja); TempFile tf("gallery_json_stamp.json"); { std::ofstream out(tf.path); out << j.dump(); } ActorGallery g = load_gallery(tf.path); CHECK(g.embedder.model_name == "arcface_w600k_r50.onnx"); CHECK(g.embedder.model_sha256 == kShaB); } /// TRACES: GR-004 | SR-001 TEST_CASE("matching embedder stamps pass", "[gallery][GR-004]") { auto chk = compare_embedder_stamps(stamp("model.onnx", kShaA), stamp("model.onnx", kShaA)); CHECK(chk.verdict == StampVerdict::match); CHECK_FALSE(chk.fatal(false)); CHECK_FALSE(chk.fatal(true)); // a proven match is never fatal, even in strict mode CHECK_NOTHROW(enforce_embedder_stamp(stamp("model.onnx", kShaA), stamp("model.onnx", kShaA), "g.h5", "model.onnx", true)); } /// TRACES: GR-004 | SR-001 TEST_CASE("the hash decides, not the filename", "[gallery][GR-004]") { // Same bytes under a different filename is the SAME model — a renamed or // relocated file must not be treated as a different one. auto same = compare_embedder_stamps(stamp("lvface.onnx", kShaA), stamp("LVFace-B_Glint360K.onnx", kShaA)); CHECK(same.verdict == StampVerdict::match); // Different bytes under the SAME filename is a DIFFERENT model — this is the // in-place re-export a name-only stamp would miss entirely, and the reason the // stamp carries a hash at all. auto differ = compare_embedder_stamps(stamp("model.onnx", kShaA), stamp("model.onnx", kShaB)); CHECK(differ.verdict == StampVerdict::mismatch); } /// TRACES: GR-004 | SR-001 TEST_CASE("mismatched embedder is fatal and names both sides", "[gallery][GR-004]") { const auto built = stamp("LVFace-B_Glint360K.onnx", kShaA); const auto loaded = stamp("arcface_w600k_r50.onnx", kShaB); auto chk = compare_embedder_stamps(built, loaded, "cast.h5", "models/r50.onnx"); REQUIRE(chk.verdict == StampVerdict::mismatch); CHECK(chk.fatal(false)); // no bypass: a mismatch is fatal in every mode CHECK(chk.fatal(true)); // Both sides must be identifiable from the message alone. CHECK(mentions(chk.message, "LVFace-B_Glint360K.onnx")); CHECK(mentions(chk.message, "arcface_w600k_r50.onnx")); CHECK(mentions(chk.message, kShaA)); CHECK(mentions(chk.message, kShaB)); CHECK(mentions(chk.message, "cast.h5")); CHECK(mentions(chk.message, "models/r50.onnx")); // ...and it must reach the caller as an error, not a log line. CHECK_THROWS_AS(enforce_embedder_stamp(built, loaded, "cast.h5", "models/r50.onnx", false), std::runtime_error); try { enforce_embedder_stamp(built, loaded, "cast.h5", "models/r50.onnx", false); FAIL("mismatch must throw"); } catch (const std::runtime_error& e) { const std::string what = e.what(); CHECK(mentions(what, "LVFace-B_Glint360K.onnx")); CHECK(mentions(what, "arcface_w600k_r50.onnx")); } } /// TRACES: GR-004 | SR-001 TEST_CASE("differing embedding width is a mismatch", "[gallery][GR-004]") { auto chk = compare_embedder_stamps(stamp("a.onnx", kShaA, 512), stamp("a.onnx", kShaA, 256)); CHECK(chk.verdict == StampVerdict::mismatch); CHECK(mentions(chk.message, "512")); CHECK(mentions(chk.message, "256")); } /// TRACES: GR-004 | SR-001 TEST_CASE("an unstamped gallery warns by default and fails under strict", "[gallery][GR-004]") { // Decision recorded in src/gallery/embedder_stamp.hpp: unstamped is UNKNOWN, // not known-bad, and every pre-GR-004 gallery is unstamped. Hard-failing them // all would make the check something people disable rather than trust; so it // warns loudly, names the risk, and is promotable to fatal for measurement runs. EmbedderStamp none; auto chk = compare_embedder_stamps(none, stamp("model.onnx", kShaA), "old.h5"); REQUIRE(chk.verdict == StampVerdict::unstamped); CHECK_FALSE(chk.fatal(false)); CHECK(chk.fatal(true)); CHECK(mentions(chk.message, "old.h5")); CHECK(mentions(chk.message, "model.onnx")); // the loaded side is still named CHECK(mentions(chk.message, "UNKNOWN")); // ...and the gallery side is honest CHECK_NOTHROW(enforce_embedder_stamp(none, stamp("model.onnx", kShaA), "old.h5", "model.onnx", false)); CHECK_THROWS_AS(enforce_embedder_stamp(none, stamp("model.onnx", kShaA), "old.h5", "model.onnx", true), std::runtime_error); } /// TRACES: GR-004 | SR-001 TEST_CASE("an unidentifiable embedder against a stamped gallery is not silent", "[gallery][GR-004]") { // e.g. a replay whose dump predates GR-004: we know what built the gallery but // not what produced the vectors being fed in. Unverifiable, so it must not // report success. auto chk = compare_embedder_stamps(stamp("model.onnx", kShaA), EmbedderStamp{}, "g.h5", "old dump.h5"); CHECK(chk.verdict == StampVerdict::unknown_embedder); CHECK_FALSE(chk.fatal(false)); CHECK(chk.fatal(true)); CHECK(mentions(chk.message, "model.onnx")); CHECK(mentions(chk.message, "old dump.h5")); } /// TRACES: GR-004 | SR-001 TEST_CASE("name-only agreement is a weak match, not a clean pass", "[gallery][GR-004]") { // A TRT deployment can run from a prebuilt .engine with the .onnx absent, so // no hash is computable. Names agreeing is evidence, not proof. auto weak = compare_embedder_stamps(stamp("model.onnx", kShaA), stamp("model.onnx", "")); CHECK(weak.verdict == StampVerdict::weak_match); CHECK_FALSE(weak.fatal(false)); CHECK(weak.fatal(true)); // Names disagreeing with no hash available is still a mismatch — the weaker // evidence is enough to convict, just not to acquit. auto bad = compare_embedder_stamps(stamp("lvface.onnx", ""), stamp("arcface.onnx", "")); CHECK(bad.verdict == StampVerdict::mismatch); CHECK(mentions(bad.message, "lvface.onnx")); CHECK(mentions(bad.message, "arcface.onnx")); } /// TRACES: GR-004 | SR-001 TEST_CASE("sha256 matches the published vectors", "[gallery][GR-004]") { // Pins the in-tree FIPS 180-4 implementation against the standard vectors. // This is what guarantees the C++ stamp and the Python (hashlib) stamp in // scripts/sae_gallery.py agree on the same model file — without it the two // halves of GR-004 could silently diverge and every check would be a mismatch. CHECK(sha256_hex("") == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); CHECK(sha256_hex("abc") == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); CHECK(sha256_hex("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq") == "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"); // Multi-block input, exercising the length-padding path past 64 bytes. CHECK(sha256_hex(std::string(1000, 'a')) == "41edece42d63e8d9bf515a9ba6932e1c20cbc9f5a5d134645adb5db1b9737ea3"); } /// TRACES: GR-004 | SR-001 TEST_CASE("make_embedder_stamp hashes a real file and degrades gracefully", "[gallery][GR-004]") { // Stands in for an ONNX: the stamp does not care what the bytes mean. TempFile tf("fake_model.onnx"); { std::ofstream out(tf.path, std::ios::binary); out << "abc"; } EmbedderStamp s = make_embedder_stamp(tf.path); CHECK(s.model_sha256 == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); CHECK_FALSE(s.model_name.empty()); CHECK(s.model_name.find('/') == std::string::npos); // basename, not full path // A model path that does not exist still yields a comparable name-only stamp // rather than an empty one, which is what keeps engine-only deployments usable. EmbedderStamp missing = make_embedder_stamp("/nonexistent/models/foo.onnx"); CHECK(missing.model_name == "foo.onnx"); CHECK(missing.model_sha256.empty()); CHECK_FALSE(missing.empty()); CHECK(make_embedder_stamp("").empty()); }