// Unit tests for gallery calibration: the sigmoid math, the pairwise fit on // separable data, and the in-memory hash-keyed cache (hit / stale / cold). // All pure, GPU-free, model-free. #include #include #include "gallery/gallery_calibration.hpp" #include "face_utils.hpp" // l2_normalise #include "types.hpp" #include #include #include #include using Catch::Matchers::WithinAbs; using Catch::Matchers::WithinRel; namespace { // n embeddings for `n_actors` actors, each a tight cluster around a random // per-actor centre — cleanly separable, so calibration should converge. void make_separable_gallery(int n_actors, int per_actor, std::vector& emb, std::vector& actor) { std::mt19937 rng(1234); std::normal_distribution centre(0.f, 1.f); std::normal_distribution jitter(0.f, 0.01f); for (int a = 0; a < n_actors; ++a) { std::array c{}; for (float& v : c) v = centre(rng); for (int k = 0; k < per_actor; ++k) { std::array raw{}; for (int d = 0; d < 512; ++d) raw[d] = c[d] + jitter(rng); emb.push_back(l2_normalise(raw.data())); actor.push_back(a); } } } } // namespace TEST_CASE("GalleryCalibration probability is monotonic and bounded", "[calibration]") { GalleryCalibration cal{10.f, -5.f, true}; float lo = cal.probability(-1.f); float mid = cal.probability(0.5f); // boundary is at sim = -b/a = 0.5 float hi = cal.probability(1.f); CHECK(lo >= 0.f); CHECK(hi <= 1.f); CHECK(lo < mid); CHECK(mid < hi); CHECK_THAT(mid, WithinAbs(0.5f, 1e-5f)); // σ(0) = 0.5 at the boundary } TEST_CASE("boundary_at inverts probability", "[calibration]") { GalleryCalibration cal{8.f, -3.f, true}; for (float p : {0.1f, 0.5f, 0.9f}) { float sim = cal.boundary_at(p); CHECK_THAT(cal.probability(sim), WithinAbs(p, 1e-5f)); } } TEST_CASE("prior odds shift the decision boundary", "[calibration]") { GalleryCalibration cal{10.f, -5.f, true}; // A positive prior (match more likely) should raise P at a fixed similarity. float base = cal.probability(0.5f); float raised = cal.probability(0.5f, /*log_prior_odds=*/2.f); CHECK(raised > base); // and correspondingly lower the similarity needed to reach P=0.5. CHECK(cal.boundary_at(0.5f, 2.f) < cal.boundary_at(0.5f)); } TEST_CASE("calibrate_gallery fits separable data", "[calibration]") { std::vector emb; std::vector actor; make_separable_gallery(/*n_actors=*/6, /*per_actor=*/8, emb, actor); GalleryCalibration cal = calibrate_gallery(emb, actor); REQUIRE(cal.valid); CHECK(cal.a > 0.f); // higher sim → higher P // Same-actor pairs sit near sim≈1, cross-actor near 0 → boundary between. float boundary = cal.boundary_at(0.5f); CHECK(boundary > 0.f); CHECK(boundary < 1.f); } TEST_CASE("calibrate_gallery returns invalid on too few pairs", "[calibration]") { // One actor, one embedding: no negative pairs, no positive pairs. std::vector emb(1); emb[0] = l2_normalise(std::array{1.f}.data()); std::vector actor{0}; GalleryCalibration cal = calibrate_gallery(emb, actor); CHECK_FALSE(cal.valid); } TEST_CASE("hash_gallery_embeddings is sensitive to changes", "[calibration]") { std::vector emb; std::vector actor; make_separable_gallery(3, 4, emb, actor); uint64_t h0 = hash_gallery_embeddings(emb, actor); CHECK(hash_gallery_embeddings(emb, actor) == h0); // stable auto emb2 = emb; emb2[0][0] += 1e-3f; CHECK(hash_gallery_embeddings(emb2, actor) != h0); // embedding change auto actor2 = actor; actor2.back() = 99; CHECK(hash_gallery_embeddings(emb, actor2) != h0); // assignment change } TEST_CASE("calibrate_gallery_cached reuses matching in-memory calibration", "[calibration]") { std::vector emb; std::vector actor; make_separable_gallery(5, 6, emb, actor); bool recomputed = false; GalleryCalibration first = calibrate_gallery_cached( emb, actor, /*cached_a=*/0.f, /*cached_b=*/0.f, /*cached_valid=*/false, /*cached_hash=*/0, /*curve_base_path=*/"", recomputed); REQUIRE(first.valid); CHECK(recomputed); // no prior cache (hash=0) → always recomputes uint64_t hash = hash_gallery_embeddings(emb, actor); // Second call, passing back the just-fitted params + matching hash, must // NOT recompute and must return the identical fitted params. recomputed = false; GalleryCalibration second = calibrate_gallery_cached( emb, actor, first.a, first.b, first.valid, hash, "", recomputed); CHECK_FALSE(recomputed); CHECK_THAT(second.a, WithinRel(first.a, 1e-6f)); CHECK_THAT(second.b, WithinRel(first.b, 1e-6f)); CHECK(second.valid == first.valid); } TEST_CASE("calibrate_gallery_cached recomputes when the gallery changes", "[calibration]") { std::vector emb; std::vector actor; make_separable_gallery(5, 6, emb, actor); bool recomputed = false; GalleryCalibration first = calibrate_gallery_cached( emb, actor, 0.f, 0.f, false, 0, "", recomputed); uint64_t stale_hash = hash_gallery_embeddings(emb, actor); // Mutate the gallery → hash no longer matches → must recompute. emb.push_back(emb.front()); actor.push_back(actor.front()); recomputed = false; GalleryCalibration second = calibrate_gallery_cached( emb, actor, first.a, first.b, first.valid, stale_hash, "", recomputed); CHECK(recomputed); CHECK(hash_gallery_embeddings(emb, actor) != stale_hash); } TEST_CASE("calibrate_gallery_cached treats hash=0 as always-recompute", "[calibration]") { std::vector emb; std::vector actor; make_separable_gallery(4, 5, emb, actor); // hash=0 is the "no cached calibration" sentinel (ActorGallery::calib_hash // default) — must fit fresh rather than treat 0 as a real cached hash. bool recomputed = false; GalleryCalibration cal = calibrate_gallery_cached( emb, actor, 0.f, 0.f, false, /*cached_hash=*/0, "", recomputed); CHECK(recomputed); CHECK(cal.valid); } // ── GR-003 — the build report ──────────────────────────────────────────────── #include "gallery/gallery_report.hpp" namespace { // A unit vector on one axis. Distinct axes are orthogonal, which is unrealistic // as a same-actor cluster but irrelevant here: these tests count actors, they do // not assess fit quality. Embedding unit_axis(int slot) { Embedding e{}; e[slot % 512] = 1.0f; return e; } } // namespace TEST_CASE("report surfaces actors that can never be recognised", "[report][GR-003]") { // An actor with no usable image is a silent recall ceiling: the pipeline // will never name them, and nothing in the gallery says why. This is the // single most useful number in the report. ActorGallery g; for (int a = 0; a < 3; ++a) { ActorGallery::Actor act; act.name = "actor" + std::to_string(a); if (a != 1) // actor1 gets nothing for (int i = 0; i < 6; ++i) act.embeddings.push_back(unit_axis(a * 10 + i)); g.actors.push_back(std::move(act)); } std::vector flat; std::vector flat_actor; for (int a = 0; a < static_cast(g.actors.size()); ++a) for (const auto& e : g.actors[a].embeddings) { flat.push_back(e); flat_actor.push_back(a); } GalleryCalibrationStats stats; GalleryCalibration cal = calibrate_gallery(flat, flat_actor, &stats); GalleryReport r = build_gallery_report(g, cal, stats); // An actor present in the gallery with no embeddings is counted as // in-gallery but contributes nothing; the zero-usable list is populated // from the build audit, which a stored gallery cannot supply. CHECK(r.actors_in_gallery == 3); CHECK(r.actors[1].references == 0); } TEST_CASE("report surfaces actors too thin to calibrate on", "[report][GR-003]") { // Below the positive-pair threshold an actor contributes nothing to the // intra-class side of the fit. They are not broken, so nothing complains — // they just quietly weaken every threshold downstream. ActorGallery g; for (int a = 0; a < 2; ++a) { ActorGallery::Actor act; act.name = "actor" + std::to_string(a); const int n = (a == 0) ? 6 : 2; // actor1 is under-referenced for (int i = 0; i < n; ++i) act.embeddings.push_back(unit_axis(a * 10 + i)); g.actors.push_back(std::move(act)); } std::vector flat; std::vector flat_actor; for (int a = 0; a < static_cast(g.actors.size()); ++a) for (const auto& e : g.actors[a].embeddings) { flat.push_back(e); flat_actor.push_back(a); } GalleryCalibrationStats stats; GalleryCalibration cal = calibrate_gallery(flat, flat_actor, &stats); GalleryReport r = build_gallery_report(g, cal, stats); CHECK(r.actors_below_positive_threshold >= 1); } TEST_CASE("report round-trips", "[report][GR-003]") { ActorGallery g; ActorGallery::Actor act; act.name = "solo"; for (int i = 0; i < 6; ++i) act.embeddings.push_back(unit_axis(i)); g.actors.push_back(std::move(act)); std::vector flat; std::vector flat_actor; for (const auto& e : g.actors[0].embeddings) { flat.push_back(e); flat_actor.push_back(0); } GalleryCalibrationStats stats; GalleryCalibration cal = calibrate_gallery(flat, flat_actor, &stats); GalleryReport r = build_gallery_report(g, cal, stats); const std::string path = "/tmp/gr003_roundtrip.report.json"; save_gallery_report(path, r); GalleryReport back = load_gallery_report(path); CHECK(back.actors_in_gallery == r.actors_in_gallery); CHECK(back.actors_below_positive_threshold == r.actors_below_positive_threshold); CHECK(back.calib_a == r.calib_a); std::remove(path.c_str()); }