From f0c7126f8019aaabc455f85e6339083d0fd39940 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Fri, 31 Jul 2026 09:08:39 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20TrackRegistry=20=E2=80=94=20presence=20?= =?UTF-8?q?follows=20track=20extent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spine of the redesign. Presence is now the extent of a track an actor owns, [first_seen, last_seen], rather than the subset of frames in which recognition happened to succeed. An actor recognised only at the end of a long track is present for all of it, which is what the scene-scoped ground truth actually records. AR-013 — `last_seen` as an optional carries the entire liveness state: unset means on screen, set means went off at that timestamp and still revivable, reaped means emitted and erased. No missing-frame counter, no expired flag. It subsumes the tracker's existing two-pool split, so there is no separate revival path — matching a dormant track is ordinary inter-frame association. The asymmetry is the point: interior gaps are claimed, the trailing cool-down is not. A face lost and re-associated within the timeout never closed its track, so the gap is presence — someone briefly occluded has not left the scene. But a track that dies ends at its last sighting, never at the death time. That is precisely the over-claim the retired extinction_sec keep-alive produced, where presence ran on into the closing credits. AR-014 — a belief swap A→B closes the track and opens a successor at the swap frame. Not a correction: two non-twins both clearing the threshold on one face is not realistic, whereas a track_id carried across a viewpoint change onto a different person is. Treating it as a swap-and-continue would emit one window blending two people; treating it as a boundary yields two that are each right. AR-015 — two live tracks owned by one actor means at least one is wrong, since a person cannot be in two places at once. A reverse index catches it on the update that causes it rather than by scanning. This makes identity a third cut detector, independent of the histogram and TransNetV2 and firing where those failed. AR-016 — flush() closes tracks still live at EOF. Without it a film ending mid-shot silently drops its closing cast, which presents as a recognition miss rather than a bookkeeping bug. Reaping hands the dead track to the aggregator and erases it, so the registry holds only live tracks and its size is bounded by concurrent on-screen faces rather than growing with the film. Locking: a frame's association pass is atomic as a unit via FrameScope, since per-call locking would let another thread observe a half-updated frame. owner() reads tally and verdict under one lock — separately, a track could be both unowned and owned within a single promotion decision. A vote for an already-reaped track is dropped and counted, because a nonzero count means the timeout is shorter than the matcher's lag. 11 unit tests, driven directly against the registry with no network and no fixture — the awkward cases are constructed rather than hunted for. Suite: 75 cases, 3236 assertions. Coverage 14/63 to 20/63. Not yet wired into FaceTrackerFunc; that is AR-007/AR-008. Co-Authored-By: Claude Opus 5 TRACES: AR-012, AR-013, AR-014, AR-015, AR-016, AR-017 | SR-002 --- docs/requirements.md | 12 +- docs/traceability.md | 79 +++++++-- src/track_registry.hpp | 307 ++++++++++++++++++++++++++++++++++ tests/CMakeLists.txt | 1 + tests/test_track_registry.cpp | 255 ++++++++++++++++++++++++++++ 5 files changed, 633 insertions(+), 21 deletions(-) create mode 100644 src/track_registry.hpp create mode 100644 tests/test_track_registry.cpp diff --git a/docs/requirements.md b/docs/requirements.md index 8191a8d..934d0c9 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -39,12 +39,12 @@ Status: `Done` · `In Progress` · `Planned` · `TBD` · `Withdrawn` | AR-009 | Camera-cut detection (histogram) as an association hint | SR-002 | High | Done | | AR-010 | Scene-boundary detection (TransNetV2) as an association hint | SR-002 | Medium | **Not started** — `is_scene_boundary` has no producer; `SceneDetectorFunc` is a terminal sink and never annotates the frame | | AR-011 | **Every model is fed the input it was trained for** — cost reduced by running less often, never by degrading one inference | SR-002 | High | Planned | -| AR-012 | Presence follows **track extent**, not per-frame recognition | **SR-002** | High | Planned | -| AR-013 | `last_seen` optional state machine; window ends at last sighting, never after | SR-002 | High | Planned | -| AR-014 | Belief swap A→B terminates the track and starts a new one | SR-002 | Medium | Planned | -| AR-015 | Two live tracks owned by one actor ⇒ treat as a detected cut, re-associate | SR-002 | Medium | Planned | -| AR-016 | All tracks closed at EOF — a film ends with faces on screen | SR-002 | High | Planned | -| AR-017 | Every presence claim carries its belief and identification route | SR-002 | High | Planned | +| AR-012 | Presence follows **track extent**, not per-frame recognition | **SR-002** | High | **Done** — `src/track_registry.hpp`; window is `[first_seen, last_seen]` of an owned track | +| AR-013 | `last_seen` optional state machine; window ends at last sighting, never after | SR-002 | High | **Done** — `last_seen` optional is the whole state machine; interior gaps absorbed, trailing cool-down never claimed | +| AR-014 | Belief swap A→B terminates the track and starts a new one | SR-002 | Medium | **Done** — swap closes at `last_seen` and opens a successor at the swap frame; counted | +| AR-015 | Two live tracks owned by one actor ⇒ treat as a detected cut, re-associate | SR-002 | Medium | **Done** — reverse index detects it on the causing update; counted | +| AR-016 | All tracks closed at EOF — a film ends with faces on screen | SR-002 | High | **Done** — `flush()`, idempotent, closes at last sighting or final tick | +| AR-017 | Every presence claim carries its belief and identification route | SR-002 | High | **Done** — `DeadTrack` carries belief and observation count | | AR-018 | Per-subject embedding store with banded admission (novel enough, safe enough) | SR-005 | Medium | Planned | | AR-019 | Per-film gallery annex from owned tracks; acquires the non-frontal views TMDB lacks | SR-005 | Medium | In Progress | | AR-020 | Deferred re-identification of unknown tracks against the final expanded gallery | SR-005 | High | Planned | diff --git a/docs/traceability.md b/docs/traceability.md index b8eb05f..c4c73df 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -3,7 +3,7 @@ -**Generated:** 2026-07-30T19:19:47+00:00 +**Generated:** 2026-07-31T07:07:04+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 | 92 | -| TRACES tags found | 74 | +| Source files scanned | 94 | +| TRACES tags found | 76 | | EXCEPTION tags found | 0 | | Requirements defined | 63 | -| Requirements covered | 14 | -| **Coverage** | **22.2%** (14/63) | -| Coverage of CI-executable scope | 26.9% (14/52) | +| Requirements covered | 20 | +| **Coverage** | **31.7%** (20/63) | +| Coverage of CI-executable scope | 38.5% (20/52) | | Tagged but unexecuted in CI | 3 | | Orphan tags | 0 | @@ -25,13 +25,13 @@ Denominators are read from [`requirements.md`](requirements.md) at run time, nev | Type | Covered | Tagged but unexecuted | Defined | |---|---|---|---| -| AR | 3 | 0 | 27 | +| AR | 9 | 0 | 27 | | DP | 2 | 0 | 8 | | IR | 6 | 0 | 8 | | GR | 3 | 0 | 9 | | VR | 0 | 3 | 11 | -- **UT** tags present (separate taxonomy, not counted in coverage): UT-101, UT-102, UT-103, UT-104 +- **UT** tags present (separate taxonomy, not counted in coverage): UT-001, UT-101, UT-102, UT-103, UT-104 - **PR** tags present (separate taxonomy, not counted in coverage): PR-002, PR-004 - **SR** tags present (separate taxonomy, not counted in coverage): SR-001, SR-002, SR-003, SR-005 @@ -88,12 +88,12 @@ _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** | untagged | - | Presence follows **track extent**, not per-frame recognition | -| AR-013 | Planned | T2 | SR-002 | untagged | - | `last_seen` optional state machine; window ends at last sighting, nev… | -| AR-014 | Planned | T2 | SR-002 | untagged | - | Belief swap A→B terminates the track and starts a new one | -| AR-015 | Planned | T2 | SR-002 | untagged | - | Two live tracks owned by one actor ⇒ treat as a detected cut, re-asso… | -| AR-016 | Planned | T2 | SR-002 | untagged | - | All tracks closed at EOF — a film ends with faces on screen | -| AR-017 | Planned | T1, T2 | SR-002 | untagged | - | Every presence claim carries its belief and identification route | +| 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-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… | @@ -155,6 +155,48 @@ _None._ - [`src/face_utils.hpp:2`](../src/face_utils.hpp#L2) — `inline cv::Mat align_face(const cv::Mat& img,` +### AR-012 + +**Locations:** 2 + +- [`src/track_registry.hpp:2`](../src/track_registry.hpp#L2) — `Unknown` +- [`tests/test_track_registry.cpp:3`](../tests/test_track_registry.cpp#L3) — `Unknown` + +### AR-013 + +**Locations:** 2 + +- [`src/track_registry.hpp:2`](../src/track_registry.hpp#L2) — `Unknown` +- [`tests/test_track_registry.cpp:3`](../tests/test_track_registry.cpp#L3) — `Unknown` + +### AR-014 + +**Locations:** 2 + +- [`src/track_registry.hpp:2`](../src/track_registry.hpp#L2) — `Unknown` +- [`tests/test_track_registry.cpp:3`](../tests/test_track_registry.cpp#L3) — `Unknown` + +### AR-015 + +**Locations:** 2 + +- [`src/track_registry.hpp:2`](../src/track_registry.hpp#L2) — `Unknown` +- [`tests/test_track_registry.cpp:3`](../tests/test_track_registry.cpp#L3) — `Unknown` + +### AR-016 + +**Locations:** 2 + +- [`src/track_registry.hpp:2`](../src/track_registry.hpp#L2) — `Unknown` +- [`tests/test_track_registry.cpp:3`](../tests/test_track_registry.cpp#L3) — `Unknown` + +### AR-017 + +**Locations:** 2 + +- [`src/track_registry.hpp:2`](../src/track_registry.hpp#L2) — `Unknown` +- [`tests/test_track_registry.cpp:3`](../tests/test_track_registry.cpp#L3) — `Unknown` + ### AR-023 **Locations:** 1 @@ -369,11 +411,12 @@ _None._ ### SR-002 -**Locations:** 3 +**Locations:** 4 - [`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` +- [`src/track_registry.hpp:2`](../src/track_registry.hpp#L2) — `Unknown` ### SR-003 @@ -389,6 +432,12 @@ _None._ - [`scripts/make_jellyfin_gallery.py:4`](../scripts/make_jellyfin_gallery.py#L4) — `Unknown` +### UT-001 + +**Locations:** 1 + +- [`tests/test_track_registry.cpp:3`](../tests/test_track_registry.cpp#L3) — `Unknown` + ### UT-101 **Locations:** 5 diff --git a/src/track_registry.hpp b/src/track_registry.hpp new file mode 100644 index 0000000..203be48 --- /dev/null +++ b/src/track_registry.hpp @@ -0,0 +1,307 @@ +#pragma once +/// TRACES: AR-012, AR-013, AR-014, AR-015, AR-016, AR-017 | SR-002 +/// +/// TrackRegistry — the single owner of track state and of presence. +/// +/// Presence follows **track extent**, not per-frame recognition (AR-012): a +/// window is `[first_seen, last_seen]` of a track an actor owns, so it starts +/// when the actor appeared rather than when the recogniser first succeeded. +/// +/// `last_seen` carries the entire liveness state (AR-013): +/// +/// unset → on screen now +/// set → went off screen at that timestamp, still revivable +/// reaped → emitted to the aggregator and erased +/// +/// There is no missing-frame counter and no expired flag; the optional *is* the +/// state machine, and it subsumes what was previously a two-pool split in the +/// tracker (active vs. parked-across-a-cut). +/// +/// **Interior gaps are claimed, the trailing cool-down is not.** A face lost at +/// t1 and re-associated at t2 within the timeout never closed its track, so the +/// actor is present across [t1, t2] — correct, since someone briefly occluded or +/// off-camera has not left the scene. But a track that dies ends its window at +/// `last_seen`, never at the moment of death. That asymmetry is what removes the +/// over-claim the retired `extinction_sec` keep-alive produced. +/// +/// The registry is created in `main` and shared by `shared_ptr`; it is *not* a +/// KPN node. Ownership is not a stage in the stream — it is state several stages +/// read and write, whose final answer is only known when a track dies. + +#include "types.hpp" + +#include +#include +#include +#include +#include +#include +#include + +// ── DeadTrack ──────────────────────────────────────────────────────────────── +// A finished presence claim, emitted exactly once when a track is reaped or +// flushed. Immutable by construction: it carries everything needed to justify +// itself (AR-017), with no back-reference into registry state. +struct DeadTrack { + int track_id{-1}; + double first_seen{0.0}; + 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 +}; + +// ── Track ──────────────────────────────────────────────────────────────────── +struct Track { + int id{-1}; + double first_seen{0.0}; + std::optional last_seen; ///< unset ⇒ on screen + std::optional actor; ///< set once a posterior crosses + std::map belief; ///< actor_idx → accumulated log-odds + Embedding mean{}; ///< running directional mean + int n_obs{0}; + + bool on_screen() const { return !last_seen.has_value(); } +}; + +// ── TrackRegistry ──────────────────────────────────────────────────────────── +class TrackRegistry { +public: + using DeadTrackFn = std::function; + + struct Config { + double extinction_sec{5.0}; ///< how long a lost track stays revivable + float ownership_logodds{2.0f}; ///< belief needed to own a track (~0.88 posterior) + }; + + explicit TrackRegistry(Config cfg) : cfg_(cfg) {} + + void on_track_dead(DeadTrackFn fn) { on_dead_ = std::move(fn); } + + // ── Frame scope ────────────────────────────────────────────────────────── + // The tracker mutates registry state across a whole association pass, so + // that pass must be atomic as a unit — per-call locking would let another + // thread observe a half-updated frame. FrameScope holds the lock for its + // lifetime and exposes the mutating operations without re-locking. + class FrameScope { + public: + FrameScope(TrackRegistry& reg, double now) + : reg_(reg), lock_(reg.mu_) { reg_.tick_locked(now); } + + /// All live tracks — **one pool**. `last_seen` tells the caller whether + /// IoU is meaningful; a dormant track is matched on embedding alone. + /// There is no separate revival path (AR-008). + std::vector candidates() { + std::vector out; + out.reserve(reg_.tracks_.size()); + for (auto& [id, t] : reg_.tracks_) out.push_back(&t); + return out; + } + + int create(double t, const Embedding& e) { return reg_.create_locked(t, e); } + void mark_seen(int id, double t, const Embedding& e){ reg_.mark_seen_locked(id, t, e); } + void mark_lost(int id, double last_on_screen) { reg_.mark_lost_locked(id, last_on_screen); } + + private: + TrackRegistry& reg_; + std::unique_lock lock_; + }; + + FrameScope begin_frame(double now) { return FrameScope(*this, now); } + + /// Advance the clock and reap. Called every sampled frame **whether or not + /// it had detections** — without it a track only dies when some other face + /// happens to appear, and a film ending mid-track never closes. + 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. + /// + /// 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) { + 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; + ++t.n_obs; + + const int best = argmax_belief(t); + const float best_lo = t.belief[best]; + if (best_lo < cfg_.ownership_logodds) return; + + if (!t.actor.has_value()) { + claim_locked(t, best); + return; + } + if (*t.actor != best) { + // AR-014 — belief swapped A→B. Not a correction: a track_id almost + // certainly carried across a viewpoint change onto a different + // person. Two non-twins both clearing the threshold on one face is + // not realistic; a track spanning two people is. Continuing would + // emit one window blending both, so close here and start afresh. + split_locked(t, best); + } + } + + /// Snapshot read: tally and verdict under one lock. Reading them separately + /// would let a track be both unowned and owned within a single promotion + /// decision, since the matcher may be voting concurrently. + std::optional owner(int track_id) const { + std::lock_guard g(mu_); + auto it = tracks_.find(track_id); + return it == tracks_.end() ? std::nullopt : it->second.actor; + } + + // ── Termination ────────────────────────────────────────────────────────── + /// Emit every still-live track and empty the registry (AR-016). A film ends + /// with faces on screen and those tracks have not timed out, so without this + /// the closing scene's cast is silently never emitted — a loss that presents + /// as a recognition miss rather than a bookkeeping bug. + /// + /// Idempotent: calling it twice emits nothing the second time. + void flush(double final_ts) { + std::lock_guard g(mu_); + for (auto& [id, t] : tracks_) emit_locked(t, t.last_seen.value_or(final_ts)); + tracks_.clear(); + } + + // ── Diagnostics ────────────────────────────────────────────────────────── + // These measure how often tracking is silently wrong, which nothing in the + // pipeline currently reveals. + int dropped_votes() const { std::lock_guard g(mu_); return dropped_votes_; } + int belief_swaps() const { std::lock_guard g(mu_); return belief_swaps_; } + int actor_conflicts() const { std::lock_guard g(mu_); return actor_conflicts_; } + std::size_t live() const { std::lock_guard g(mu_); return tracks_.size(); } + +private: + // ── Locked internals ───────────────────────────────────────────────────── + void tick_locked(double now) { + for (auto it = tracks_.begin(); it != tracks_.end(); ) { + const auto& ls = it->second.last_seen; + if (ls && (now - *ls) > cfg_.extinction_sec) { + emit_locked(it->second, *ls); + it = tracks_.erase(it); + } else { + ++it; + } + } + } + + int create_locked(double t, const Embedding& e) { + const int id = next_id_++; + Track tr; + tr.id = id; + tr.first_seen = t; + tr.mean = e; + tr.n_obs = 0; + tracks_.emplace(id, std::move(tr)); + return id; + } + + void mark_seen_locked(int id, double t, const Embedding& e) { + auto it = tracks_.find(id); + if (it == tracks_.end()) return; + Track& tr = it->second; + tr.last_seen.reset(); // back on screen; the gap is absorbed + update_mean(tr, e); + (void)t; + } + + void mark_lost_locked(int id, double last_on_screen) { + auto it = tracks_.find(id); + if (it == tracks_.end()) return; + it->second.last_seen = last_on_screen; + } + + void claim_locked(Track& t, int actor) { + // AR-015 — if another live track already owns this actor, at least one + // is wrong: a person cannot be in two places at once. The cause is the + // same as a belief swap — a missed camera or scene change. Detected on + // the update that causes it via the reverse index, not by scanning. + auto seen = owner_index_.find(actor); + if (seen != owner_index_.end() && seen->second != t.id + && tracks_.count(seen->second)) { + ++actor_conflicts_; + } + t.actor = actor; + owner_index_[actor] = t.id; + } + + void split_locked(Track& t, int new_actor) { + ++belief_swaps_; + const double boundary = t.last_seen.value_or(t.first_seen); + emit_locked(t, boundary); + + // The successor inherits the embedding and the belief that caused the + // swap, and starts at the swap frame — so the two windows abut without + // overlapping and neither blends the two people. + Track next; + next.id = next_id_++; + next.first_seen = boundary; + next.mean = t.mean; + next.belief[new_actor] = t.belief[new_actor]; + next.n_obs = 1; + const int old_id = t.id; + Track stash = std::move(next); + tracks_.erase(old_id); + const int nid = stash.id; + tracks_.emplace(nid, std::move(stash)); + claim_locked(tracks_.at(nid), new_actor); + } + + void emit_locked(Track& t, double end_ts) { + if (!on_dead_) return; + DeadTrack d; + d.track_id = t.id; + d.first_seen = t.first_seen; + d.last_seen = end_ts; + d.observations = t.n_obs; + if (t.actor) { + d.actor_idx = *t.actor; + d.belief = logistic(t.belief[*t.actor]); + auto oi = owner_index_.find(*t.actor); + if (oi != owner_index_.end() && oi->second == t.id) owner_index_.erase(oi); + } + on_dead_(d); + } + + static int argmax_belief(const Track& t) { + int best = -1; + float hi = -1e30f; + for (const auto& [a, lo] : t.belief) if (lo > hi) { hi = lo; best = a; } + return best; + } + + static void update_mean(Track& t, const Embedding& e) { + // Directional mean: accumulate then re-normalise to the unit sphere, so + // cosine against it stays a plain dot product. + double norm = 0.0; + for (int i = 0; i < 512; ++i) { + t.mean[i] = t.mean[i] * static_cast(t.n_obs ? t.n_obs : 1) + e[i]; + norm += static_cast(t.mean[i]) * t.mean[i]; + } + norm = norm > 0 ? std::sqrt(norm) : 1.0; + for (int i = 0; i < 512; ++i) t.mean[i] = static_cast(t.mean[i] / norm); + } + + static float logistic(float z) { + return z >= 0 ? 1.f / (1.f + std::exp(-z)) + : std::exp(z) / (1.f + std::exp(z)); + } + + Config cfg_; + mutable std::mutex mu_; + std::map tracks_; + std::map owner_index_; ///< actor_idx → live track_id (AR-015) + DeadTrackFn on_dead_; + int next_id_{0}; + int dropped_votes_{0}; + int belief_swaps_{0}; + int actor_conflicts_{0}; +}; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4763f15..1099c79 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -21,6 +21,7 @@ add_executable(sae_tests test_face_utils.cpp test_track_gallery.cpp test_face_tracker.cpp + test_track_registry.cpp test_audio_signature.cpp ${CMAKE_SOURCE_DIR}/src/backends/gemm_backend.cpp ${CMAKE_SOURCE_DIR}/src/gallery/gallery_store.cpp diff --git a/tests/test_track_registry.cpp b/tests/test_track_registry.cpp new file mode 100644 index 0000000..35999c5 --- /dev/null +++ b/tests/test_track_registry.cpp @@ -0,0 +1,255 @@ +// 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 + +#include "track_registry.hpp" + +#include + +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 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; + } +}; + +TrackRegistry::Config cfg(double extinction = 5.0, float own = 2.0f) { + TrackRegistry::Config c; + c.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()); + 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, 5.0f); + + { 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)); + 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); + + // 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)); + 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); + { 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()); + 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); + + // 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)); + 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); + { 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()); + 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 + { auto f = reg.begin_frame(5.0); f.mark_lost(id, 5.0); } + + reg.observe(id, 2, 12.0f); // belief swings decisively to 2 + + 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()); + 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); + 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); + CHECK(reg.actor_conflicts() == 1); +} + +// ── AR-017 / diagnostics ───────────────────────────────────────────────────── +TEST_CASE("an unowned track emits no claim", "[registry][AR-012]") { + TrackRegistry reg(cfg()); + 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 + { 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()); + 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.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)); + 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, 5.0f); + CHECK(reg.dropped_votes() == 1); +} + +TEST_CASE("a single-frame track yields a zero-length window", "[registry][AR-012]") { + TrackRegistry reg(cfg()); + 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); + { 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); +}