fix(expansion): finish AR-018, retiring the last two expansion cosines

AR-018 was marked Done while the promotion path still ran on the
constants it was meant to replace. track_gallery.hpp rejected a track
when buffer_spread (1 minus the minimum pairwise cosine) exceeded
expand_track_spread_max, and skipped a view when its raw gal_sim cleared
expand_novelty_sim. Both were bare cosines with no recorded EXCEPTION,
so both were defects under the AR-024 invariant rather than tagging gaps.

The calibrated band was real but unreachable. expand_band_lo/hi were
declared in Config and read nowhere, and set_band() had no callers, so
the gate always ran at the hardcoded 0.90/0.95 while --expand-novelty-sim
and --expand-spread-max stayed live flags.

The spread gate becomes store_coherence: the band's lower bound asked of
every pair in the store, in probability space, rather than a second
constant. admit() compares a newcomer only against its nearest existing
member, so a gradually drifting track chains A to B to C with every step
inside the band while A and C are strangers — the shape a track-ID
collision takes over a slow pan. The bound is re-asked pairwise before
anything reaches an actor's annex.

The novelty gate is deleted rather than converted. SPEC section AR-018
contrasts the band with expand_novelty_sim as the thing it replaces, and
AR-019 requires only that the band is satisfied. Novelty-seeking now
lives entirely in the eviction ordering, which ranks by similarity to the
actor's references instead of cutting at a constant, so there is nothing
left to tune but the two bounds.

BufEntry stored a raw cosine and the eviction loop compared two of them.
The map is monotonic so the ranking was never wrong, but it left a bare
cosine as a decision variable; it now stores the calibrated probability.

The [AR-018] Catch2 tag previously sat on the spread gate, reporting the
replaced mechanism as verification of its replacement. It now sits on the
band: both bounds asserted exactly, since they are inclusive and an
off-by-one there is invisible anywhere else; refusal counted on each
side; and the config bounds driven away from the shipped defaults so a
hardcoded fallback fails. The case that carries the invariant is "band
thresholds probability, not cosine" — under a calibration shifted by
0.10, cosine 0.84 is admitted and cosine 0.92 refused, the opposite of
their raw verdicts. A raw-cosine gate passes an identity-calibrated test
by accident and cannot pass that one. 15 cases, 38 assertions, passing.

scene_preview.cpp takes the flag rename because it would otherwise
reference deleted Config fields. It still does not compile, for reasons
predating this change: it also reads track_max_embed_dist and
track_max_frames_missing, retired by the earlier AR-024 tracker work, and
constructs FaceTrackerFunc with one argument where the registry and
calibration are now required.

Two notes for anyone reading the chain. The main.cpp flag rename and the
AR-018/AR-024 register rows landed in 35e7033, whose trailer names AR-004
only, so git log --grep=AR-018 will not surface them. And
docs/traceability.md is left uncommitted on purpose: regenerating it now
would bake in VR-013 rows for two experiment scripts that are not yet
committed.

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

TRACES: AR-018, AR-024 | SR-005
This commit is contained in:
2026-07-31 22:48:07 +02:00
co-authored by Claude Opus 5
parent ffdad9873d
commit eff696b49a
5 changed files with 332 additions and 129 deletions
+27 -9
View File
@@ -666,12 +666,30 @@ An embedding is admitted only if its similarity to one already in the store fall
admitting it risks poisoning the store. admitting it risks poisoning the store.
A starting band of roughly **0.900.95** is the working estimate, to be tuned A starting band of roughly **0.900.95** is the working estimate, to be tuned
(VR-007). Note this is deliberately conservative compared to the current (VR-007). Note this is deliberately conservative compared to the retired
`expand_novelty_sim` (0.55), which promotes embeddings *far* from the gallery — `expand_novelty_sim` (0.55), which promoted embeddings *far* from the gallery —
much more aggressive, and much more exposed to admitting the wrong person. much more aggressive, and much more exposed to admitting the wrong person.
Both bounds must be expressed as calibrated probabilities, not raw cosines (AR-024). Both bounds must be expressed as calibrated probabilities, not raw cosines (AR-024).
The lower bound is asked twice. `admit` compares a newcomer against its
*closest* existing member, which a gradually drifting track can chain past: every
step inside the band while the endpoints are strangers — the shape a track-ID
collision takes over a slow pan. So the same bound is re-applied across **every
pair** in the store before promotion. One bound, two enforcement points; not a
second constant.
Novelty is deliberately **not** a threshold. The store's eviction policy orders
its members by similarity to the actor's existing references and drops the
best-recognised one, so novelty-seeking is a ranking with nothing to tune, and
the band's upper bound already refuses the redundant views at the door.
**Current:** implemented in `gallery/track_gallery.hpp``admit` at the door,
`store_coherence` at promotion, both bounds from `Config::expand_band_lo/hi`.
Refusals are counted (`band_rejected`).
**Gap:** the bounds themselves are unswept working values (VR-007).
### AR-019 — Expansion of known actors ### AR-019 — Expansion of known actors
When a track is owned (AR-012), its store is promoted into a **per-film, in-memory When a track is owned (AR-012), its store is promoted into a **per-film, in-memory
@@ -772,14 +790,14 @@ natural unit for anonymous presence, should that be adopted (AR-012, TBD).
open (VR-007). open (VR-007).
**Current:** `src/gallery/track_gallery.hpp` implements a per-track diversity **Current:** `src/gallery/track_gallery.hpp` implements a per-track diversity
buffer with eviction biased to gallery-far poses, promotion gated on buffer with eviction biased to gallery-far poses, admission and promotion both
`expand_novelty_sim` / `expand_track_spread_max`, cleared on `is_cut`. Wired at gated on the AR-018 band in probability space, cleared on `is_cut`. Wired at
`identity_matcher_node.hpp:227`, cleared at `:126`. `identity_matcher_node.hpp:227`, cleared at `:126`; the calibration is handed
over at `:114`.
**Gap:** the band rule of AR-018 replacing the current novelty/spread gates; all **Gap:** all three quiet-signal conditions rather than only `is_cut`; and the
three quiet-signal conditions rather than only `is_cut`; probability space whole of AR-020 — the TBI queue, the deferred pass, and deferring output until it
throughout (AR-024); and the whole of AR-020 — the TBI queue, the deferred pass, and completes.
deferring output until it completes.
## AR-022 — Unidentified-track capture ## AR-022 — Unidentified-track capture
+4 -6
View File
@@ -153,16 +153,14 @@ struct Config {
// Banded admission for the per-subject store, in PROBABILITY space. An // Banded admission for the per-subject store, in PROBABILITY space. An
// embedding joins only if P(same person) against something already stored // embedding joins only if P(same person) against something already stored
// lands inside [lo, hi]: above hi it is redundant, below lo it is evidence // lands inside [lo, hi]: above hi it is redundant, below lo it is evidence
// the track is not one person. Replaces expand_novelty_sim, a raw cosine. // the track is not one person. The same lo is re-applied to the whole store
// at promotion time — see track_gallery.hpp. This is the only threshold the
// expansion path has: it replaces the raw-cosine expand_novelty_sim (0.55)
// and expand_track_spread_max (0.60), which are retired (AR-024).
// Working values pending VR-007; sweep both bounds, they fail in opposite // Working values pending VR-007; sweep both bounds, they fail in opposite
// directions. // directions.
float expand_band_lo{0.90f}; float expand_band_lo{0.90f};
float expand_band_hi{0.95f}; float expand_band_hi{0.95f};
float expand_novelty_sim{0.55f}; // promote only embeddings whose best sim to the
// actor's refs is below this (gallery-far / novel)
float expand_track_spread_max{0.60f}; // reject promotion if the retained buffer's
// internal spread (1 - min pairwise sim) exceeds
// this — guards track-ID collisions / two people
int expand_min_anchor_frames{3}; // require ≥N accepted frames naming the actor before int expand_min_anchor_frames{3}; // require ≥N accepted frames naming the actor before
// the track is confirmed and its buffer promoted // the track is confirmed and its buffer promoted
std::string expand_debug_dir; // if set, dump promoted mugshots + embeddings here std::string expand_debug_dir; // if set, dump promoted mugshots + embeddings here
+61 -49
View File
@@ -36,13 +36,15 @@
// 2. Promotion (on confirmation). A track is "owned" by actor A once ≥N frames // 2. Promotion (on confirmation). A track is "owned" by actor A once ≥N frames
// have been accepted (by the matcher's calibrated posterior) as A. On // have been accepted (by the matcher's calibrated posterior) as A. On
// confirmation the retained buffer — the hard, gallery-far poses — is // confirmation the retained buffer — the hard, gallery-far poses — is
// promoted into A's per-film annex, after two safety gates: // promoted into A's per-film annex, subject to one safety gate: the band's
// • novelty: only embeddings whose best sim to A's refs is below // lower bound, re-applied across the whole store (see `store_coherence`).
// expand_novelty_sim are added (skip poses already covered); //
// • spread: if the retained buffer's internal spread (1 min pairwise // There is exactly one threshold here, the AR-018 band, and it is a calibrated
// cosine sim) exceeds expand_track_spread_max the whole track is // probability. Novelty is no longer a threshold at all — the eviction policy
// rejected — such spread signals a track-ID collision merging two // above *orders* by gallery similarity rather than cutting at a constant, and
// people, whose embeddings must never enter A's annex. // the band's upper bound refuses the redundant views at the door. The raw
// cosines this replaces, expand_novelty_sim and expand_track_spread_max, are
// retired under AR-024.
// //
// The annex is CPU-side and in-memory: it is small (tens of embeddings) so the // The annex is CPU-side and in-memory: it is small (tens of embeddings) so the
// matcher scans it with a scalar loop, and it is discarded when the process // matcher scans it with a scalar loop, and it is discarded when the process
@@ -59,16 +61,15 @@ struct TrackGallery {
explicit TrackGallery(const Config& cfg) explicit TrackGallery(const Config& cfg)
: enabled_(cfg.expand_gallery) : enabled_(cfg.expand_gallery)
, buffer_size_(std::max(1, cfg.expand_buffer_size)) , buffer_size_(std::max(1, cfg.expand_buffer_size))
, novelty_sim_(cfg.expand_novelty_sim) , band_lo_(cfg.expand_band_lo)
, spread_max_(cfg.expand_track_spread_max) , band_hi_(cfg.expand_band_hi)
, min_anchor_frames_(std::max(1, cfg.expand_min_anchor_frames)) , min_anchor_frames_(std::max(1, cfg.expand_min_anchor_frames))
, debug_dir_(cfg.expand_debug_dir) , debug_dir_(cfg.expand_debug_dir)
{ {
if (!enabled_) return; if (!enabled_) return;
std::cerr << "[track_gallery] per-film expansion ON" std::cerr << "[track_gallery] per-film expansion ON"
<< " buffer=" << buffer_size_ << " buffer=" << buffer_size_
<< " novelty_sim<" << novelty_sim_ << " band=[" << band_lo_ << ", " << band_hi_ << "]"
<< " spread_max=" << spread_max_
<< " min_anchor_frames=" << min_anchor_frames_; << " min_anchor_frames=" << min_anchor_frames_;
if (!debug_dir_.empty()) { if (!debug_dir_.empty()) {
std::filesystem::create_directories(debug_dir_); std::filesystem::create_directories(debug_dir_);
@@ -88,7 +89,9 @@ struct TrackGallery {
// track_id : face_tracker track (1 = untracked, ignored) // track_id : face_tracker track (1 = untracked, ignored)
// emb : this frame's raw embedding // emb : this frame's raw embedding
// best_actor : actor with the highest gallery similarity for this face // best_actor : actor with the highest gallery similarity for this face
// best_gal_sim : that similarity (best sim to best_actor's baked+annex refs) // best_gal_sim : that similarity (best sim to best_actor's baked+annex
// refs) — a raw cosine, the last one in this class: it is
// calibrated on entry and only the probability is stored
// accepted : true if the matcher accepted this face as best_actor // accepted : true if the matcher accepted this face as best_actor
// crop : aligned crop, retained only when debug dumping is on // crop : aligned crop, retained only when debug dumping is on
void observe(int track_id, const Embedding& emb, void observe(int track_id, const Embedding& emb,
@@ -134,7 +137,6 @@ struct TrackGallery {
/// band falls back to treating cosine as probability, which is wrong but /// band falls back to treating cosine as probability, which is wrong but
/// bounded — and the default is loud in the header rather than silent. /// bounded — and the default is loud in the header rather than silent.
void set_calibration(std::function<float(float)> c) { calibrate_ = std::move(c); } void set_calibration(std::function<float(float)> c) { calibrate_ = std::move(c); }
void set_band(float lo, float hi) { band_lo_ = lo; band_hi_ = hi; }
/// Embeddings the band refused. A store that admits nothing is as wrong as /// Embeddings the band refused. A store that admits nothing is as wrong as
/// one that admits everything, and neither is visible without this. /// one that admits everything, and neither is visible without this.
@@ -146,7 +148,10 @@ struct TrackGallery {
private: private:
struct BufEntry { struct BufEntry {
Embedding emb; Embedding emb;
float gal_sim{0.f}; // best sim to owning actor's refs when observed /// P(same person) against the owning actor's refs when observed
/// calibrated at the door (AR-024), so the eviction ordering below is a
/// comparison of probabilities and the struct holds no bare cosine.
float gal_p{0.f};
cv::Mat crop; // populated only when debug_dir_ set cv::Mat crop; // populated only when debug_dir_ set
}; };
@@ -193,8 +198,8 @@ private:
if (!admit(ts, emb)) { ++rejected_; return; } if (!admit(ts, emb)) { ++rejected_; return; }
BufEntry e; BufEntry e;
e.emb = emb; e.emb = emb;
e.gal_sim = gal_sim; e.gal_p = calibrate_(gal_sim);
if (!debug_dir_.empty() && !crop.empty()) e.crop = crop.clone(); if (!debug_dir_.empty() && !crop.empty()) e.crop = crop.clone();
if (static_cast<int>(ts.buf.size()) < buffer_size_) { if (static_cast<int>(ts.buf.size()) < buffer_size_) {
@@ -203,13 +208,17 @@ private:
} }
// Buffer full: evict the member the gallery recognises best (highest // Buffer full: evict the member the gallery recognises best (highest
// gal_sim) — least informative — but only if the newcomer is at least as // gal_p) — least informative — but only if the newcomer is at least as
// novel. Keeping the most gallery-far views is the whole point. // novel. Keeping the most gallery-far views is the whole point.
int worst_i = -1; //
float worst_sim = e.gal_sim; // newcomer's sim is the bar to beat // This is an *ordering*, not a threshold: there is no constant to tune,
// and novelty-seeking lives here rather than in a cutoff. It ranks
// probabilities, so it says the same thing across models (AR-024).
int worst_i = -1;
float worst_p = e.gal_p; // newcomer's probability is the bar to beat
for (int i = 0; i < static_cast<int>(ts.buf.size()); ++i) { for (int i = 0; i < static_cast<int>(ts.buf.size()); ++i) {
if (ts.buf[i].gal_sim > worst_sim) { if (ts.buf[i].gal_p > worst_p) {
worst_sim = ts.buf[i].gal_sim; worst_p = ts.buf[i].gal_p;
worst_i = i; worst_i = i;
} }
} }
@@ -224,25 +233,18 @@ private:
int actor = owning_actor(ts); int actor = owning_actor(ts);
if (actor < 0) return; if (actor < 0) return;
// ── Safety gate: internal spread ───────────────────────────────────── // ── Safety gate: the band's lower bound, across the whole store ──────
// A legitimate single-person track varies in pose but stays reasonably float worst = store_coherence(ts.buf);
// self-similar. Large spread signals two people merged under one track if (worst < band_lo_) {
// ID — reject the whole track rather than poison the actor's annex.
float spread = buffer_spread(ts.buf);
if (spread > spread_max_) {
std::cerr << "[track_gallery] track " << track_id std::cerr << "[track_gallery] track " << track_id
<< " → actor " << actor << " → actor " << actor
<< " REJECTED (spread " << spread << " REJECTED (worst pairwise P=" << worst
<< " > " << spread_max_ << ", likely ID collision)\n"; << " < " << band_lo_ << ", likely ID collision)\n";
return; return;
} }
int added = 0; int added = 0;
for (const auto& be : ts.buf) { for (const auto& be : ts.buf) {
// ── Safety gate: novelty ─────────────────────────────────────────
// Skip poses the gallery already covers; only gallery-far views are
// worth the annex slot (and the extra per-frame scan cost).
if (be.gal_sim >= novelty_sim_) continue;
annex_.push_back({be.emb, actor}); annex_.push_back({be.emb, actor});
if (!debug_dir_.empty() && !be.crop.empty()) if (!debug_dir_.empty() && !be.crop.empty())
dump_mugshot(track_id, actor, added, be); dump_mugshot(track_id, actor, added, be);
@@ -251,10 +253,9 @@ private:
std::cerr << "[track_gallery] track " << track_id std::cerr << "[track_gallery] track " << track_id
<< " confirmed actor " << actor << " confirmed actor " << actor
<< " (" << ts.accepted_frames << " accepted frames, spread " << " (" << ts.accepted_frames << " accepted frames, worst "
<< spread << ") — promoted " << added << "/" << "pairwise P=" << worst << ") — promoted " << added
<< ts.buf.size() << " views; annex now " << " views; annex now " << annex_.size() << "\n";
<< annex_.size() << "\n";
} }
/// Prefer the registry's verdict; fall back to the local tally only when no /// Prefer the registry's verdict; fall back to the local tally only when no
@@ -272,21 +273,34 @@ private:
return best; return best;
} }
// Spread = 1 min pairwise cosine similarity over the buffer (0 when <2). /// TRACES: AR-018, AR-024 | SR-005
static float buffer_spread(const std::vector<BufEntry>& buf) { /// The store's weakest pairwise P(same person) — the band's lower bound
float min_sim = std::numeric_limits<float>::max(); /// asked of every pair, not just of the best match at the door.
///
/// `admit` compares a newcomer against its *closest* existing member, so a
/// track that drifts gradually can chain A→B→C with every step inside the
/// band while A and C are strangers. That is precisely the shape a track-ID
/// collision takes when two people are merged over a slow pan, so the bound
/// is re-asked here across all pairs before anything reaches an actor's
/// annex. Same bound, same probability space — not a second constant.
///
/// A store of one has no pair to disagree; it is coherent by construction,
/// hence 1.
float store_coherence(const std::vector<BufEntry>& buf) const {
float worst = std::numeric_limits<float>::max();
for (size_t i = 0; i < buf.size(); ++i) for (size_t i = 0; i < buf.size(); ++i)
for (size_t j = i + 1; j < buf.size(); ++j) for (size_t j = i + 1; j < buf.size(); ++j)
min_sim = std::min(min_sim, cosine_similarity(buf[i].emb, buf[j].emb)); worst = std::min(worst,
if (min_sim == std::numeric_limits<float>::max()) return 0.f; calibrate_(cosine_similarity(buf[i].emb, buf[j].emb)));
return 1.f - min_sim; if (worst == std::numeric_limits<float>::max()) return 1.f;
return worst;
} }
void dump_mugshot(int track_id, int actor, int idx, const BufEntry& be) { void dump_mugshot(int track_id, int actor, int idx, const BufEntry& be) {
#ifdef SAE_DEBUG #ifdef SAE_DEBUG
char name[64]; char name[64];
std::snprintf(name, sizeof(name), "trk%d_actor%d_%d_sim%.3f.jpg", std::snprintf(name, sizeof(name), "trk%d_actor%d_%d_p%.3f.jpg",
track_id, actor, idx, be.gal_sim); track_id, actor, idx, be.gal_p);
cv::imwrite((std::filesystem::path(debug_dir_) / name).string(), be.crop); cv::imwrite((std::filesystem::path(debug_dir_) / name).string(), be.crop);
#else #else
(void)track_id; (void)actor; (void)idx; (void)be; (void)track_id; (void)actor; (void)idx; (void)be;
@@ -296,14 +310,12 @@ private:
/// cosine → P(same person). The one probability space the pipeline reasons /// cosine → P(same person). The one probability space the pipeline reasons
/// in; see gallery_calibration.hpp's same_person_probability. /// in; see gallery_calibration.hpp's same_person_probability.
std::function<float(float)> calibrate_{[](float c) { return std::max(0.f, c); }}; std::function<float(float)> calibrate_{[](float c) { return std::max(0.f, c); }};
float band_lo_{0.90f};
float band_hi_{0.95f};
std::size_t rejected_{0}; ///< admissions refused by the band std::size_t rejected_{0}; ///< admissions refused by the band
bool enabled_; bool enabled_;
int buffer_size_; int buffer_size_;
float novelty_sim_; float band_lo_; ///< AR-018, from cfg.expand_band_lo
float spread_max_; float band_hi_; ///< AR-018, from cfg.expand_band_hi
int min_anchor_frames_; int min_anchor_frames_;
std::string debug_dir_; std::string debug_dir_;
+2 -2
View File
@@ -96,8 +96,8 @@ static Config parse_args(int argc, char** argv) {
// Per-film gallery expansion — preview supports it (same cfg fields). // Per-film gallery expansion — preview supports it (same cfg fields).
else if (arg("--expand-gallery")) cfg.expand_gallery = true; else if (arg("--expand-gallery")) cfg.expand_gallery = true;
else if (arg("--expand-buffer")) cfg.expand_buffer_size = std::stoi(next()); else if (arg("--expand-buffer")) cfg.expand_buffer_size = std::stoi(next());
else if (arg("--expand-novelty-sim")) cfg.expand_novelty_sim = std::stof(next()); else if (arg("--expand-band-lo")) cfg.expand_band_lo = std::stof(next());
else if (arg("--expand-spread-max")) cfg.expand_track_spread_max = std::stof(next()); else if (arg("--expand-band-hi")) cfg.expand_band_hi = std::stof(next());
else if (arg("--expand-min-anchor")) cfg.expand_min_anchor_frames = std::stoi(next()); else if (arg("--expand-min-anchor")) cfg.expand_min_anchor_frames = std::stoi(next());
// Scene detection is scene_analyze-only (needs the dense TransNetV2 branch). // Scene detection is scene_analyze-only (needs the dense TransNetV2 branch).
// Accept the flags so a shared command line runs, but note they're inert // Accept the flags so a shared command line runs, but note they're inert
+238 -63
View File
@@ -1,7 +1,16 @@
// TRACES: AR-018, AR-019, AR-024 | SR-005
//
// Unit tests for TrackGallery (gallery/track_gallery.hpp): per-film gallery // Unit tests for TrackGallery (gallery/track_gallery.hpp): per-film gallery
// expansion driven by track continuity. Pure, GPU-free, model-free — exercises // expansion driven by track continuity. Pure, GPU-free, model-free — exercises
// the diversity-buffer eviction policy, the novelty/spread safety gates, // the AR-018 banded admission at both bounds, the promotion-time coherence
// plurality ownership, and idempotent promotion via the public interface. // gate, the diversity-buffer eviction policy, plurality ownership, and
// idempotent promotion, all through the public interface.
//
// The band is defined in PROBABILITY space (AR-024), so every case below states
// its own cosine → probability map instead of inheriting the header's fallback.
// A test that never names the mapping is not testing the band, it is testing a
// coincidence: with the fallback the two spaces happen to coincide, and a gate
// that silently reverted to raw cosine would still pass.
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp> #include <catch2/matchers/catch_matchers_floating_point.hpp>
@@ -14,30 +23,60 @@
namespace { namespace {
// Unit-norm embedding pointing along one axis (cosine sim to another one-hot is constexpr float kPi = 3.14159265358979323846f;
// 0, to itself 1) — lets tests dial gallery similarity precisely.
// The band as the tests drive it. Kept in one place so a change to the shipped
// defaults does not silently invalidate the arithmetic in each case.
constexpr float kBandLo = 0.90f;
constexpr float kBandHi = 0.95f;
// Identity map: probability == cosine, so a case can place an embedding at an
// exact probability. cosine_similarity is a bare dot product over unit vectors
// (types.hpp), so the placements below are bit-exact, not approximate.
float identity_cal(float c) { return c; }
// Unit-norm embedding pointing along one axis. Cosine sim to another one-hot is
// 0, to itself 1.
Embedding one_hot(int slot) { Embedding one_hot(int slot) {
Embedding e{}; Embedding e{};
e[slot] = 1.0f; e[slot] = 1.0f;
return e; return e;
} }
// Unit-norm embedding in the plane of axes i,j at angle t from i. Cosine sim to // Unit-norm embedding in the plane of axes i,j at cosine `cos_t` from axis i.
// one_hot(i) is cos(t) — used to place a view at a chosen gallery similarity. // Cosine sim to one_hot(i) is exactly cos_t.
Embedding at_sim(int i, int j, float cos_t) { Embedding at_sim(int i, int j, float cos_t) {
Embedding e{}; Embedding e{};
float s = std::sqrt(std::max(0.f, 1.f - cos_t * cos_t));
e[i] = cos_t; e[i] = cos_t;
e[j] = s; e[j] = std::sqrt(std::max(0.f, 1.f - cos_t * cos_t));
return e;
}
// A spoke: shares axis 0 with every other spoke, and is otherwise unique. Any
// two DISTINCT spokes have cosine similarity exactly cos_t², so one constant
// places a whole mutually-in-band store. A spoke against itself is 1.0 — above
// the band's ceiling, i.e. redundant, which is the intended reading.
Embedding spoke(int k, float cos_t) { return at_sim(0, k, cos_t); }
// cos_t chosen so pairwise similarity between distinct spokes is 0.9197 —
// comfortably inside [0.90, 0.95], clear of both bounds.
constexpr float kSpokeCos = 0.959f;
// Two embeddings `deg` apart in the plane of axes 0,1. Cosine is cos(deg), so a
// chain of these can step through the band while its endpoints fall outside it.
Embedding on_circle(float deg) {
Embedding e{};
e[0] = std::cos(deg * kPi / 180.f);
e[1] = std::sin(deg * kPi / 180.f);
return e; return e;
} }
Config expand_cfg() { Config expand_cfg() {
Config cfg; Config cfg;
cfg.expand_gallery = true; cfg.expand_gallery = true;
cfg.expand_buffer_size = 3; cfg.expand_buffer_size = 3;
cfg.expand_novelty_sim = 0.55f; cfg.expand_band_lo = kBandLo;
cfg.expand_track_spread_max = 0.60f; cfg.expand_band_hi = kBandHi;
cfg.expand_min_anchor_frames = 3; cfg.expand_min_anchor_frames = 3;
return cfg; return cfg;
} }
@@ -59,92 +98,228 @@ TEST_CASE("disabled: no annex growth when expand_gallery is off", "[track_galler
CHECK(tg.annex().empty()); CHECK(tg.annex().empty());
} }
TEST_CASE("confirmed track promotes gallery-far views", "[track_gallery]") { // ── AR-018: the band ─────────────────────────────────────────────────────────
TrackGallery tg(expand_cfg());
REQUIRE(tg.enabled());
// A track owned by actor 0. Every frame is accepted as actor 0, but each TEST_CASE("band bounds come from config, not a hardcoded default", "[track_gallery][AR-018]") {
// view is gallery-far (sim 0.30 < novelty 0.55) yet mutually self-similar // The bounds were declared in Config and read nowhere, so the gate ran at
// enough to pass the spread gate. // whatever the header happened to initialise. Drive them somewhere the
for (int f = 0; f < 3; ++f) // defaults are not and require the gate to follow.
tg.observe(7, at_sim(0, 1, 0.30f + 0.001f * f), 0, 0.30f + 0.001f * f, true, kNoCrop); Config cfg = expand_cfg();
cfg.expand_band_lo = 0.40f;
cfg.expand_band_hi = 0.60f;
TrackGallery tg(cfg);
tg.set_calibration(identity_cal);
// 3 accepted frames == min_anchor_frames → confirmed and promoted. tg.observe(1, one_hot(0), 0, 0.30f, true, kNoCrop);
CHECK_FALSE(tg.annex().empty()); // P = 0.50: inside the configured band, far below the shipped default lo.
for (const auto& ae : tg.annex()) CHECK(ae.actor_idx == 0); tg.observe(1, at_sim(0, 1, 0.50f), 0, 0.30f, true, kNoCrop);
CHECK(tg.band_rejected() == 0);
// P = 0.92: inside the shipped default band, above the configured ceiling.
tg.observe(1, at_sim(0, 2, 0.92f), 0, 0.30f, true, kNoCrop);
CHECK(tg.band_rejected() == 1);
} }
TEST_CASE("novelty gate skips views the gallery already covers", "[track_gallery]") { TEST_CASE("band admits at each bound exactly", "[track_gallery][AR-018]") {
// The verification plan asks for the bounds themselves, not a point safely
// inside them: an off-by-one in the comparison is invisible anywhere else.
// Both bounds are inclusive.
SECTION("lower bound exactly") {
TrackGallery tg(expand_cfg());
tg.set_calibration(identity_cal);
tg.observe(1, one_hot(0), 0, 0.30f, true, kNoCrop);
tg.observe(1, at_sim(0, 1, kBandLo), 0, 0.30f, true, kNoCrop);
CHECK(tg.band_rejected() == 0);
}
SECTION("upper bound exactly") {
TrackGallery tg(expand_cfg());
tg.set_calibration(identity_cal);
tg.observe(1, one_hot(0), 0, 0.30f, true, kNoCrop);
tg.observe(1, at_sim(0, 1, kBandHi), 0, 0.30f, true, kNoCrop);
CHECK(tg.band_rejected() == 0);
}
}
TEST_CASE("store never admits below the lower bound", "[track_gallery][AR-018]") {
// The lower bound is the poisoning guard: an embedding unlike everything
// already on the track is evidence the track is not one person.
TrackGallery tg(expand_cfg()); TrackGallery tg(expand_cfg());
// All views are recognised well (sim 0.90 ≥ novelty 0.55): nothing worth tg.set_calibration(identity_cal);
// promoting even though the track is confirmed. tg.observe(1, one_hot(0), 0, 0.30f, true, kNoCrop);
for (int f = 0; f < 3; ++f)
tg.observe(2, one_hot(0), 0, 0.90f, true, kNoCrop); tg.observe(1, at_sim(0, 1, kBandLo - 0.01f), 0, 0.30f, true, kNoCrop);
CHECK(tg.annex().empty()); CHECK(tg.band_rejected() == 1);
tg.observe(1, one_hot(400), 0, 0.30f, true, kNoCrop); // orthogonal: P = 0
CHECK(tg.band_rejected() == 2);
}
TEST_CASE("store never admits above the upper bound", "[track_gallery][AR-018]") {
// The upper bound is the redundancy guard: another look at a pose the store
// already covers teaches the annex nothing and costs a slot.
TrackGallery tg(expand_cfg());
tg.set_calibration(identity_cal);
tg.observe(1, one_hot(0), 0, 0.30f, true, kNoCrop);
tg.observe(1, at_sim(0, 1, kBandHi + 0.01f), 0, 0.30f, true, kNoCrop);
CHECK(tg.band_rejected() == 1);
tg.observe(1, one_hot(0), 0, 0.30f, true, kNoCrop); // identical: P = 1
CHECK(tg.band_rejected() == 2);
}
TEST_CASE("band thresholds probability, not cosine", "[track_gallery][AR-018][AR-024]") {
// The invariant's actual claim, and the one a raw-cosine gate passes by
// accident under an identity calibration. With a calibration that shifts by
// +0.10, two embeddings get the OPPOSITE verdict from the one their bare
// cosines would earn — so admission here can only come from the calibrated
// value having been used.
TrackGallery tg(expand_cfg());
tg.set_calibration([](float c) { return c + 0.10f; });
tg.observe(1, one_hot(0), 0, 0.30f, true, kNoCrop);
// cosine 0.84 (below lo, would be refused raw) → P = 0.94, inside the band.
tg.observe(1, at_sim(0, 1, 0.84f), 0, 0.30f, true, kNoCrop);
CHECK(tg.band_rejected() == 0);
// cosine 0.92 (inside the band, would be admitted raw) → P = 1.02, above it.
tg.observe(1, at_sim(0, 2, 0.92f), 0, 0.30f, true, kNoCrop);
CHECK(tg.band_rejected() == 1);
} }
TEST_CASE("a two-person track never poisons the annex", "[track_gallery][AR-018]") { TEST_CASE("a two-person track never poisons the annex", "[track_gallery][AR-018]") {
TrackGallery tg(expand_cfg()); TrackGallery tg(expand_cfg());
tg.set_calibration(identity_cal);
// Two orthogonal identities under one track ID — a track-ID collision. // Two orthogonal identities under one track ID — a track-ID collision.
// // The band refuses the outsider at the door, so the store never becomes
// The banded admission (AR-018) now catches this EARLIER than the spread // two-person in the first place.
// gate did: an embedding unlike everything already on the track falls below tg.observe(3, spoke(1, kSpokeCos), 0, 0.30f, true, kNoCrop);
// the band's lower bound and is refused entry, so the buffer never becomes tg.observe(3, spoke(2, kSpokeCos), 0, 0.30f, true, kNoCrop);
// two-person in the first place. The spread gate remains as a second line
// for a track that drifts gradually rather than jumping.
//
// The assertion is on the outcome, not the mechanism: whichever gate fires,
// the outsider must not reach the actor's annex.
tg.observe(3, at_sim(0, 1, 0.30f), 0, 0.30f, true, kNoCrop);
tg.observe(3, at_sim(0, 1, 0.30f), 0, 0.30f, true, kNoCrop);
tg.observe(3, one_hot(400), 0, 0.30f, true, kNoCrop); // orthogonal outlier tg.observe(3, one_hot(400), 0, 0.30f, true, kNoCrop); // orthogonal outlier
CHECK(tg.band_rejected() > 0); // refused at the door CHECK(tg.band_rejected() == 1); // refused at the door
REQUIRE_FALSE(tg.annex().empty()); // the legitimate views still promote
for (const auto& e : tg.annex()) for (const auto& e : tg.annex())
CHECK(cosine_similarity(e.emb, one_hot(400)) < 0.5f); CHECK(cosine_similarity(e.emb, one_hot(400)) < 0.5f);
} }
TEST_CASE("unconfirmed track (too few accepts) does not promote", "[track_gallery]") { TEST_CASE("a track that drifts through the band is refused at promotion",
"[track_gallery][AR-018]") {
// `admit` compares a newcomer against its CLOSEST existing member, so a
// gradual drift chains past it: each step is in-band while the endpoints are
// strangers. This is the shape a collision takes over a slow pan, and the
// reason the lower bound is re-asked across every pair before promotion.
TrackGallery tg(expand_cfg()); TrackGallery tg(expand_cfg());
// Only 2 accepted frames < min_anchor_frames 3; extra non-accepted frames tg.set_calibration(identity_cal);
// fill the buffer but don't count toward ownership.
tg.observe(4, at_sim(0, 1, 0.30f), 0, 0.30f, true, kNoCrop); tg.observe(5, on_circle(0.f), 0, 0.30f, true, kNoCrop);
tg.observe(4, at_sim(0, 1, 0.31f), 0, 0.31f, true, kNoCrop); tg.observe(5, on_circle(25.f), 0, 0.30f, true, kNoCrop); // P=0.906 vs 0° → in band
tg.observe(4, at_sim(0, 1, 0.32f), 0, 0.32f, false, kNoCrop); tg.observe(5, on_circle(50.f), 0, 0.30f, true, kNoCrop); // P=0.906 vs 25° → in band
CHECK(tg.band_rejected() == 0); // every step passed the door...
// ...but 0° and 50° are P=0.643 apart, below the floor: the whole track goes.
CHECK(tg.annex().empty()); CHECK(tg.annex().empty());
} }
TEST_CASE("plurality actor wins a mixed-vote track", "[track_gallery]") { // ── AR-019: ownership and promotion ──────────────────────────────────────────
Config cfg = expand_cfg();
cfg.expand_min_anchor_frames = 3; TEST_CASE("confirmed track promotes its store", "[track_gallery][AR-019]") {
TrackGallery tg(cfg); TrackGallery tg(expand_cfg());
// Actor 5 accepted twice, actor 6 once → plurality is 5. All views novel. tg.set_calibration(identity_cal);
tg.observe(8, at_sim(0, 1, 0.30f), 5, 0.30f, true, kNoCrop); REQUIRE(tg.enabled());
tg.observe(8, at_sim(0, 1, 0.31f), 5, 0.31f, true, kNoCrop);
tg.observe(8, at_sim(0, 1, 0.32f), 6, 0.32f, true, kNoCrop); // A track owned by actor 0: every frame accepted, every view mutually
// in-band (P = 0.9197 between distinct spokes) and gallery-far (0.30).
for (int k = 1; k <= 3; ++k)
tg.observe(7, spoke(k, kSpokeCos), 0, 0.30f, true, kNoCrop);
// 3 accepted frames == min_anchor_frames → confirmed and promoted.
CHECK(tg.annex().size() == 3);
for (const auto& ae : tg.annex()) CHECK(ae.actor_idx == 0);
}
TEST_CASE("registry ownership overrides the local tally", "[track_gallery][AR-019]") {
TrackGallery tg(expand_cfg());
tg.set_calibration(identity_cal);
// Local accepted-frame plurality says actor 5; the registry's accumulated
// posterior says actor 9. The registry is authoritative.
tg.set_owner(11, 9);
for (int k = 1; k <= 3; ++k)
tg.observe(11, spoke(k, kSpokeCos), 5, 0.30f, true, kNoCrop);
REQUIRE_FALSE(tg.annex().empty());
for (const auto& ae : tg.annex()) CHECK(ae.actor_idx == 9);
}
TEST_CASE("unconfirmed track (too few accepts) does not promote", "[track_gallery][AR-019]") {
TrackGallery tg(expand_cfg());
tg.set_calibration(identity_cal);
// Only 2 accepted frames < min_anchor_frames 3; the third fills the buffer
// but doesn't count toward ownership.
tg.observe(4, spoke(1, kSpokeCos), 0, 0.30f, true, kNoCrop);
tg.observe(4, spoke(2, kSpokeCos), 0, 0.30f, true, kNoCrop);
tg.observe(4, spoke(3, kSpokeCos), 0, 0.30f, false, kNoCrop);
CHECK(tg.annex().empty());
}
TEST_CASE("plurality actor wins a mixed-vote track", "[track_gallery][AR-019]") {
TrackGallery tg(expand_cfg());
tg.set_calibration(identity_cal);
// No registry attached (unit-test path): actor 5 accepted twice, actor 6
// once → plurality is 5.
tg.observe(8, spoke(1, kSpokeCos), 5, 0.30f, true, kNoCrop);
tg.observe(8, spoke(2, kSpokeCos), 5, 0.30f, true, kNoCrop);
tg.observe(8, spoke(3, kSpokeCos), 6, 0.30f, true, kNoCrop);
REQUIRE_FALSE(tg.annex().empty()); REQUIRE_FALSE(tg.annex().empty());
for (const auto& ae : tg.annex()) CHECK(ae.actor_idx == 5); for (const auto& ae : tg.annex()) CHECK(ae.actor_idx == 5);
} }
TEST_CASE("promotion is idempotent across a long track", "[track_gallery]") { TEST_CASE("promotion is idempotent across a long track", "[track_gallery][AR-019]") {
TrackGallery tg(expand_cfg()); TrackGallery tg(expand_cfg());
for (int f = 0; f < 3; ++f) tg.set_calibration(identity_cal);
tg.observe(9, at_sim(0, 1, 0.30f + 0.001f * f), 0, 0.30f + 0.001f * f, true, kNoCrop); for (int k = 1; k <= 3; ++k)
tg.observe(9, spoke(k, kSpokeCos), 0, 0.30f, true, kNoCrop);
size_t after_confirm = tg.annex().size(); size_t after_confirm = tg.annex().size();
REQUIRE(after_confirm > 0); REQUIRE(after_confirm > 0);
// Keep feeding the confirmed track: annex must not grow again. // Keep feeding the confirmed track: annex must not grow again.
for (int f = 0; f < 10; ++f) for (int f = 0; f < 10; ++f)
tg.observe(9, at_sim(0, 1, 0.30f), 0, 0.30f, true, kNoCrop); tg.observe(9, spoke(4 + f, kSpokeCos), 0, 0.30f, true, kNoCrop);
CHECK(tg.annex().size() == after_confirm); CHECK(tg.annex().size() == after_confirm);
} }
TEST_CASE("clear_tracks drops buffers before confirmation", "[track_gallery]") { TEST_CASE("clear_tracks drops buffers before confirmation", "[track_gallery][AR-019]") {
TrackGallery tg(expand_cfg()); TrackGallery tg(expand_cfg());
tg.set_calibration(identity_cal);
// Two accepts, then a cut clears buffers; the third accept starts fresh and // Two accepts, then a cut clears buffers; the third accept starts fresh and
// can't reach the anchor threshold on its own. // can't reach the anchor threshold on its own.
tg.observe(1, at_sim(0, 1, 0.30f), 0, 0.30f, true, kNoCrop); tg.observe(1, spoke(1, kSpokeCos), 0, 0.30f, true, kNoCrop);
tg.observe(1, at_sim(0, 1, 0.31f), 0, 0.31f, true, kNoCrop); tg.observe(1, spoke(2, kSpokeCos), 0, 0.30f, true, kNoCrop);
tg.clear_tracks(); tg.clear_tracks();
tg.observe(1, at_sim(0, 1, 0.32f), 0, 0.32f, true, kNoCrop); tg.observe(1, spoke(3, kSpokeCos), 0, 0.30f, true, kNoCrop);
CHECK(tg.annex().empty()); CHECK(tg.annex().empty());
} }
TEST_CASE("eviction keeps the gallery-far views", "[track_gallery][AR-018]") {
// Novelty is no longer a threshold — it is this ordering. With the buffer
// full, a more gallery-far newcomer must displace the best-recognised
// member, and a less novel one must be dropped rather than displace a
// better sample.
Config cfg = expand_cfg();
cfg.expand_buffer_size = 2;
cfg.expand_min_anchor_frames = 4;
TrackGallery tg(cfg);
tg.set_calibration(identity_cal);
tg.observe(6, spoke(1, kSpokeCos), 0, 0.80f, true, kNoCrop); // well recognised
tg.observe(6, spoke(2, kSpokeCos), 0, 0.40f, true, kNoCrop);
tg.observe(6, spoke(3, kSpokeCos), 0, 0.20f, true, kNoCrop); // novel: evicts the 0.80
tg.observe(6, spoke(4, kSpokeCos), 0, 0.90f, true, kNoCrop); // least novel: dropped
REQUIRE(tg.annex().size() == 2);
// Survivors are the two most gallery-far views: spokes 2 and 3.
for (const auto& ae : tg.annex()) {
const bool is_2 = cosine_similarity(ae.emb, spoke(2, kSpokeCos)) > 0.99f;
const bool is_3 = cosine_similarity(ae.emb, spoke(3, kSpokeCos)) > 0.99f;
CHECK((is_2 || is_3));
}
}