GR-003 — the calibration fit already computed per-actor dedup counts, how many actors are eligible for positive pairs, and a 200-bin histogram of the intra and inter distributions, then discarded all of it to stderr. Nothing persisted, so nobody could audit whether a gallery was any good. The report is written alongside the gallery at build time. That is the right moment: the matcher fits the same sigmoid at analysis time, but by then the answer is per-run and nobody is looking, whereas build time is when a gallery's quality is actually decided. What it surfaces, in order of usefulness: - actors with no usable image — a silent recall ceiling, since the pipeline can never name them and nothing else says why - actors below the positive-pair threshold — not broken, so nothing complains; they just quietly weaken every threshold downstream - near-duplicate references removed, per actor and total - the fitted calibration AND the two distributions behind it That last one is the point. Every threshold in the pipeline is expressed in the probability space this sigmoid defines, so if the distributions overlap heavily the calibration is weak and every downstream decision inherits it — while the gallery still looks fine from the outside. The gallery-derived prior, intra/(intra+inter), is computed and reported but the shipped default of 0.5 is deliberately left alone. The spec records these as disagreeing; now the real value is visible, so the decision can be made on evidence rather than argument. Three tests: a zero-image actor is visible in the report, an under-referenced actor is counted, and the report round-trips through JSON. Suite: 95 cases, 6142 assertions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: GR-003 | SR-001
264 lines
10 KiB
C++
264 lines
10 KiB
C++
// 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 <catch2/catch_test_macros.hpp>
|
||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||
|
||
#include "gallery/gallery_calibration.hpp"
|
||
#include "face_utils.hpp" // l2_normalise
|
||
#include "types.hpp"
|
||
|
||
#include <array>
|
||
#include <random>
|
||
#include <string>
|
||
#include <vector>
|
||
|
||
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<Embedding>& emb,
|
||
std::vector<int>& actor) {
|
||
std::mt19937 rng(1234);
|
||
std::normal_distribution<float> centre(0.f, 1.f);
|
||
std::normal_distribution<float> jitter(0.f, 0.01f);
|
||
for (int a = 0; a < n_actors; ++a) {
|
||
std::array<float, 512> c{};
|
||
for (float& v : c) v = centre(rng);
|
||
for (int k = 0; k < per_actor; ++k) {
|
||
std::array<float, 512> 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<Embedding> emb;
|
||
std::vector<int> 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<Embedding> emb(1);
|
||
emb[0] = l2_normalise(std::array<float, 512>{1.f}.data());
|
||
std::vector<int> actor{0};
|
||
GalleryCalibration cal = calibrate_gallery(emb, actor);
|
||
CHECK_FALSE(cal.valid);
|
||
}
|
||
|
||
TEST_CASE("hash_gallery_embeddings is sensitive to changes", "[calibration]") {
|
||
std::vector<Embedding> emb;
|
||
std::vector<int> 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<Embedding> emb;
|
||
std::vector<int> 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<Embedding> emb;
|
||
std::vector<int> 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<Embedding> emb;
|
||
std::vector<int> 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<Embedding> flat;
|
||
std::vector<int> flat_actor;
|
||
for (int a = 0; a < static_cast<int>(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<Embedding> flat;
|
||
std::vector<int> flat_actor;
|
||
for (int a = 0; a < static_cast<int>(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<Embedding> flat;
|
||
std::vector<int> 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());
|
||
}
|