#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

        /// 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<Embedding>& 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<float>(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<int>(views.size()) < cfg_.max_views) {
            views.push_back(e);
        }
        return w;
    }

private:
    Calibrate cal_;
    Config    cfg_;
};
