diff --git a/docs/requirements.md b/docs/requirements.md index aa7be67..9446397 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -46,7 +46,7 @@ Status: `Done` · `In Progress` · `Planned` · `TBD` · `Withdrawn` | 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 | **Done** — banded admission in probability space; replaces `expand_novelty_sim`. Rejections counted | -| AR-019 | Per-film gallery annex from owned tracks; acquires the non-frontal views TMDB lacks | SR-005 | Medium | In Progress | +| AR-019 | Per-film gallery annex from owned tracks; acquires the non-frontal views TMDB lacks | SR-005 | Medium | **Done** — all three discontinuity signals clear the buffers; ownership comes from the registry, not a second local tally | | AR-020 | Deferred re-identification of unknown tracks against the final expanded gallery | SR-005 | High | 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 | diff --git a/docs/traceability.md b/docs/traceability.md index 69f6d03..5bb4799 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -3,7 +3,7 @@ -**Generated:** 2026-07-31T13:06:54+00:00 +**Generated:** 2026-07-31T13:11:03+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`). @@ -12,12 +12,12 @@ Denominators are read from [`requirements.md`](requirements.md) at run time, nev | Metric | Value | |---|---| | Source files scanned | 108 | -| TRACES tags found | 105 | +| TRACES tags found | 110 | | EXCEPTION tags found | 0 | | Requirements defined | 67 | -| Requirements covered | 31 | -| **Coverage** | **46.3%** (31/67) | -| Coverage of CI-executable scope | 56.4% (31/55) | +| Requirements covered | 32 | +| **Coverage** | **47.8%** (32/67) | +| Coverage of CI-executable scope | 58.2% (32/55) | | Tagged but unexecuted in CI | 4 | | 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 | 18 | 1 | 30 | +| AR | 19 | 1 | 30 | | DP | 2 | 0 | 8 | | IR | 8 | 0 | 8 | | GR | 3 | 0 | 9 | @@ -83,7 +83,7 @@ _None._ | AR-002 | Planned | T2 | SR-002 | untagged | - | Minimum face size **32×32 px** (VR-005 measured), expressed in **orig… | | AR-003 | **Done** — `max_fac… | T1, T2, T4 | SR-002 | covered | `src/config.hpp`, `src/nodes/face_detector_node.hpp`, `src/nodes/identity_matcher_node.hpp` | No fixed per-frame face cap — crowd scenes must not lose background c… | | AR-004 | **Done** — KPN node… | T1, T4 | SR-002 | covered | `src/main.cpp`, `src/nodes/identity_matcher_node.hpp`, `tests/test_replay_fixtures.cpp` | Backpressure: unbounded faces/frame absorbed by slowing, never by dro… | -| AR-005 | Done | T1, T3 | SR-002 | covered | `src/face_utils.hpp` | Align to 112×112 via ArcFace 5-point similarity transform | +| AR-005 | **Done** — `umeyama… | T1, T3 | SR-002 | covered | `src/face_utils.hpp`, `tests/test_face_utils.cpp` | Align to 112×112 via ArcFace 5-point similarity transform, fitted by … | | AR-006 | Done | T3 | SR-002 | untagged | - | 512-d L2-normalised embeddings, batched | | AR-007 | **Done** — `track_a… | T2 | SR-002 | covered | `src/config.hpp`, `src/main.cpp`, `src/nodes/face_tracker_node.hpp` | Associate detections by IoU + embedding, with **frame-dependent** wei… | | AR-008 | **Done** — one pool… | T2 | SR-002 | covered | `src/config.hpp`, `src/main.cpp`, `src/nodes/face_tracker_node.hpp` | One track pool keyed on `last_seen`; no separate revival path | @@ -96,19 +96,19 @@ _None._ | 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/main.cpp`, `src/nodes/result_sink_node.hpp`, `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/nodes/result_sink_node.hpp`, `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 | **Done** — banded a… | T1, T2 | SR-005 | covered | `src/config.hpp`, `src/gallery/track_gallery.hpp`, `src/nodes/identity_matcher_node.hpp` | 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`, `src/nodes/identity_matcher_node.hpp` | Fit sigmoid calibration from intra/inter similarity distributions | -| AR-024 | **Done** — associat… | T1, static | SR-002 | covered | `src/config.hpp`, `src/evidence_discount.hpp`, `src/gallery/gallery_calibration.hpp`, `src/main.cpp`, `src/nodes/face_tracker_node.hpp`, `src/nodes/identity_matcher_node.hpp` | **Always the calibrated probability, never a raw cosine** — exception… | +| AR-024 | **Done** — associat… | T1, static | SR-002 | covered | `src/config.hpp`, `src/evidence_discount.hpp`, `src/gallery/gallery_calibration.hpp`, `src/gallery/track_gallery.hpp`, `src/main.cpp`, `src/nodes/face_tracker_node.hpp`, `src/nodes/identity_matcher_node.hpp` | **Always the calibrated probability, never a raw cosine** — exception… | | AR-025 | **Done** — log-odds… | T1 | SR-002 | covered | `src/evidence_discount.hpp`, `src/nodes/identity_matcher_node.hpp` | Per-track Bayesian accumulation in log-odds, with correlated-observat… | | AR-026 | In Progress | T1, T4 | SR-001 | covered | `src/backends/gemm_backend.cpp` | All similarity computed as GEMM, including annex and deferred pass | | AR-027 | Planned | T4 | SR-001 | tagged, unexecuted | `src/backends/gemm_backend.cpp` | Throughput acceptable for **arbitrary** gallery size | | AR-028 | Planned | T2 | SR-002 | untagged | - | **Embedding input quality assessed and carried** — every face scored … | | AR-029 | Planned | T1 | SR-002 | untagged | - | Sharpness measure on the **aligned crop** (scale-normalised, so it ca… | -| AR-030 | Planned | T1 | SR-002 | covered | `src/face_utils.hpp` | Visibility measure from the AR-001 5-point landmarks — extreme pose o… | +| AR-030 | **In Progress** — m… | T1 | SR-002 | covered | `src/face_utils.hpp`, `tests/test_face_utils.cpp` | Visibility measure from the AR-001 5-point landmarks — extreme pose o… | | 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-002 | Done | T1, manual | PR-004 | covered | `src/main.cpp` | Batch CLI over one title | | DP-003 | Planned | T1, manual | PR-004 | untagged | - | On-demand resident service with bounded, observable queue | @@ -161,7 +161,7 @@ _None._ - [`src/config.hpp:44`](../src/config.hpp#L44) — `Unknown` - [`src/nodes/face_detector_node.hpp:47`](../src/nodes/face_detector_node.hpp#L47) — `private:` -- [`src/nodes/identity_matcher_node.hpp:149`](../src/nodes/identity_matcher_node.hpp#L149) — `std::vector host_query(static_cast(kMaxFaces) * 512);` +- [`src/nodes/identity_matcher_node.hpp:155`](../src/nodes/identity_matcher_node.hpp#L155) — `std::vector host_query(static_cast(kMaxFaces) * 512);` ### AR-004 @@ -169,14 +169,15 @@ _None._ - [`src/main.cpp:86`](../src/main.cpp#L86) — `static constexpr std::size_t kSceneJoinDepth = 256;` - [`src/main.cpp:299`](../src/main.cpp#L299) — `Unknown` -- [`src/nodes/identity_matcher_node.hpp:149`](../src/nodes/identity_matcher_node.hpp#L149) — `std::vector host_query(static_cast(kMaxFaces) * 512);` +- [`src/nodes/identity_matcher_node.hpp:155`](../src/nodes/identity_matcher_node.hpp#L155) — `std::vector host_query(static_cast(kMaxFaces) * 512);` - [`tests/test_replay_fixtures.cpp:3`](../tests/test_replay_fixtures.cpp#L3) — `Unknown` ### AR-005 -**Locations:** 1 +**Locations:** 2 - [`src/face_utils.hpp:2`](../src/face_utils.hpp#L2) — `Unknown` +- [`tests/test_face_utils.cpp:1`](../tests/test_face_utils.cpp#L1) — `Unknown` ### AR-007 @@ -214,8 +215,8 @@ _None._ - [`src/main.cpp:213`](../src/main.cpp#L213) — `reg_cfg, EvidenceDiscounter(same_person));` - [`src/main.cpp:230`](../src/main.cpp#L230) — `reg_cfg, EvidenceDiscounter(same_person));` -- [`src/nodes/identity_matcher_node.hpp:119`](../src/nodes/identity_matcher_node.hpp#L119) — `const GalleryCalibration& calibration() const { return cal_; }` -- [`src/nodes/identity_matcher_node.hpp:255`](../src/nodes/identity_matcher_node.hpp#L255) — `Unknown` +- [`src/nodes/identity_matcher_node.hpp:125`](../src/nodes/identity_matcher_node.hpp#L125) — `const GalleryCalibration& calibration() const { return cal_; }` +- [`src/nodes/identity_matcher_node.hpp:261`](../src/nodes/identity_matcher_node.hpp#L261) — `Unknown` - [`src/nodes/result_sink_node.hpp:49`](../src/nodes/result_sink_node.hpp#L49) — `static constexpr std::string_view label() { return "result_sink"; }` - [`src/nodes/result_sink_node.hpp:161`](../src/nodes/result_sink_node.hpp#L161) — `struct ActorMeta { std::string name, imdb_id, tmdb_id, jellyfin_id; };` - [`src/track_registry.hpp:2`](../src/track_registry.hpp#L2) — `Unknown` @@ -261,32 +262,44 @@ _None._ - [`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-018 + +**Locations:** 3 + +- [`src/config.hpp:152`](../src/config.hpp#L152) — `Unknown` +- [`src/gallery/track_gallery.hpp:150`](../src/gallery/track_gallery.hpp#L150) — `struct TrackState` +- [`src/nodes/identity_matcher_node.hpp:110`](../src/nodes/identity_matcher_node.hpp#L110) — `std::vector host_gallery(static_cast(n_gallery_) * 512);` + ### AR-023 **Locations:** 3 - [`src/gallery/gallery_calibration.hpp:2`](../src/gallery/gallery_calibration.hpp#L2) — `Unknown` - [`src/gallery/gallery_calibration.hpp:53`](../src/gallery/gallery_calibration.hpp#L53) — `float boundary_at(float p = 0.5f, float log_prior_odds = 0.f) const` -- [`src/nodes/identity_matcher_node.hpp:111`](../src/nodes/identity_matcher_node.hpp#L111) — `const GalleryCalibration& calibration() const { return cal_; }` +- [`src/nodes/identity_matcher_node.hpp:117`](../src/nodes/identity_matcher_node.hpp#L117) — `const GalleryCalibration& calibration() const { return cal_; }` ### AR-024 -**Locations:** 6 +**Locations:** 10 - [`src/config.hpp:108`](../src/config.hpp#L108) — `Unknown` +- [`src/config.hpp:152`](../src/config.hpp#L152) — `Unknown` - [`src/evidence_discount.hpp:2`](../src/evidence_discount.hpp#L2) — `Unknown` - [`src/gallery/gallery_calibration.hpp:53`](../src/gallery/gallery_calibration.hpp#L53) — `float boundary_at(float p = 0.5f, float log_prior_odds = 0.f) const` +- [`src/gallery/track_gallery.hpp:122`](../src/gallery/track_gallery.hpp#L122) — `void forget(int track_id) { tracks_.erase(track_id); }` +- [`src/gallery/track_gallery.hpp:150`](../src/gallery/track_gallery.hpp#L150) — `struct TrackState` - [`src/main.cpp:213`](../src/main.cpp#L213) — `reg_cfg, EvidenceDiscounter(same_person));` - [`src/nodes/face_tracker_node.hpp:2`](../src/nodes/face_tracker_node.hpp#L2) — `Unknown` -- [`src/nodes/identity_matcher_node.hpp:111`](../src/nodes/identity_matcher_node.hpp#L111) — `const GalleryCalibration& calibration() const { return cal_; }` +- [`src/nodes/identity_matcher_node.hpp:110`](../src/nodes/identity_matcher_node.hpp#L110) — `std::vector host_gallery(static_cast(n_gallery_) * 512);` +- [`src/nodes/identity_matcher_node.hpp:117`](../src/nodes/identity_matcher_node.hpp#L117) — `const GalleryCalibration& calibration() const { return cal_; }` ### AR-025 **Locations:** 3 - [`src/evidence_discount.hpp:2`](../src/evidence_discount.hpp#L2) — `Unknown` -- [`src/nodes/identity_matcher_node.hpp:119`](../src/nodes/identity_matcher_node.hpp#L119) — `const GalleryCalibration& calibration() const { return cal_; }` -- [`src/nodes/identity_matcher_node.hpp:255`](../src/nodes/identity_matcher_node.hpp#L255) — `Unknown` +- [`src/nodes/identity_matcher_node.hpp:125`](../src/nodes/identity_matcher_node.hpp#L125) — `const GalleryCalibration& calibration() const { return cal_; }` +- [`src/nodes/identity_matcher_node.hpp:261`](../src/nodes/identity_matcher_node.hpp#L261) — `Unknown` ### AR-026 @@ -302,9 +315,10 @@ _None._ ### AR-030 -**Locations:** 1 +**Locations:** 2 - [`src/face_utils.hpp:2`](../src/face_utils.hpp#L2) — `Unknown` +- [`tests/test_face_utils.cpp:1`](../tests/test_face_utils.cpp#L1) — `Unknown` ### DP-001 @@ -537,7 +551,7 @@ _None._ ### SR-002 -**Locations:** 29 +**Locations:** 30 - [`src/config.hpp:44`](../src/config.hpp#L44) — `Unknown` - [`src/config.hpp:108`](../src/config.hpp#L108) — `Unknown` @@ -555,10 +569,10 @@ _None._ - [`src/nodes/face_detector_node.hpp:2`](../src/nodes/face_detector_node.hpp#L2) — `Unknown` - [`src/nodes/face_detector_node.hpp:47`](../src/nodes/face_detector_node.hpp#L47) — `private:` - [`src/nodes/face_tracker_node.hpp:2`](../src/nodes/face_tracker_node.hpp#L2) — `Unknown` -- [`src/nodes/identity_matcher_node.hpp:111`](../src/nodes/identity_matcher_node.hpp#L111) — `const GalleryCalibration& calibration() const { return cal_; }` -- [`src/nodes/identity_matcher_node.hpp:119`](../src/nodes/identity_matcher_node.hpp#L119) — `const GalleryCalibration& calibration() const { return cal_; }` -- [`src/nodes/identity_matcher_node.hpp:149`](../src/nodes/identity_matcher_node.hpp#L149) — `std::vector host_query(static_cast(kMaxFaces) * 512);` -- [`src/nodes/identity_matcher_node.hpp:255`](../src/nodes/identity_matcher_node.hpp#L255) — `Unknown` +- [`src/nodes/identity_matcher_node.hpp:117`](../src/nodes/identity_matcher_node.hpp#L117) — `const GalleryCalibration& calibration() const { return cal_; }` +- [`src/nodes/identity_matcher_node.hpp:125`](../src/nodes/identity_matcher_node.hpp#L125) — `const GalleryCalibration& calibration() const { return cal_; }` +- [`src/nodes/identity_matcher_node.hpp:155`](../src/nodes/identity_matcher_node.hpp#L155) — `std::vector host_query(static_cast(kMaxFaces) * 512);` +- [`src/nodes/identity_matcher_node.hpp:261`](../src/nodes/identity_matcher_node.hpp#L261) — `Unknown` - [`src/nodes/result_sink_node.hpp:49`](../src/nodes/result_sink_node.hpp#L49) — `static constexpr std::string_view label() { return "result_sink"; }` - [`src/nodes/result_sink_node.hpp:63`](../src/nodes/result_sink_node.hpp#L63) — `void set_pre_write_hook(std::function fn) { pre_write_ = std::move(fn); }` - [`src/nodes/result_sink_node.hpp:161`](../src/nodes/result_sink_node.hpp#L161) — `struct ActorMeta { std::string name, imdb_id, tmdb_id, jellyfin_id; };` @@ -568,6 +582,7 @@ _None._ - [`src/nodes/scene_detector_node.hpp:144`](../src/nodes/scene_detector_node.hpp#L144) — `void write_output()` - [`src/scene_boundaries.hpp:2`](../src/scene_boundaries.hpp#L2) — `Unknown` - [`src/track_registry.hpp:2`](../src/track_registry.hpp#L2) — `Unknown` +- [`tests/test_face_utils.cpp:1`](../tests/test_face_utils.cpp#L1) — `Unknown` ### SR-003 @@ -582,8 +597,12 @@ _None._ ### SR-005 -**Locations:** 1 +**Locations:** 5 +- [`src/config.hpp:152`](../src/config.hpp#L152) — `Unknown` +- [`src/gallery/track_gallery.hpp:122`](../src/gallery/track_gallery.hpp#L122) — `void forget(int track_id) { tracks_.erase(track_id); }` +- [`src/gallery/track_gallery.hpp:150`](../src/gallery/track_gallery.hpp#L150) — `struct TrackState` +- [`src/nodes/identity_matcher_node.hpp:110`](../src/nodes/identity_matcher_node.hpp#L110) — `std::vector host_gallery(static_cast(n_gallery_) * 512);` - [`scripts/make_jellyfin_gallery.py:4`](../scripts/make_jellyfin_gallery.py#L4) — `Unknown` ### UT-001 diff --git a/src/gallery/track_gallery.hpp b/src/gallery/track_gallery.hpp index 3eecddf..000776e 100644 --- a/src/gallery/track_gallery.hpp +++ b/src/gallery/track_gallery.hpp @@ -119,6 +119,16 @@ struct TrackGallery { // matcher when it observes a cut or track disappearance. void forget(int track_id) { tracks_.erase(track_id); } + /// TRACES: AR-019 | SR-005 + /// The registry's verdict on who this track is. Authoritative: it comes from + /// the Bayesian accumulation (AR-025), where the local tally counted raw + /// accepted frames and so weighted thirty near-identical looks the same as + /// thirty distinct ones. + void set_owner(int track_id, int actor_idx) { + if (track_id < 0 || actor_idx < 0) return; + tracks_[track_id].registry_owner = actor_idx; + } + /// TRACES: AR-024 | SR-005 /// Supply the calibration belonging to the active embedder. Without it the /// band falls back to treating cosine as probability, which is wrong but @@ -145,6 +155,7 @@ private: std::map actor_votes; // actor_idx → accepted-frame count int accepted_frames{0}; bool promoted{false}; + int registry_owner{-1}; ///< AR-019: authoritative }; /// TRACES: AR-018, AR-024 | SR-005 @@ -210,7 +221,7 @@ private: void promote(int track_id, TrackState& ts) { ts.promoted = true; // idempotent: never promote a track twice - int actor = plurality_actor(ts); + int actor = owning_actor(ts); if (actor < 0) return; // ── Safety gate: internal spread ───────────────────────────────────── @@ -246,6 +257,13 @@ private: << annex_.size() << "\n"; } + /// Prefer the registry's verdict; fall back to the local tally only when no + /// registry is attached (unit tests, replay harness). + static int owning_actor(const TrackState& ts) { + if (ts.registry_owner >= 0) return ts.registry_owner; + return plurality_actor(ts); + } + static int plurality_actor(const TrackState& ts) { int best = -1, best_votes = 0; for (const auto& [ai, v] : ts.actor_votes) { diff --git a/src/nodes/identity_matcher_node.hpp b/src/nodes/identity_matcher_node.hpp index 2c09949..776e658 100644 --- a/src/nodes/identity_matcher_node.hpp +++ b/src/nodes/identity_matcher_node.hpp @@ -144,7 +144,18 @@ struct IdentityMatcherFunc { // mix embeddings from two viewpoints under one buffer, so we still drop // every diversity buffer here — a revived track simply re-accumulates its // buffer from post-cut frames. Stale cross-cut embeddings are never promoted. - if (tf.source.is_cut) track_gallery_.clear_tracks(); + /// TRACES: AR-019 | SR-005 + // Promotion may only borrow same-identity evidence from a span where + // identity is certain, so ALL THREE discontinuity signals clear the + // buffers, not just the histogram cut: + // is_cut — camera-angle change + // is_scene_boundary — different scene (AR-010; previously never set, + // so this half of the gate was dead) + // The third, an identity contradiction (AR-015), is enforced by the + // registry: a track whose belief swapped is closed outright, so it can + // no longer promote anything. + if (tf.source.is_cut || tf.source.is_scene_boundary) + track_gallery_.clear_tracks(); const int n_faces = static_cast(tf.embeddings.size()); std::vector actors; @@ -271,6 +282,16 @@ struct IdentityMatcherFunc { registry_->observe(tf.track_ids[fi], best_actor, p, tf.embeddings[fi]); } + // TRACES: AR-019 | SR-005 + // Ownership is the registry's, computed once. TrackGallery used to + // tally its own plurality vote over accepted frames, which meant two + // different answers to "who is this track" could coexist — and the + // expansion one ignored the Bayesian accumulation entirely. + if (registry_ && tf.track_ids[fi] >= 0) { + if (auto owner = registry_->owner(tf.track_ids[fi])) + track_gallery_.set_owner(tf.track_ids[fi], *owner); + } + track_gallery_.observe(tf.track_ids[fi], tf.embeddings[fi], best_actor, best_s, accept, tf.crops[fi]);