test: add Catch2 unit test suite (gallery, calibration, tracking, similarity)

GPU-free, model-free tests for the pure logic: gallery HDF5 save/load
round-trips (actors, embeddings, embedded calibration) and legacy JSON
read back-compat; the calibration sigmoid fit, boundary inversion, and the
in-memory hash-keyed cache reuse/staleness; TrackGallery's diversity-buffer
eviction, novelty/spread safety gates, and promotion; FaceTracker's IoU/
embedding association and cross-cut track revival; and the GEMM similarity
backend (forced to CPU so the suite runs without a GPU).

Verified: all 39 test cases / 1640 assertions pass (cmake -DSAE_BUILD_TESTS=ON).
This commit is contained in:
2026-07-19 19:10:57 +02:00
parent 6f0ad83a55
commit 76df2f66aa
7 changed files with 816 additions and 0 deletions
+170
View File
@@ -0,0 +1,170 @@
// 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);
}