feat: registry owns correlation discounting (AR-024, AR-025)

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
This commit is contained in:
2026-07-31 09:15:44 +02:00
co-authored by Claude Opus 5
parent f0c7126f80
commit 843852e19c
5 changed files with 253 additions and 50 deletions
+2 -2
View File
@@ -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-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-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-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-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 | Planned | | 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-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 | | AR-027 | Throughput acceptable for **arbitrary** gallery size | SR-001 | High | Planned |
+29 -16
View File
@@ -3,7 +3,7 @@
<!-- GENERATED FILE - do not edit by hand. --> <!-- GENERATED FILE - do not edit by hand. -->
<!-- Regenerate: scripts/traceability/traceability-gate.sh --> <!-- Regenerate: scripts/traceability/traceability-gate.sh -->
**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`). 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 | | Metric | Value |
|---|---| |---|---|
| Source files scanned | 94 | | Source files scanned | 95 |
| TRACES tags found | 76 | | TRACES tags found | 77 |
| EXCEPTION tags found | 0 | | EXCEPTION tags found | 0 |
| Requirements defined | 63 | | Requirements defined | 63 |
| Requirements covered | 20 | | Requirements covered | 22 |
| **Coverage** | **31.7%** (20/63) | | **Coverage** | **34.9%** (22/63) |
| Coverage of CI-executable scope | 38.5% (20/52) | | Coverage of CI-executable scope | 42.3% (22/52) |
| Tagged but unexecuted in CI | 3 | | Tagged but unexecuted in CI | 3 |
| Orphan tags | 0 | | 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 | | Type | Covered | Tagged but unexecuted | Defined |
|---|---|---|---| |---|---|---|---|
| AR | 9 | 0 | 27 | | AR | 11 | 0 | 27 |
| DP | 2 | 0 | 8 | | DP | 2 | 0 | 8 |
| IR | 6 | 0 | 8 | | IR | 6 | 0 | 8 |
| GR | 3 | 0 | 9 | | 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-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-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-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-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 | 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-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 | 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-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 | 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-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 | 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-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 | 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-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-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-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-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-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-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-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-024 | **Done** (registry … | T1, static | SR-002 | covered | `src/evidence_discount.hpp` | **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-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-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 | | 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… | | 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` - [`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 ### DP-001
**Locations:** 1 **Locations:** 1
@@ -411,8 +423,9 @@ _None._
### SR-002 ### 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/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/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` - [`src/nodes/face_detector_node.hpp:2`](../src/nodes/face_detector_node.hpp#L2) — `Unknown`
+85
View File
@@ -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 <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_;
};
+36 -8
View File
@@ -29,7 +29,9 @@
/// read and write, whose final answer is only known when a track dies. /// read and write, whose final answer is only known when a track dies.
#include "types.hpp" #include "types.hpp"
#include "evidence_discount.hpp"
#include <cmath>
#include <cstdint> #include <cstdint>
#include <functional> #include <functional>
#include <map> #include <map>
@@ -48,7 +50,8 @@ struct DeadTrack {
double last_seen{0.0}; ///< always the last sighting, never the death time double last_seen{0.0}; ///< always the last sighting, never the death time
int actor_idx{-1}; ///< -1 when the track was never owned int actor_idx{-1}; ///< -1 when the track was never owned
float belief{0.0f}; ///< accumulated posterior for actor_idx 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 ──────────────────────────────────────────────────────────────────── // ── Track ────────────────────────────────────────────────────────────────────
@@ -59,6 +62,8 @@ struct Track {
std::optional<int> actor; ///< set once a posterior crosses std::optional<int> actor; ///< set once a posterior crosses
std::map<int, float> belief; ///< actor_idx → accumulated log-odds std::map<int, float> belief; ///< actor_idx → accumulated log-odds
Embedding mean{}; ///< running directional mean Embedding mean{}; ///< running directional mean
std::vector<Embedding> views; ///< distinct looks, for AR-025 discounting
float discounted_weight{0.f}; ///< sum of applied weights
int n_obs{0}; int n_obs{0};
bool on_screen() const { return !last_seen.has_value(); } 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) 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); } 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); } void tick(double now) { std::lock_guard g(mu_); tick_locked(now); }
// ── Evidence ───────────────────────────────────────────────────────────── // ── Evidence ─────────────────────────────────────────────────────────────
/// Fold one observation into a track's belief (AR-025). `delta_logodds` is /// Fold one observation into a track's belief (AR-025).
/// already discounted for correlation by the caller — that judgement belongs ///
/// with whatever can tell a novel pose from a redundant one. /// `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 vote for a track that has already been reaped is dropped and counted:
/// a nonzero `dropped_votes()` means the timeout is shorter than the /// a nonzero `dropped_votes()` means the timeout is shorter than the
/// matcher's lag, which is a real misconfiguration and must not be silent. /// 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_); std::lock_guard g(mu_);
auto it = tracks_.find(track_id); auto it = tracks_.find(track_id);
if (it == tracks_.end()) { ++dropped_votes_; return; } if (it == tracks_.end()) { ++dropped_votes_; return; }
Track& t = it->second; 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; ++t.n_obs;
const int best = argmax_belief(t); const int best = argmax_belief(t);
@@ -261,7 +281,8 @@ private:
d.track_id = t.id; d.track_id = t.id;
d.first_seen = t.first_seen; d.first_seen = t.first_seen;
d.last_seen = end_ts; d.last_seen = end_ts;
d.observations = t.n_obs; d.observations = t.n_obs;
d.effective_obs = t.discounted_weight;
if (t.actor) { if (t.actor) {
d.actor_idx = *t.actor; d.actor_idx = *t.actor;
d.belief = logistic(t.belief[*t.actor]); d.belief = logistic(t.belief[*t.actor]);
@@ -295,7 +316,14 @@ private:
: std::exp(z) / (1.f + std::exp(z)); : 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_; Config cfg_;
EvidenceDiscounter discounter_;
mutable std::mutex mu_; mutable std::mutex mu_;
std::map<int, Track> tracks_; std::map<int, Track> tracks_;
std::map<int, int> owner_index_; ///< actor_idx → live track_id (AR-015) std::map<int, int> owner_index_; ///< actor_idx → live track_id (AR-015)
+101 -24
View File
@@ -11,6 +11,9 @@
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include "track_registry.hpp" #include "track_registry.hpp"
#include "evidence_discount.hpp"
#include <algorithm>
#include <vector> #include <vector>
@@ -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 cfg(double extinction = 5.0, float own = 2.0f) {
TrackRegistry::Config c; TrackRegistry::Config c;
c.extinction_sec = extinction; 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 ─────────────────────── // ── AR-012 — the change this whole redesign exists for ───────────────────────
TEST_CASE("window starts at first sighting, not at first recognition", "[registry][AR-012]") { 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); Sink sink; sink.attach(reg);
int id; int id;
@@ -58,7 +67,7 @@ TEST_CASE("window starts at first sighting, not at first recognition", "[registr
auto f = reg.begin_frame(t); auto f = reg.begin_frame(t);
f.mark_seen(id, t, axis(0)); 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); } { auto f = reg.begin_frame(31.0); f.mark_lost(id, 30.0); }
reg.tick(40.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 ─────────────────── // ── AR-013 — the asymmetry that removes the old over-claim ───────────────────
TEST_CASE("interior gaps are absorbed; the trailing cool-down is not", TEST_CASE("interior gaps are absorbed; the trailing cool-down is not",
"[registry][AR-013]") { "[registry][AR-013]") {
TrackRegistry reg(cfg(/*extinction=*/5.0)); TrackRegistry reg(cfg(/*extinction=*/5.0), disc());
Sink sink; sink.attach(reg); Sink sink; sink.attach(reg);
int id; int id;
{ auto f = reg.begin_frame(0.0); id = f.create(0.0, axis(0)); } { 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 // Off screen at 10, back at 13 — inside the timeout, so the same track
// continues and the actor is claimed present *through* the gap. // 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]") { 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); Sink sink; sink.attach(reg);
int a; int a;
{ auto f = reg.begin_frame(0.0); a = f.create(0.0, axis(0)); } { 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); } { auto f = reg.begin_frame(10.0); f.mark_lost(a, 10.0); }
reg.tick(30.0); // well past extinction 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 ─────────────────────────────────────────── // ── AR-016 — the silent-loss guard ───────────────────────────────────────────
TEST_CASE("EOF flush closes tracks still on screen", "[registry][AR-016]") { TEST_CASE("EOF flush closes tracks still on screen", "[registry][AR-016]") {
TrackRegistry reg(cfg()); TrackRegistry reg(cfg(), disc());
Sink sink; sink.attach(reg); Sink sink; sink.attach(reg);
int id; int id;
{ auto f = reg.begin_frame(100.0); id = f.create(100.0, axis(0)); } { 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. // A film almost always ends with faces on screen; these have not timed out.
reg.flush(/*final_ts=*/120.0); 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", TEST_CASE("flush closes a lost-but-unreaped track at its last sighting",
"[registry][AR-016]") { "[registry][AR-016]") {
TrackRegistry reg(cfg(/*extinction=*/60.0)); TrackRegistry reg(cfg(/*extinction=*/60.0), disc());
Sink sink; sink.attach(reg); Sink sink; sink.attach(reg);
int id; int id;
{ auto f = reg.begin_frame(0.0); id = f.create(0.0, axis(0)); } { 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); } { auto f = reg.begin_frame(10.0); f.mark_lost(id, 10.0); }
reg.flush(/*final_ts=*/50.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 ─────────────── // ── AR-014 — belief swap is a track boundary, not a correction ───────────────
TEST_CASE("belief swap closes one window and opens another", "[registry][AR-014]") { 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); Sink sink; sink.attach(reg);
int id; int id;
{ auto f = reg.begin_frame(0.0); id = f.create(0.0, axis(0)); } { 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); } { 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); CHECK(reg.belief_swaps() == 1);
REQUIRE(sink.claims.size() == 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 ──────────────────────── // ── AR-015 — identity contradiction as a cut detector ────────────────────────
TEST_CASE("two live tracks owned by one actor is counted", "[registry][AR-015]") { 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); Sink sink; sink.attach(reg);
int a, b; int a, b;
{ auto f = reg.begin_frame(0.0); a = f.create(0.0, axis(0)); b = f.create(0.0, axis(1)); } { 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); CHECK(reg.actor_conflicts() == 0);
// One person cannot be in two places at once, so this is a missed camera or // 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. // 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); CHECK(reg.actor_conflicts() == 1);
} }
// ── AR-017 / diagnostics ───────────────────────────────────────────────────── // ── AR-017 / diagnostics ─────────────────────────────────────────────────────
TEST_CASE("an unowned track emits no claim", "[registry][AR-012]") { TEST_CASE("an unowned track emits no claim", "[registry][AR-012]") {
TrackRegistry reg(cfg()); TrackRegistry reg(cfg(), disc());
Sink sink; sink.attach(reg); Sink sink; sink.attach(reg);
int id; int id;
{ auto f = reg.begin_frame(0.0); id = f.create(0.0, axis(0)); } { 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); } { auto f = reg.begin_frame(1.0); f.mark_lost(id, 1.0); }
reg.tick(100.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]") { TEST_CASE("claims carry the belief that justified them", "[registry][AR-017]") {
TrackRegistry reg(cfg()); TrackRegistry reg(cfg(), disc());
Sink sink; sink.attach(reg); Sink sink; sink.attach(reg);
int id; int id;
{ auto f = reg.begin_frame(0.0); id = f.create(0.0, axis(0)); } { 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); reg.flush(1.0);
REQUIRE(sink.claims.size() == 1); 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]") { 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); Sink sink; sink.attach(reg);
int id; 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. // 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. // 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); CHECK(reg.dropped_votes() == 1);
} }
TEST_CASE("a single-frame track yields a zero-length window", "[registry][AR-012]") { 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); Sink sink; sink.attach(reg);
int id; int id;
{ auto f = reg.begin_frame(42.0); id = f.create(42.0, axis(0)); } { 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); } { auto f = reg.begin_frame(43.0); f.mark_lost(id, 42.0); }
reg.tick(100.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].first_seen == 42.0);
CHECK(sink.claims[0].last_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
}