fix(AR-013): reap tracks on the evidence watermark, not the tracker's clock

The registry closed a track when the *tracker's* timestamp passed
`track_extinction_sec`. But votes arrive from the matcher, which is a separate
KPN node behind a channel, and much the slower of the pair. Backpressure —
working exactly as AR-004 intends — turns that channel's depth into lag, so
the tracker's clock can be far ahead of the last frame anybody has voted on.
Tracks were therefore closed before their evidence arrived: the votes landed
on ids that no longer existed, were counted as dropped, and the track was
emitted unowned or not at all.

The symptom is the part worth remembering: **a deeper channel produced fewer
identifications, from identical input.** On the SuperHero fixture, 5 actors /
16 windows at depth 32 against 3 actors / 5 windows at depth 10322; through
the replay harness, capacity 32 gave 5 actors and 10322 gave 0. A throughput
knob was silently changing the answer, which makes every sweep tuned against
it suspect.

The fix is not to bound the channel against `track_extinction_sec` — that
makes an algorithm constant police a throughput knob and leaves the result a
function of scheduling. It is to reap on an evidence watermark: the matcher
advances it as it folds each frame in, and a track is only finished once
everything up to its extinction point has actually been voted on. Same device
`SceneBoundaries::scored_through()` uses for the AR-010 join — a consumer past
that point is asking about frames nobody has looked at yet, and the honest
answer is to wait rather than guess.

Association keeps the tracker's clock, and separating the two is the other
half. They answer different questions: "may this detection link to that
track?" is asked now, about a box seen `track_extinction_sec` ago; "is that
track finished?" cannot be answered until every vote is in. Deferring
association to the evidence clock — which deferring the erase alone did — left
retired tracks associable for as long as the matcher lagged, so a new face
re-associated onto a long-dead track and two people merged into one window.

The watermark is monotonic and only ever *delays* a reap, so no window is
extended by it: AR-013's "a window ends at the last sighting, never after" is
a property of `emit_locked`, which takes `last_seen` and never `now`.

`dropped_votes` is exposed and reported — by main at shutdown and through the
replay bindings — because this failed silently for as long as it did precisely
because nothing counted it. It warns rather than aborts: a dropped frame means
the output describes footage nobody analysed and is always wrong, while a
dropped vote degrades a claim without falsifying it, and there is no
measurement yet of how often it happens on real content.

replay.py's channel capacity stops being the whole film. It was sized that way
to dodge a PyNode overflow drop that AR-004 has since replaced with parking,
and removing backpressure that way is what made the defect above so extreme.

Tag separators in kpn_bindings.cpp corrected to pipes between requirement
types, which the traceability gate was reporting as diagnostics; the matrix is
regenerated and reports 0 orphan tags.

149/149.

TRACES: AR-004, AR-012, AR-013, AR-025 | VR-011 | SR-002 | PR-002
This commit is contained in:
2026-08-08 12:08:15 +02:00
parent 0e27339ab6
commit 24d35cbde3
8 changed files with 494 additions and 140 deletions
+80
View File
@@ -370,3 +370,83 @@ TEST_CASE("confidence grows across frames of the same face", "[registry][AR-025]
// ...but it must still be worth far less than 50 independent looks would be.
CHECK(sink.claims[0].effective_obs < 25.0f);
}
// ── The evidence watermark: presence must not depend on node speed ───────────
/// TRACES: UT-001 | AR-012, AR-013, AR-025 | SR-002
TEST_CASE("a track is not reaped until the evidence clock passes it",
"[registry][AR-013]") {
// The tracker and the matcher are separate KPN nodes, and backpressure --
// working exactly as AR-004 intends -- lets the tracker run a whole
// channel's depth ahead. Reaping on the tracker's clock therefore closed
// tracks before their votes arrived: the votes landed on ids that no longer
// existed and the run silently under-reported. On the SuperHero fixture that
// was 5 actors at channel depth 32 against 0 actors at depth 10322, from
// identical input.
TrackRegistry reg(cfg(/*extinction=*/5.0), disc());
Sink sink; sink.attach(reg);
reg.expect_evidence(); // as IdentityMatcherFunc::set_registry does
int id;
{
auto s = reg.begin_frame(0.0);
id = s.create(0.0, axis(1));
s.mark_lost(id, 0.0);
}
// The tracker races 100 s ahead. Nothing has voted yet, so nothing may die:
// an unvoted track is not a finished track, it is an unanswered question.
{ auto s = reg.begin_frame(100.0); (void)s; }
CHECK(sink.claims.empty());
// A vote arriving very late still lands, because the track is still there.
reg.observe(id, /*actor*/ 3, /*posterior*/ 0.99f, axis(1));
CHECK(reg.dropped_votes() == 0);
// Only once the evidence clock passes last_seen + extinction does it close.
reg.advance_evidence(4.0);
CHECK(sink.claims.empty());
reg.advance_evidence(6.0);
REQUIRE(sink.claims.size() == 1);
CHECK(sink.claims[0].actor_idx == 3);
// AR-013 still holds: the window ends at the last sighting, never at the
// moment of death, and never at the watermark that authorised it.
CHECK(sink.claims[0].last_seen == 0.0);
}
/// TRACES: UT-001 | AR-008, AR-013 | SR-002
TEST_CASE("a track retired from association is still open to evidence",
"[registry][AR-008]") {
// The two clocks answer different questions and must not share an answer.
// Association asks "may this detection link to that track?" on the tracker's
// clock; reaping asks "is that track finished?" and cannot answer until the
// votes are in. Deferring both to the evidence clock was the second half of
// this bug: retired tracks lingered in the candidate pool for as long as the
// matcher lagged, so a new face re-associated onto a long-dead track and two
// people merged into one window.
TrackRegistry reg(cfg(/*extinction=*/5.0), disc());
Sink sink; sink.attach(reg);
reg.expect_evidence();
int id;
{
auto s = reg.begin_frame(0.0);
id = s.create(0.0, axis(1));
s.mark_lost(id, 0.0);
}
{
auto s = reg.begin_frame(3.0); // inside the window
CHECK(s.candidates().size() == 1); // still associable
}
{
auto s = reg.begin_frame(50.0); // far outside it
CHECK(s.candidates().empty()); // retired from association...
}
// ...but not gone, and still able to receive the votes in flight for it.
reg.observe(id, 7, 0.99f, axis(1));
CHECK(reg.dropped_votes() == 0);
reg.advance_evidence(50.0);
REQUIRE(sink.claims.size() == 1);
CHECK(sink.claims[0].actor_idx == 7);
}