AR-018 — an embedding joins a track's store only if its similarity to something already there falls inside a band, rather than merely being far from the gallery. Above the upper bound it is redundant: another look at a pose the store already covers, teaching the annex nothing while costing a slot a novel view could have used. Below the lower bound it is suspect: within one track every face is the same person by construction, so an embedding unlike everything else on the track is evidence that construction failed — a track-ID collision or a bad detection. Admitting it is exactly how an actor's annex gets poisoned with someone else's face. The old gate had only the upper half of that idea, expressed as a raw cosine against the gallery. Both bounds are now calibrated probabilities (AR-024), so the same number means the same thing here as in association and evidence weighting rather than three different things. This catches track-ID collisions EARLIER than the spread gate did — at the door rather than at promotion — so the buffer never becomes two-person in the first place. The spread gate stays as a second line for a track that drifts gradually instead of jumping. The existing test was asserting the mechanism rather than the outcome, so it was rewritten to assert what actually matters: whichever gate fires, the outsider must not reach the annex. Rejections are counted. A store that admits nothing is as broken as one that admits everything, and neither is visible otherwise. Band defaults 0.90-0.95 are working values pending VR-007; the two bounds fail in opposite directions and must be swept separately. Suite: 92 cases, 6133 assertions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: AR-018, AR-024 | SR-005
151 lines
6.1 KiB
C++
151 lines
6.1 KiB
C++
// Unit tests for TrackGallery (gallery/track_gallery.hpp): per-film gallery
|
|
// expansion driven by track continuity. Pure, GPU-free, model-free — exercises
|
|
// the diversity-buffer eviction policy, the novelty/spread safety gates,
|
|
// plurality ownership, and idempotent promotion via the public interface.
|
|
#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 <array>
|
|
#include <cmath>
|
|
|
|
namespace {
|
|
|
|
// Unit-norm embedding pointing along one axis (cosine sim to another one-hot is
|
|
// 0, to itself 1) — lets tests dial gallery similarity precisely.
|
|
Embedding one_hot(int slot) {
|
|
Embedding e{};
|
|
e[slot] = 1.0f;
|
|
return e;
|
|
}
|
|
|
|
// Unit-norm embedding in the plane of axes i,j at angle t from i. Cosine sim to
|
|
// one_hot(i) is cos(t) — used to place a view at a chosen gallery similarity.
|
|
Embedding at_sim(int i, int j, float cos_t) {
|
|
Embedding e{};
|
|
float s = std::sqrt(std::max(0.f, 1.f - cos_t * cos_t));
|
|
e[i] = cos_t;
|
|
e[j] = s;
|
|
return e;
|
|
}
|
|
|
|
Config expand_cfg() {
|
|
Config cfg;
|
|
cfg.expand_gallery = true;
|
|
cfg.expand_buffer_size = 3;
|
|
cfg.expand_novelty_sim = 0.55f;
|
|
cfg.expand_track_spread_max = 0.60f;
|
|
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().empty());
|
|
}
|
|
|
|
TEST_CASE("confirmed track promotes gallery-far views", "[track_gallery]") {
|
|
TrackGallery tg(expand_cfg());
|
|
REQUIRE(tg.enabled());
|
|
|
|
// A track owned by actor 0. Every frame is accepted as actor 0, but each
|
|
// view is gallery-far (sim 0.30 < novelty 0.55) yet mutually self-similar
|
|
// enough to pass the spread gate.
|
|
for (int f = 0; f < 3; ++f)
|
|
tg.observe(7, at_sim(0, 1, 0.30f + 0.001f * f), 0, 0.30f + 0.001f * f, true, kNoCrop);
|
|
|
|
// 3 accepted frames == min_anchor_frames → confirmed and promoted.
|
|
CHECK_FALSE(tg.annex().empty());
|
|
for (const auto& ae : tg.annex()) CHECK(ae.actor_idx == 0);
|
|
}
|
|
|
|
TEST_CASE("novelty gate skips views the gallery already covers", "[track_gallery]") {
|
|
TrackGallery tg(expand_cfg());
|
|
// All views are recognised well (sim 0.90 ≥ novelty 0.55): nothing worth
|
|
// promoting even though the track is confirmed.
|
|
for (int f = 0; f < 3; ++f)
|
|
tg.observe(2, one_hot(0), 0, 0.90f, true, kNoCrop);
|
|
CHECK(tg.annex().empty());
|
|
}
|
|
|
|
TEST_CASE("a two-person track never poisons the annex", "[track_gallery][AR-018]") {
|
|
TrackGallery tg(expand_cfg());
|
|
// Two orthogonal identities under one track ID — a track-ID collision.
|
|
//
|
|
// The banded admission (AR-018) now catches this EARLIER than the spread
|
|
// gate did: an embedding unlike everything already on the track falls below
|
|
// the band's lower bound and is refused entry, so the buffer never becomes
|
|
// two-person in the first place. The spread gate remains as a second line
|
|
// for a track that drifts gradually rather than jumping.
|
|
//
|
|
// The assertion is on the outcome, not the mechanism: whichever gate fires,
|
|
// the outsider must not reach the actor's annex.
|
|
tg.observe(3, at_sim(0, 1, 0.30f), 0, 0.30f, true, kNoCrop);
|
|
tg.observe(3, at_sim(0, 1, 0.30f), 0, 0.30f, true, kNoCrop);
|
|
tg.observe(3, one_hot(400), 0, 0.30f, true, kNoCrop); // orthogonal outlier
|
|
|
|
CHECK(tg.band_rejected() > 0); // refused at the door
|
|
for (const auto& e : tg.annex())
|
|
CHECK(cosine_similarity(e.emb, one_hot(400)) < 0.5f);
|
|
}
|
|
|
|
TEST_CASE("unconfirmed track (too few accepts) does not promote", "[track_gallery]") {
|
|
TrackGallery tg(expand_cfg());
|
|
// Only 2 accepted frames < min_anchor_frames 3; extra non-accepted frames
|
|
// fill the buffer but don't count toward ownership.
|
|
tg.observe(4, at_sim(0, 1, 0.30f), 0, 0.30f, true, kNoCrop);
|
|
tg.observe(4, at_sim(0, 1, 0.31f), 0, 0.31f, true, kNoCrop);
|
|
tg.observe(4, at_sim(0, 1, 0.32f), 0, 0.32f, false, kNoCrop);
|
|
CHECK(tg.annex().empty());
|
|
}
|
|
|
|
TEST_CASE("plurality actor wins a mixed-vote track", "[track_gallery]") {
|
|
Config cfg = expand_cfg();
|
|
cfg.expand_min_anchor_frames = 3;
|
|
TrackGallery tg(cfg);
|
|
// Actor 5 accepted twice, actor 6 once → plurality is 5. All views novel.
|
|
tg.observe(8, at_sim(0, 1, 0.30f), 5, 0.30f, true, kNoCrop);
|
|
tg.observe(8, at_sim(0, 1, 0.31f), 5, 0.31f, true, kNoCrop);
|
|
tg.observe(8, at_sim(0, 1, 0.32f), 6, 0.32f, true, kNoCrop);
|
|
REQUIRE_FALSE(tg.annex().empty());
|
|
for (const auto& ae : tg.annex()) CHECK(ae.actor_idx == 5);
|
|
}
|
|
|
|
TEST_CASE("promotion is idempotent across a long track", "[track_gallery]") {
|
|
TrackGallery tg(expand_cfg());
|
|
for (int f = 0; f < 3; ++f)
|
|
tg.observe(9, at_sim(0, 1, 0.30f + 0.001f * f), 0, 0.30f + 0.001f * f, true, kNoCrop);
|
|
size_t 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, at_sim(0, 1, 0.30f), 0, 0.30f, true, kNoCrop);
|
|
CHECK(tg.annex().size() == after_confirm);
|
|
}
|
|
|
|
TEST_CASE("clear_tracks drops buffers before confirmation", "[track_gallery]") {
|
|
TrackGallery tg(expand_cfg());
|
|
// 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, at_sim(0, 1, 0.30f), 0, 0.30f, true, kNoCrop);
|
|
tg.observe(1, at_sim(0, 1, 0.31f), 0, 0.31f, true, kNoCrop);
|
|
tg.clear_tracks();
|
|
tg.observe(1, at_sim(0, 1, 0.32f), 0, 0.32f, true, kNoCrop);
|
|
CHECK(tg.annex().empty());
|
|
}
|