Files
scene-actor-extraction/tests/test_track_registry.cpp
dtourolle 7b73bf923a fix(AR-025): only evidence spends the evidence budget
The correlation discount is an effective-sample correction: with observations
correlated at rho, the n-th is worth n_eff(n+1) - n_eff(n) = 2/((n+1)(n+2))
at rho=0.5, so it decays quadratically and the total converges to 1/rho = 2.
That saturation is deliberate and stays — a long static shot must not
out-argue varied evidence purely by lasting longer.

What was not deliberate is that every scored face spent it. An observation at
p=0.02 contributes log(0.98) = -0.02 of belief, which is nothing, while
consuming the same increment as one at p=0.95. On SuperHero-2, track 3 carried
103 observations for an effective weight of 2.026 and a belief of 0.455
against a 0.881 threshold — with the 51 frames that *did* identify the actor
arriving when each was worth 0.0002. The budget had been spent by the frames
that recognised nobody.

It also made the answer depend on frame rate: deliver more frames, dilute the
budget with more non-matches, and a track that was owned stops being owned.
That is the defect AR-013 already had to fix once for reaping, still present
in accumulation. It is how this was found — SuperHero-2 identified Jeremy
before a KPN throughput fix and not after, from identical input, and bisect
put the change at the KPN bump in d98dc28 rather than anywhere in this repo.

The floor is 0.5, which is where the posterior stops favouring the hypothesis,
not a tuned threshold. Near-misses still count: the identity matcher
deliberately feeds every scored face rather than only accepted ones, and an
observation at 0.7 — under the matcher's 0.754 acceptance — still accumulates,
which the second test pins. Below 0.5 the observation argues *against*, which
noisy-OR cannot represent, so declining to spend a budget on it loses nothing.

Scored against the DVU knowledge-graph ground truth, all ten SuperHero scenes:

                 TP   FP   FN   precision   recall     F1
  before          9    0   15       1.000    0.375   0.545
  after          13    0   14       1.000    0.481   0.650

Four more true positives and no false positives — the recall gain is not
bought with precision. Scene 7's Isabelle and scene 10's Isabelle and Jeremy
are all in the ground truth. Apples-to-apples over scenes 1-9 (scene 10's
before-run timed out): TP 9 to 11, FN 15 to 13, FP 0 either way.

An earlier attempt at this gave each actor its own budget. It was a no-op:
measured on the same track, n_obs_for[best] equalled n_obs at 103, because
every observation votes for the best-matching actor. Reverted rather than
kept.

151/151.

TRACES: AR-025, AR-013 | SR-002
2026-08-08 14:48:40 +02:00

520 lines
21 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.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);
}
// ── AR-025 — non-matches must not spend an actor's evidence budget ───────────
TEST_CASE("frames that recognise nobody do not exhaust the budget",
"[registry][AR-025]") {
// The correlation discount is an effective-sample correction: with
// observations correlated at rho, the n-th is worth
// n_eff(n+1) - n_eff(n) = 2/((n+1)(n+2)) at rho=0.5, so it decays
// quadratically and the total converges to 1/rho = 2. That saturation is
// deliberate — a long static shot must not out-argue varied evidence by
// lasting longer.
//
// What was not deliberate is that every scored face spent it, including
// ones that matched nobody. An observation at p=0.02 contributes
// log(0.98) = -0.02 of belief — nothing — while consuming the same
// increment as one at p=0.95. Measured on SuperHero-2: 103 observations on
// one track, effective weight 2.026, belief 0.455 against a 0.881
// threshold, with the 51 frames that *did* identify the actor arriving
// when each was worth 0.0002. The identification was lost.
//
// It also made the answer depend on frame rate — deliver more frames,
// dilute the budget with more non-matches, and a track that was owned stops
// being owned — which is the defect AR-013 already had to fix once.
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 long run of frames that match nobody: the detector saw a face, the
// matcher could not place it. These are not evidence for actor 1.
for (int i = 0; i < 40; ++i) reg.observe(id, 1, 0.02f, axis(0));
// Then the actor is clearly recognised. Before this fix the budget was
// already spent and these could not move the belief.
for (int i = 0; i < 6; ++i) reg.observe(id, 1, 0.9f, axis(0));
reg.flush(1.0);
REQUIRE(sink.claims.size() == 1);
INFO("belief " << sink.claims[0].belief
<< " effective_obs " << sink.claims[0].effective_obs);
CHECK(sink.claims[0].actor_idx == 1);
CHECK(sink.claims[0].belief > 0.88f);
}
TEST_CASE("a near-miss is still evidence", "[registry][AR-025]") {
// The floor is at 0.5 — where the posterior stops favouring the hypothesis
// — not at the matcher's acceptance threshold. A run of near-misses for one
// actor is informative and must still accumulate, which is the property the
// identity matcher's comment relies on when it feeds every scored face
// rather than only the accepted ones.
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)); }
// 0.7 is below the matcher's acceptance threshold (0.754 on the SuperHero
// gallery) and above the 0.5 floor: a frame that would not be reported as
// an identification, but is still evidence. Twelve of them accumulate to
// ~0.89, past the 0.881 ownership threshold.
for (int i = 0; i < 12; ++i) reg.observe(id, 3, 0.7f, axis(0));
reg.flush(1.0);
REQUIRE(sink.claims.size() == 1);
INFO("belief " << sink.claims[0].belief);
CHECK(sink.claims[0].actor_idx == 3); // owned on near-misses alone
}