diff --git a/docs/SPEC.md b/docs/SPEC.md index 25b60a9..88f963d 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -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. A starting band of roughly **0.90–0.95** is the working estimate, to be tuned -(VR-007). Note this is deliberately conservative compared to the current -`expand_novelty_sim` (0.55), which promotes embeddings *far* from the gallery — +(VR-007). Note this is deliberately conservative compared to the retired +`expand_novelty_sim` (0.55), which promoted embeddings *far* from the gallery — 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). +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 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). **Current:** `src/gallery/track_gallery.hpp` implements a per-track diversity -buffer with eviction biased to gallery-far poses, promotion gated on -`expand_novelty_sim` / `expand_track_spread_max`, cleared on `is_cut`. Wired at -`identity_matcher_node.hpp:227`, cleared at `:126`. +buffer with eviction biased to gallery-far poses, admission and promotion both +gated on the AR-018 band in probability space, cleared on `is_cut`. Wired at +`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 -three quiet-signal conditions rather than only `is_cut`; probability space -throughout (AR-024); and the whole of AR-020 — the TBI queue, the deferred pass, and -deferring output until it completes. +**Gap:** all three quiet-signal conditions rather than only `is_cut`; and the +whole of AR-020 — the TBI queue, the deferred pass, and deferring output until it +completes. ## AR-022 — Unidentified-track capture diff --git a/src/config.hpp b/src/config.hpp index a9e1d54..c56472c 100644 --- a/src/config.hpp +++ b/src/config.hpp @@ -153,16 +153,14 @@ struct Config { // Banded admission for the per-subject store, in PROBABILITY space. An // 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 - // 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 // directions. float expand_band_lo{0.90f}; 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 // the track is confirmed and its buffer promoted std::string expand_debug_dir; // if set, dump promoted mugshots + embeddings here diff --git a/src/gallery/track_gallery.hpp b/src/gallery/track_gallery.hpp index 000776e..db6cebe 100644 --- a/src/gallery/track_gallery.hpp +++ b/src/gallery/track_gallery.hpp @@ -36,13 +36,15 @@ // 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 // confirmation the retained buffer — the hard, gallery-far poses — is -// promoted into A's per-film annex, after two safety gates: -// • novelty: only embeddings whose best sim to A's refs is below -// expand_novelty_sim are added (skip poses already covered); -// • spread: if the retained buffer's internal spread (1 − min pairwise -// cosine sim) exceeds expand_track_spread_max the whole track is -// rejected — such spread signals a track-ID collision merging two -// people, whose embeddings must never enter A's annex. +// promoted into A's per-film annex, subject to one safety gate: the band's +// lower bound, re-applied across the whole store (see `store_coherence`). +// +// There is exactly one threshold here, the AR-018 band, and it is a calibrated +// probability. Novelty is no longer a threshold at all — the eviction policy +// above *orders* by gallery similarity rather than cutting at a constant, and +// 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 // 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) : enabled_(cfg.expand_gallery) , buffer_size_(std::max(1, cfg.expand_buffer_size)) - , novelty_sim_(cfg.expand_novelty_sim) - , spread_max_(cfg.expand_track_spread_max) + , band_lo_(cfg.expand_band_lo) + , band_hi_(cfg.expand_band_hi) , min_anchor_frames_(std::max(1, cfg.expand_min_anchor_frames)) , debug_dir_(cfg.expand_debug_dir) { if (!enabled_) return; std::cerr << "[track_gallery] per-film expansion ON" << " buffer=" << buffer_size_ - << " novelty_sim<" << novelty_sim_ - << " spread_max=" << spread_max_ + << " band=[" << band_lo_ << ", " << band_hi_ << "]" << " min_anchor_frames=" << min_anchor_frames_; if (!debug_dir_.empty()) { std::filesystem::create_directories(debug_dir_); @@ -88,7 +89,9 @@ struct TrackGallery { // track_id : face_tracker track (−1 = untracked, ignored) // emb : this frame's raw embedding // 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 // crop : aligned crop, retained only when debug dumping is on 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 /// bounded — and the default is loud in the header rather than silent. void set_calibration(std::function 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 /// one that admits everything, and neither is visible without this. @@ -146,7 +148,10 @@ struct TrackGallery { private: struct BufEntry { 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 }; @@ -193,8 +198,8 @@ private: if (!admit(ts, emb)) { ++rejected_; return; } BufEntry e; - e.emb = emb; - e.gal_sim = gal_sim; + e.emb = emb; + e.gal_p = calibrate_(gal_sim); if (!debug_dir_.empty() && !crop.empty()) e.crop = crop.clone(); if (static_cast(ts.buf.size()) < buffer_size_) { @@ -203,13 +208,17 @@ private: } // 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. - 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(ts.buf.size()); ++i) { - if (ts.buf[i].gal_sim > worst_sim) { - worst_sim = ts.buf[i].gal_sim; + if (ts.buf[i].gal_p > worst_p) { + worst_p = ts.buf[i].gal_p; worst_i = i; } } @@ -224,25 +233,18 @@ private: int actor = owning_actor(ts); if (actor < 0) return; - // ── Safety gate: internal spread ───────────────────────────────────── - // A legitimate single-person track varies in pose but stays reasonably - // self-similar. Large spread signals two people merged under one track - // ID — reject the whole track rather than poison the actor's annex. - float spread = buffer_spread(ts.buf); - if (spread > spread_max_) { + // ── Safety gate: the band's lower bound, across the whole store ────── + float worst = store_coherence(ts.buf); + if (worst < band_lo_) { std::cerr << "[track_gallery] track " << track_id << " → actor " << actor - << " REJECTED (spread " << spread - << " > " << spread_max_ << ", likely ID collision)\n"; + << " REJECTED (worst pairwise P=" << worst + << " < " << band_lo_ << ", likely ID collision)\n"; return; } int added = 0; 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}); if (!debug_dir_.empty() && !be.crop.empty()) dump_mugshot(track_id, actor, added, be); @@ -251,10 +253,9 @@ private: std::cerr << "[track_gallery] track " << track_id << " confirmed actor " << actor - << " (" << ts.accepted_frames << " accepted frames, spread " - << spread << ") — promoted " << added << "/" - << ts.buf.size() << " views; annex now " - << annex_.size() << "\n"; + << " (" << ts.accepted_frames << " accepted frames, worst " + << "pairwise P=" << worst << ") — promoted " << added + << " views; annex now " << annex_.size() << "\n"; } /// Prefer the registry's verdict; fall back to the local tally only when no @@ -272,21 +273,34 @@ private: return best; } - // Spread = 1 − min pairwise cosine similarity over the buffer (0 when <2). - static float buffer_spread(const std::vector& buf) { - float min_sim = std::numeric_limits::max(); + /// TRACES: AR-018, AR-024 | SR-005 + /// The store's weakest pairwise P(same person) — the band's lower bound + /// 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& buf) const { + float worst = std::numeric_limits::max(); for (size_t i = 0; i < buf.size(); ++i) for (size_t j = i + 1; j < buf.size(); ++j) - min_sim = std::min(min_sim, cosine_similarity(buf[i].emb, buf[j].emb)); - if (min_sim == std::numeric_limits::max()) return 0.f; - return 1.f - min_sim; + worst = std::min(worst, + calibrate_(cosine_similarity(buf[i].emb, buf[j].emb))); + if (worst == std::numeric_limits::max()) return 1.f; + return worst; } void dump_mugshot(int track_id, int actor, int idx, const BufEntry& be) { #ifdef SAE_DEBUG char name[64]; - std::snprintf(name, sizeof(name), "trk%d_actor%d_%d_sim%.3f.jpg", - track_id, actor, idx, be.gal_sim); + std::snprintf(name, sizeof(name), "trk%d_actor%d_%d_p%.3f.jpg", + track_id, actor, idx, be.gal_p); cv::imwrite((std::filesystem::path(debug_dir_) / name).string(), be.crop); #else (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 /// in; see gallery_calibration.hpp's same_person_probability. std::function 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 bool enabled_; int buffer_size_; - float novelty_sim_; - float spread_max_; + float band_lo_; ///< AR-018, from cfg.expand_band_lo + float band_hi_; ///< AR-018, from cfg.expand_band_hi int min_anchor_frames_; std::string debug_dir_; diff --git a/src/scene_preview.cpp b/src/scene_preview.cpp index e7892cf..b5e78be 100644 --- a/src/scene_preview.cpp +++ b/src/scene_preview.cpp @@ -96,8 +96,8 @@ static Config parse_args(int argc, char** argv) { // Per-film gallery expansion — preview supports it (same cfg fields). 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-novelty-sim")) cfg.expand_novelty_sim = std::stof(next()); - else if (arg("--expand-spread-max")) cfg.expand_track_spread_max = std::stof(next()); + else if (arg("--expand-band-lo")) cfg.expand_band_lo = 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()); // 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 diff --git a/tests/test_track_gallery.cpp b/tests/test_track_gallery.cpp index 9fc2f3b..46422b1 100644 --- a/tests/test_track_gallery.cpp +++ b/tests/test_track_gallery.cpp @@ -1,7 +1,16 @@ +// TRACES: AR-018, AR-019, AR-024 | SR-005 +// // Unit tests for TrackGallery (gallery/track_gallery.hpp): per-film gallery // expansion driven by track continuity. Pure, GPU-free, model-free — exercises -// the diversity-buffer eviction policy, the novelty/spread safety gates, -// plurality ownership, and idempotent promotion via the public interface. +// the AR-018 banded admission at both bounds, the promotion-time coherence +// 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 #include @@ -14,30 +23,60 @@ namespace { -// Unit-norm embedding pointing along one axis (cosine sim to another one-hot is -// 0, to itself 1) — lets tests dial gallery similarity precisely. +constexpr float kPi = 3.14159265358979323846f; + +// 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 e{}; e[slot] = 1.0f; return e; } -// Unit-norm embedding in the plane of axes i,j at angle t from i. Cosine sim to -// one_hot(i) is cos(t) — used to place a view at a chosen gallery similarity. +// Unit-norm embedding in the plane of axes i,j at cosine `cos_t` from axis i. +// Cosine sim to one_hot(i) is exactly cos_t. Embedding at_sim(int i, int j, float cos_t) { Embedding e{}; - float s = std::sqrt(std::max(0.f, 1.f - cos_t * 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; } Config expand_cfg() { Config cfg; - cfg.expand_gallery = true; - cfg.expand_buffer_size = 3; - cfg.expand_novelty_sim = 0.55f; - cfg.expand_track_spread_max = 0.60f; + cfg.expand_gallery = true; + cfg.expand_buffer_size = 3; + cfg.expand_band_lo = kBandLo; + cfg.expand_band_hi = kBandHi; cfg.expand_min_anchor_frames = 3; return cfg; } @@ -59,92 +98,228 @@ TEST_CASE("disabled: no annex growth when expand_gallery is off", "[track_galler CHECK(tg.annex().empty()); } -TEST_CASE("confirmed track promotes gallery-far views", "[track_gallery]") { - TrackGallery tg(expand_cfg()); - REQUIRE(tg.enabled()); +// ── AR-018: the band ───────────────────────────────────────────────────────── - // A track owned by actor 0. Every frame is accepted as actor 0, but each - // view is gallery-far (sim 0.30 < novelty 0.55) yet mutually self-similar - // enough to pass the spread gate. - for (int f = 0; f < 3; ++f) - tg.observe(7, at_sim(0, 1, 0.30f + 0.001f * f), 0, 0.30f + 0.001f * f, true, kNoCrop); +TEST_CASE("band bounds come from config, not a hardcoded default", "[track_gallery][AR-018]") { + // The bounds were declared in Config and read nowhere, so the gate ran at + // whatever the header happened to initialise. Drive them somewhere the + // defaults are not and require the gate to follow. + 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. - CHECK_FALSE(tg.annex().empty()); - for (const auto& ae : tg.annex()) CHECK(ae.actor_idx == 0); + tg.observe(1, one_hot(0), 0, 0.30f, true, kNoCrop); + // P = 0.50: inside the configured band, far below the shipped default lo. + 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()); - // All views are recognised well (sim 0.90 ≥ novelty 0.55): nothing worth - // promoting even though the track is confirmed. - for (int f = 0; f < 3; ++f) - tg.observe(2, one_hot(0), 0, 0.90f, true, kNoCrop); - CHECK(tg.annex().empty()); + 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.01f), 0, 0.30f, true, kNoCrop); + 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]") { TrackGallery tg(expand_cfg()); + tg.set_calibration(identity_cal); // Two orthogonal identities under one track ID — a track-ID collision. - // - // The banded admission (AR-018) now catches this EARLIER than the spread - // gate did: an embedding unlike everything already on the track falls below - // the band's lower bound and is refused entry, so the buffer never becomes - // 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); + // The band refuses the outsider at the door, so the store never becomes + // two-person in the first place. + tg.observe(3, spoke(1, kSpokeCos), 0, 0.30f, true, kNoCrop); + tg.observe(3, spoke(2, kSpokeCos), 0, 0.30f, true, kNoCrop); 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()) 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()); - // Only 2 accepted frames < min_anchor_frames 3; extra non-accepted frames - // 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(4, at_sim(0, 1, 0.31f), 0, 0.31f, true, kNoCrop); - tg.observe(4, at_sim(0, 1, 0.32f), 0, 0.32f, false, kNoCrop); + tg.set_calibration(identity_cal); + + tg.observe(5, on_circle(0.f), 0, 0.30f, true, kNoCrop); + tg.observe(5, on_circle(25.f), 0, 0.30f, true, kNoCrop); // P=0.906 vs 0° → in band + 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()); } -TEST_CASE("plurality actor wins a mixed-vote track", "[track_gallery]") { - Config cfg = expand_cfg(); - cfg.expand_min_anchor_frames = 3; - TrackGallery tg(cfg); - // Actor 5 accepted twice, actor 6 once → plurality is 5. All views novel. - tg.observe(8, at_sim(0, 1, 0.30f), 5, 0.30f, true, kNoCrop); - 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); +// ── AR-019: ownership and promotion ────────────────────────────────────────── + +TEST_CASE("confirmed track promotes its store", "[track_gallery][AR-019]") { + TrackGallery tg(expand_cfg()); + tg.set_calibration(identity_cal); + REQUIRE(tg.enabled()); + + // 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()); 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()); - for (int f = 0; f < 3; ++f) - tg.observe(9, at_sim(0, 1, 0.30f + 0.001f * f), 0, 0.30f + 0.001f * f, true, kNoCrop); + tg.set_calibration(identity_cal); + 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(); REQUIRE(after_confirm > 0); // Keep feeding the confirmed track: annex must not grow again. 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); } -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()); + tg.set_calibration(identity_cal); // Two accepts, then a cut clears buffers; the third accept starts fresh and // 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, at_sim(0, 1, 0.31f), 0, 0.31f, true, kNoCrop); + tg.observe(1, spoke(1, kSpokeCos), 0, 0.30f, true, kNoCrop); + tg.observe(1, spoke(2, kSpokeCos), 0, 0.30f, true, kNoCrop); 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()); } + +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)); + } +}