diff --git a/src/track_registry.hpp b/src/track_registry.hpp index 133e769..6b69b59 100644 --- a/src/track_registry.hpp +++ b/src/track_registry.hpp @@ -181,29 +181,45 @@ public: /// alone. There is no separate revival path (AR-008). /// /// TRACES: AR-008, AR-013 | SR-002 - /// Filtered on the TRACKER's clock, deliberately, while reaping runs on - /// the matcher's evidence watermark. The two answer different questions - /// and must not share an answer: + /// Association and reaping share ONE clock — the evidence watermark when a + /// matcher is attached, the tracker clock otherwise (they coincide when + /// there is only one). `candidates()` and `reap_locked()` apply the SAME + /// `track_extinction_sec` horizon against that clock, so the offered pool + /// and the live pool are the same set: /// - /// "may this detection link to that track?" — a tracking question, - /// asked now, about a box observed `track_extinction_sec` ago. - /// "is that track finished, so its claim can be emitted?" — a presence - /// question, which cannot be answered until every vote is in. + /// offered ⟺ (clock - last_seen) ≤ track_extinction_sec + /// reaped/erased ⟺ (clock - last_seen) > track_extinction_sec /// - /// Conflating them makes the result depend on node speed in one - /// direction or the other. Reaping on the tracker's clock closed tracks - /// before their votes arrived. Deferring association to the evidence - /// clock — which is what deferring the erase alone did — left retired - /// tracks in the pool for as long as the matcher lagged, so a new face + /// This closes two symmetric failures. (1) Offering on the tracker's clock + /// (ahead of the watermark) let a face associate onto a track the registry + /// had ALREADY reaped on the watermark; the vote then landed on a dead id + /// and was dropped (record_vote → dropped_votes_). Rare live (small lag), + /// but replay runs the tracker far ahead of the matcher and lost ~0.3% of + /// votes. (2) Historically, offering on a LOOSER horizon than the reap left + /// retired tracks in the pool while the matcher lagged, so a new face /// re-associated onto a long-dead track and two people merged into one - /// window. Measured: 5 actors / 16 windows at channel depth 32 against - /// 3 actors / 5 windows at depth 10322, from identical input. + /// window (measured: 5 actors/16 windows at depth 32 vs 3/5 at depth 10322). + /// A single clock and a single threshold make both impossible: nothing is + /// offered past its reap horizon, nothing is reaped while still offerable. std::vector candidates() { std::vector out; out.reserve(reg_.tracks_.size()); + // Filter association on the SAME clock reaping uses (the evidence + // watermark when a matcher is attached, else the tracker clock). The + // two used to differ deliberately — the tracker offered on now_ while + // the registry reaped on evidence_through_ — but that let the tracker + // associate a face onto a track the registry had already reaped on the + // watermark, whose vote then landed on a dead id and was dropped + // (record_vote → dropped_votes_). In the live pipeline the lag is tiny + // so it rarely bit; in replay the Python source runs the tracker far + // ahead of the matcher and ~0.3% of votes were lost. One clock for both + // "may this associate?" and "is this reaped?" closes the race: a track + // past the horizon is neither offered nor reaped-out-from-under a vote. + const double clock = + reg_.awaits_evidence_ ? reg_.evidence_through_ : reg_.now_; for (auto& [id, t] : reg_.tracks_) { if (t.last_seen && - (reg_.now_ - *t.last_seen) > reg_.cfg_.track_extinction_sec) + (clock - *t.last_seen) > reg_.cfg_.track_extinction_sec) continue; // retired from association; still awaiting evidence out.push_back(&t); }