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
86 lines
3.4 KiB
C++
86 lines
3.4 KiB
C++
#pragma once
|
|
/// TRACES: AR-024, AR-025 | SR-002
|
|
///
|
|
/// EvidenceDiscounter — how much a single observation is allowed to move a
|
|
/// track's belief.
|
|
///
|
|
/// **The independence problem.** Per-frame identity evidence is accumulated as
|
|
/// log-odds along a track (AR-025), which is only valid for *independent*
|
|
/// observations. Consecutive frames of one track are nothing of the kind: near
|
|
/// identical pose, lighting and expression. Treating them as independent drives
|
|
/// the posterior to certainty on what is effectively one measurement — thirty
|
|
/// frames of the same face at the same angle is not thirty pieces of evidence.
|
|
///
|
|
/// The mitigation is to weight each observation by how much it *adds*: a view
|
|
/// the track has already contributed is discounted toward zero, a genuinely new
|
|
/// pose counts in full. This reuses the same judgement the diversity buffer
|
|
/// makes for gallery expansion (AR-019) — which embeddings on a track are
|
|
/// mutually distinct — rather than inventing a second notion of novelty.
|
|
///
|
|
/// Owned by TrackRegistry rather than left to callers. A caller that forgot to
|
|
/// discount, or applied it twice, would silently produce confident wrong
|
|
/// answers, and the registry is the one place where all evidence converges.
|
|
///
|
|
/// **Similarity enters as a calibrated probability, never a raw cosine**
|
|
/// (AR-024): "is this the same view" is a decision, and a bare cosine threshold
|
|
/// means something different for every model and every face size.
|
|
|
|
#include "types.hpp"
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <functional>
|
|
#include <vector>
|
|
|
|
class EvidenceDiscounter {
|
|
public:
|
|
/// cosine similarity → P(same view). Supplied by the caller so the
|
|
/// calibration fitted for the active embedder is used (AR-023/AR-024).
|
|
using Calibrate = std::function<float(float)>;
|
|
|
|
struct Config {
|
|
int max_views{8}; ///< distinct views remembered per track
|
|
float admit_below{0.6f}; ///< P(same view) under this ⇒ a new view
|
|
float floor{0.0f}; ///< minimum weight for a redundant observation
|
|
};
|
|
|
|
// Two constructors rather than a defaulted argument: `Config{}` as a default
|
|
// argument would reference Config's own member initializers before the
|
|
// enclosing class is complete, which is ill-formed.
|
|
explicit EvidenceDiscounter(Calibrate cal)
|
|
: cal_(std::move(cal)), cfg_() {}
|
|
|
|
EvidenceDiscounter(Calibrate cal, Config cfg)
|
|
: cal_(std::move(cal)), cfg_(cfg) {}
|
|
|
|
/// Weight in [0,1] for one observation, updating `views` when the
|
|
/// observation is novel enough to count as a distinct look at the subject.
|
|
///
|
|
/// The first observation on a track always counts in full: there is nothing
|
|
/// for it to be redundant with.
|
|
float weight(std::vector<Embedding>& views, const Embedding& e) const {
|
|
if (views.empty()) {
|
|
views.push_back(e);
|
|
return 1.0f;
|
|
}
|
|
|
|
float p_same = 0.0f;
|
|
for (const auto& v : views)
|
|
p_same = std::max(p_same, cal_(cosine_similarity(v, e)));
|
|
|
|
// Weight is the probability this is *not* a repeat of something already
|
|
// counted. A near-duplicate contributes ~0; an unseen pose ~1.
|
|
const float w = std::max(cfg_.floor, 1.0f - p_same);
|
|
|
|
if (p_same < cfg_.admit_below &&
|
|
static_cast<int>(views.size()) < cfg_.max_views) {
|
|
views.push_back(e);
|
|
}
|
|
return w;
|
|
}
|
|
|
|
private:
|
|
Calibrate cal_;
|
|
Config cfg_;
|
|
};
|