feat: registry owns correlation discounting (AR-024, AR-025)
Moves two responsibilities inside the registry that callers should never have been trusted with. AR-025 — per-frame evidence is discounted for correlation by the registry itself, via EvidenceDiscounter. Log-odds accumulation is only valid for independent observations, and consecutive frames of one track are anything but: near-identical pose, lighting and expression. Accumulated naively, thirty frames of the same face at the same angle drive the posterior to certainty on what is effectively one measurement. Each observation is weighted by how much it adds — a view already contributed counts for ~nothing, a genuinely new pose counts in full. This reuses the novelty judgement gallery expansion already makes rather than inventing a second one. The discounter is a separate class the registry holds, so it stays testable and swappable, but it is a constructor argument rather than an option: there is no correct way to accumulate without it. AR-024 — observe() takes a calibrated probability and converts to log-odds internally. A caller can no longer hand it a raw cosine, which would have been silently wrong rather than obviously so. Retiring the remaining raw-cosine constants in the tracker is still open. DeadTrack now reports effective_obs alongside observations: the raw count and the evidence that actually counted. A large gap between them is a track the camera stared at, and worth seeing. Three tests, one of which is the point: two tracks given the same number of observations at the same posterior, one repeating a single view and one seeing eight distinct ones, must not end up equally confident. Without discounting they would be identical. Fixed a test that asserted a belief swap on tied evidence. A tie leaves ownership where it is — a challenger must out-accumulate the incumbent, since one contrary observation is noise. The original test passed only because it fed raw log-odds directly. Suite: 78 cases, 3245 assertions. Coverage 20/63 to 22/63. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: AR-024, AR-025 | SR-002
This commit is contained in:
+36
-8
@@ -29,7 +29,9 @@
|
||||
/// read and write, whose final answer is only known when a track dies.
|
||||
|
||||
#include "types.hpp"
|
||||
#include "evidence_discount.hpp"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
@@ -48,7 +50,8 @@ struct DeadTrack {
|
||||
double last_seen{0.0}; ///< always the last sighting, never the death time
|
||||
int actor_idx{-1}; ///< -1 when the track was never owned
|
||||
float belief{0.0f}; ///< accumulated posterior for actor_idx
|
||||
int observations{0}; ///< evidence updates that landed on this track
|
||||
int observations{0}; ///< evidence updates that landed on this track
|
||||
float effective_obs{0.f}; ///< sum of discounted weights — the evidence that counted
|
||||
};
|
||||
|
||||
// ── Track ────────────────────────────────────────────────────────────────────
|
||||
@@ -59,6 +62,8 @@ struct Track {
|
||||
std::optional<int> actor; ///< set once a posterior crosses
|
||||
std::map<int, float> belief; ///< actor_idx → accumulated log-odds
|
||||
Embedding mean{}; ///< running directional mean
|
||||
std::vector<Embedding> views; ///< distinct looks, for AR-025 discounting
|
||||
float discounted_weight{0.f}; ///< sum of applied weights
|
||||
int n_obs{0};
|
||||
|
||||
bool on_screen() const { return !last_seen.has_value(); }
|
||||
@@ -74,7 +79,10 @@ public:
|
||||
float ownership_logodds{2.0f}; ///< belief needed to own a track (~0.88 posterior)
|
||||
};
|
||||
|
||||
explicit TrackRegistry(Config cfg) : cfg_(cfg) {}
|
||||
/// The discounter is a constructor argument rather than an option: there is
|
||||
/// no correct way to accumulate per-frame evidence without it.
|
||||
TrackRegistry(Config cfg, EvidenceDiscounter discounter)
|
||||
: cfg_(cfg), discounter_(std::move(discounter)) {}
|
||||
|
||||
void on_track_dead(DeadTrackFn fn) { on_dead_ = std::move(fn); }
|
||||
|
||||
@@ -115,20 +123,32 @@ public:
|
||||
void tick(double now) { std::lock_guard g(mu_); tick_locked(now); }
|
||||
|
||||
// ── Evidence ─────────────────────────────────────────────────────────────
|
||||
/// Fold one observation into a track's belief (AR-025). `delta_logodds` is
|
||||
/// already discounted for correlation by the caller — that judgement belongs
|
||||
/// with whatever can tell a novel pose from a redundant one.
|
||||
/// Fold one observation into a track's belief (AR-025).
|
||||
///
|
||||
/// `posterior` is a **calibrated probability**, never a raw cosine
|
||||
/// (AR-024) — the registry converts it to log-odds itself, so the
|
||||
/// accumulation cannot be fed an uncalibrated number by a careless caller.
|
||||
///
|
||||
/// Correlation discounting is applied **here**, not by the caller.
|
||||
/// Consecutive frames of one track are near-identical, and accumulating
|
||||
/// them as independent evidence drives the posterior to certainty on what
|
||||
/// is effectively a single measurement. Leaving that to callers would mean
|
||||
/// a forgotten or doubly-applied discount produces confident wrong answers
|
||||
/// silently; the registry is the one place all evidence converges, so it is
|
||||
/// the one place the correction belongs.
|
||||
///
|
||||
/// A vote for a track that has already been reaped is dropped and counted:
|
||||
/// a nonzero `dropped_votes()` means the timeout is shorter than the
|
||||
/// matcher's lag, which is a real misconfiguration and must not be silent.
|
||||
void observe(int track_id, int actor_idx, float delta_logodds) {
|
||||
void observe(int track_id, int actor_idx, float posterior, const Embedding& e) {
|
||||
std::lock_guard g(mu_);
|
||||
auto it = tracks_.find(track_id);
|
||||
if (it == tracks_.end()) { ++dropped_votes_; return; }
|
||||
|
||||
Track& t = it->second;
|
||||
t.belief[actor_idx] += delta_logodds;
|
||||
const float w = discounter_.weight(t.views, e);
|
||||
t.belief[actor_idx] += w * logit(posterior);
|
||||
t.discounted_weight += w;
|
||||
++t.n_obs;
|
||||
|
||||
const int best = argmax_belief(t);
|
||||
@@ -261,7 +281,8 @@ private:
|
||||
d.track_id = t.id;
|
||||
d.first_seen = t.first_seen;
|
||||
d.last_seen = end_ts;
|
||||
d.observations = t.n_obs;
|
||||
d.observations = t.n_obs;
|
||||
d.effective_obs = t.discounted_weight;
|
||||
if (t.actor) {
|
||||
d.actor_idx = *t.actor;
|
||||
d.belief = logistic(t.belief[*t.actor]);
|
||||
@@ -295,7 +316,14 @@ private:
|
||||
: std::exp(z) / (1.f + std::exp(z));
|
||||
}
|
||||
|
||||
static float logit(float p) {
|
||||
const float eps = 1e-6f;
|
||||
p = std::min(1.f - eps, std::max(eps, p));
|
||||
return std::log(p / (1.f - p));
|
||||
}
|
||||
|
||||
Config cfg_;
|
||||
EvidenceDiscounter discounter_;
|
||||
mutable std::mutex mu_;
|
||||
std::map<int, Track> tracks_;
|
||||
std::map<int, int> owner_index_; ///< actor_idx → live track_id (AR-015)
|
||||
|
||||
Reference in New Issue
Block a user