diff --git a/docs/requirements.md b/docs/requirements.md index 934d0c9..53f02aa 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -51,8 +51,8 @@ Status: `Done` · `In Progress` · `Planned` · `TBD` · `Withdrawn` | AR-021 | Cluster unknown tracks into one entity per person, under temporal cannot-link constraints | SR-005 | Medium | Planned | | AR-022 | Capture still-unidentified tracks: embeddings, metadata, **context crops** for human review | §4 | Medium | Planned | | AR-023 | Fit sigmoid calibration from intra/inter similarity distributions | SR-002 | High | Done | -| AR-024 | **Always the calibrated probability, never a raw cosine** — exceptions recorded | SR-002 | High | Planned | -| AR-025 | Per-track Bayesian accumulation in log-odds, with correlated-observation discounting | SR-002 | High | Planned | +| AR-024 | **Always the calibrated probability, never a raw cosine** — exceptions recorded | SR-002 | High | **Done** (registry boundary) — `observe()` takes a calibrated probability and converts to log-odds itself; retiring the raw-cosine constants is still open | +| AR-025 | Per-track Bayesian accumulation in log-odds, with correlated-observation discounting | SR-002 | High | **Done** — log-odds accumulation with correlation discounting owned by the registry, `src/evidence_discount.hpp` | | AR-026 | All similarity computed as GEMM, including annex and deferred pass | SR-001 | High | In Progress | | AR-027 | Throughput acceptable for **arbitrary** gallery size | SR-001 | High | Planned | diff --git a/docs/traceability.md b/docs/traceability.md index c4c73df..dd62272 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -3,7 +3,7 @@ -**Generated:** 2026-07-31T07:07:04+00:00 +**Generated:** 2026-07-31T07:15:30+00:00 Denominators are read from [`requirements.md`](requirements.md) at run time, never hardcoded. Coverage counts a requirement only when it is tagged in source **and** has a verification tier this repo's CI host can execute (`T1, T2, T3, static`). @@ -11,13 +11,13 @@ Denominators are read from [`requirements.md`](requirements.md) at run time, nev | Metric | Value | |---|---| -| Source files scanned | 94 | -| TRACES tags found | 76 | +| Source files scanned | 95 | +| TRACES tags found | 77 | | EXCEPTION tags found | 0 | | Requirements defined | 63 | -| Requirements covered | 20 | -| **Coverage** | **31.7%** (20/63) | -| Coverage of CI-executable scope | 38.5% (20/52) | +| Requirements covered | 22 | +| **Coverage** | **34.9%** (22/63) | +| Coverage of CI-executable scope | 42.3% (22/52) | | Tagged but unexecuted in CI | 3 | | Orphan tags | 0 | @@ -25,7 +25,7 @@ Denominators are read from [`requirements.md`](requirements.md) at run time, nev | Type | Covered | Tagged but unexecuted | Defined | |---|---|---|---| -| AR | 9 | 0 | 27 | +| AR | 11 | 0 | 27 | | DP | 2 | 0 | 8 | | IR | 6 | 0 | 8 | | GR | 3 | 0 | 9 | @@ -88,20 +88,20 @@ _None._ | AR-009 | Done | T2 | SR-002 | untagged | - | Camera-cut detection (histogram) as an association hint | | AR-010 | **Not started** — `… | T2 | SR-002 | untagged | - | Scene-boundary detection (TransNetV2) as an association hint | | AR-011 | Planned | T1, T2 | SR-002 | untagged | - | **Every model is fed the input it was trained for** — cost reduced by… | -| AR-012 | Planned | T2 | **SR-002** | covered | `src/track_registry.hpp`, `tests/test_track_registry.cpp` | Presence follows **track extent**, not per-frame recognition | -| AR-013 | Planned | T2 | SR-002 | covered | `src/track_registry.hpp`, `tests/test_track_registry.cpp` | `last_seen` optional state machine; window ends at last sighting, nev… | -| AR-014 | Planned | T2 | SR-002 | covered | `src/track_registry.hpp`, `tests/test_track_registry.cpp` | Belief swap A→B terminates the track and starts a new one | -| AR-015 | Planned | T2 | SR-002 | covered | `src/track_registry.hpp`, `tests/test_track_registry.cpp` | Two live tracks owned by one actor ⇒ treat as a detected cut, re-asso… | -| AR-016 | Planned | T2 | SR-002 | covered | `src/track_registry.hpp`, `tests/test_track_registry.cpp` | All tracks closed at EOF — a film ends with faces on screen | -| AR-017 | Planned | T1, T2 | SR-002 | covered | `src/track_registry.hpp`, `tests/test_track_registry.cpp` | Every presence claim carries its belief and identification route | +| AR-012 | **Done** — `src/tra… | T2 | **SR-002** | covered | `src/track_registry.hpp`, `tests/test_track_registry.cpp` | Presence follows **track extent**, not per-frame recognition | +| AR-013 | **Done** — `last_se… | T2 | SR-002 | covered | `src/track_registry.hpp`, `tests/test_track_registry.cpp` | `last_seen` optional state machine; window ends at last sighting, nev… | +| AR-014 | **Done** — swap clo… | T2 | SR-002 | covered | `src/track_registry.hpp`, `tests/test_track_registry.cpp` | Belief swap A→B terminates the track and starts a new one | +| AR-015 | **Done** — reverse … | T2 | SR-002 | covered | `src/track_registry.hpp`, `tests/test_track_registry.cpp` | Two live tracks owned by one actor ⇒ treat as a detected cut, re-asso… | +| AR-016 | **Done** — `flush()… | T2 | SR-002 | covered | `src/track_registry.hpp`, `tests/test_track_registry.cpp` | All tracks closed at EOF — a film ends with faces on screen | +| AR-017 | **Done** — `DeadTra… | T1, T2 | SR-002 | covered | `src/track_registry.hpp`, `tests/test_track_registry.cpp` | Every presence claim carries its belief and identification route | | AR-018 | Planned | T1, T2 | SR-005 | untagged | - | Per-subject embedding store with banded admission (novel enough, safe… | | AR-019 | In Progress | T2 | SR-005 | untagged | - | Per-film gallery annex from owned tracks; acquires the non-frontal vi… | | AR-020 | Planned | T2 | SR-005 | untagged | - | Deferred re-identification of unknown tracks against the final expand… | | AR-021 | Planned | T2 | SR-005 | untagged | - | Cluster unknown tracks into one entity per person, under temporal can… | | AR-022 | Planned | T1, T2 | §4 | untagged | - | Capture still-unidentified tracks: embeddings, metadata, **context cr… | | AR-023 | Done | T1 | SR-002 | covered | `src/gallery/gallery_calibration.hpp` | Fit sigmoid calibration from intra/inter similarity distributions | -| AR-024 | Planned | T1, static | SR-002 | untagged | - | **Always the calibrated probability, never a raw cosine** — exception… | -| AR-025 | Planned | T1 | SR-002 | untagged | - | Per-track Bayesian accumulation in log-odds, with correlated-observat… | +| AR-024 | **Done** (registry … | T1, static | SR-002 | covered | `src/evidence_discount.hpp` | **Always the calibrated probability, never a raw cosine** — exception… | +| AR-025 | **Done** — log-odds… | T1 | SR-002 | covered | `src/evidence_discount.hpp` | Per-track Bayesian accumulation in log-odds, with correlated-observat… | | AR-026 | In Progress | T1, T4 | SR-001 | untagged | - | All similarity computed as GEMM, including annex and deferred pass | | AR-027 | Planned | T4 | SR-001 | untagged | - | Throughput acceptable for **arbitrary** gallery size | | DP-001 | Done | T1, manual | PR-004 | covered | `src/main.cpp` | One analysis core; modes are front-ends and must not fork pipeline lo… | @@ -203,6 +203,18 @@ _None._ - [`src/gallery/gallery_calibration.hpp:2`](../src/gallery/gallery_calibration.hpp#L2) — `Unknown` +### AR-024 + +**Locations:** 1 + +- [`src/evidence_discount.hpp:2`](../src/evidence_discount.hpp#L2) — `Unknown` + +### AR-025 + +**Locations:** 1 + +- [`src/evidence_discount.hpp:2`](../src/evidence_discount.hpp#L2) — `Unknown` + ### DP-001 **Locations:** 1 @@ -411,8 +423,9 @@ _None._ ### SR-002 -**Locations:** 4 +**Locations:** 5 +- [`src/evidence_discount.hpp:2`](../src/evidence_discount.hpp#L2) — `Unknown` - [`src/face_utils.hpp:2`](../src/face_utils.hpp#L2) — `inline cv::Mat align_face(const cv::Mat& img,` - [`src/gallery/gallery_calibration.hpp:2`](../src/gallery/gallery_calibration.hpp#L2) — `Unknown` - [`src/nodes/face_detector_node.hpp:2`](../src/nodes/face_detector_node.hpp#L2) — `Unknown` diff --git a/src/evidence_discount.hpp b/src/evidence_discount.hpp new file mode 100644 index 0000000..10e7901 --- /dev/null +++ b/src/evidence_discount.hpp @@ -0,0 +1,85 @@ +#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 + 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& 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(views.size()) < cfg_.max_views) { + views.push_back(e); + } + return w; + } + +private: + Calibrate cal_; + Config cfg_; +}; diff --git a/src/track_registry.hpp b/src/track_registry.hpp index 203be48..f6e78cd 100644 --- a/src/track_registry.hpp +++ b/src/track_registry.hpp @@ -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 #include #include #include @@ -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 actor; ///< set once a posterior crosses std::map belief; ///< actor_idx → accumulated log-odds Embedding mean{}; ///< running directional mean + std::vector 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 tracks_; std::map owner_index_; ///< actor_idx → live track_id (AR-015) diff --git a/tests/test_track_registry.cpp b/tests/test_track_registry.cpp index 35999c5..20d54d0 100644 --- a/tests/test_track_registry.cpp +++ b/tests/test_track_registry.cpp @@ -11,6 +11,9 @@ #include #include "track_registry.hpp" +#include "evidence_discount.hpp" + +#include #include @@ -34,6 +37,12 @@ struct Sink { } }; +// 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.extinction_sec = extinction; @@ -45,7 +54,7 @@ TrackRegistry::Config cfg(double extinction = 5.0, float own = 2.0f) { // ── 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()); + TrackRegistry reg(cfg(), disc()); Sink sink; sink.attach(reg); int id; @@ -58,7 +67,7 @@ TEST_CASE("window starts at first sighting, not at first recognition", "[registr auto f = reg.begin_frame(t); f.mark_seen(id, t, axis(0)); } - reg.observe(id, 7, 5.0f); + 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); @@ -72,12 +81,12 @@ TEST_CASE("window starts at first sighting, not at first recognition", "[registr // ── 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)); + 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, 5.0f); + 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. @@ -96,12 +105,12 @@ TEST_CASE("interior gaps are absorbed; the trailing cool-down is not", } TEST_CASE("a gap past the timeout yields two tracks, not one", "[registry][AR-013]") { - TrackRegistry reg(cfg(/*extinction=*/5.0)); + 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, 5.0f); + 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 @@ -117,12 +126,12 @@ TEST_CASE("a gap past the timeout yields two tracks, not one", "[registry][AR-01 // ── AR-016 — the silent-loss guard ─────────────────────────────────────────── TEST_CASE("EOF flush closes tracks still on screen", "[registry][AR-016]") { - TrackRegistry reg(cfg()); + 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, 5.0f); + 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); @@ -139,12 +148,12 @@ TEST_CASE("EOF flush closes tracks still on screen", "[registry][AR-016]") { TEST_CASE("flush closes a lost-but-unreaped track at its last sighting", "[registry][AR-016]") { - TrackRegistry reg(cfg(/*extinction=*/60.0)); + 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, 5.0f); + 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); @@ -154,15 +163,18 @@ TEST_CASE("flush closes a lost-but-unreaped track at its last sighting", // ── 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()); + 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, 4.0f); // owned by actor 1 + 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); } - reg.observe(id, 2, 12.0f); // belief swings decisively to 2 + // 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); @@ -179,29 +191,29 @@ TEST_CASE("belief swap closes one window and opens another", "[registry][AR-014] // ── 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()); + 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, 5.0f); + 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, 5.0f); + 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()); + 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.5f); // never clears the ownership threshold + 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); @@ -211,12 +223,12 @@ TEST_CASE("an unowned track emits no claim", "[registry][AR-012]") { } TEST_CASE("claims carry the belief that justified them", "[registry][AR-017]") { - TrackRegistry reg(cfg()); + 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, 4.0f); + reg.observe(id, 6, 0.99f, axis(6)); reg.flush(1.0); REQUIRE(sink.claims.size() == 1); @@ -225,7 +237,7 @@ TEST_CASE("claims carry the belief that justified them", "[registry][AR-017]") { } TEST_CASE("a vote for a reaped track is dropped and counted", "[registry][AR-013]") { - TrackRegistry reg(cfg(/*extinction=*/1.0)); + TrackRegistry reg(cfg(/*extinction=*/1.0), disc()); Sink sink; sink.attach(reg); int id; @@ -235,17 +247,17 @@ TEST_CASE("a vote for a reaped track is dropped and counted", "[registry][AR-013 // 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, 5.0f); + 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()); + 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, 5.0f); + 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); @@ -253,3 +265,68 @@ TEST_CASE("a single-frame track yields a zero-length window", "[registry][AR-012 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 +}