AR-018 was marked Done while the promotion path still ran on the
constants it was meant to replace. track_gallery.hpp rejected a track
when buffer_spread (1 minus the minimum pairwise cosine) exceeded
expand_track_spread_max, and skipped a view when its raw gal_sim cleared
expand_novelty_sim. Both were bare cosines with no recorded EXCEPTION,
so both were defects under the AR-024 invariant rather than tagging gaps.
The calibrated band was real but unreachable. expand_band_lo/hi were
declared in Config and read nowhere, and set_band() had no callers, so
the gate always ran at the hardcoded 0.90/0.95 while --expand-novelty-sim
and --expand-spread-max stayed live flags.
The spread gate becomes store_coherence: the band's lower bound asked of
every pair in the store, in probability space, rather than a second
constant. admit() compares a newcomer only against its nearest existing
member, so a gradually drifting track chains A to B to C with every step
inside the band while A and C are strangers — the shape a track-ID
collision takes over a slow pan. The bound is re-asked pairwise before
anything reaches an actor's annex.
The novelty gate is deleted rather than converted. SPEC section AR-018
contrasts the band with expand_novelty_sim as the thing it replaces, and
AR-019 requires only that the band is satisfied. Novelty-seeking now
lives entirely in the eviction ordering, which ranks by similarity to the
actor's references instead of cutting at a constant, so there is nothing
left to tune but the two bounds.
BufEntry stored a raw cosine and the eviction loop compared two of them.
The map is monotonic so the ranking was never wrong, but it left a bare
cosine as a decision variable; it now stores the calibrated probability.
The [AR-018] Catch2 tag previously sat on the spread gate, reporting the
replaced mechanism as verification of its replacement. It now sits on the
band: both bounds asserted exactly, since they are inclusive and an
off-by-one there is invisible anywhere else; refusal counted on each
side; and the config bounds driven away from the shipped defaults so a
hardcoded fallback fails. The case that carries the invariant is "band
thresholds probability, not cosine" — under a calibration shifted by
0.10, cosine 0.84 is admitted and cosine 0.92 refused, the opposite of
their raw verdicts. A raw-cosine gate passes an identity-calibrated test
by accident and cannot pass that one. 15 cases, 38 assertions, passing.
scene_preview.cpp takes the flag rename because it would otherwise
reference deleted Config fields. It still does not compile, for reasons
predating this change: it also reads track_max_embed_dist and
track_max_frames_missing, retired by the earlier AR-024 tracker work, and
constructs FaceTrackerFunc with one argument where the registry and
calibration are now required.
Two notes for anyone reading the chain. The main.cpp flag rename and the
AR-018/AR-024 register rows landed in 35e7033, whose trailer names AR-004
only, so git log --grep=AR-018 will not surface them. And
docs/traceability.md is left uncommitted on purpose: regenerating it now
would bake in VR-013 rows for two experiment scripts that are not yet
committed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TRACES: AR-018, AR-024 | SR-005
326 lines
14 KiB
C++
326 lines
14 KiB
C++
// TRACES: AR-018, AR-019, AR-024 | SR-005
|
|
//
|
|
// 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 instead of inheriting the header's fallback.
|
|
// A test that never names the mapping is not testing the band, it is testing a
|
|
// coincidence: with the fallback the two spaces happen to coincide, and a gate
|
|
// that silently reverted to raw cosine would still pass.
|
|
#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 {
|
|
|
|
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;
|
|
}
|
|
|
|
// 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().empty());
|
|
}
|
|
|
|
// ── 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);
|
|
// 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_FALSE(tg.annex().empty()); // the legitimate views still promote
|
|
for (const auto& e : tg.annex())
|
|
CHECK(cosine_similarity(e.emb, 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().empty());
|
|
}
|
|
|
|
// ── 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);
|
|
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 (const auto& ae : tg.annex()) CHECK(ae.actor_idx == 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_FALSE(tg.annex().empty());
|
|
for (const auto& ae : tg.annex()) CHECK(ae.actor_idx == 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().empty());
|
|
}
|
|
|
|
TEST_CASE("plurality actor wins a mixed-vote track", "[track_gallery][AR-019]") {
|
|
TrackGallery tg(expand_cfg());
|
|
tg.set_calibration(identity_cal);
|
|
// No registry attached (unit-test path): actor 5 accepted twice, actor 6
|
|
// once → plurality is 5.
|
|
tg.observe(8, spoke(1, kSpokeCos), 5, 0.30f, true, kNoCrop);
|
|
tg.observe(8, spoke(2, kSpokeCos), 5, 0.30f, true, kNoCrop);
|
|
tg.observe(8, spoke(3, kSpokeCos), 6, 0.30f, 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][AR-019]") {
|
|
TrackGallery tg(expand_cfg());
|
|
tg.set_calibration(identity_cal);
|
|
for (int k = 1; k <= 3; ++k)
|
|
tg.observe(9, spoke(k, kSpokeCos), 0, 0.30f, 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, 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().empty());
|
|
}
|
|
|
|
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);
|
|
|
|
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 (const auto& ae : tg.annex()) {
|
|
const bool is_2 = cosine_similarity(ae.emb, spoke(2, kSpokeCos)) > 0.99f;
|
|
const bool is_3 = cosine_similarity(ae.emb, spoke(3, kSpokeCos)) > 0.99f;
|
|
CHECK((is_2 || is_3));
|
|
}
|
|
}
|