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
This commit is contained in:
2026-08-08 14:48:40 +02:00
parent 24d35cbde3
commit 7b73bf923a
2 changed files with 114 additions and 3 deletions
+47 -3
View File
@@ -97,7 +97,12 @@ struct Track {
Embedding mean{}; ///< running directional mean Embedding mean{}; ///< running directional mean
std::vector<Embedding> views; ///< distinct looks, for AR-025 discounting std::vector<Embedding> views; ///< distinct looks, for AR-025 discounting
float discounted_weight{0.f}; ///< sum of applied weights 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(); } bool on_screen() const { return !last_seen.has_value(); }
}; };
@@ -119,6 +124,39 @@ public:
/// extends a presence claim. /// extends a presence claim.
double track_extinction_sec{5.0}; double track_extinction_sec{5.0};
float ownership_logodds{2.0f}; ///< belief needed to own a track (~0.88 posterior) 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 /// The discounter is a constructor argument rather than an option: there is
@@ -256,7 +294,13 @@ public:
if (it == tracks_.end()) { ++dropped_votes_; return; } if (it == tracks_.end()) { ++dropped_votes_; return; }
Track& t = it->second; 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 // 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 // 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)); 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.belief[actor_idx] += w * std::log(1.f - p);
t.discounted_weight += w; t.discounted_weight += w;
++t.n_obs; ++t.n_evidence;
const int best = argmax_belief(t); const int best = argmax_belief(t);
const float best_p = 1.f - std::exp(t.belief[best]); const float best_p = 1.f - std::exp(t.belief[best]);
+67
View File
@@ -450,3 +450,70 @@ TEST_CASE("a track retired from association is still open to evidence",
REQUIRE(sink.claims.size() == 1); REQUIRE(sink.claims.size() == 1);
CHECK(sink.claims[0].actor_idx == 7); 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
}