// 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 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; } }; 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()); 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, 5.0f); { 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)); 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, 5.0f); // 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)); 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, 5.0f); { 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()); 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, 5.0f); // 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)); 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, 5.0f); { 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()); 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, 4.0f); // owned by actor 1 { auto f = reg.begin_frame(5.0); f.mark_lost(id, 5.0); } reg.observe(id, 2, 12.0f); // belief swings decisively to 2 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()); 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, 5.0f); 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, 5.0f); CHECK(reg.actor_conflicts() == 1); } // ── AR-017 / diagnostics ───────────────────────────────────────────────────── TEST_CASE("an unowned track emits no claim", "[registry][AR-012]") { TrackRegistry reg(cfg()); 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.5f); // 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()); 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, 4.0f); 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)); 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, 5.0f); CHECK(reg.dropped_votes() == 1); } TEST_CASE("a single-frame track yields a zero-length window", "[registry][AR-012]") { TrackRegistry reg(cfg()); 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, 5.0f); { 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); }