Both failed the first time CI was able to run this suite, and neither is
a new break. They assert semantics that two deliberate changes replaced,
and nothing noticed because nothing had ever executed them.
1477c53 (2026-08-09) made a dormant track associable only if it had been
identified: an unowned dormant track has no actor to re-attach to, so it
only enlarges the matcher's comparison set and invites a new face landing
on an anonymous stub. Its message records the trade -- "a small recall
cost (~1-3pp on some films, e.g. Lord of War -2.5) for a cleaner, bounded
pool. Kept deliberately." Both tests build tracks that are never
identified, so since that commit they are dropped from the pool one line
before the extinction horizon they were written to exercise. The tests
date from 2026-07-31 and 2026-08-08 -- they are older than the gate.
Fixed by owning the track while it is still on screen, which is what a
real run does and what makes the horizon reachable at all.
The second test needed more than that, and the rename says why. It was
built on association and reaping answering on DIFFERENT clocks -- the
tracker's for "may this associate?", the watermark for "is this
finished?" -- which is exactly the arrangement track_registry.hpp:184-203
now rules out: one clock, one threshold, "nothing is offered past its
reap horizon, nothing is reaped while still offerable". So there is no
longer an interval in which a track is retired from association but still
alive, and a test named for that interval cannot pass. Rewritten to
assert what the unified clock actually promises: a tracker racing 50 s
past the horizon retires nothing while the matcher lags, the votes in
flight still land, and the watermark passing the horizon retires, reaps
and emits in one step.
Kept: the AR-008 tag, the late-vote check and dropped_votes() == 0, which
were the point of the test and still hold.
Full suite now 151/151 in sae-builder-cpu.
TRACES: AR-008, AR-013 | SR-002
542 lines
22 KiB
C++
542 lines
22 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("association and reaping retire a track on the same clock",
|
|
"[registry][AR-008]") {
|
|
// Renamed and rewritten for the unified clock. This test used to assert the
|
|
// opposite arrangement -- association answered on the tracker's clock while
|
|
// reaping answered on the evidence watermark -- and it was written when that
|
|
// was true. It is not true now: candidates() and reap_locked() take the SAME
|
|
// clock and the SAME threshold, so "retired from association" and "reaped"
|
|
// are one event, and the interval the old name described does not exist.
|
|
//
|
|
// Both halves of the AR-013 bug are visible here. Offering on a LOOSER
|
|
// horizon than the reap left retired tracks 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. Reaping on a looser horizon
|
|
// than the offer killed tracks whose votes were still in flight. One clock
|
|
// makes both unrepresentable.
|
|
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));
|
|
}
|
|
// Owned before it goes dormant, for the reason above: an unidentified
|
|
// dormant track is not a candidate at any horizon (1477c53).
|
|
reg.observe(id, 7, 0.99f, axis(1));
|
|
{
|
|
auto s = reg.begin_frame(0.0);
|
|
s.mark_lost(id, 0.0);
|
|
}
|
|
|
|
{
|
|
auto s = reg.begin_frame(3.0); // watermark inside the window
|
|
CHECK(s.candidates().size() == 1); // still associable
|
|
}
|
|
// The tracker races far past the horizon while the matcher lags. This
|
|
// retires nothing, and that is the point of the watermark: the clock that
|
|
// decides has not moved, so the track is still offered.
|
|
{
|
|
auto s = reg.begin_frame(50.0);
|
|
CHECK(s.candidates().size() == 1);
|
|
}
|
|
// Which is what keeps it able to receive the votes still in flight for it.
|
|
reg.observe(id, 7, 0.99f, axis(1));
|
|
CHECK(reg.dropped_votes() == 0);
|
|
|
|
// Only the watermark passing last_seen + extinction retires it -- and the
|
|
// same call reaps and emits it, so there is no interval in between.
|
|
reg.advance_evidence(50.0);
|
|
{
|
|
auto s = reg.begin_frame(50.0);
|
|
CHECK(s.candidates().empty());
|
|
}
|
|
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
|
|
}
|