Files
scene-actor-extraction/tests/test_track_gallery.cpp
dtourolle 88c42573a5 fix(pipeline): finish three changes that had only been half applied
Each of these was recorded as done and was done in one place out of two.

AR-011 -- the TransNetV2 dedup window. The derived window
(dedup_window_sec, median observed interval halved) reached scenes.json
and nothing else. SceneBoundaries, the path that actually feeds
is_scene_boundary to the tracker, kept the literal 0.04 s under a
comment claiming it "matches the dedup scenes.json applies, so the two
views agree". They did not agree. 0.04 is one frame at 25 fps and wider
than a frame at 30, so two cuts on consecutive frames merged into one
and the loss was invisible: the pipeline simply saw fewer boundaries.
The detector now supplies the window it derived.

AR-019 -- ownership. The register says ownership "comes from the
registry, not a second local tally". Both existed: promotion fired on a
local accepted-frame count and fell back to a local per-actor plurality
when the registry had not yet claimed the track. That fallback was
reachable in the live pipeline, not just in tests -- three accepted
frames arrive well before a posterior crosses the ownership threshold --
so in practice the plurality usually decided, and it could not see the
AR-025 correlation discounting it was meant to defer to. The tally is
gone; promotion now requires the registry's verdict, with the accepted
-frame count demoted to an explicit evidence floor.

AR-017 -- the route. DeadTrack carried belief but no route, and the sink
wrote the literal string "live", so a field the schema publishes could
not distinguish anything. AR-017's own verification asks for "deferred
and pooled routes distinguishable". Route is now an enum on the claim.
Only `live` occurs today; `deferred` exists so AR-020's pass has
somewhere to write instead of a serialisation change to make.

Also: TrackGallery::forget had no callers, under a comment asserting the
matcher called it "on a cut or track disappearance". The cut half was
true by another route; the disappearance half was not, so a track that
died quietly kept its diversity buffer until the next cut cleared
everything. Replaced with prune_dead against the registry's own
liveness, the same shape as the tracker's prune_boxes -- a second
opinion about which tracks exist is a second thing that can be wrong.

Removes dead logistic/logit helpers and fixes five TRACES tags that used
a comma where a pipe separates requirement types, which the gate had
been reporting as diagnostics.

TRACES: AR-011, AR-017, AR-019 | IR-002 | SR-002, SR-005
2026-08-05 17:33:12 +02:00

425 lines
18 KiB
C++

// TRACES: UT-005 | AR-018, AR-019, AR-024, AR-026 | SR-005, SR-001
//
// Unit tests for TrackGallery (gallery/track_gallery.hpp): per-film gallery
// expansion driven by track continuity. Pure, GPU-free, model-free — exercises
// the AR-018 banded admission at both bounds, the promotion-time coherence
// gate, the diversity-buffer eviction policy, plurality ownership, and
// idempotent promotion, all through the public interface.
//
// The band is defined in PROBABILITY space (AR-024), so every case below states
// its own cosine → probability map. It has to: set_calibration is now mandatory
// and there is no header default to inherit.
//
// There used to be one — `max(0, cosine)` — and it was the reason this comment
// was originally needed. Under it the two spaces coincided, so a test that
// forgot to name the mapping still passed, and a gate that silently reverted to
// raw cosine passed with it. The default is gone rather than merely discouraged,
// which is why identity_cal below is now an explicit choice a case makes and not
// a restatement of what would have happened anyway.
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "config.hpp"
#include "gallery/track_gallery.hpp"
#include "types.hpp"
#include <algorithm>
#include <stdexcept>
#include <array>
#include <cmath>
#include <vector>
namespace {
constexpr float kPi = 3.14159265358979323846f;
// The band as the tests drive it. Kept in one place so a change to the shipped
// defaults does not silently invalidate the arithmetic in each case.
constexpr float kBandLo = 0.90f;
constexpr float kBandHi = 0.95f;
// Identity map: probability == cosine, so a case can place an embedding at an
// exact probability. cosine_similarity is a bare dot product over unit vectors
// (types.hpp), so the placements below are bit-exact, not approximate.
float identity_cal(float c) { return c; }
// Unit-norm embedding pointing along one axis. Cosine sim to another one-hot is
// 0, to itself 1.
Embedding one_hot(int slot) {
Embedding e{};
e[slot] = 1.0f;
return e;
}
// TRACES: AR-026 | SR-001
// The annex is a contiguous row-major matrix, not a vector of structs, so that
// the matcher can hand whole blocks of new rows to the GEMM path. Tests that
// want to compare one promoted view read it back through this.
Embedding annex_view(const TrackGallery& tg, int row) {
const float* p = tg.annex_row(row);
Embedding e{};
std::copy(p, p + 512, e.begin());
return e;
}
// Unit-norm embedding in the plane of axes i,j at cosine `cos_t` from axis i.
// Cosine sim to one_hot(i) is exactly cos_t.
Embedding at_sim(int i, int j, float cos_t) {
Embedding e{};
e[i] = cos_t;
e[j] = std::sqrt(std::max(0.f, 1.f - cos_t * cos_t));
return e;
}
// A spoke: shares axis 0 with every other spoke, and is otherwise unique. Any
// two DISTINCT spokes have cosine similarity exactly cos_t², so one constant
// places a whole mutually-in-band store. A spoke against itself is 1.0 — above
// the band's ceiling, i.e. redundant, which is the intended reading.
Embedding spoke(int k, float cos_t) { return at_sim(0, k, cos_t); }
// cos_t chosen so pairwise similarity between distinct spokes is 0.9197 —
// comfortably inside [0.90, 0.95], clear of both bounds.
constexpr float kSpokeCos = 0.959f;
// Two embeddings `deg` apart in the plane of axes 0,1. Cosine is cos(deg), so a
// chain of these can step through the band while its endpoints fall outside it.
Embedding on_circle(float deg) {
Embedding e{};
e[0] = std::cos(deg * kPi / 180.f);
e[1] = std::sin(deg * kPi / 180.f);
return e;
}
Config expand_cfg() {
Config cfg;
cfg.expand_gallery = true;
cfg.expand_buffer_size = 3;
cfg.expand_band_lo = kBandLo;
cfg.expand_band_hi = kBandHi;
cfg.expand_min_anchor_frames = 3;
return cfg;
}
const cv::Mat kNoCrop; // debug dumping off → crop unused
} // namespace
TEST_CASE("disabled: no annex growth when expand_gallery is off", "[track_gallery]") {
// expand_gallery defaults to true (see config.hpp) as of the rep4 bake-off —
// set it explicitly false here since this test exercises the disabled path,
// not whatever the struct's current default happens to be.
Config cfg;
cfg.expand_gallery = false;
TrackGallery tg(cfg);
REQUIRE_FALSE(tg.enabled());
for (int f = 0; f < 10; ++f)
tg.observe(1, one_hot(1), /*actor*/ 0, /*sim*/ 0.2f, /*accept*/ true, kNoCrop);
CHECK(tg.annex_size() == 0);
}
// ── AR-018: the band ─────────────────────────────────────────────────────────
TEST_CASE("band bounds come from config, not a hardcoded default", "[track_gallery][AR-018]") {
// The bounds were declared in Config and read nowhere, so the gate ran at
// whatever the header happened to initialise. Drive them somewhere the
// defaults are not and require the gate to follow.
Config cfg = expand_cfg();
cfg.expand_band_lo = 0.40f;
cfg.expand_band_hi = 0.60f;
TrackGallery tg(cfg);
tg.set_calibration(identity_cal);
tg.observe(1, one_hot(0), 0, 0.30f, true, kNoCrop);
// P = 0.50: inside the configured band, far below the shipped default lo.
tg.observe(1, at_sim(0, 1, 0.50f), 0, 0.30f, true, kNoCrop);
CHECK(tg.band_rejected() == 0);
// P = 0.92: inside the shipped default band, above the configured ceiling.
tg.observe(1, at_sim(0, 2, 0.92f), 0, 0.30f, true, kNoCrop);
CHECK(tg.band_rejected() == 1);
}
TEST_CASE("band admits at each bound exactly", "[track_gallery][AR-018]") {
// The verification plan asks for the bounds themselves, not a point safely
// inside them: an off-by-one in the comparison is invisible anywhere else.
// Both bounds are inclusive.
SECTION("lower bound exactly") {
TrackGallery tg(expand_cfg());
tg.set_calibration(identity_cal);
tg.observe(1, one_hot(0), 0, 0.30f, true, kNoCrop);
tg.observe(1, at_sim(0, 1, kBandLo), 0, 0.30f, true, kNoCrop);
CHECK(tg.band_rejected() == 0);
}
SECTION("upper bound exactly") {
TrackGallery tg(expand_cfg());
tg.set_calibration(identity_cal);
tg.observe(1, one_hot(0), 0, 0.30f, true, kNoCrop);
tg.observe(1, at_sim(0, 1, kBandHi), 0, 0.30f, true, kNoCrop);
CHECK(tg.band_rejected() == 0);
}
}
TEST_CASE("store never admits below the lower bound", "[track_gallery][AR-018]") {
// The lower bound is the poisoning guard: an embedding unlike everything
// already on the track is evidence the track is not one person.
TrackGallery tg(expand_cfg());
tg.set_calibration(identity_cal);
tg.observe(1, one_hot(0), 0, 0.30f, true, kNoCrop);
tg.observe(1, at_sim(0, 1, kBandLo - 0.01f), 0, 0.30f, true, kNoCrop);
CHECK(tg.band_rejected() == 1);
tg.observe(1, one_hot(400), 0, 0.30f, true, kNoCrop); // orthogonal: P = 0
CHECK(tg.band_rejected() == 2);
}
TEST_CASE("store never admits above the upper bound", "[track_gallery][AR-018]") {
// The upper bound is the redundancy guard: another look at a pose the store
// already covers teaches the annex nothing and costs a slot.
TrackGallery tg(expand_cfg());
tg.set_calibration(identity_cal);
tg.observe(1, one_hot(0), 0, 0.30f, true, kNoCrop);
tg.observe(1, at_sim(0, 1, kBandHi + 0.01f), 0, 0.30f, true, kNoCrop);
CHECK(tg.band_rejected() == 1);
tg.observe(1, one_hot(0), 0, 0.30f, true, kNoCrop); // identical: P = 1
CHECK(tg.band_rejected() == 2);
}
TEST_CASE("band thresholds probability, not cosine", "[track_gallery][AR-018][AR-024]") {
// The invariant's actual claim, and the one a raw-cosine gate passes by
// accident under an identity calibration. With a calibration that shifts by
// +0.10, two embeddings get the OPPOSITE verdict from the one their bare
// cosines would earn — so admission here can only come from the calibrated
// value having been used.
TrackGallery tg(expand_cfg());
tg.set_calibration([](float c) { return c + 0.10f; });
tg.observe(1, one_hot(0), 0, 0.30f, true, kNoCrop);
// cosine 0.84 (below lo, would be refused raw) → P = 0.94, inside the band.
tg.observe(1, at_sim(0, 1, 0.84f), 0, 0.30f, true, kNoCrop);
CHECK(tg.band_rejected() == 0);
// cosine 0.92 (inside the band, would be admitted raw) → P = 1.02, above it.
tg.observe(1, at_sim(0, 2, 0.92f), 0, 0.30f, true, kNoCrop);
CHECK(tg.band_rejected() == 1);
}
TEST_CASE("a two-person track never poisons the annex", "[track_gallery][AR-018]") {
TrackGallery tg(expand_cfg());
tg.set_calibration(identity_cal);
// AR-019: promotion needs the registry's verdict; the local
// accepted-frame plurality that used to supply it is gone.
tg.set_owner(3, 0);
// Two orthogonal identities under one track ID — a track-ID collision.
// The band refuses the outsider at the door, so the store never becomes
// two-person in the first place.
tg.observe(3, spoke(1, kSpokeCos), 0, 0.30f, true, kNoCrop);
tg.observe(3, spoke(2, kSpokeCos), 0, 0.30f, true, kNoCrop);
tg.observe(3, one_hot(400), 0, 0.30f, true, kNoCrop); // orthogonal outlier
CHECK(tg.band_rejected() == 1); // refused at the door
REQUIRE(tg.annex_size() > 0); // the legitimate views still promote
for (int i = 0; i < tg.annex_size(); ++i)
CHECK(cosine_similarity(annex_view(tg, i), one_hot(400)) < 0.5f);
}
TEST_CASE("a track that drifts through the band is refused at promotion",
"[track_gallery][AR-018]") {
// `admit` compares a newcomer against its CLOSEST existing member, so a
// gradual drift chains past it: each step is in-band while the endpoints are
// strangers. This is the shape a collision takes over a slow pan, and the
// reason the lower bound is re-asked across every pair before promotion.
TrackGallery tg(expand_cfg());
tg.set_calibration(identity_cal);
tg.observe(5, on_circle(0.f), 0, 0.30f, true, kNoCrop);
tg.observe(5, on_circle(25.f), 0, 0.30f, true, kNoCrop); // P=0.906 vs 0° → in band
tg.observe(5, on_circle(50.f), 0, 0.30f, true, kNoCrop); // P=0.906 vs 25° → in band
CHECK(tg.band_rejected() == 0); // every step passed the door...
// ...but 0° and 50° are P=0.643 apart, below the floor: the whole track goes.
CHECK(tg.annex_size() == 0);
}
// ── AR-019: ownership and promotion ──────────────────────────────────────────
TEST_CASE("confirmed track promotes its store", "[track_gallery][AR-019]") {
TrackGallery tg(expand_cfg());
tg.set_calibration(identity_cal);
// AR-019: promotion needs the registry's verdict; the local
// accepted-frame plurality that used to supply it is gone.
tg.set_owner(7, 0);
REQUIRE(tg.enabled());
// A track owned by actor 0: every frame accepted, every view mutually
// in-band (P = 0.9197 between distinct spokes) and gallery-far (0.30).
for (int k = 1; k <= 3; ++k)
tg.observe(7, spoke(k, kSpokeCos), 0, 0.30f, true, kNoCrop);
// 3 accepted frames == min_anchor_frames → confirmed and promoted.
CHECK(tg.annex_size() == 3);
for (int actor : tg.annex_actors()) CHECK(actor == 0);
}
TEST_CASE("registry ownership overrides the local tally", "[track_gallery][AR-019]") {
TrackGallery tg(expand_cfg());
tg.set_calibration(identity_cal);
// Local accepted-frame plurality says actor 5; the registry's accumulated
// posterior says actor 9. The registry is authoritative.
tg.set_owner(11, 9);
for (int k = 1; k <= 3; ++k)
tg.observe(11, spoke(k, kSpokeCos), 5, 0.30f, true, kNoCrop);
REQUIRE(tg.annex_size() > 0);
for (int actor : tg.annex_actors()) CHECK(actor == 9);
}
TEST_CASE("unconfirmed track (too few accepts) does not promote", "[track_gallery][AR-019]") {
TrackGallery tg(expand_cfg());
tg.set_calibration(identity_cal);
// Only 2 accepted frames < min_anchor_frames 3; the third fills the buffer
// but doesn't count toward ownership.
tg.observe(4, spoke(1, kSpokeCos), 0, 0.30f, true, kNoCrop);
tg.observe(4, spoke(2, kSpokeCos), 0, 0.30f, true, kNoCrop);
tg.observe(4, spoke(3, kSpokeCos), 0, 0.30f, false, kNoCrop);
CHECK(tg.annex_size() == 0);
}
TEST_CASE("an unowned track never promotes, however many frames it accepts",
"[track_gallery][AR-019]") {
TrackGallery tg(expand_cfg());
tg.set_calibration(identity_cal);
// This case used to assert the opposite: it drove a mixed-vote track with
// no registry owner and expected the local plurality winner (actor 5) to
// take the promotion. That fallback is gone. AR-019 says ownership comes
// from the registry and not from a second local tally, and the tally could
// not see the AR-025 discounting -- so it weighted thirty near-identical
// looks like thirty distinct ones.
//
// Ten accepted frames, no set_owner, nothing promoted.
for (int k = 1; k <= 10; ++k)
tg.observe(8, spoke(k, kSpokeCos), 5, 0.30f, true, kNoCrop);
CHECK(tg.annex_size() == 0);
}
TEST_CASE("promotion is idempotent across a long track", "[track_gallery][AR-019]") {
TrackGallery tg(expand_cfg());
tg.set_calibration(identity_cal);
// AR-019: promotion needs the registry's verdict; the local
// accepted-frame plurality that used to supply it is gone.
tg.set_owner(9, 0);
for (int k = 1; k <= 3; ++k)
tg.observe(9, spoke(k, kSpokeCos), 0, 0.30f, true, kNoCrop);
const int after_confirm = tg.annex_size();
REQUIRE(after_confirm > 0);
// Keep feeding the confirmed track: annex must not grow again.
for (int f = 0; f < 10; ++f)
tg.observe(9, spoke(4 + f, kSpokeCos), 0, 0.30f, true, kNoCrop);
CHECK(tg.annex_size() == after_confirm);
}
TEST_CASE("clear_tracks drops buffers before confirmation", "[track_gallery][AR-019]") {
TrackGallery tg(expand_cfg());
tg.set_calibration(identity_cal);
// Two accepts, then a cut clears buffers; the third accept starts fresh and
// can't reach the anchor threshold on its own.
tg.observe(1, spoke(1, kSpokeCos), 0, 0.30f, true, kNoCrop);
tg.observe(1, spoke(2, kSpokeCos), 0, 0.30f, true, kNoCrop);
tg.clear_tracks();
tg.observe(1, spoke(3, kSpokeCos), 0, 0.30f, true, kNoCrop);
CHECK(tg.annex_size() == 0);
}
TEST_CASE("eviction keeps the gallery-far views", "[track_gallery][AR-018]") {
// Novelty is no longer a threshold — it is this ordering. With the buffer
// full, a more gallery-far newcomer must displace the best-recognised
// member, and a less novel one must be dropped rather than displace a
// better sample.
Config cfg = expand_cfg();
cfg.expand_buffer_size = 2;
cfg.expand_min_anchor_frames = 4;
TrackGallery tg(cfg);
tg.set_calibration(identity_cal);
// AR-019: promotion needs the registry's verdict; the local
// accepted-frame plurality that used to supply it is gone.
tg.set_owner(6, 0);
tg.observe(6, spoke(1, kSpokeCos), 0, 0.80f, true, kNoCrop); // well recognised
tg.observe(6, spoke(2, kSpokeCos), 0, 0.40f, true, kNoCrop);
tg.observe(6, spoke(3, kSpokeCos), 0, 0.20f, true, kNoCrop); // novel: evicts the 0.80
tg.observe(6, spoke(4, kSpokeCos), 0, 0.90f, true, kNoCrop); // least novel: dropped
REQUIRE(tg.annex_size() == 2);
// Survivors are the two most gallery-far views: spokes 2 and 3.
for (int i = 0; i < tg.annex_size(); ++i) {
const Embedding view = annex_view(tg, i);
const bool is_2 = cosine_similarity(view, spoke(2, kSpokeCos)) > 0.99f;
const bool is_3 = cosine_similarity(view, spoke(3, kSpokeCos)) > 0.99f;
CHECK((is_2 || is_3));
}
}
// TRACES: UT-005 | AR-026 | SR-001
// The annex reaches the GEMM path by being drained, not re-read: the matcher
// pushes newly promoted rows into the similarity engine once per frame. Draining
// must therefore be exactly-once — a row handed over twice becomes a duplicate
// gallery entry that quietly doubles an actor's best-of-N chances, and a row
// never handed over is a promotion that silently does nothing.
TEST_CASE("promotions drain exactly once, in matrix order",
"[track_gallery][AR-026]") {
TrackGallery tg(expand_cfg());
tg.set_calibration(identity_cal);
// AR-019: promotion needs the registry's verdict; the local
// accepted-frame plurality that used to supply it is gone.
tg.set_owner(7, 0);
tg.set_owner(8, 4);
std::vector<float> emb;
std::vector<int> actor;
CHECK(tg.drain_promotions(emb, actor) == 0); // nothing promoted yet
for (int k = 1; k <= 3; ++k)
tg.observe(7, spoke(k, kSpokeCos), 0, 0.30f, true, kNoCrop);
REQUIRE(tg.annex_size() == 3);
const int drained = tg.drain_promotions(emb, actor);
CHECK(drained == 3);
CHECK(actor.size() == 3);
CHECK(emb.size() == 3 * 512);
for (int a : actor) CHECK(a == 0);
// Drained rows are the annex rows, in the same order — the engine's row i
// and flat_actor_[i] have to keep naming the same view.
for (int i = 0; i < drained; ++i)
for (int d = 0; d < 512; ++d)
CHECK(emb[static_cast<size_t>(i) * 512 + d] == tg.annex_row(i)[d]);
// Draining again yields nothing: the engine already holds these.
CHECK(tg.drain_promotions(emb, actor) == 0);
CHECK(actor.size() == 3);
// A second track promotes, and only its rows are handed over.
for (int k = 1; k <= 3; ++k)
tg.observe(8, spoke(k, kSpokeCos), 4, 0.30f, true, kNoCrop);
CHECK(tg.drain_promotions(emb, actor) == 3);
CHECK(actor.size() == 6);
CHECK(actor[5] == 4);
}
// ── AR-024: the calibration is not optional ─────────────────────────────────
/// TRACES: UT-005 | AR-024 | SR-005
TEST_CASE("a null calibration is refused, not silently replaced", "[track_gallery][AR-024]") {
// The class used to default calibrate_ to max(0, cosine). That made
// expand_band_lo = 0.90 mean "cosine above 0.9" here and "P(same person)
// above 0.9" in production — two very different gates, with nothing
// announcing which one was in force. FaceTrackerFunc already refused to
// construct without a calibration for exactly this reason; the expansion
// store now matches it.
TrackGallery tg(expand_cfg());
CHECK_THROWS_AS(tg.set_calibration(nullptr), std::invalid_argument);
}