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).
171 lines
6.3 KiB
C++
171 lines
6.3 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);
|
||
}
|