Moves two responsibilities inside the registry that callers should never have been trusted with. AR-025 — per-frame evidence is discounted for correlation by the registry itself, via EvidenceDiscounter. Log-odds accumulation is only valid for independent observations, and consecutive frames of one track are anything but: near-identical pose, lighting and expression. Accumulated naively, thirty frames of the same face at the same angle drive the posterior to certainty on what is effectively one measurement. Each observation is weighted by how much it adds — a view already contributed counts for ~nothing, a genuinely new pose counts in full. This reuses the novelty judgement gallery expansion already makes rather than inventing a second one. The discounter is a separate class the registry holds, so it stays testable and swappable, but it is a constructor argument rather than an option: there is no correct way to accumulate without it. AR-024 — observe() takes a calibrated probability and converts to log-odds internally. A caller can no longer hand it a raw cosine, which would have been silently wrong rather than obviously so. Retiring the remaining raw-cosine constants in the tracker is still open. DeadTrack now reports effective_obs alongside observations: the raw count and the evidence that actually counted. A large gap between them is a track the camera stared at, and worth seeing. Three tests, one of which is the point: two tracks given the same number of observations at the same posterior, one repeating a single view and one seeing eight distinct ones, must not end up equally confident. Without discounting they would be identical. Fixed a test that asserted a belief swap on tied evidence. A tie leaves ownership where it is — a challenger must out-accumulate the incumbent, since one contrary observation is noise. The original test passed only because it fed raw log-odds directly. Suite: 78 cases, 3245 assertions. Coverage 20/63 to 22/63. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: AR-024, AR-025 | SR-002
333 lines
12 KiB
C++
333 lines
12 KiB
C++
// 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 <catch2/catch_test_macros.hpp>
|
|
|
|
#include "track_registry.hpp"
|
|
#include "evidence_discount.hpp"
|
|
|
|
#include <algorithm>
|
|
|
|
#include <vector>
|
|
|
|
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<DeadTrack> 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
|
|
}
|