Four test files and one node header carried no TRACES tag, so the requirements they verify read as implemented-but-unverified. Tagging a test is what distinguishes the two. test_calibration.cpp is AR-023; its three [report] cases verify GR-003 and are tagged separately, since the report is fitted from the same distributions but is its own requirement. test_similarity.cpp is the CI half of AR-026 — equivalence against hand-computed dot products, where throughput at scale is AR-027 and cannot run on this host. test_face_tracker.cpp is AR-007 and AR-008. Two headers described code that no longer exists. face_aligner_node.hpp still documented the RANSAC fit AR-005 replaced with an Umeyama least-squares fit over all five points — not merely out of date but the opposite of what the file does, and it reads as a rationale for discarding the landmarks AR-030 measures. test_face_tracker.cpp still described the park/revive branch AR-008 deleted, and the raw-cosine cut_revive_sim that guarded it, which AR-024 retired. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: AR-005, AR-007, AR-008, AR-023, AR-026, AR-030 | GR-003 | SR-001, SR-002
273 lines
10 KiB
C++
273 lines
10 KiB
C++
// TRACES: AR-023 | SR-002
|
||
//
|
||
// 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.
|
||
//
|
||
// The three `[report]` cases at the bottom carry their own GR-003 tags: they
|
||
// verify the build report, which is fitted from the same distributions but is a
|
||
// separate requirement.
|
||
#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
|
||
|
||
// TRACES: GR-003 | SR-001
|
||
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);
|
||
}
|
||
|
||
// TRACES: GR-003 | SR-001
|
||
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);
|
||
}
|
||
|
||
// TRACES: GR-003 | SR-001
|
||
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());
|
||
}
|