The per-film annex was folded in after the gallery multiply by a host-side
cosine loop over a vector of {embedding, actor} structs, justified in-comment
by "tens of embeddings". AR-018/AR-019 retired that assumption: every owned
track promotes, so the annex grows with cast size and film length.
TrackGallery now holds it as a contiguous row-major matrix with a parallel
actor index — the flat_emb_/flat_actor_ shape the baked gallery already uses —
and hands newly promoted rows to the matcher once per frame. The matcher pushes
them into the similarity engine's resident matrix through a new
ISimilarityEngine::append_rows, so one SGEMM covers baked and promoted
references alike and best-of-N is a single pass over one similarity column.
Capacity doubles on overflow, and the GPU backends grow device-to-device, so a
promotion never re-uploads the gallery across the bus.
Absorbing promotions runs once per frame, after every face has been scored.
Appending mid-frame would invalidate the similarity pointer the chunk loop is
still reading, and it also removes an incidental dependence on face order
within a frame — a promotion helps subsequent frames, never the one that
produced it, which is the semantics the expansion store already documented.
OpenBLAS becomes a requirement of the CPU GEMM backend rather than an
opportunistic upgrade. That path is what CI and the cpu builder image run, so
falling back to the scalar loop in silence meant AR-027 could be measured — or
believed — on a kernel no release uses. The loop survives as the correctness
oracle the BLAS backends are diffed against, behind SAE_ALLOW_SCALAR_GEMM.
Call site 3, the deferred TBI pass, is untouched: it does not exist until
AR-020, so AR-026 stays In Progress.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TRACES: AR-026 | UT-004, UT-005 | SR-001
18 KiB
Implementation plan — per requirement
One entry per requirement that needs work. Requirements marked Done in
requirements.md are omitted.
Ordering is derived from dependencies, not assigned to phases. Each entry lists what it depends on; anything with no unmet dependency is startable. This replaces the earlier phase-based plan, which encoded ordering assumptions that stopped being true as the design changed.
Verification for each requirement is specified in
requirements.md — this document covers how to build it,
not how to prove it.
Startable now (no unmet dependencies)
GR-004 · IR-004 · IR-005 · IR-007 · IR-008 · VR-005 · AR-011 ·
AR-023 extension · tooling port
These touch disjoint files and can proceed concurrently.
Blocked on the registry
Everything in AR-007 … AR-022 depends on AR-012/AR-013 landing first,
because they all read or write track state. This group is one coherent
refactor, not parallel work — splitting it across concurrent efforts produces
incompatible designs in the same files.
Algorithm
AR-012, AR-013 — TrackRegistry (the spine)
Depends on: nothing. Blocks: AR-007, AR-008, AR-014 … AR-022.
Everything else in Part A waits on this, so it goes first.
Ownership: a shared resource, not a node
The registry is external to the dataflow network, created in main and
handed to each node that needs it as std::shared_ptr<TrackRegistry>. Lifetime
is guaranteed by refcount rather than by the "object must outlive the node"
convention, so no ordering assumption exists between network teardown and
registry destruction.
This is idiomatic here: node functors are already constructed outside the network
and passed by reference (main.cpp:186-207), and KPN provides SharedResource<T>
for state shared across nodes (KPN SPEC §163, §445).
Not a node, because ownership is not a stage in the stream — it is state several
stages read and write, whose final answer is only known when a track dies.
Not inside TrackGallery, because that would couple presence to expand_gallery,
a switchable feature.
The registry is the tracker's state. FaceTrackerFunc does not keep its own
tracks_/inactive_ maps and mirror them in — it operates on the registry
directly. Two parallel copies could disagree, and every divergence would surface
as wrong presence windows, silently.
Per-track state
Track
first_seen : double set once, at creation
last_seen : optional<double> UNSET while on screen; set to the last
on-screen timestamp when the face is lost
actor : optional<int> set when a posterior crosses the threshold
belief : {actor_idx -> accumulated_logodds} Bayesian, not a tally
embedding : Embedding running directional mean, for association
last_seen carries the entire liveness state. Unset = on screen; set = went off
at T. No separate missing-frames counter, no expired flag — the optional is the
state machine, and it subsumes the current two-pool split (tracks_ = unset,
inactive_ = set).
Lifecycle
face detected, no match → new track, first_seen = t, last_seen = unset
actor identified → update belief; set actor when threshold crossed
face lost → last_seen = t_last_on_screen (stays revivable)
face seen again, embedding match → last_seen = unset (same track continues)
tick(t), t - last_seen > timeout → emit to aggregator, DELETE the entry
A presence window is [first_seen, last_seen]. Nothing else.
Interior gaps are claimed; the trailing cool-down is not. A face lost at t₁
and re-acquired at t₂ within the timeout never closed its track, so the actor is
present across [t₁, t₂] — correct, since someone briefly occluded or off-camera
has not left the scene. But a track that dies ends at last_seen, not at the
moment of death. That asymmetry is what removes the old extinction_sec
over-claim.
Reaping is a handoff, not a deletion into a holding pen. The dead track goes to the result aggregator immediately and the registry drops it, so the registry holds only live tracks and its size is bounded by concurrent on-screen faces.
Interface
TrackRegistry
tick(timestamp) ← FaceTrackerFunc, every frame
candidates() -> span<Track&> → all live tracks
create(timestamp, embedding) -> track_id
mark_seen(track_id, timestamp, embedding) → updates mean, clears last_seen
mark_lost(track_id, last_on_screen_timestamp)
on_vote(track_id, actor_idx, posterior) ← IdentityMatcherFunc
owner(track_id) -> optional<actor_idx> → TrackGallery
on_track_dead : callback(DeadTrack) → ResultSinkFunc
flush() ← at EOF
candidates() returns one pool; last_seen tells the caller whether IoU
applies. There is no separate revival path — matching a dormant track is ordinary
inter-frame association.
tick() advances the clock so dead tracks are reaped independently of detection
activity; without it a track only dies when some other face happens to appear.
Locking
The tracker mutates registry state across a frame's association pass, so that
pass holds the lock for its duration (a frame_scope() handle). Every other
caller's operations must be individually atomic. A single std::mutex over the
whole registry is the right start — contention is a few small updates per frame
against per-frame work measured in GPU milliseconds.
Two cases constrain the API:
owner()is a read-modify-read in disguise:TrackGallerycalls it whileIdentityMatchermay be voting on the same track. Tally and verdict must be read under one lock as a snapshot, or a track can be both unowned and owned within a single promotion decision.on_vote()arrives downstream of the tracker'stick()for the same frame, so a vote may land after the clock moved on. Rule: a vote for a known track always lands on its tally, regardless of clock. Only reaping is clock-driven. A vote for an already-reaped track is dropped and counted — a nonzero count means the timeout is shorter than the matcher's lag.
on_track_dead fires from inside tick() while the frame lock is held, so the
callback must not re-enter the registry. Keep it to a push onto the aggregator's
storage.
AR-016 — EOF flush
Depends on: AR-012.
flush() emits every still-live track through the same callback, closing at
last_seen if set and the final tick timestamp otherwise. Idempotent, leaving the
registry empty; the sink's written_.exchange(true) guard
(result_sink_node.hpp:66) shows the shape.
Must run on every termination path that produces output. Not SIGTERM during opportunistic runs (DP-004) — those push no partial result, so there is nothing to flush.
Without it a film ending mid-shot silently drops its closing cast, which looks like a recognition miss rather than a bookkeeping bug.
AR-014, AR-015 — Contradiction rules
Depends on: AR-012, AR-025.
| Condition | Meaning | Action |
|---|---|---|
| Belief on one track swaps A → B | track_id carried across a viewpoint change onto a different person |
Close at last_seen, open a new track for B at the swap frame |
| Two live tracks owned by one actor | One person split in two, or an identity attached to the wrong track | Treat as a detected cut: reset affected state, re-associate on embedding |
The second makes identity a third cut detector, independent of histogram and
TransNetV2, firing where those failed. Detect it via a reverse index
actor_idx → live track_ids, so the condition is caught on the update that
causes it rather than by scanning.
Both counted and reported — the rates measure how often tracking is silently wrong, which nothing currently reveals.
AR-007, AR-008 — Tracker on one pool
Depends on: AR-012, AR-024.
FaceTrackerFunc is constructed with the registry and uses it as state; its
tracks_/inactive_ maps and the cross-cut revival branch collapse into one
pool keyed on last_seen. Per frame: tick(), association over candidates(),
then create/mark_seen/mark_lost.
track_alpha becomes frame-dependent — normal frames use the tuned blend,
frames flagged is_cut/is_scene_boundary drop toward embedding-only.
AR-024 — Probability space everywhere
Depends on: AR-023. Blocks: AR-007, AR-018, AR-021, AR-025.
Cuts across tracker, matcher and expansion, so it lands with the registry work
rather than after it. Retires track_max_embed_dist, cut_revive_sim,
expand_novelty_sim, expand_track_spread_max.
Enforcement is a static grep check for bare cosine outside a tagged
EXCEPTION — a unit test cannot prove absence across a codebase.
AR-025 — Bayesian accumulation
Depends on: AR-023, AR-024.
Log-odds per candidate actor, added per frame. on_vote() is an update, not an
increment.
The independence problem must be handled explicitly. Consecutive frames are highly correlated; naive accumulation drives the posterior to certainty on what is effectively one observation. Preferred mitigation: update only on sufficiently novel observations, reusing the diversity buffer's existing judgement rather than inventing a second one. The registry should receive already-discounted evidence.
AR-017 — Claims carry belief and route
Depends on: AR-012, AR-025. DeadTrack carries posterior plus how it was
identified (live / deferred / pooled).
AR-018 … AR-021 — Expansion, deferred pass, clustering
Depends on: AR-012, AR-024, AR-026.
Ordering within the group: AR-018 (banded store) → AR-019 (annex) → AR-020 (TBI queue + deferred pass) → AR-021 (clustering).
AR-021 needs the temporal cannot-link constraint from track extents, so it cannot start before AR-012. The annex must be a contiguous matrix with promotions appended (AR-026), not a list.
Output timing changes: the sink can no longer finalise at EOF — the deferred pass runs after and may add windows (IR-003).
AR-022 — Unidentified capture
Depends on: AR-020. Unidentified = TBI entries surviving the deferred pass.
Context crops opt-in behind --dump-unidentified-crops.
AR-001 … AR-004 — Detection and backpressure
Depends on: nothing (AR-002, AR-011); AR-004 blocks AR-003.
- AR-002 —
min_face_pxstays 40 (VR-013 measured it end to end) but must be expressed in original resolution rather than decoded-frame space. The value is already right inconfig.hpp; the change is the coordinate space. - AR-011 — feed TransNetV2 at native rate; derive the dedup window from
source fps rather than the hardcoded
0.04 s. - AR-004 — backpressure.
kMaxFaces(identity_matcher_node.hpp:133) currently throws; channel capacities of 16 (main.cpp:204-207) were sized against ≤10 faces/frame. Must block on bytes in flight, not item counts. - AR-003 — remove
max_faces. Gated on AR-004, not a follow-up to it.
AR-026, AR-027 — GEMM and scale
Depends on: nothing to start. The annex CPU loop has moved into the GEMM path: the annex is a contiguous matrix, promotions are appended to the engine's resident gallery, and the CPU backend now requires OpenBLAS. What is left of AR-026 is call site 3, the deferred pass — so the rest of AR-026 lands with AR-020 rather than before it.
Gallery
GR-004 — Model binding — DONE
Depended on: nothing. Landed before any measurement work, as intended.
Stamp = model basename + SHA-256 of the ONNX, written as the /embedder group at
build time (gallery_builder.cpp, sae_gallery.save_gallery_hdf5) and verified
at load in scene_analyze, scene_preview, the sae_kpn matcher binding,
replay.py, optimize.py and movienet_eval.py. Mismatch is a hard error naming
both sides, with no bypass. Embedding dumps carry the same stamp, since a replay
has no live embedder to check against.
Unstamped legacy galleries warn loudly and proceed rather than failing:
unknown is not known-bad, and hard-failing every pre-existing gallery would turn
the check into something people disable. --require-gallery-stamp /
SAE_REQUIRE_GALLERY_STAMP=1 promotes that to a hard error — measurement runs
should set it. scripts/stamp_gallery.py re-binds an existing gallery without
re-embedding, so the warning state is cheap to leave.
Cross-model similarities are meaningless but look plausible — this fails silently and expensively, and it would corrupt every measurement taken during the rest of this work.
GR-003 — Coverage reporting
Depends on: nothing. Surface what calibration already computes and discards
(kHistBins = 200): zero-image actors, under-referenced actors, dedup counts,
and the intra/inter PDFs.
GR-006 … GR-008 — Provenance tiers
Depends on: AR-019. Tier per embedding (baked / harvested / confirmed);
harvested persisted but flagged; bell-curve outlier check
(EXCEPTION: AR-024).
Integration
IR-004, IR-005, IR-007, IR-008 — Audio signature
Depends on: nothing. Fully independent — no existing pipeline file is touched. Best candidate for concurrent work.
Implement server spec §3 exactly. Audio decode is a second stream from the
already-linked FFmpeg. Media < 120 s: no signature, no offset. Emit and honour
the v1: prefix.
The golden-vector fixture is shared with the plugin repo and runs on CPU, so the one place two implementations must agree bit-for-bit is verifiable in CI.
IR-001 … IR-003 — Truth file
Depends on: AR-017 (belief), AR-020 (output timing).
Windows carry belief and route; extraction.* gains extinction_sec and
gallery_scope; anneal_sec removed. All breaking → one coordinated
schema_version bump with IR-004 (SR-003).
Validation
VR-005 — Minimum face size study
Depends on: nothing. Standalone Python, no C++ contact. Done — knee at 24–32 px. It measures the embedder with alignment held perfect, so it bounds the answer from below rather than setting it; AR-002's floor comes from VR-013, which sweeps input resolution end to end and lands at 40 px.
VR-013 — Cross-source identification probe
Depends on: sae_embed exposing detect(), align_face(), embed_crop()
and the gallery calibration — it drives the shipped C++ rather than reimplementing
it, which is what VR-005 could not do.
Gallery from one recording, probes from another, sweeping the probe's input
resolution before the detector, so detection and landmark regression degrade
with the frame. experiments/xsource/.
Findings. Holding 90% of the plateau needs ~50 px end to end against VR-005's
~22 px; min_face_px 40 is right and 32 would admit faces in the falling region.
FPI is 0.0% at every scale — resolution loss goes entirely to TBI, never to a
wrong name. The ceiling is cross-view, not resolution: everyone matches
themselves within a recording (0.55–0.85) and collapses across two (0.14–0.45),
and only the subject with frontal gallery references identified reliably — so
the lever is gallery pose coverage (docs/pose-expansion.md), not a better
landmark model. Averaging SCRFD's NMS-discarded landmark estimates lifts
cross-clip TPI 41% → 49% for one forward pass.
Open. Four identities and one shoot, so the shape is the result and the absolute rates are not. Both clips hold all four people, so there is no out-of-gallery class and the 10×-weighted out-of-cast misID is untested — holding one identity out of the gallery would fix that.
VR-014 — Audio-signature offset recovery
Depends on: sae_audio exposing compute_signature() and
signature_from_mono() — it drives the shipped C++, as VR-013 does, so the
thing measured is the thing that ships.
scripts/validation/test_audio_offset.py over
tests/fixtures/audio/superhero_offset_200s.flac: 200 s of public-domain film audio
(the same SuperHero clips the replay fixtures use), long enough for a 120 s
window to slide past the ±600-frame search cap. The slide itself is numpy here
on purpose — matching belongs to the consumer, so writing it out keeps this a
test of the signature rather than of somebody's matcher.
Findings. Alignment is a solved problem here: the offset is the nearest frame
in every in-cap trial, worst error 46 ms against a 500 ms budget, and 46 ms is
the quantisation floor — offsets are whole 92.88 ms frames, so no correct answer
can be worse. The runtime/2 anchor's factor of two holds through real trimmed
files, and out-of-cap offsets and unrelated content are both declined.
The score is where the slack is, and it costs a tier rather than accuracy. It
tracks sub-frame misalignment — 0.94–0.99 near a frame boundary, 0.69–0.73 at
half a frame — so two thirds of correct alignments miss the server's 0.85 audio
threshold and land in loose. UT-108 measures the fix rather than proposing one:
±1 frame of slack in the score returns all 40 to audio (min 0.906) with false
matches unmoved at 0.12–0.16, costing 81 ms of the budget. See
SPEC.md IR-004 — the score is normative in the server spec, so the
change is theirs to make.
Open. One source, one language, one era of recording. The shape (offset exact, score set by sub-frame phase) should hold generally, but the absolute scores are this fixture's.
VR-001 — Dump audit
Depends on: nothing. Read-only investigation: confirm the HDF5 dump preserves everything needed to reconstruct tracks deterministically, including the park/revive path. Prerequisite for the CI strategy, since T2 replay is how most of AR-007 … AR-022 is verified.
VR-006 … VR-009
Depends on: their subjects landing. VR-009 (posterior calibration holds) depends on AR-025 and is what stops the Bayesian accumulation being decoration.
Withdrawn from the old plan
The phase structure, the --presence-mode {frame,track} flag, and "Phase 2 —
retune anneal_sec/extinction_sec". Those constants are withdrawn rather than
retuned; comparison against old behaviour uses recorded reference output instead
of a second live code path.