Files
scene-actor-extraction/tests/test_calibration.cpp
T
dtourolleandClaude Opus 5 ffdad9873d test: tag the untagged suites; correct two stale headers
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
2026-07-31 22:47:59 +02:00

273 lines
10 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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());
}