Compare commits
2
Commits
35e703350f
...
05a3142d27
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05a3142d27 | ||
|
|
e327ab19e7 |
+27
-9
@@ -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
|
||||
|
||||
|
||||
+4
-6
@@ -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
|
||||
|
||||
@@ -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<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
|
||||
/// 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<int>(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<int>(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<BufEntry>& buf) {
|
||||
float min_sim = std::numeric_limits<float>::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<BufEntry>& buf) const {
|
||||
float worst = std::numeric_limits<float>::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<float>::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<float>::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<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
|
||||
|
||||
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_;
|
||||
|
||||
|
||||
@@ -4,12 +4,16 @@
|
||||
#include <iostream>
|
||||
|
||||
// ── FaceAlignerFunc ───────────────────────────────────────────────────────────
|
||||
/// TRACES: AR-005, AR-030 | SR-002
|
||||
///
|
||||
// KPN node: applies a 5-point similarity transform to each detected face,
|
||||
// producing a 112×112 BGR crop suitable for ArcFace inference.
|
||||
//
|
||||
// Alignment uses cv::estimateAffinePartial2D (RANSAC) to fit the detected
|
||||
// landmarks to ArcFace canonical positions. Degenerate detections (where the
|
||||
// affine fit fails) are silently dropped from the output vectors.
|
||||
// Alignment is an Umeyama least-squares fit over all five landmarks (AR-005),
|
||||
// not a robust one: a RANSAC fit discards the very landmarks AR-030 reads.
|
||||
// Degenerate detections (where the fit fails) are dropped from the output
|
||||
// vectors. The fit's residual is the AR-030 visibility measure and comes free,
|
||||
// since the warp needs the transform anyway.
|
||||
|
||||
struct FaceAlignerFunc {
|
||||
static constexpr std::string_view label() { return "face_aligner"; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
// TRACES: AR-023 | SR-002
|
||||
//
|
||||
// Unit tests for gallery calibration: the sigmoid math, the pairwise fit on
|
||||
// separable data, and the in-memory hash-keyed cache (hit / stale / cold).
|
||||
// All pure, GPU-free, model-free.
|
||||
//
|
||||
// The three `[report]` cases at the bottom carry their own GR-003 tags: they
|
||||
// verify the build report, which is fitted from the same distributions but is a
|
||||
// separate requirement.
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||||
|
||||
@@ -183,6 +189,7 @@ Embedding unit_axis(int slot) {
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// TRACES: GR-003 | SR-001
|
||||
TEST_CASE("report surfaces actors that can never be recognised", "[report][GR-003]") {
|
||||
// An actor with no usable image is a silent recall ceiling: the pipeline
|
||||
// will never name them, and nothing in the gallery says why. This is the
|
||||
@@ -212,6 +219,7 @@ TEST_CASE("report surfaces actors that can never be recognised", "[report][GR-00
|
||||
CHECK(r.actors[1].references == 0);
|
||||
}
|
||||
|
||||
// TRACES: GR-003 | SR-001
|
||||
TEST_CASE("report surfaces actors too thin to calibrate on", "[report][GR-003]") {
|
||||
// Below the positive-pair threshold an actor contributes nothing to the
|
||||
// intra-class side of the fit. They are not broken, so nothing complains —
|
||||
@@ -237,6 +245,7 @@ TEST_CASE("report surfaces actors too thin to calibrate on", "[report][GR-003]")
|
||||
CHECK(r.actors_below_positive_threshold >= 1);
|
||||
}
|
||||
|
||||
// TRACES: GR-003 | SR-001
|
||||
TEST_CASE("report round-trips", "[report][GR-003]") {
|
||||
ActorGallery g;
|
||||
ActorGallery::Actor act;
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
// TRACES: AR-007, AR-008 | SR-002
|
||||
//
|
||||
// Unit tests for FaceTrackerFunc (nodes/face_tracker_node.hpp): frame-to-frame
|
||||
// track linking and, crucially, cross-cut re-association. Pure, GPU-free,
|
||||
// model-free — drives the node's operator() with hand-built EmbeddedSceneFrames
|
||||
// and inspects the emitted track_ids.
|
||||
//
|
||||
// The behaviour under test: on a camera-angle change (Frame::is_cut) the tracker
|
||||
// parks its tracks instead of destroying them, and revives a parked track_id
|
||||
// when a post-cut detection's raw last-frame-embedding cosine similarity clears
|
||||
// cut_revive_sim. IoU is deliberately driven to 0 across the cut (boxes moved) so
|
||||
// only the embedding path can re-link — exactly the scenario a cut creates.
|
||||
// The behaviour under test: there is one track pool keyed on `last_seen`
|
||||
// (AR-008), so a face lost across a camera-angle change (Frame::is_cut) is an
|
||||
// ordinary association candidate rather than a parked track needing a revival
|
||||
// path — the raw-cosine `cut_revive_sim` that guarded that path is retired
|
||||
// (AR-024). On a cut the association weight drops to embedding-only (AR-007),
|
||||
// and IoU is deliberately driven to 0 across the cut (boxes moved) so only the
|
||||
// embedding path can re-link — exactly the scenario a cut creates.
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include "config.hpp"
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
// TRACES: AR-026 | SR-001
|
||||
//
|
||||
// Unit tests for the CPU reference similarity engine (backends/gemm_backend.cpp,
|
||||
// SAE_GEMM_CPU) and the l2_normalise helper. All pure, GPU-free, model-free.
|
||||
//
|
||||
// This is the CI half of AR-026: equivalence between the GEMM path and
|
||||
// hand-computed dot products on small input. Throughput at scale (AR-027) is T4
|
||||
// and cannot run here.
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||||
|
||||
|
||||
+238
-63
@@ -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 <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user