// Unit tests for TrackRegistry (track_registry.hpp): presence as track extent.
//
// TRACES: AR-012, AR-013, AR-014, AR-015, AR-016, AR-017 | UT-001
//
// Pure, GPU-free, model-free — drives the registry directly with synthetic
// timestamps and evidence. Node functors and this registry are plain objects
// constructed outside the KPN network, so the awkward cases can be built
// exactly rather than hunted for in a clip: a gap one frame under the timeout,
// a belief swap, two live tracks converging on one actor, a film ending
// mid-track.
#include <catch2/catch_test_macros.hpp>

#include "track_registry.hpp"
#include "evidence_discount.hpp"

#include <algorithm>

#include <vector>

namespace {

Embedding axis(int slot) {
    Embedding e{};
    e[slot] = 1.0f;
    return e;
}

// Collects the claims a registry emits, which is the whole observable output.
struct Sink {
    std::vector<DeadTrack> claims;
    void attach(TrackRegistry& r) {
        r.on_track_dead([this](const DeadTrack& d) { claims.push_back(d); });
    }
    const DeadTrack* forActor(int a) const {
        for (const auto& c : claims) if (c.actor_idx == a) return &c;
        return nullptr;
    }
};

// A discounter whose calibration is deliberately trivial, so the tests exercise
// registry behaviour rather than a fitted sigmoid.
EvidenceDiscounter disc() {
    return EvidenceDiscounter([](float cos) { return std::max(0.f, cos); });
}

TrackRegistry::Config cfg(double extinction = 5.0, float own = 2.0f) {
    TrackRegistry::Config c;
    c.track_extinction_sec = extinction;
    c.ownership_logodds = own;
    return c;
}

} // namespace

// ── AR-012 — the change this whole redesign exists for ───────────────────────
TEST_CASE("window starts at first sighting, not at first recognition", "[registry][AR-012]") {
    TrackRegistry reg(cfg(), disc());
    Sink sink; sink.attach(reg);

    int id;
    { auto f = reg.begin_frame(10.0); id = f.create(10.0, axis(0)); }

    // Seen for 20s but only recognised at the very end — the pose was wrong
    // until then. This is the case the old per-frame design got wrong: it would
    // have reported presence starting at 30, not 10.
    for (double t = 11.0; t <= 30.0; t += 1.0) {
        auto f = reg.begin_frame(t);
        f.mark_seen(id, t, axis(0));
    }
    reg.observe(id, 7, 0.99f, axis(7));

    { auto f = reg.begin_frame(31.0); f.mark_lost(id, 30.0); }
    reg.tick(40.0);

    REQUIRE(sink.claims.size() == 1);
    CHECK(sink.claims[0].actor_idx  == 7);
    CHECK(sink.claims[0].first_seen == 10.0);   // ← not 30.0
    CHECK(sink.claims[0].last_seen  == 30.0);
}

// ── AR-013 — the asymmetry that removes the old over-claim ───────────────────
TEST_CASE("interior gaps are absorbed; the trailing cool-down is not",
          "[registry][AR-013]") {
    TrackRegistry reg(cfg(/*extinction=*/5.0), disc());
    Sink sink; sink.attach(reg);

    int id;
    { auto f = reg.begin_frame(0.0); id = f.create(0.0, axis(0)); }
    reg.observe(id, 3, 0.99f, axis(3));

    // Off screen at 10, back at 13 — inside the timeout, so the same track
    // continues and the actor is claimed present *through* the gap.
    { auto f = reg.begin_frame(10.0); f.mark_lost(id, 10.0); }
    { auto f = reg.begin_frame(13.0); f.mark_seen(id, 13.0, axis(0)); }
    CHECK(sink.claims.empty());          // nothing closed
    CHECK(reg.live() == 1);

    // Lost for good at 20. The window must end there, not at the death time.
    { auto f = reg.begin_frame(20.0); f.mark_lost(id, 20.0); }
    reg.tick(20.0 + 5.0 + 0.001);

    REQUIRE(sink.claims.size() == 1);
    CHECK(sink.claims[0].first_seen == 0.0);
    CHECK(sink.claims[0].last_seen  == 20.0);   // ← not 25.001
}

TEST_CASE("a gap past the timeout yields two tracks, not one", "[registry][AR-013]") {
    TrackRegistry reg(cfg(/*extinction=*/5.0), disc());
    Sink sink; sink.attach(reg);

    int a;
    { auto f = reg.begin_frame(0.0); a = f.create(0.0, axis(0)); }
    reg.observe(a, 1, 0.99f, axis(1));
    { auto f = reg.begin_frame(10.0); f.mark_lost(a, 10.0); }

    reg.tick(30.0);                        // well past extinction
    REQUIRE(sink.claims.size() == 1);
    CHECK(sink.claims[0].last_seen == 10.0);

    // A face reappearing after the timeout is genuinely a new track: past the
    // re-acquisition window there are no grounds to assert continuity.
    int b;
    { auto f = reg.begin_frame(31.0); b = f.create(31.0, axis(0)); }
    CHECK(b != a);
}

// ── AR-016 — the silent-loss guard ───────────────────────────────────────────
TEST_CASE("EOF flush closes tracks still on screen", "[registry][AR-016]") {
    TrackRegistry reg(cfg(), disc());
    Sink sink; sink.attach(reg);

    int id;
    { auto f = reg.begin_frame(100.0); id = f.create(100.0, axis(0)); }
    reg.observe(id, 5, 0.99f, axis(5));

    // A film almost always ends with faces on screen; these have not timed out.
    reg.flush(/*final_ts=*/120.0);

    REQUIRE(sink.claims.size() == 1);
    CHECK(sink.claims[0].actor_idx == 5);
    CHECK(sink.claims[0].last_seen == 120.0);

    sink.claims.clear();
    reg.flush(130.0);
    CHECK(sink.claims.empty());          // idempotent
    CHECK(reg.live() == 0);
}

TEST_CASE("flush closes a lost-but-unreaped track at its last sighting",
          "[registry][AR-016]") {
    TrackRegistry reg(cfg(/*extinction=*/60.0), disc());
    Sink sink; sink.attach(reg);

    int id;
    { auto f = reg.begin_frame(0.0); id = f.create(0.0, axis(0)); }
    reg.observe(id, 2, 0.99f, axis(2));
    { auto f = reg.begin_frame(10.0); f.mark_lost(id, 10.0); }

    reg.flush(/*final_ts=*/50.0);
    REQUIRE(sink.claims.size() == 1);
    CHECK(sink.claims[0].last_seen == 10.0);   // last sighting, not EOF
}

// ── AR-014 — belief swap is a track boundary, not a correction ───────────────
TEST_CASE("belief swap closes one window and opens another", "[registry][AR-014]") {
    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)); }
    reg.observe(id, 1, 0.99f, axis(1));                 // owned by actor 1
    { auto f = reg.begin_frame(5.0); f.mark_lost(id, 5.0); }

    // The swap must out-accumulate the incumbent, not merely tie it: one
    // contrary observation is noise, and a tie leaves ownership where it is.
    reg.observe(id, 2, 0.99f, axis(2));
    reg.observe(id, 2, 0.99f, axis(3));

    CHECK(reg.belief_swaps() == 1);
    REQUIRE(sink.claims.size() == 1);
    CHECK(sink.claims[0].actor_idx == 1);
    CHECK(sink.claims[0].last_seen == 5.0);   // closed at its last sighting

    // The successor is a distinct track, so nothing blends the two people.
    reg.flush(9.0);
    const DeadTrack* second = sink.forActor(2);
    REQUIRE(second != nullptr);
    CHECK(second->track_id != id);
    CHECK(second->first_seen == 5.0);         // abuts, does not overlap
}

// ── AR-015 — identity contradiction as a cut detector ────────────────────────
TEST_CASE("two live tracks owned by one actor is counted", "[registry][AR-015]") {
    TrackRegistry reg(cfg(), disc());
    Sink sink; sink.attach(reg);

    int a, b;
    { auto f = reg.begin_frame(0.0); a = f.create(0.0, axis(0)); b = f.create(0.0, axis(1)); }

    reg.observe(a, 9, 0.99f, axis(9));
    CHECK(reg.actor_conflicts() == 0);

    // One person cannot be in two places at once, so this is a missed camera or
    // scene change that split them — detected on the update that causes it.
    reg.observe(b, 9, 0.99f, axis(9));
    CHECK(reg.actor_conflicts() == 1);
}

// ── AR-017 / diagnostics ─────────────────────────────────────────────────────
TEST_CASE("an unowned track emits no claim", "[registry][AR-012]") {
    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)); }
    reg.observe(id, 4, 0.62f, axis(4));            // never clears the ownership threshold
    { auto f = reg.begin_frame(1.0); f.mark_lost(id, 1.0); }
    reg.tick(100.0);

    // Someone was there, but nothing can be claimed about who.
    CHECK(sink.claims.size() == 1);
    CHECK(sink.claims[0].actor_idx == -1);
}

TEST_CASE("claims carry the belief that justified them", "[registry][AR-017]") {
    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)); }
    reg.observe(id, 6, 0.99f, axis(6));
    reg.flush(1.0);

    REQUIRE(sink.claims.size() == 1);
    CHECK(sink.claims[0].belief > 0.9f);       // logistic(4.0) ≈ 0.982
    CHECK(sink.claims[0].observations == 1);
}

TEST_CASE("a vote for a reaped track is dropped and counted", "[registry][AR-013]") {
    TrackRegistry reg(cfg(/*extinction=*/1.0), disc());
    Sink sink; sink.attach(reg);

    int id;
    { auto f = reg.begin_frame(0.0); id = f.create(0.0, axis(0)); }
    { auto f = reg.begin_frame(1.0); f.mark_lost(id, 1.0); }
    reg.tick(10.0);                       // reaped

    // The matcher runs downstream of the tracker, so a late vote is expected.
    // Silently ignoring it would hide a timeout shorter than the matcher's lag.
    reg.observe(id, 3, 0.99f, axis(3));
    CHECK(reg.dropped_votes() == 1);
}

TEST_CASE("a single-frame track yields a zero-length window", "[registry][AR-012]") {
    TrackRegistry reg(cfg(), disc());
    Sink sink; sink.attach(reg);

    int id;
    { auto f = reg.begin_frame(42.0); id = f.create(42.0, axis(0)); }
    reg.observe(id, 8, 0.99f, axis(8));
    { auto f = reg.begin_frame(43.0); f.mark_lost(id, 42.0); }
    reg.tick(100.0);

    REQUIRE(sink.claims.size() == 1);
    CHECK(sink.claims[0].first_seen == 42.0);
    CHECK(sink.claims[0].last_seen  == 42.0);
}

// ── AR-025 — correlated observations must not accumulate as independent ──────
TEST_CASE("repeated identical views do not reach the certainty of distinct ones",
          "[registry][AR-025]") {
    // Thirty frames of the same face at the same angle is not thirty pieces of
    // evidence. Without discounting, log-odds accumulate linearly and the
    // posterior saturates on what is effectively a single measurement.
    TrackRegistry same(cfg(), disc());
    TrackRegistry varied(cfg(), disc());
    Sink s_same, s_varied;
    s_same.attach(same);
    s_varied.attach(varied);

    int a, b;
    { auto f = same.begin_frame(0.0);   a = f.create(0.0, axis(0)); }
    { auto f = varied.begin_frame(0.0); b = f.create(0.0, axis(0)); }

    for (int i = 0; i < 8; ++i) {
        same.observe(a, 1, 0.9f, axis(0));        // the identical view, every time
        varied.observe(b, 1, 0.9f, axis(i + 1));  // a genuinely new look each time
    }

    same.flush(1.0);
    varied.flush(1.0);

    REQUIRE(s_same.claims.size() == 1);
    REQUIRE(s_varied.claims.size() == 1);

    // Same raw observation count, but only the varied track earned the evidence.
    CHECK(s_same.claims[0].observations == s_varied.claims[0].observations);
    CHECK(s_same.claims[0].effective_obs < s_varied.claims[0].effective_obs);
    CHECK(s_same.claims[0].effective_obs < 2.0f);   // ~one view's worth
}

TEST_CASE("the first observation on a track always counts in full",
          "[registry][AR-025]") {
    // There is nothing for it to be redundant with.
    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)); }
    reg.observe(id, 1, 0.9f, axis(0));
    reg.flush(1.0);

    REQUIRE(sink.claims.size() == 1);
    CHECK(sink.claims[0].effective_obs == 1.0f);
}

TEST_CASE("the registry takes a probability, not a cosine", "[registry][AR-024]") {
    // A posterior at the decision boundary must not move belief at all: 0.5
    // carries no information either way, and its log-odds are zero. Feeding a
    // raw cosine here would be silently wrong rather than obviously so, which
    // is why the conversion lives inside the registry.
    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)); }
    reg.observe(id, 1, 0.5f, axis(0));
    reg.flush(1.0);

    REQUIRE(sink.claims.size() == 1);
    CHECK(sink.claims[0].actor_idx == -1);   // never owned
}

// ── AR-025 — repeated evidence must GROW confidence, not cap it ──────────────
TEST_CASE("confidence grows across frames of the same face", "[registry][AR-025]") {
    // Found on a real clip: 318 frame-level identifications across 385 frames
    // produced ZERO owned tracks. The truth file named nobody while the matcher
    // was accepting on most frames.
    //
    // Cause: the correlation discount was an annihilator rather than an
    // attenuator. Weight = 1 - P(same view), so once a track had one stored
    // view every later frame of that same face scored ~0.01 and belief stopped
    // moving. A single observation just over the accept threshold is
    // logit(0.78) ~ 1.27, under the ownership bar — recognised every frame,
    // owned on none.
    //
    // Correlated evidence should accumulate SLOWER than independent evidence,
    // never stop accumulating. Each frame is a Bayesian update.
    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 face held on screen: the same person, the same pose, frame after frame.
    for (int i = 0; i < 50; ++i) {
        // The frame scope must close before observe(): it holds the registry
        // lock for its lifetime and the mutex is not recursive, so observing
        // inside the scope self-deadlocks. In the pipeline these are separate
        // nodes, so the ordering falls out naturally — but the API allows the
        // mistake, and it hangs rather than failing.
        { auto f = reg.begin_frame(i * 0.2); f.mark_seen(id, i * 0.2, axis(0)); }
        reg.observe(id, 5, 0.78f, axis(0));
    }
    reg.flush(20.0);

    REQUIRE(sink.claims.size() == 1);
    CHECK(sink.claims[0].actor_idx == 5);

    // ...but it must still be worth far less than 50 independent looks would be.
    CHECK(sink.claims[0].effective_obs < 25.0f);
}

// ── The evidence watermark: presence must not depend on node speed ───────────

/// TRACES: UT-001 | AR-012, AR-013, AR-025 | SR-002
TEST_CASE("a track is not reaped until the evidence clock passes it",
          "[registry][AR-013]") {
    // The tracker and the matcher are separate KPN nodes, and backpressure --
    // working exactly as AR-004 intends -- lets the tracker run a whole
    // channel's depth ahead. Reaping on the tracker's clock therefore closed
    // tracks before their votes arrived: the votes landed on ids that no longer
    // existed and the run silently under-reported. On the SuperHero fixture that
    // was 5 actors at channel depth 32 against 0 actors at depth 10322, from
    // identical input.
    TrackRegistry reg(cfg(/*extinction=*/5.0), disc());
    Sink sink; sink.attach(reg);
    reg.expect_evidence();   // as IdentityMatcherFunc::set_registry does

    int id;
    {
        auto s = reg.begin_frame(0.0);
        id = s.create(0.0, axis(1));
        s.mark_lost(id, 0.0);
    }

    // The tracker races 100 s ahead. Nothing has voted yet, so nothing may die:
    // an unvoted track is not a finished track, it is an unanswered question.
    { auto s = reg.begin_frame(100.0); (void)s; }
    CHECK(sink.claims.empty());

    // A vote arriving very late still lands, because the track is still there.
    reg.observe(id, /*actor*/ 3, /*posterior*/ 0.99f, axis(1));
    CHECK(reg.dropped_votes() == 0);

    // Only once the evidence clock passes last_seen + extinction does it close.
    reg.advance_evidence(4.0);
    CHECK(sink.claims.empty());
    reg.advance_evidence(6.0);
    REQUIRE(sink.claims.size() == 1);
    CHECK(sink.claims[0].actor_idx == 3);
    // AR-013 still holds: the window ends at the last sighting, never at the
    // moment of death, and never at the watermark that authorised it.
    CHECK(sink.claims[0].last_seen == 0.0);
}

/// TRACES: UT-001 | AR-008, AR-013 | SR-002
TEST_CASE("a track retired from association is still open to evidence",
          "[registry][AR-008]") {
    // The two clocks answer different questions and must not share an answer.
    // Association asks "may this detection link to that track?" on the tracker's
    // clock; reaping asks "is that track finished?" and cannot answer until the
    // votes are in. Deferring both to the evidence clock was the second half of
    // this bug: retired tracks lingered in the candidate pool for as long as the
    // matcher lagged, so a new face re-associated onto a long-dead track and two
    // people merged into one window.
    TrackRegistry reg(cfg(/*extinction=*/5.0), disc());
    Sink sink; sink.attach(reg);
    reg.expect_evidence();

    int id;
    {
        auto s = reg.begin_frame(0.0);
        id = s.create(0.0, axis(1));
        s.mark_lost(id, 0.0);
    }

    {
        auto s = reg.begin_frame(3.0);          // inside the window
        CHECK(s.candidates().size() == 1);      // still associable
    }
    {
        auto s = reg.begin_frame(50.0);         // far outside it
        CHECK(s.candidates().empty());          // retired from association...
    }
    // ...but not gone, and still able to receive the votes in flight for it.
    reg.observe(id, 7, 0.99f, axis(1));
    CHECK(reg.dropped_votes() == 0);
    reg.advance_evidence(50.0);
    REQUIRE(sink.claims.size() == 1);
    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
}
