From 08941540cbd480be9aa457f2d68864e8ee76e0da Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Fri, 31 Jul 2026 10:10:51 +0200 Subject: [PATCH] feat: presence windows come from registry claims (schema_version 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sink no longer reconstructs presence from per-frame detections. A reaped track already IS a window — [first_seen, last_seen] of a track an actor owned — so it is pushed straight to the aggregator when it dies and written out as-is. AR-012 completed end to end. The annealing pass is deleted, not disabled: anneal_sec existed only to bridge gaps between isolated accepted frames, and a track that survives its own gaps leaves it nothing to do. The field is REMOVED from the output rather than zeroed — a field naming a mechanism the pipeline no longer has is actively misleading to anyone reading a manifest, and would outlive everyone who remembers why it reads 0. IR-002 — schema_version 2, matching jRay/SPEC.md JR-002. Windows become objects carrying `belief` and `route` rather than bare float pairs, so a consumer can caveat or filter instead of treating every window as equally certain. The new `extraction` block carries `extinction_sec` (the successor to anneal_sec, and what a consumer actually needs to interpret a window) and `gallery_scope` — global vs limited being the strongest single quality signal when two manifests compete for one cut, since identical gallery_size can mean very different recall. AR-016 wired: a pre-write hook flushes the registry with the last timestamp seen, so tracks still live at EOF are emitted. A film ends with faces on screen and those tracks have not timed out; without this the closing scene's cast is silently dropped, which reads as a recognition miss rather than a bookkeeping bug. IR-003 stays In Progress deliberately: the sink now writes after the flush, but the deferred re-identification pass (AR-020) does not exist yet, so output is still final at EOF rather than after it. This is a BREAKING format change and part of the coordinated SR-003 bump — it must ship together with the jRay reader and the server's acceptance of the new shape, not ahead of them. Suite: 80 cases, 3250 assertions. Co-Authored-By: Claude Opus 5 TRACES: AR-012, AR-016, IR-002, IR-003 | SR-002, SR-003 --- docs/requirements.md | 4 +- docs/traceability.md | 63 ++++++++++++----- src/config.hpp | 8 ++- src/main.cpp | 11 +++ src/nodes/result_sink_node.hpp | 119 +++++++++++++++++++++++---------- 5 files changed, 151 insertions(+), 54 deletions(-) diff --git a/docs/requirements.md b/docs/requirements.md index cbb9859..3ba8cd7 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -74,8 +74,8 @@ Status: `Done` · `In Progress` · `Planned` · `TBD` · `Withdrawn` | ID | Requirement | Traces to | Priority | Status | |---|---|---|---|---| | IR-001 | Emit the JRay truth format as sibling `.jray.json` | SR-003 | High | Done | -| IR-002 | Windows carry belief + route; `extraction.*` carries `extinction_sec`, `gallery_scope` | SR-003 | High | Planned | -| IR-003 | Output written **after** the deferred pass, not at EOF | SR-003 | High | Planned | +| IR-002 | Windows carry belief + route; `extraction.*` carries `extinction_sec`, `gallery_scope` | SR-003 | High | **Done** — `schema_version: 2`; windows are objects with `belief` + `route`; `extraction.*` carries `extinction_sec` and `gallery_scope`; `anneal_sec` removed | +| IR-003 | Output written **after** the deferred pass, not at EOF | SR-003 | High | **In Progress** — sink builds windows from registry claims and flushes at EOF; the deferred pass (AR-020) does not exist yet, so output is still final at EOF | | IR-004 | Compute the audio signature exactly per server spec §3 | SR-003 | Medium | **Done** — `src/audio_signature.*`; not yet emitted into the truth file (IR-002) | | IR-005 | Golden-vector fixture shared with the plugin repo to prove bit-exactness | SR-003 | High | **Done** — `tests/fixtures/audio/`; v1 parameters now normative in server spec §3 | | IR-007 | Media < 120 s: emit no signature, apply no sync offset — identical rule in both producers | SR-003 | Low | **Done** | diff --git a/docs/traceability.md b/docs/traceability.md index dd62272..542f37a 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -3,7 +3,7 @@ -**Generated:** 2026-07-31T07:15:30+00:00 +**Generated:** 2026-07-31T08:01:31+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 | 95 | -| TRACES tags found | 77 | +| TRACES tags found | 82 | | EXCEPTION tags found | 0 | | Requirements defined | 63 | -| Requirements covered | 22 | -| **Coverage** | **34.9%** (22/63) | -| Coverage of CI-executable scope | 42.3% (22/52) | +| Requirements covered | 24 | +| **Coverage** | **38.1%** (24/63) | +| Coverage of CI-executable scope | 46.2% (24/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 | 11 | 0 | 27 | +| AR | 13 | 0 | 27 | | DP | 2 | 0 | 8 | | IR | 6 | 0 | 8 | | GR | 3 | 0 | 9 | @@ -83,12 +83,12 @@ _None._ | AR-004 | Planned | T1, T4 | SR-002 | untagged | - | 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-006 | Done | T3 | SR-002 | untagged | - | 512-d L2-normalised embeddings, batched | -| AR-007 | In Progress | T2 | SR-002 | untagged | - | Associate detections by IoU + embedding, with **frame-dependent** wei… | -| AR-008 | Planned | T2 | SR-002 | untagged | - | One track pool keyed on `last_seen`; no separate revival path | +| 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 | | 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 | **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-012 | **Done** — `src/tra… | T2 | **SR-002** | covered | `src/main.cpp`, `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… | @@ -99,8 +99,8 @@ _None._ | 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 | **Done** (registry … | T1, static | SR-002 | covered | `src/evidence_discount.hpp` | **Always the calibrated probability, never a raw cosine** — exception… | +| 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-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 | @@ -155,10 +155,27 @@ _None._ - [`src/face_utils.hpp:2`](../src/face_utils.hpp#L2) — `inline cv::Mat align_face(const cv::Mat& img,` +### AR-007 + +**Locations:** 3 + +- [`src/config.hpp:97`](../src/config.hpp#L97) — `Unknown` +- [`src/main.cpp:199`](../src/main.cpp#L199) — `reg_cfg, EvidenceDiscounter(same_person));` +- [`src/nodes/face_tracker_node.hpp:2`](../src/nodes/face_tracker_node.hpp#L2) — `Unknown` + +### AR-008 + +**Locations:** 3 + +- [`src/config.hpp:97`](../src/config.hpp#L97) — `Unknown` +- [`src/main.cpp:199`](../src/main.cpp#L199) — `reg_cfg, EvidenceDiscounter(same_person));` +- [`src/nodes/face_tracker_node.hpp:2`](../src/nodes/face_tracker_node.hpp#L2) — `Unknown` + ### AR-012 -**Locations:** 2 +**Locations:** 3 +- [`src/main.cpp:199`](../src/main.cpp#L199) — `reg_cfg, EvidenceDiscounter(same_person));` - [`src/track_registry.hpp:2`](../src/track_registry.hpp#L2) — `Unknown` - [`tests/test_track_registry.cpp:3`](../tests/test_track_registry.cpp#L3) — `Unknown` @@ -199,15 +216,22 @@ _None._ ### AR-023 -**Locations:** 1 +**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:110`](../src/nodes/identity_matcher_node.hpp#L110) — `const GalleryCalibration& calibration() const { return cal_; }` ### AR-024 -**Locations:** 1 +**Locations:** 6 +- [`src/config.hpp:97`](../src/config.hpp#L97) — `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/main.cpp:199`](../src/main.cpp#L199) — `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:110`](../src/nodes/identity_matcher_node.hpp#L110) — `const GalleryCalibration& calibration() const { return cal_; }` ### AR-025 @@ -252,7 +276,7 @@ _None._ - [`src/gallery/gallery_store.cpp:219`](../src/gallery/gallery_store.cpp#L219) — `Unknown` - [`src/kpn_bindings.cpp:167`](../src/kpn_bindings.cpp#L167) — `Unknown` - [`src/kpn_bindings.cpp:217`](../src/kpn_bindings.cpp#L217) — `Unknown` -- [`src/main.cpp:176`](../src/main.cpp#L176) — `Unknown` +- [`src/main.cpp:174`](../src/main.cpp#L174) — `Unknown` - [`src/nodes/embedding_dump_node.hpp:30`](../src/nodes/embedding_dump_node.hpp#L30) — `static constexpr std::string_view label() { return "embedding_dump"; }` - [`src/nodes/embedding_dump_node.hpp:102`](../src/nodes/embedding_dump_node.hpp#L102) — `H5::StrType str(H5::PredType::C_S1, H5T_VARIABLE);` - [`src/scene_preview.cpp:133`](../src/scene_preview.cpp#L133) — `int main(int argc, char** argv)` @@ -383,7 +407,7 @@ _None._ - [`src/gallery/gallery_store.cpp:219`](../src/gallery/gallery_store.cpp#L219) — `Unknown` - [`src/kpn_bindings.cpp:167`](../src/kpn_bindings.cpp#L167) — `Unknown` - [`src/kpn_bindings.cpp:217`](../src/kpn_bindings.cpp#L217) — `Unknown` -- [`src/main.cpp:176`](../src/main.cpp#L176) — `Unknown` +- [`src/main.cpp:174`](../src/main.cpp#L174) — `Unknown` - [`src/nodes/embedding_dump_node.hpp:30`](../src/nodes/embedding_dump_node.hpp#L30) — `static constexpr std::string_view label() { return "embedding_dump"; }` - [`src/nodes/embedding_dump_node.hpp:102`](../src/nodes/embedding_dump_node.hpp#L102) — `H5::StrType str(H5::PredType::C_S1, H5T_VARIABLE);` - [`src/scene_preview.cpp:133`](../src/scene_preview.cpp#L133) — `int main(int argc, char** argv)` @@ -423,12 +447,17 @@ _None._ ### SR-002 -**Locations:** 5 +**Locations:** 10 +- [`src/config.hpp:97`](../src/config.hpp#L97) — `Unknown` - [`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/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/main.cpp:199`](../src/main.cpp#L199) — `reg_cfg, EvidenceDiscounter(same_person));` - [`src/nodes/face_detector_node.hpp:2`](../src/nodes/face_detector_node.hpp#L2) — `Unknown` +- [`src/nodes/face_tracker_node.hpp:2`](../src/nodes/face_tracker_node.hpp#L2) — `Unknown` +- [`src/nodes/identity_matcher_node.hpp:110`](../src/nodes/identity_matcher_node.hpp#L110) — `const GalleryCalibration& calibration() const { return cal_; }` - [`src/track_registry.hpp:2`](../src/track_registry.hpp#L2) — `Unknown` ### SR-003 diff --git a/src/config.hpp b/src/config.hpp index df25c86..5031e4c 100644 --- a/src/config.hpp +++ b/src/config.hpp @@ -16,7 +16,13 @@ enum class Verbosity { struct Config { // ── Input ───────────────────────────────────────────────────────────────── std::string movie_path; - std::string gallery_path; // gallery.json produced by build_gallery + std::string gallery_path; + // TRACES: IR-002 | SR-003 + // "global" (matched against the whole library) or "limited" (this title's + // credited cast only). The strongest single quality signal when two + // manifests compete for the same cut: identical gallery_size can mean very + // different recall depending on which was used. + std::string gallery_scope{"global"}; // gallery.json produced by build_gallery // ── Output ─────────────────────────────────────────────────────────────── std::string output_path; // annotations.json diff --git a/src/main.cpp b/src/main.cpp index 13f2131..dcf2dd1 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -208,9 +208,20 @@ int main(int argc, char** argv) { matcher_fn.set_registry(registry); + FaceTrackerFunc ftracker_fn{cfg, registry, same_person}; SceneTrackerFunc tracker_fn {cfg}; ResultSinkFunc sink_fn {cfg, done}; + + /// TRACES: AR-012, AR-016, IR-002, IR-003 | SR-002 + // A reaped track goes straight to the aggregator, so the registry holds only + // live tracks and its size is bounded by concurrent on-screen faces rather + // than growing with the film. + registry->on_track_dead([&sink_fn](const DeadTrack& d) { sink_fn.add_claim(d); }); + // AR-016: a film ends with faces on screen and those tracks have not timed + // out. Without this flush the closing scene's cast is silently never + // emitted — a loss that reads as a recognition miss, not a bookkeeping bug. + sink_fn.set_pre_write_hook([registry](double last_ts) { registry->flush(last_ts); }); #ifdef SAE_DEBUG DebugRendererFunc debug_fn {cfg}; #endif diff --git a/src/nodes/result_sink_node.hpp b/src/nodes/result_sink_node.hpp index d604a8f..419e3ff 100644 --- a/src/nodes/result_sink_node.hpp +++ b/src/nodes/result_sink_node.hpp @@ -2,6 +2,7 @@ /// TRACES: IR-001 | SR-003 #include "types.hpp" #include "config.hpp" +#include "track_registry.hpp" #include #include @@ -10,6 +11,8 @@ #include #include #include +#include +#include #include #include @@ -43,10 +46,26 @@ using json = nlohmann::json; struct ResultSinkFunc { static constexpr std::string_view label() { return "result_sink"; } + /// TRACES: AR-012, AR-017, IR-002 | SR-002, SR-003 + /// A finished presence claim from the registry. Called from inside the + /// registry's reap while it holds its own lock, so this must stay a cheap + /// push and must never re-enter the registry. + void add_claim(const DeadTrack& d) { + if (d.actor_idx < 0) return; // never owned: nothing to claim + std::lock_guard g(claims_mu_); + claims_.push_back(d); + } + ResultSinkFunc(const Config& cfg, std::atomic& done) : cfg_(cfg), done_(done) {} + /// TRACES: AR-016 | SR-002 + /// Runs immediately before the output is written, with the last timestamp + /// seen. Used to flush tracks still live at EOF, which have not timed out + /// and would otherwise never be emitted. + void set_pre_write_hook(std::function fn) { pre_write_ = std::move(fn); } + void operator()(SceneAnnotation sa) { if (sa.eof) { flush(); @@ -59,12 +78,20 @@ struct ResultSinkFunc { << " unknowns=" << count_unknown(sa.visible_actors) << std::flush; + for (const auto& ia : sa.visible_actors) { + if (ia.actor_idx < 0) continue; + auto& m = actor_meta_[ia.actor_idx]; + if (m.name.empty()) + m = {ia.name, ia.imdb_id, ia.tmdb_id, ia.jellyfin_id}; + } + last_ts_ = sa.timestamp_sec; frames_.push_back(std::move(sa)); } // Write accumulated results and signal done. Safe to call more than once. void flush() { if (written_.exchange(true)) return; + if (pre_write_) pre_write_(last_ts_); write_output(); done_.store(true, std::memory_order_release); } @@ -72,7 +99,7 @@ struct ResultSinkFunc { private: // Bump when the minimal/standard output JSON structure changes in a way // the Jellyfin plugin needs to detect. - static constexpr int kSchemaVersion = 1; + static constexpr int kSchemaVersion = 2; // SR-003 coordinated bump static int count_known(const std::vector& v) { int n = 0; @@ -92,10 +119,19 @@ private: if (cfg_.verbosity == Verbosity::xray) { root = build_xray(); } else { + /// TRACES: IR-002 | SR-003 + /// schema_version 2, per jRay/SPEC.md JR-002. anneal_sec is REMOVED + /// rather than zeroed: a field naming a mechanism the pipeline no + /// longer has is actively misleading, and would outlive everyone who + /// remembers why it reads 0. extinction_sec succeeds it as the + /// parameter that actually shapes window extent. root["schema_version"] = kSchemaVersion; - root["movie"] = cfg_.movie_path; - root["sample_fps"] = cfg_.sample_fps; - root["anneal_sec"] = cfg_.anneal_sec; + root["movie"] = cfg_.movie_path; + root["extraction"] = { + {"sample_fps", cfg_.sample_fps}, + {"extinction_sec", cfg_.track_extinction_sec}, + {"gallery_scope", cfg_.gallery_scope}, + }; root["actors"] = build_epochs(); if (cfg_.verbosity == Verbosity::standard) root["frames"] = build_standard(); @@ -110,42 +146,45 @@ private: std::cerr << "[result_sink] done.\n"; } + struct Window { + double start{0.0}; + double end{0.0}; + float belief{0.f}; ///< the posterior that justified the claim (AR-017) + }; struct ActorWindow { std::string name, imdb_id, tmdb_id, jellyfin_id; - std::vector> scenes; // [start_sec, end_sec] + std::vector scenes; }; + struct ActorMeta { std::string name, imdb_id, tmdb_id, jellyfin_id; }; // Core logic: merge per-frame detections into annealed [start, end] windows. + /// TRACES: AR-012, IR-002 | SR-002 + /// A claim already IS a window — `[first_seen, last_seen]` of a track the + /// actor owned. There is no annealing pass: `anneal_sec` existed to bridge + /// gaps between isolated accepted frames, and a track that survives its own + /// gaps leaves it nothing to do (see the AR-012 withdrawal note). std::vector build_actor_windows() { - struct Info { std::string name, imdb_id, tmdb_id, jellyfin_id; }; - std::map actor_info; - std::map> timestamps; + std::lock_guard g(claims_mu_); - for (const auto& frame : frames_) { - for (const auto& ia : frame.visible_actors) { - if (ia.actor_idx < 0) continue; - actor_info[ia.actor_idx] = {ia.name, ia.imdb_id, ia.tmdb_id, ia.jellyfin_id}; - timestamps[ia.actor_idx].push_back(frame.timestamp_sec); + std::map by_actor; + for (const auto& c : claims_) { + auto& aw = by_actor[c.actor_idx]; + if (aw.name.empty()) { + auto it = actor_meta_.find(c.actor_idx); + if (it != actor_meta_.end()) { + aw.name = it->second.name; + aw.imdb_id = it->second.imdb_id; + aw.tmdb_id = it->second.tmdb_id; + aw.jellyfin_id = it->second.jellyfin_id; + } } + aw.scenes.push_back({c.first_seen, c.last_seen, c.belief}); } std::vector result; - for (auto& [idx, ts_vec] : timestamps) { - ActorWindow aw; - aw.name = actor_info[idx].name; - aw.imdb_id = actor_info[idx].imdb_id; - aw.tmdb_id = actor_info[idx].tmdb_id; - aw.jellyfin_id = actor_info[idx].jellyfin_id; - - double win_start = ts_vec[0], win_end = ts_vec[0]; - for (size_t i = 1; i < ts_vec.size(); ++i) { - if (ts_vec[i] - win_end > cfg_.anneal_sec) { - aw.scenes.push_back({win_start, win_end}); - win_start = ts_vec[i]; - } - win_end = ts_vec[i]; - } - aw.scenes.push_back({win_start, win_end}); + for (auto& [idx, aw] : by_actor) { + std::sort(aw.scenes.begin(), aw.scenes.end(), + [](const Window& a, const Window& b) { return a.start < b.start; }); result.push_back(std::move(aw)); } return result; @@ -154,9 +193,16 @@ private: json build_epochs() { json actors = json::array(); for (const auto& aw : build_actor_windows()) { + // Objects, not float pairs: a window carries the belief that + // justified it and the route by which it was identified (AR-017), + // so a consumer can caveat or filter rather than treating every + // window as equally certain. json windows = json::array(); - for (const auto& [s, e] : aw.scenes) - windows.push_back({s, e}); + for (const auto& w : aw.scenes) + windows.push_back({{"start", w.start}, + {"end", w.end}, + {"belief", w.belief}, + {"route", "live"}}); json ja; ja["name"] = aw.name; ja["imdb_id"] = aw.imdb_id; @@ -174,9 +220,9 @@ private: json build_xray() { std::map> xray; for (const auto& aw : build_actor_windows()) { - for (const auto& [start, end] : aw.scenes) { - int t0 = static_cast(std::floor(start)); - int t1 = static_cast(std::ceil(end)); + for (const auto& w : aw.scenes) { + int t0 = static_cast(std::floor(w.start)); + int t1 = static_cast(std::ceil(w.end)); for (int t = t0; t <= t1; ++t) xray[t].push_back(aw.name); } @@ -227,4 +273,9 @@ private: std::atomic& done_; std::atomic written_{false}; std::vector frames_; + std::function pre_write_; + double last_ts_{0.0}; + std::mutex claims_mu_; + std::vector claims_; + std::map actor_meta_; ///< actor_idx → identity keys };