#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 #include #include #include 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; struct Config { int max_views{8}; ///< distinct views remembered per track float admit_below{0.6f}; ///< P(same view) under this ⇒ a new view /// Ceiling on the correlation between two observations of one track. /// /// This is what bounds the accumulation. `n_eff = n / (1 + (n-1)·rho)` /// tends to `1/rho` as `n` grows, so `rho_max` sets how much a single /// repeated view can ever be worth: 0.5 caps it at two observations, /// no matter how long the shot runs. /// /// 0.5 caps a repeated view at two independent observations' worth, /// which is what lets a track the matcher accepts on frame after frame /// actually become owned. Higher values starve ownership; the sweep /// (VR-007) decides where it belongs. /// /// It is capped below 1 deliberately. P(same view) near 1 says the two /// crops look alike; it does not say the second carries no information. /// A fresh frame is a fresh detection, a fresh alignment and a fresh /// noise realisation, so a little independent evidence survives even a /// perfectly held pose. Setting this to 1 recovers the original bug — /// belief frozen after the first frame. float rho_max{0.5f}; }; // 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) {} /// The marginal evidence one observation adds, in units of independent /// observations. /// /// Each frame is a Bayesian update, so confidence must keep growing — but /// correlated observations must grow it less, and must not grow it without /// bound. The standard treatment is **effective sample size**: /// /// n_eff(n) = n / (1 + (n-1)·rho) /// /// and this returns `n_eff(n) - n_eff(n-1)`, the gain from *this* frame. /// The shape is right at both ends: with rho = 0 every frame counts fully /// and the belief accumulates linearly, while as rho rises the series /// converges on `1/rho` and a held pose stops adding no matter how long it /// is held. /// /// The two failure modes it sits between are both real and both were hit: /// a weight of 0 for repeats froze the belief after one frame, so a track /// recognised on 318 frames was owned on none; a constant floor grew it /// linearly forever, so a long shot could out-argue genuinely varied /// evidence purely by lasting longer. /// /// `rho` is estimated from P(same view) against the closest stored view, /// capped by `rho_max`. The first observation has nothing to be redundant /// with and counts in full. /// `n_seen` is the count of observations already folded into THIS track. /// It is a parameter rather than discounter state because one discounter /// serves every track: holding the count internally would pool unrelated /// tracks into one effective sample, so a busy film would silently discount /// each track by how many others happened to be on screen. float weight(std::vector& views, int n_seen, 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))); const float rho = std::min(cfg_.rho_max, std::max(0.0f, p_same)); const float n_prev = static_cast(std::max(1, n_seen)); const float n_now = n_prev + 1.0f; auto n_eff = [rho](float n) { return n / (1.0f + (n - 1.0f) * rho); }; const float w = std::max(0.0f, n_eff(n_now) - n_eff(n_prev)); if (p_same < cfg_.admit_below && static_cast(views.size()) < cfg_.max_views) { views.push_back(e); } return w; } private: Calibrate cal_; Config cfg_; };