// Unit tests for TrackRegistry (track_registry.hpp): presence as track extent. // // TRACES: AR-012, AR-013, AR-014, AR-015, AR-016, AR-017 | UT-001 // // Pure, GPU-free, model-free — drives the registry directly with synthetic // timestamps and evidence. Node functors and this registry are plain objects // constructed outside the KPN network, so the awkward cases can be built // exactly rather than hunted for in a clip: a gap one frame under the timeout, // a belief swap, two live tracks converging on one actor, a film ending // mid-track. #include #include "track_registry.hpp" #include "evidence_discount.hpp" #include #include namespace { Embedding axis(int slot) { Embedding e{}; e[slot] = 1.0f; return e; } // Collects the claims a registry emits, which is the whole observable output. struct Sink { std::vector claims; void attach(TrackRegistry& r) { r.on_track_dead([this](const DeadTrack& d) { claims.push_back(d); }); } const DeadTrack* forActor(int a) const { for (const auto& c : claims) if (c.actor_idx == a) return &c; return nullptr; } }; // A discounter whose calibration is deliberately trivial, so the tests exercise // registry behaviour rather than a fitted sigmoid. EvidenceDiscounter disc() { return EvidenceDiscounter([](float cos) { return std::max(0.f, cos); }); } TrackRegistry::Config cfg(double extinction = 5.0, float own = 2.0f) { TrackRegistry::Config c; c.extinction_sec = extinction; c.ownership_logodds = own; return c; } } // namespace // ── AR-012 — the change this whole redesign exists for ─────────────────────── TEST_CASE("window starts at first sighting, not at first recognition", "[registry][AR-012]") { TrackRegistry reg(cfg(), disc()); Sink sink; sink.attach(reg); int id; { auto f = reg.begin_frame(10.0); id = f.create(10.0, axis(0)); } // Seen for 20s but only recognised at the very end — the pose was wrong // until then. This is the case the old per-frame design got wrong: it would // have reported presence starting at 30, not 10. for (double t = 11.0; t <= 30.0; t += 1.0) { auto f = reg.begin_frame(t); f.mark_seen(id, t, axis(0)); } reg.observe(id, 7, 0.99f, axis(7)); { auto f = reg.begin_frame(31.0); f.mark_lost(id, 30.0); } reg.tick(40.0); REQUIRE(sink.claims.size() == 1); CHECK(sink.claims[0].actor_idx == 7); CHECK(sink.claims[0].first_seen == 10.0); // ← not 30.0 CHECK(sink.claims[0].last_seen == 30.0); } // ── AR-013 — the asymmetry that removes the old over-claim ─────────────────── TEST_CASE("interior gaps are absorbed; the trailing cool-down is not", "[registry][AR-013]") { TrackRegistry reg(cfg(/*extinction=*/5.0), disc()); Sink sink; sink.attach(reg); int id; { auto f = reg.begin_frame(0.0); id = f.create(0.0, axis(0)); } reg.observe(id, 3, 0.99f, axis(3)); // Off screen at 10, back at 13 — inside the timeout, so the same track // continues and the actor is claimed present *through* the gap. { auto f = reg.begin_frame(10.0); f.mark_lost(id, 10.0); } { auto f = reg.begin_frame(13.0); f.mark_seen(id, 13.0, axis(0)); } CHECK(sink.claims.empty()); // nothing closed CHECK(reg.live() == 1); // Lost for good at 20. The window must end there, not at the death time. { auto f = reg.begin_frame(20.0); f.mark_lost(id, 20.0); } reg.tick(20.0 + 5.0 + 0.001); REQUIRE(sink.claims.size() == 1); CHECK(sink.claims[0].first_seen == 0.0); CHECK(sink.claims[0].last_seen == 20.0); // ← not 25.001 } TEST_CASE("a gap past the timeout yields two tracks, not one", "[registry][AR-013]") { TrackRegistry reg(cfg(/*extinction=*/5.0), disc()); Sink sink; sink.attach(reg); int a; { auto f = reg.begin_frame(0.0); a = f.create(0.0, axis(0)); } reg.observe(a, 1, 0.99f, axis(1)); { auto f = reg.begin_frame(10.0); f.mark_lost(a, 10.0); } reg.tick(30.0); // well past extinction REQUIRE(sink.claims.size() == 1); CHECK(sink.claims[0].last_seen == 10.0); // A face reappearing after the timeout is genuinely a new track: past the // re-acquisition window there are no grounds to assert continuity. int b; { auto f = reg.begin_frame(31.0); b = f.create(31.0, axis(0)); } CHECK(b != a); } // ── AR-016 — the silent-loss guard ─────────────────────────────────────────── TEST_CASE("EOF flush closes tracks still on screen", "[registry][AR-016]") { TrackRegistry reg(cfg(), disc()); Sink sink; sink.attach(reg); int id; { auto f = reg.begin_frame(100.0); id = f.create(100.0, axis(0)); } reg.observe(id, 5, 0.99f, axis(5)); // A film almost always ends with faces on screen; these have not timed out. reg.flush(/*final_ts=*/120.0); REQUIRE(sink.claims.size() == 1); CHECK(sink.claims[0].actor_idx == 5); CHECK(sink.claims[0].last_seen == 120.0); sink.claims.clear(); reg.flush(130.0); CHECK(sink.claims.empty()); // idempotent CHECK(reg.live() == 0); } TEST_CASE("flush closes a lost-but-unreaped track at its last sighting", "[registry][AR-016]") { TrackRegistry reg(cfg(/*extinction=*/60.0), disc()); Sink sink; sink.attach(reg); int id; { auto f = reg.begin_frame(0.0); id = f.create(0.0, axis(0)); } reg.observe(id, 2, 0.99f, axis(2)); { auto f = reg.begin_frame(10.0); f.mark_lost(id, 10.0); } reg.flush(/*final_ts=*/50.0); REQUIRE(sink.claims.size() == 1); CHECK(sink.claims[0].last_seen == 10.0); // last sighting, not EOF } // ── AR-014 — belief swap is a track boundary, not a correction ─────────────── TEST_CASE("belief swap closes one window and opens another", "[registry][AR-014]") { TrackRegistry reg(cfg(), disc()); Sink sink; sink.attach(reg); int id; { auto f = reg.begin_frame(0.0); id = f.create(0.0, axis(0)); } reg.observe(id, 1, 0.99f, axis(1)); // owned by actor 1 { auto f = reg.begin_frame(5.0); f.mark_lost(id, 5.0); } // The swap must out-accumulate the incumbent, not merely tie it: one // contrary observation is noise, and a tie leaves ownership where it is. reg.observe(id, 2, 0.99f, axis(2)); reg.observe(id, 2, 0.99f, axis(3)); CHECK(reg.belief_swaps() == 1); REQUIRE(sink.claims.size() == 1); CHECK(sink.claims[0].actor_idx == 1); CHECK(sink.claims[0].last_seen == 5.0); // closed at its last sighting // The successor is a distinct track, so nothing blends the two people. reg.flush(9.0); const DeadTrack* second = sink.forActor(2); REQUIRE(second != nullptr); CHECK(second->track_id != id); CHECK(second->first_seen == 5.0); // abuts, does not overlap } // ── AR-015 — identity contradiction as a cut detector ──────────────────────── TEST_CASE("two live tracks owned by one actor is counted", "[registry][AR-015]") { TrackRegistry reg(cfg(), disc()); Sink sink; sink.attach(reg); int a, b; { auto f = reg.begin_frame(0.0); a = f.create(0.0, axis(0)); b = f.create(0.0, axis(1)); } reg.observe(a, 9, 0.99f, axis(9)); CHECK(reg.actor_conflicts() == 0); // One person cannot be in two places at once, so this is a missed camera or // scene change that split them — detected on the update that causes it. reg.observe(b, 9, 0.99f, axis(9)); CHECK(reg.actor_conflicts() == 1); } // ── AR-017 / diagnostics ───────────────────────────────────────────────────── TEST_CASE("an unowned track emits no claim", "[registry][AR-012]") { TrackRegistry reg(cfg(), disc()); Sink sink; sink.attach(reg); int id; { auto f = reg.begin_frame(0.0); id = f.create(0.0, axis(0)); } reg.observe(id, 4, 0.62f, axis(4)); // never clears the ownership threshold { auto f = reg.begin_frame(1.0); f.mark_lost(id, 1.0); } reg.tick(100.0); // Someone was there, but nothing can be claimed about who. CHECK(sink.claims.size() == 1); CHECK(sink.claims[0].actor_idx == -1); } TEST_CASE("claims carry the belief that justified them", "[registry][AR-017]") { TrackRegistry reg(cfg(), disc()); Sink sink; sink.attach(reg); int id; { auto f = reg.begin_frame(0.0); id = f.create(0.0, axis(0)); } reg.observe(id, 6, 0.99f, axis(6)); reg.flush(1.0); REQUIRE(sink.claims.size() == 1); CHECK(sink.claims[0].belief > 0.9f); // logistic(4.0) ≈ 0.982 CHECK(sink.claims[0].observations == 1); } TEST_CASE("a vote for a reaped track is dropped and counted", "[registry][AR-013]") { TrackRegistry reg(cfg(/*extinction=*/1.0), disc()); Sink sink; sink.attach(reg); int id; { auto f = reg.begin_frame(0.0); id = f.create(0.0, axis(0)); } { auto f = reg.begin_frame(1.0); f.mark_lost(id, 1.0); } reg.tick(10.0); // reaped // The matcher runs downstream of the tracker, so a late vote is expected. // Silently ignoring it would hide a timeout shorter than the matcher's lag. reg.observe(id, 3, 0.99f, axis(3)); CHECK(reg.dropped_votes() == 1); } TEST_CASE("a single-frame track yields a zero-length window", "[registry][AR-012]") { TrackRegistry reg(cfg(), disc()); Sink sink; sink.attach(reg); int id; { auto f = reg.begin_frame(42.0); id = f.create(42.0, axis(0)); } reg.observe(id, 8, 0.99f, axis(8)); { auto f = reg.begin_frame(43.0); f.mark_lost(id, 42.0); } reg.tick(100.0); REQUIRE(sink.claims.size() == 1); CHECK(sink.claims[0].first_seen == 42.0); CHECK(sink.claims[0].last_seen == 42.0); } // ── AR-025 — correlated observations must not accumulate as independent ────── TEST_CASE("repeated identical views do not reach the certainty of distinct ones", "[registry][AR-025]") { // Thirty frames of the same face at the same angle is not thirty pieces of // evidence. Without discounting, log-odds accumulate linearly and the // posterior saturates on what is effectively a single measurement. TrackRegistry same(cfg(), disc()); TrackRegistry varied(cfg(), disc()); Sink s_same, s_varied; s_same.attach(same); s_varied.attach(varied); int a, b; { auto f = same.begin_frame(0.0); a = f.create(0.0, axis(0)); } { auto f = varied.begin_frame(0.0); b = f.create(0.0, axis(0)); } for (int i = 0; i < 8; ++i) { same.observe(a, 1, 0.9f, axis(0)); // the identical view, every time varied.observe(b, 1, 0.9f, axis(i + 1)); // a genuinely new look each time } same.flush(1.0); varied.flush(1.0); REQUIRE(s_same.claims.size() == 1); REQUIRE(s_varied.claims.size() == 1); // Same raw observation count, but only the varied track earned the evidence. CHECK(s_same.claims[0].observations == s_varied.claims[0].observations); CHECK(s_same.claims[0].effective_obs < s_varied.claims[0].effective_obs); CHECK(s_same.claims[0].effective_obs < 2.0f); // ~one view's worth } TEST_CASE("the first observation on a track always counts in full", "[registry][AR-025]") { // There is nothing for it to be redundant with. TrackRegistry reg(cfg(), disc()); Sink sink; sink.attach(reg); int id; { auto f = reg.begin_frame(0.0); id = f.create(0.0, axis(0)); } reg.observe(id, 1, 0.9f, axis(0)); reg.flush(1.0); REQUIRE(sink.claims.size() == 1); CHECK(sink.claims[0].effective_obs == 1.0f); } TEST_CASE("the registry takes a probability, not a cosine", "[registry][AR-024]") { // A posterior at the decision boundary must not move belief at all: 0.5 // carries no information either way, and its log-odds are zero. Feeding a // raw cosine here would be silently wrong rather than obviously so, which // is why the conversion lives inside the registry. TrackRegistry reg(cfg(), disc()); Sink sink; sink.attach(reg); int id; { auto f = reg.begin_frame(0.0); id = f.create(0.0, axis(0)); } reg.observe(id, 1, 0.5f, axis(0)); reg.flush(1.0); REQUIRE(sink.claims.size() == 1); CHECK(sink.claims[0].actor_idx == -1); // never owned } // ── AR-025 — repeated evidence must GROW confidence, not cap it ────────────── TEST_CASE("confidence grows across frames of the same face", "[registry][AR-025]") { // Found on a real clip: 318 frame-level identifications across 385 frames // produced ZERO owned tracks. The truth file named nobody while the matcher // was accepting on most frames. // // Cause: the correlation discount was an annihilator rather than an // attenuator. Weight = 1 - P(same view), so once a track had one stored // view every later frame of that same face scored ~0.01 and belief stopped // moving. A single observation just over the accept threshold is // logit(0.78) ~ 1.27, under the ownership bar — recognised every frame, // owned on none. // // Correlated evidence should accumulate SLOWER than independent evidence, // never stop accumulating. Each frame is a Bayesian update. TrackRegistry reg(cfg(), disc()); Sink sink; sink.attach(reg); int id; { auto f = reg.begin_frame(0.0); id = f.create(0.0, axis(0)); } // A face held on screen: the same person, the same pose, frame after frame. for (int i = 0; i < 50; ++i) { // The frame scope must close before observe(): it holds the registry // lock for its lifetime and the mutex is not recursive, so observing // inside the scope self-deadlocks. In the pipeline these are separate // nodes, so the ordering falls out naturally — but the API allows the // mistake, and it hangs rather than failing. { auto f = reg.begin_frame(i * 0.2); f.mark_seen(id, i * 0.2, axis(0)); } reg.observe(id, 5, 0.78f, axis(0)); } reg.flush(20.0); REQUIRE(sink.claims.size() == 1); CHECK(sink.claims[0].actor_idx == 5); // ...but it must still be worth far less than 50 independent looks would be. CHECK(sink.claims[0].effective_obs < 25.0f); }