diff --git a/src/track_registry.hpp b/src/track_registry.hpp index a73aa34..133e769 100644 --- a/src/track_registry.hpp +++ b/src/track_registry.hpp @@ -97,7 +97,12 @@ struct Track { Embedding mean{}; ///< running directional mean std::vector views; ///< distinct looks, for AR-025 discounting float discounted_weight{0.f}; ///< sum of applied weights - int n_obs{0}; + int n_obs{0}; ///< every scored face on this track + /// Observations that were actually evidence, and so spent the correlation + /// budget. Indexing the effective-sample correction by this rather than by + /// n_obs is what stops non-matches exhausting it — see + /// Config::evidence_floor_p. + int n_evidence{0}; bool on_screen() const { return !last_seen.has_value(); } }; @@ -119,6 +124,39 @@ public: /// extends a presence claim. double track_extinction_sec{5.0}; float ownership_logodds{2.0f}; ///< belief needed to own a track (~0.88 posterior) + + /// TRACES: AR-025 | SR-002 + /// Posterior below which an observation is not evidence *for* an actor, + /// and so does not spend that actor's correlation budget. + /// + /// The budget is an effective-sample correction: with observations + /// correlated at rho, the weight of the n-th is + /// `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 is the + /// intended behaviour — a long static shot must not out-argue varied + /// evidence purely by lasting longer. + /// + /// What was not intended is *who spends it*. Every scored face was + /// folded in, so an observation at p=0.02 — which contributes + /// log(0.98) = -0.02 of belief, nothing — consumed the same increment + /// as one at p=0.95. On SuperHero-2 track 3 that exhausted the budget + /// on the frames that recognised nobody: 103 observations, 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, which is the defect + /// AR-013 already had to fix once: deliver more frames, dilute the + /// budget with more non-matches, and a track that was owned stops + /// being owned. Measured — the same clip identified the actor before a + /// KPN throughput fix and not after, from identical input. + /// + /// 0.5 is the point where the posterior stops favouring the hypothesis + /// at all, not a tuned threshold. Near-misses still count, which is the + /// design: an observation at 0.6 is evidence and is folded in. Below + /// 0.5 the observation argues *against*, which noisy-OR cannot + /// represent, so nothing is lost by declining to spend a budget on it. + float evidence_floor_p{0.5f}; }; /// The discounter is a constructor argument rather than an option: there is @@ -256,7 +294,13 @@ public: if (it == tracks_.end()) { ++dropped_votes_; return; } Track& t = it->second; - const float w = discounter_.weight(t.views, t.n_obs, e); + ++t.n_obs; // every scored face is seen, whether or not it is evidence + + // Not evidence *for* this actor: contributes ~nothing to the belief and + // must not spend the correlation budget. See Config::evidence_floor_p. + if (posterior < cfg_.evidence_floor_p) return; + + const float w = discounter_.weight(t.views, t.n_evidence, e); // Weighted lazy-OR: P_new = 1 − (1 − P_old)·(1 − p)^w, which in log // space is a plain sum. w is the discounted evidence (AR-025), so a @@ -265,7 +309,7 @@ public: const float p = std::min(1.f - 1e-6f, std::max(1e-6f, posterior)); t.belief[actor_idx] += w * std::log(1.f - p); t.discounted_weight += w; - ++t.n_obs; + ++t.n_evidence; const int best = argmax_belief(t); const float best_p = 1.f - std::exp(t.belief[best]); diff --git a/tests/test_track_registry.cpp b/tests/test_track_registry.cpp index 9f67f87..4c99178 100644 --- a/tests/test_track_registry.cpp +++ b/tests/test_track_registry.cpp @@ -450,3 +450,70 @@ TEST_CASE("a track retired from association is still open to evidence", 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 +}