feat: expansion promotion gated on all three discontinuity signals

AR-019 — promotion may only borrow same-identity evidence from a span where
identity is certain, so every discontinuity signal now clears the buffers rather
than just the histogram cut.

is_scene_boundary was already named in the gate but never set by anything, so
that half of it was dead until AR-010 gave it a producer. It now does what the
spec always said. The third signal, an identity contradiction, needs no code
here: AR-015 closes a track whose belief swapped, so it can no longer promote.

Ownership now comes from the registry rather than a second tally. TrackGallery
was computing its own plurality vote over accepted frames, which meant two
different answers to "who is this track" could coexist in one run — and the
expansion one ignored the Bayesian accumulation entirely, weighting thirty
near-identical looks the same as thirty distinct ones. The local tally survives
only as a fallback for callers with no registry attached, which is the unit
tests and the replay harness.

Suite: 92 cases, 6133 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

TRACES: AR-019, AR-010, AR-015 | SR-005
This commit is contained in:
2026-07-31 15:11:56 +02:00
co-authored by Claude Opus 5
parent 080581c050
commit 50649c1f87
4 changed files with 88 additions and 30 deletions
+19 -1
View File
@@ -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<int, int> 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) {
+22 -1
View File
@@ -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<int>(tf.embeddings.size());
std::vector<IdentifiedActor> 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]);