docs/SPEC.md specified this removal, listed its parts, and ended "grep
for both names and expect no survivors". There were about forty.
docs/requirements.md meanwhile recorded both constants as Withdrawn and
"deleted rather than retained at zero", on the grounds that a field
naming a mechanism the pipeline no longer has is actively misleading.
Neither statement was true of the code: Config still carried
extinction_sec 57.4 and anneal_sec 35.5, --extinction and --anneal still
parsed, and SceneTrackerFunc still ran its keep-alive in both shipped
pipelines, announcing its timeout at every startup.
SceneTrackerFunc is replaced by FrameAnnotationFunc, which is stateless:
same ports, same output type, no keep-alive. Presence belongs to
TrackRegistry (AR-012), where a window is the extent of a track an actor
owned and ends at the last sighting (AR-013). The keep-alive answered
that question a second time and answered it worse, by re-opening exactly
the trailing cool-down AR-013 refuses.
Visible change: --verbosity standard's frames[].identified listed every
actor inside the keep-alive, including ones absent from the frame. It
now lists what was matched in that frame. Minimal and xray output is
untouched -- both were already built from registry claims and never
consulted this node. No schema bump: the published extraction block
reports track_extinction_sec, a different knob that bounds
re-association and never extends a claim.
TrackRegistry::Config::extinction_sec is renamed track_extinction_sec to
match the Config field feeding it, so the grep SPEC.md asks for now
returns nothing rather than one confusing false positive.
Two targets turned out to have been silently dead, both since the
AR-007/AR-008 tracker redesign, and both for the same reason -- they
construct FaceTrackerFunc from a Config alone, a signature that stopped
existing when association moved into probability space:
- scene_preview is fixed here. It now mirrors main.cpp's construction
order exactly (matcher, then registry, then tracker) and wires the
registry's claims into the sink, which it was not doing. DP-001 says
modes are front-ends that must not fork pipeline logic; this one had
forked it and then rotted.
- sae_kpn is not fixed. Restructuring the seam so the tracker can reach
a calibration that only exists once the matcher is built is VR-011's
rewrite, not a patch, and presence claims do not cross the seam at all
today. It is now behind SAE_BUILD_KPN_BINDINGS=OFF with the reason
recorded, so `cmake --build` succeeds and the breakage is attributed
rather than rediscovered.
That second one is worth stating plainly: VR-002 ("replay drives the
real KPN nodes, not a reimplementation") is marked Done, and the module
that makes replay possible has not compiled for some time. The .so in a
stale build/ predates the change.
Python side: the two names are gone from optimize.py, replay.py and
run_holdout_all_models.py as Config keys. anneal_sec survives as
REPLAY_LOCAL_KEYS -- it still configures replay.py's own windowing,
which is a Python reimplementation that no longer matches the sink and
is documented as such. That divergence is VR-011's.
TRACES: AR-012, AR-013 | DP-001 | SR-002
373 lines
14 KiB
C++
373 lines
14 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);
|
|
}
|