// 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.track_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); } // ── The evidence watermark: presence must not depend on node speed ─────────── /// TRACES: UT-001 | AR-012, AR-013, AR-025 | SR-002 TEST_CASE("a track is not reaped until the evidence clock passes it", "[registry][AR-013]") { // The tracker and the matcher are separate KPN nodes, and backpressure -- // working exactly as AR-004 intends -- lets the tracker run a whole // channel's depth ahead. Reaping on the tracker's clock therefore closed // tracks before their votes arrived: the votes landed on ids that no longer // existed and the run silently under-reported. On the SuperHero fixture that // was 5 actors at channel depth 32 against 0 actors at depth 10322, from // identical input. TrackRegistry reg(cfg(/*extinction=*/5.0), disc()); Sink sink; sink.attach(reg); reg.expect_evidence(); // as IdentityMatcherFunc::set_registry does int id; { auto s = reg.begin_frame(0.0); id = s.create(0.0, axis(1)); s.mark_lost(id, 0.0); } // The tracker races 100 s ahead. Nothing has voted yet, so nothing may die: // an unvoted track is not a finished track, it is an unanswered question. { auto s = reg.begin_frame(100.0); (void)s; } CHECK(sink.claims.empty()); // A vote arriving very late still lands, because the track is still there. reg.observe(id, /*actor*/ 3, /*posterior*/ 0.99f, axis(1)); CHECK(reg.dropped_votes() == 0); // Only once the evidence clock passes last_seen + extinction does it close. reg.advance_evidence(4.0); CHECK(sink.claims.empty()); reg.advance_evidence(6.0); REQUIRE(sink.claims.size() == 1); CHECK(sink.claims[0].actor_idx == 3); // AR-013 still holds: the window ends at the last sighting, never at the // moment of death, and never at the watermark that authorised it. CHECK(sink.claims[0].last_seen == 0.0); } /// TRACES: UT-001 | AR-008, AR-013 | SR-002 TEST_CASE("a track retired from association is still open to evidence", "[registry][AR-008]") { // The two clocks answer different questions and must not share an answer. // Association asks "may this detection link to that track?" on the tracker's // clock; reaping asks "is that track finished?" and cannot answer until the // votes are in. Deferring both to the evidence clock was the second half of // this bug: retired tracks lingered in the candidate pool for as long as the // matcher lagged, so a new face re-associated onto a long-dead track and two // people merged into one window. TrackRegistry reg(cfg(/*extinction=*/5.0), disc()); Sink sink; sink.attach(reg); reg.expect_evidence(); int id; { auto s = reg.begin_frame(0.0); id = s.create(0.0, axis(1)); s.mark_lost(id, 0.0); } { auto s = reg.begin_frame(3.0); // inside the window CHECK(s.candidates().size() == 1); // still associable } { auto s = reg.begin_frame(50.0); // far outside it CHECK(s.candidates().empty()); // retired from association... } // ...but not gone, and still able to receive the votes in flight for it. reg.observe(id, 7, 0.99f, axis(1)); CHECK(reg.dropped_votes() == 0); reg.advance_evidence(50.0); REQUIRE(sink.claims.size() == 1); CHECK(sink.claims[0].actor_idx == 7); }