59a2927a1552347413f8df969450eb4bfc1d08d8
103
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
59a2927a15 |
fix(AR-004): derive the scene join depth instead of pinning it at 256
`kSceneJoinDepth` was a constant 256, used for two different channels. Both
uses were wrong, in different ways.
**It is a span of film, not a count of slots.** The join needs the sampled
branch to trail the dense one by however long TransNetV2 takes to be able to
answer: its input queue (128) plus the window it must fill (100), over native
frame rate — about 9.5 s at 24 fps, which is the conservative floor since a
slower source makes the same frame count span more film. The slots needed to
hold that depend on `sample_fps`, so the constant meant ~256 s of lag at 1 fps
— 27x what the join needs — and nothing recomputed it when `sample_fps`
changed. The one number AR-010's correctness rests on drifted with an
unrelated knob. It is now derived, giving a constant 19 s of lag at any sample
rate: 19 slots at 1 fps, 95 at 5.
**The decimator's buffer was on the wrong side of the decimator.** Its *input*
carries the full-rate stream, so 256 slots held ~256 full decoded frames of
which, at 1 fps against 24 fps native, 23 in every 24 existed only to be
discarded by the predicate a moment later. Holding ~1.5 GB of images for
frames the very next node throws away is the worst available use of the
budget. A filter is a pass-through, not a reservoir: the lag belongs after
decimation, where a slot buys 1/sample_fps seconds instead of 1/native_fps.
Its input is now sized only to keep it fed.
Together, at 1080p and 1 fps, those two channels go from ~3.0 GB to ~206 MB
while the join keeps 2x margin over its requirement. Every message embeds
`Frame source`, so a slot on either branch holds a full decoded image — which
is visible now that the byte counter is honest (
|
||
|
|
5e46f52ad2 |
fix(AR-004): make the channel byte counter measure the payload
`kpn::ChannelDataSize<T>` is what a channel reports as bytes pushed, and its primary template returns `sizeof(T)`. It was never specialised in this repo — only in a KPN example — so every message type reported its header size. Each of them is a few vectors and a `cv::Mat` header owning megabytes on the heap, so a message carrying a full decoded frame was reported at roughly 200 bytes against 5.9 MB at 1080p. Four orders of magnitude. That is not a cosmetic stat. It is the one instrument for choosing channel capacities against a memory ceiling — the open half of AR-004 — and anyone who read the MB/s column to size a channel was reading fiction. The gap could not be measured with the tool that exists to measure it. Every message embeds `Frame source`, so this is not confined to the crop-carrying channels: the full decoded image rides the whole chain, and the byte figure now says so. Two things worth stating about what the number means. `cv::Mat` is reference-counted, so one frame referenced from several messages is counted once per reference — an upper bound on distinct bytes, and the right bound for "what would this channel keep alive if nothing else held it", which is the question a capacity answers. And an eof sentinel carries no image, so it costs only its header and is not charged for one. Declared against a forward declaration of the primary template rather than by including <kpn/channel.hpp>, so the message definitions keep no dependency on the framework carrying them, and any translation unit that can see these types also sees their sizes — which is what stops one channel being instantiated with the default while another gets the specialisation. Verified in both directions: with the specialisations removed, three of the five cases fail, `SceneAnnotation` reporting 40 bytes against the 37,632 its single 112x112 crop occupies. 145/145 with them. No behaviour change — this only corrects what is reported. Choosing capacities against the corrected numbers is the next commit. TRACES: AR-004 | SR-002 |
||
|
|
bb7a9ed718 |
docs(AR-004): record the three holes the wedging audit closed
The register said "two remaining holes now closed" and the SPEC's Current:
still described `push_blocking`, which parking replaced. Both now match the
code.
Three additions, in the order they surfaced:
(c) FilterNode and RouterNode were the last data paths still using the
throwing push() with the exception swallowed, so a full output discarded
the value — including the EOF sentinel. The decimator passes EOF by
predicate but its output is reliably full, the embedder being the slowest
node, so the token went nowhere and nothing downstream shut down. That is
the wedge the runs were being killed for, and it is worth the register
saying so plainly.
(d) The sentinel could be delivered ahead of a value still queued behind it,
losing the tail to any consumer treating EOF as a hard stop.
(e) Two firings of one node could overlap, which breaks the one-slot park
itself: a parked value can be overwritten with no drop recorded.
(e) also corrects (b). The startup lost wake was recorded as a missed
empty->non-empty edge closed by a level-triggered re-check; the actual cause
was the callbacks being written while a running neighbour read them — ten
ThreadSanitizer races — and they are now installed in a prepare() pass before
any node starts. The re-check stays and is still needed, but for a benign
ordering rather than as cover for a race.
Two things the requirement now carries that it did not before. A channel holds
at most one undelivered sentinel: a second is refused and reported rather than
silently overwriting the first, which matters the moment a pipeline is reused
for a second input. And a lossless decimator is a backpressure point rather
than a relief valve, so the source throttles to the face branch instead of
quietly thinning it — what the requirement asks for, but it changes the shape
of a loaded run and is not yet benchmarked.
The Gap is unchanged and still open: capacity is counted in items, not bytes,
so a crowd frame carrying 60 crops occupies one slot exactly as an empty one
does. AR-004 stays **Mostly** for that reason.
Verification plan updated with the cases the KPN suite now pins.
TRACES: AR-004 | SR-002
|
||
|
|
48332d2041 |
feat(replay): the whole replay chain is C++, including the sink
The sae_kpn module has not compiled since the AR-007/AR-008 tracker redesign, and was switched off at the build rather than patched because the fix is a restructuring. Two failures, one cause. It did not compile: `add_face_tracker` built FaceTrackerFunc from a Config alone, and the tracker has required a TrackRegistry and a calibration since association moved into probability space. And presence was rebuilt in Python. `replay.py::build_minimal` merged per-frame detections into windows by annealing gaps, which is what the pipeline did before AR-012. The sink builds a window from a TrackRegistry claim instead — the extent of a track an actor owned, starting when they appeared rather than when recognition first succeeded. Those answer different questions, so every sweep was tuning against a contract the shipped code had stopped honouring. Both follow from the seam being a factory per node. The chain has a construction order — the matcher fits the calibration, the registry needs a discounter built from it, the tracker needs both, and the sink needs the registry's claims — and independent factories cannot express it, so the tracker kept being built against a signature that no longer existed. One `add_pipeline` mirrors main.cpp exactly and is now the only way to build the chain, so the ordering cannot be got wrong again from Python. DP-001 is the requirement behind it: a replay harness is a front-end, and its job is to supply frames and read the result, not to re-derive presence. Lifetimes needed a home. ResultSinkFunc holds `const Config&` and `std::atomic<bool>&`, which under main() are locals in a frame outliving the pipeline; there is no such frame when the network is built and torn down from Python. ReplaySession owns both for the network's lifetime, keyed by network and released explicitly — a sweep builds one network per replay and the sink retains every annotation, so holding them forever would grow with films x configs. Getting this wrong presented as an empty output_path: the sink announced `[result_sink] writing ` and wrote nothing. test_sae_kpn.py is ported rather than left behind. It called all three removed factories and asserted on SceneAnnotations read back per frame; neither half survives, so it now waits on pipeline_done and asserts on the file the sink writes. Verified against gallery_lvface.h5: three frames through the real chain, timestamps 0/1/2, truth file written. EOF is a control token the sink flushes on and does not record, so three inputs give three frames, never four. SAE_BUILD_KPN_BINDINGS goes back to ON. TRACES: VR-011, VR-002 | DP-001 | PR-002 |
||
|
|
1141172b04 |
chore: update KPN to the wedging-audit fixes
Twenty commits, of which the one that matters for this repo is the filter and router losslessness. FilterNode and RouterNode were the last nodes on a data path still using the throwing push() and swallowing the exception, so `decimate` — which passes EOF by predicate — discarded that token whenever its output was full. Which it reliably is: the embedder is the slowest node in the chain. Nothing downstream ever received EOF, `done` was never set, and the run had to be killed. That is the wedge. The rest, in rough order of how much they affect a run here: - A sentinel could be delivered ahead of a value still queued behind it, losing that value to any consumer treating EOF as a hard stop. - Two fire_once invocations for one node could overlap, which breaks the one-slot park: a parked value can be overwritten with no drop recorded. - A node's push/space callbacks were written while a running neighbour read them — ten ThreadSanitizer races, and the root cause of the startup lost wake that AR-004 records as closed by a level-triggered re-check. - shutdown() polled every channel in the graph with no deadline, and could fail to terminate outright on an index underflow in the fill calculation. - Submitting to a stopped pool indexed a cleared vector: a segfault, which reproduced 12 runs in 20 once sources stopped before their consumers. - stop() returned while a firing was still touching the node's members. - Idle pool workers burned ~6.5 cores while one task ran (1991 ms of CPU against 0.4 ms). Latent here, since every node owns a private one-thread pool, but not for anything using a shared one. Two consequences worth holding onto. `decimate` is now a backpressure point rather than a relief valve, so the source throttles to the face branch instead of quietly thinning it — the intended behaviour, but it changes the shape of a loaded run and has not been benchmarked. And a channel now carries at most one undelivered sentinel; a second is refused and reported rather than silently overwriting the first. Verified: KPN 148/148 with ThreadSanitizer clean across six runs, this repo 138/138 with every target building warning-free against the new headers. TRACES: AR-004 | SR-002 |
||
|
|
90b44e0975 |
docs(register): make the status column describe the code
Eleven rows corrected, in both directions. Overstated: AR-011 (the derived dedup window reached scenes.json only), AR-017 (route was a literal), AR-019 (the local plurality tally was still deciding), AR-024 (its "static check" enforcement did not exist), DP-001 (scene_preview had forked and stopped compiling), VR-002 (the Python replay bindings have not compiled since the tracker redesign, and the fixtures it calls committed are gitignored registry artifacts). Understated: VR-010 was marked Planned while five VR-010 tags sat in the code implementing it. Rescoped: VR-007 now names the four AR-025 constants it was already being deferred to for, and which no sweep could reach until this pass. The Withdrawn note gets the longest correction, because it asserted a removal that had not happened and nothing could have caught that: the gate reads tags, and a withdrawn requirement has no tag to be orphaned. The general form is now written down there -- a status column is a claim, and the only claims this project checks automatically are the ones a test or a static check makes. Four of the rows above are the same pattern: recorded as done, and done in one place out of two. New: VR-016, a cadence study for cut_threshold. It is the one always-on signal with no recorded provenance, and its input rate depends on an unrelated flag -- with --scene-detect off, camera_pos compares frames a full second apart at the default sample_fps, and with it on, native-rate frames. Same constant, two meanings, and is_cut drives track_alpha to 0 and clears every expansion buffer. Also stops check_raw_cosine.py inflating its own metric: the extractor scans scripts/, so the tool's prose describing the exception tag was counted as four recorded exceptions. The count now reads 1, which is the number of real ones. TRACES: AR-011, AR-017, AR-019, AR-024, AR-025 | DP-001, DP-007 | IR-004 | VR-002, VR-007, VR-010, VR-016 |
||
|
|
06373817a2 |
refactor(config): give the tuned constants a real provenance, and make them reachable
Two problems, both of which made a number look more settled than it is. The provenance was a dead link. config.hpp cited docs/rep4-optimizer-results.md for prob_threshold, extinction_sec, anneal_sec and the expansion default. That file was renamed to model-bakeoff.md and then rewritten; the comments were never repointed, so the most consequential constant in the pipeline appeared to have no source at all. Following it up produced something worse than a broken link. prob_threshold=0.754 comes from the ORIGINAL rep4 document (still readable at `git show d340da7:docs/rep4-optimizer-results.md`). The rewrite that replaced it reports finding "a real scoring bug in optimize.py: a candidate whose hardest film's replay timed out was averaged over survivors instead of penalized, silently rewarding partial coverage. Affected 3 of 16 training combos". So 0.754 was fitted under scoring that was later found wrong, the corrected sweep converged elsewhere, and no corrected prob_threshold is recorded anywhere. The comment now says that, along with the surviving document's own verdict that the optimum "generalizes unevenly -- strong on 3 of 5 held-out films, badly broken on 2". Four constants were unreachable. ownership_logodds lived on TrackRegistry::Config, and max_views/admit_below/rho_max on EvidenceDiscounter::Config, which main built with the one-argument constructor -- so nothing short of a recompile could move any of them. rho_max's own comment defers to "the sweep (VR-007)" for where it belongs, and that sweep could not reach it. They now live in Config with CLI flags and are exposed to the replay harness. ownership_logodds is worth singling out: below it a track makes no presence claim at all, so it decides whether an actor is reported rather than how confidently -- arguably the most consequential constant after prob_threshold, and until now unswept and unsettable. No behaviour change: every default is the value that was compiled in. TRACES: AR-025, AR-017 | SR-002 |
||
|
|
88c42573a5 |
fix(pipeline): finish three changes that had only been half applied
Each of these was recorded as done and was done in one place out of two. AR-011 -- the TransNetV2 dedup window. The derived window (dedup_window_sec, median observed interval halved) reached scenes.json and nothing else. SceneBoundaries, the path that actually feeds is_scene_boundary to the tracker, kept the literal 0.04 s under a comment claiming it "matches the dedup scenes.json applies, so the two views agree". They did not agree. 0.04 is one frame at 25 fps and wider than a frame at 30, so two cuts on consecutive frames merged into one and the loss was invisible: the pipeline simply saw fewer boundaries. The detector now supplies the window it derived. AR-019 -- ownership. The register says ownership "comes from the registry, not a second local tally". Both existed: promotion fired on a local accepted-frame count and fell back to a local per-actor plurality when the registry had not yet claimed the track. That fallback was reachable in the live pipeline, not just in tests -- three accepted frames arrive well before a posterior crosses the ownership threshold -- so in practice the plurality usually decided, and it could not see the AR-025 correlation discounting it was meant to defer to. The tally is gone; promotion now requires the registry's verdict, with the accepted -frame count demoted to an explicit evidence floor. AR-017 -- the route. DeadTrack carried belief but no route, and the sink wrote the literal string "live", so a field the schema publishes could not distinguish anything. AR-017's own verification asks for "deferred and pooled routes distinguishable". Route is now an enum on the claim. Only `live` occurs today; `deferred` exists so AR-020's pass has somewhere to write instead of a serialisation change to make. Also: TrackGallery::forget had no callers, under a comment asserting the matcher called it "on a cut or track disappearance". The cut half was true by another route; the disappearance half was not, so a track that died quietly kept its diversity buffer until the next cut cleared everything. Replaced with prune_dead against the registry's own liveness, the same shape as the tracker's prune_boxes -- a second opinion about which tracks exist is a second thing that can be wrong. Removes dead logistic/logit helpers and fixes five TRACES tags that used a comma where a pipe separates requirement types, which the gate had been reporting as diagnostics. TRACES: AR-011, AR-017, AR-019 | IR-002 | SR-002, SR-005 |
||
|
|
7c7d4934ae |
refactor(presence): execute the extinction_sec/anneal_sec withdrawal
docs/SPEC.md specified this removal, listed its parts, and ended "grep
for both names and expect no survivors". There were about forty.
docs/requirements.md meanwhile recorded both constants as Withdrawn and
"deleted rather than retained at zero", on the grounds that a field
naming a mechanism the pipeline no longer has is actively misleading.
Neither statement was true of the code: Config still carried
extinction_sec 57.4 and anneal_sec 35.5, --extinction and --anneal still
parsed, and SceneTrackerFunc still ran its keep-alive in both shipped
pipelines, announcing its timeout at every startup.
SceneTrackerFunc is replaced by FrameAnnotationFunc, which is stateless:
same ports, same output type, no keep-alive. Presence belongs to
TrackRegistry (AR-012), where a window is the extent of a track an actor
owned and ends at the last sighting (AR-013). The keep-alive answered
that question a second time and answered it worse, by re-opening exactly
the trailing cool-down AR-013 refuses.
Visible change: --verbosity standard's frames[].identified listed every
actor inside the keep-alive, including ones absent from the frame. It
now lists what was matched in that frame. Minimal and xray output is
untouched -- both were already built from registry claims and never
consulted this node. No schema bump: the published extraction block
reports track_extinction_sec, a different knob that bounds
re-association and never extends a claim.
TrackRegistry::Config::extinction_sec is renamed track_extinction_sec to
match the Config field feeding it, so the grep SPEC.md asks for now
returns nothing rather than one confusing false positive.
Two targets turned out to have been silently dead, both since the
AR-007/AR-008 tracker redesign, and both for the same reason -- they
construct FaceTrackerFunc from a Config alone, a signature that stopped
existing when association moved into probability space:
- scene_preview is fixed here. It now mirrors main.cpp's construction
order exactly (matcher, then registry, then tracker) and wires the
registry's claims into the sink, which it was not doing. DP-001 says
modes are front-ends that must not fork pipeline logic; this one had
forked it and then rotted.
- sae_kpn is not fixed. Restructuring the seam so the tracker can reach
a calibration that only exists once the matcher is built is VR-011's
rewrite, not a patch, and presence claims do not cross the seam at all
today. It is now behind SAE_BUILD_KPN_BINDINGS=OFF with the reason
recorded, so `cmake --build` succeeds and the breakage is attributed
rather than rediscovered.
That second one is worth stating plainly: VR-002 ("replay drives the
real KPN nodes, not a reimplementation") is marked Done, and the module
that makes replay possible has not compiled for some time. The .so in a
stale build/ predates the change.
Python side: the two names are gone from optimize.py, replay.py and
run_holdout_all_models.py as Config keys. anneal_sec survives as
REPLAY_LOCAL_KEYS -- it still configures replay.py's own windowing,
which is a Python reimplementation that no longer matches the sink and
is documented as such. That divergence is VR-011's.
TRACES: AR-012, AR-013 | DP-001 | SR-002
|
||
|
|
e1de98e783 |
feat(ar-024): enforce the invariant statically, and delete the fallback it caught
AR-024's register row gives its verification tier as "Static check -- no bare cosine outside a tagged EXCEPTION". No such check existed, so the invariant was enforced by reading, and reading had missed a live violation. scripts/ci/check_raw_cosine.py is that check, wired into the traceability workflow as a blocking step. It is honest about its reach: it catches direct cosine_similarity() uses not routed through a calibration, and it cannot follow a cosine through a variable across statements. That limit is documented in the script rather than left for someone to discover after trusting a pass. What it caught, and what this commit removes with it: The identity matcher's no-calibration fallback thresholded raw cosine distance (match_threshold) plus a ratio test (match_ratio, match_ratio_ceil). Worse than the invariant breach: it fed max(0, cosine) into TrackRegistry::observe, whose contract reads "posterior is a calibrated probability, never a raw cosine (AR-024) ... so the accumulation cannot be fed an uncalibrated number by a careless caller". It could, and did. And it disagreed with the rest of the pipeline about what "the fit failed" means -- same_person_probability answers that with the untuned default sigmoid and a loud warning, so association stayed in probability space while matching alone left it. One run, two policies, no announcement. Now one rule: cal_.probability() always, with a warning when the fit is not real. A worse answer than a fitted calibration, a better one than a number whose units nothing else shares. TrackGallery::set_calibration is mandatory for the same reason. Its default was max(0, cosine), which made expand_band_lo = 0.90 mean "cosine > 0.9" in a test and "P(same person) > 0.9" in production. FaceTrackerFunc already threw without one; the expansion store now matches. One exception is recorded, in the calibration's own dedup. It is not a close call: at 1 - 1e-7 it asks whether two vectors are the same vector, and it runs on the fit's input, so a calibrated comparison there would have to be calibrated by the fit it is feeding. Also drops seven dead keys from the optimizer's CFG_KEYS. Config keys are read with a contains() check, so each one had been silently inert since the field behind it was deleted -- a sweep varying one of them measured nothing and reported an ordinary-looking F1. TRACES: AR-024, AR-023 | SR-002 |
||
|
|
fd078c399f |
ci(tests): actually run the tier the verification strategy is built on
docs/requirements.md describes four verification tiers and argues that T1 (functor unit) and T2 (replay) are "the only tiers that can exist in CI at all". The traceability gate then reports a CI-scope coverage fraction over exactly those tiers. Nothing ran them: the only workflow was the gate itself, which reads source comments, and SAE_BUILD_TESTS defaults to OFF. "Covered" meant a TRACES tag existed in a file. That is the same failure the gate's own config warns about one level up -- counting a test that cannot run -- and the gate cannot see it, because a tag is all a static reader has. Runs in the pinned DP-007 CPU builder image, which the image script already expected this workflow to exist (it names unit-tests.yml and asserts its tag). Nothing here calls a model: T1 constructs node functors directly and T2 replays a precomputed dump, so the GPU-free N100 runner is sufficient by construction rather than by concession. Two deliberate hard failures. A missing replay fixture fails the job instead of skipping, because pull_artifacts.sh warns-and-continues and a T2 test whose input never arrived must not look like a pass. And the image reporting a tag other than the pinned one fails rather than building against an unknown toolchain. It found a real bug on its first run: see the preceding commit. ctest runs each case in its own process, which turned a 1-in-4 heap corruption from noise in the aggregate binary into a reproducible failure. TRACES: DP-007 | PR-004 |
||
|
|
bedc859d3c |
fix(audio): zero-initialise the channel layouts before the resampler copy
av_channel_layout_copy() documents that it always uninitialises the
destination first, and av_channel_layout_uninit() calls av_freep() on
u.map. Declaring the layouts without {} therefore handed free() whatever
pointer-shaped garbage occupied that stack slot.
Not theoretical: UT-103 aborted with "free(): invalid pointer" in about
1 run in 4. Zero failures in 40 runs after the fix, against 10 in 40
before it.
Two things kept it hidden, both worth remembering:
- It is stack-dependent, so it disappears under an AddressSanitizer
build and reads as a flake in the aggregate test binary, where the
case usually passes. ctest, which runs each case in its own process,
is what made it a reproducible failure rather than noise.
- UT-103 is the only test that reaches this branch, because it is the
only one whose input is stereo. The golden-vector tests use a mono
11025 Hz fixture chosen so the vector cannot depend on libswresample
— which is right, and means bit-exactness against the golden vector
is not evidence about the downmix path.
TRACES: IR-004 | SR-003
|
||
|
|
718dad688d |
docs(register): record the quality vector, the benchmark, and what lossless fanout costs
Status for the two changes just landed, plus the consequence AR-004's fix has for the scene join. The annotator's old comment said blocking there was safe because the branches are independent. That stopped being true when the fanout became lossless: it now stops popping once one branch stops taking, so a starved detector and a waiting annotator would wedge. What actually makes it safe is join depth -- the fanout can run the dense branch ahead by the whole of the sampled branch's buffering, which at kSceneJoinDepth 256 and sample_fps 5 against a 25 fps source is ~1200 dense frames against TransNetV2's 100-frame window. Cutting kSceneJoinDepth below the window would reintroduce the wedge, so it is now a correctness precondition rather than a tuning knob. TRACES: AR-004, AR-010, AR-028, AR-029 | VR-015 | SR-002 |
||
|
|
a5ee3c05ce |
feat(benchmark): per-node cost and bottleneck attribution for a run
--benchmark <path> reports cumulative CPU and wall time per node and names the node pacing the run. The pacing node is located from sampled channel occupancy, not from time-in-node: backpressure inflates time-in-node for everything downstream of the real bottleneck, so the obvious measure names the victim rather than the cause. Sampling starts with the network and stops before it is destroyed. Channel fill is instantaneous and everything has drained by shutdown, so a single read at the end reports an idle pipeline however congested it was. kill -USR1 dumps the table from a running or wedged process. Channel occupancy identifies a stalled node -- full input, empty output -- without a debug build or a debugger, which is the difference between diagnosing the AR-004 hang in seconds and reproducing it under gdb. Two knobs this exposes for measurement rather than sets: SAE_CV_THREADS, because OpenCV's TBB arena and KPN's thread-per-node are two schedulers unaware of each other on the same cores; and SAE_CUDA_BLOCKING_SYNC, because the default spin-wait held the embedder thread at 99.7% user time while nvidia-powerd cut the GPU's clock from 1005 to 210 MHz. Neither default changes until a measurement says it should. TRACES: VR-015 | PR-004 |
||
|
|
777c98cb33 |
feat(quality): score every face on sharpness and alignment before it is evidence
Every embedding now carries the quality of the input it came from. Both axes fall out of the AR-005 warp for free: crop_sharpness() is the normalised Laplacian variance over the aligned 112x112, so contrast and size cannot leak into it, and the alignment residual is the part of the landmark deformation a similarity transform cannot explain, so in-plane roll reads as zero and foreshortening does not. Carried, not consumed. Nothing discounts or thresholds on either number yet -- that is AR-030 and VR-012, and the knee has to be located against recorded data before a gate is chosen. What this change buys is that the data exists to locate it with. No face is admitted unscored: the -1 sentinel is preserved rather than clamped, and a degenerate landmark fit is counted rather than silently dropped. Takes the VR-001 dump to schema_version 2. The bump is not for readers, which check for the datasets by name and replay a v1 dump unchanged; it is so a consumer can tell "never scored" from "scored zero", which is not recoverable from the arrays afterwards. TRACES: AR-028, AR-029, AR-030 | VR-001 | SR-002 |
||
|
|
c1155cb607 |
feat(gemm): the annex is a matrix, not a list — scored by the same GEMM
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
|
||
|
|
f33403fff8 |
feat(scene): feed TransNetV2 at native rate, derive the dedup window from it
Closes both violations SPEC.md named under "Every model gets the input it was trained for". They are one bug, not two. The dense stream defaulted to 12 fps, so a 100-frame TransNetV2 window spanned ~8.3 s against the ~4 s it was trained on: half-speed motion over twice its temporal context. Boundary timestamps stayed correct throughout, which is exactly why the degradation was invisible and why the compressed separation it produced (~0.50 baseline against ~0.7+ peaks) was read as a property of the ONNX export rather than of the input. Dedup then merged boundaries closer than a literal 0.04 s — one frame at 25 fps, and wider than a frame at 30, so two cuts on consecutive frames became one. Nothing in scenes.json showed it; the file simply had fewer boundaries. Native rate is where that constant did the most damage, which is why fixing the decode rate without fixing the dedup would have made things worse. dedup_window_sec() now takes the median interval the detector was actually fed and halves it. Half a frame rather than a whole one: the only thing being merged is one frame scored by two overlapping windows, and two distinct frames are a full interval apart. Cost is real — dense decode is the pipeline's cost driver. It is accepted; dense_scale and scene_stride remain the reductions that do not run the model off-distribution. scene_threshold 0.60 was fitted against the 12 fps input and is now stale, so VR-006 goes from Low to Medium: it is no longer a refinement, it is a constant that no longer describes the input. AR-002 rides along because it was already implemented, just untagged and unverified — the register said Planned while the code was correct. The size filter becomes FaceDetectorFunc::drop_undersized(), tested at the threshold and at dense_scale 0.5, and checked end to end against the superhero dump, whose smallest face is exactly its recorded 32 px minimum, so the fixture check cannot pass vacuously. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: AR-002, AR-011 | SR-002 | UT-002, UT-003, IT-001 |
||
|
|
079b490ede | Merge branch 'feature/ci-images' into feature/opencv5 | ||
|
|
a3827646b9 |
feat(ci): CPU builder image for the unit-test workflow
TRACES: DP-007 | PR-004 Pinned by tag rather than :latest, so a workflow run is reproducible against the image it was written for. |
||
|
|
22758da118 |
docs: SuperHero benchmark — how to reproduce it and what it scores
Records the reference film end to end: fetching the annotations and mugshots, fusing the scene clips into one stream, building the gallery at the 66 px face floor, and the measured result (precision 1.00, recall 0.65, F1 0.79). Three things are written down because each cost time to discover: - Why Bali was withdrawn. Its reference crops have a median detected face of 27 px against a 69 px maximum, so every reference was upscaled past what the embedder was trained for (AR-011). No threshold fixed it — at 66 px, 2 of 69 references survived. Any accuracy figure recorded against Bali measures upscaling artifacts as much as the pipeline. - Run it as one film. Per-scene clips defeat per-film gallery expansion (AR-019) and pay model load ten times over. - Check you are on the GPU. ORT's CUDA provider fails to load here and falls back to CPU silently, so a build-ort timing is a CPU number wearing a GPU label — a 15x error whose only symptom is a number with no baseline. TRACES: AR-011, AR-019 | VR-001, VR-005 | SR-002 |
||
|
|
960a7c4eed | chore: bump KPN to 454f72c (ignore generated ORT cache) | ||
|
|
ae60ac7657 |
chore(models): track 2d106det and the larger SCRFD variants via LFS
Detector variants used by the resolution and min-face studies. LFS per .gitattributes, so the repo carries pointers rather than 20 MB of weights. |
||
|
|
f891e579c5 |
chore(traces): put TRACES tags on their own line; regenerate the report
The parser reads a tag up to end of line, so `# TRACES: GR-004 | SR-001 — prose` swallowed the prose into the tag and the row went unmatched. Splitting the comment leaves the tag greppable by the same pattern as the code tags and the commit trailers, which is the point of the house format. Mechanical throughout; no logic touched. The regenerated report reflects this session's new tags: 137 -> 148 found, and one more tagged-but-unexecuted, which is the SuperHero accuracy assertion that is documented but not yet a test. |
||
|
|
d98dc2855a |
refactor(bench): SuperHero replaces Road to Bali as the reference film
Bali was chosen because the TRECVID DVU set ships character mugshots, but its reference crops are unusable at scale: median detected face 27 px against a 69 px maximum, so every reference was upscaled 4x or more past what the embedder was trained for (AR-011). A 66 px floor left 2 of 69 references; no threshold exists that both keeps the faces in distribution and leaves enough of them to calibrate. SuperHero is 69 px median and 241 px max. Its gallery builds at a 66 px floor with 14 references over 5 characters, and calibrates on its own (a=15.2867 b=-4.98633, 100% train accuracy) instead of borrowing constants. Measured on the fused 17-minute film, one stream rather than per-scene clips so presence windows cross real scene boundaries as SR-002 intends: precision 1.00, recall 0.65, F1 0.79 — 13 true positives, 0 false positives, 7 misses. Every out-of-gallery character was declined rather than forced onto a nearest match. The misses are the short scenes (14 s, 38 s, 27 s), consistent with per-track accumulation needing sightings. - build_gallery gains --min-face-px, filtering the *detected face* rather than the crop. The DVU images are scene crops, not mugshots, so crop dimensions say nothing about face scale. A poisoned reference is permanent in a way a bad frame is not: it corrupts every future match against that identity. - scripts/fetch_dvu.sh fetches mugshots, scene graphs and segmentation for any DVU film. NIST names the same film three different ways, so KG_DIR and KG_FILE are overridable rather than derived. This exists as a script because the first copy of this data was assembled ad hoc in /tmp and was lost with it, taking the working gallery along. - Replay fixtures move to the artifact registry: push/pull_artifacts.sh gain a replay-fixtures target, and tests/fixtures/dumps/.gitignore keeps them out of git. superhero.h5 is ~9 MB and regenerating it needs the film, the models and a GPU — none of which CI has. The gallery ships with the dumps, since a dump only replays against the gallery it was produced with. - AR-012 and AR-013 coverage is ported onto the new fixture rather than dropped with the Bali cases: 12369 assertions, up from 7991, since the film is an order of magnitude larger than the clips. Suite: 15679 assertions, 101 test cases. TRACES: AR-011, AR-012, AR-013 | VR-001, VR-005 | SR-002 |
||
|
|
eff696b49a |
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
|
||
|
|
ffdad9873d |
test: tag the untagged suites; correct two stale headers
Four test files and one node header carried no TRACES tag, so the requirements they verify read as implemented-but-unverified. Tagging a test is what distinguishes the two. test_calibration.cpp is AR-023; its three [report] cases verify GR-003 and are tagged separately, since the report is fitted from the same distributions but is its own requirement. test_similarity.cpp is the CI half of AR-026 — equivalence against hand-computed dot products, where throughput at scale is AR-027 and cannot run on this host. test_face_tracker.cpp is AR-007 and AR-008. Two headers described code that no longer exists. face_aligner_node.hpp still documented the RANSAC fit AR-005 replaced with an Umeyama least-squares fit over all five points — not merely out of date but the opposite of what the file does, and it reads as a rationale for discarding the landmarks AR-030 measures. test_face_tracker.cpp still described the park/revive branch AR-008 deleted, and the raw-cosine cut_revive_sim that guarded it, which AR-024 retired. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: AR-005, AR-007, AR-008, AR-023, AR-026, AR-030 | GR-003 | SR-001, SR-002 |
||
|
|
5c6603e63b |
fix(kpn): park on full outputs; surface node exceptions
Adopts the KPN backpressure fix (28e0667) and registers the application error listener it exposes. `push_blocking` parked a scheduler worker inside the push. Each ObjectNode owns a private single-thread pool, so the parked thread was the only one that could drain that node's own input: under sustained backpressure frame_source, camera_pos, face_detector and face_aligner all slept in nanosleep at once and the pipeline stopped. Nodes now hold the value, release the worker, and resume on a channel space-callback. main.cpp registers set_error_handler so a node that throws names itself and its exception. Previously the exception was discarded at the node boundary and survived only as "node 'x' stopped unexpectedly", which says that a node died but not why — the missing detail that made this slow to diagnose. AR-004 drops from Done to Mostly. Two gaps are recorded rather than claimed fixed: a hang surviving at roughly 1 run in 20 against a 300 s timeout (down from every run failing), and FanoutNode still dropping on overflow instead of parking, which sheds frames on the AR-010 scene join precisely when the dense branch falls behind. TRACES: AR-004 | SR-002 |
||
|
|
3bf4d60a6f |
docs: regenerate the traceability matrix for VR-014
The committed matrix predated the audio-signature binding, so VR-014 and the four UT tags in `test_audio_offset.py` were absent from it while being present in the register — the one inconsistency a generated file is supposed to make impossible. VR-014 also needed an explicit tier row. The blanket `VR-* | Out of CI` line is right about every other study and wrong about this one: its fixture is committed and its signature is CPU-only DSP, so it is a test a CI host can run rather than a measurement someone has to remember to repeat. Left as an exception under the blanket rather than rewriting the rule, because the rule still describes the other thirteen. Coverage unchanged at 38/69; the gate reports no orphan tags. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: VR-014 |
||
|
|
5f6daefc40 |
Merge branch 'feature/quality-knee' into feature/opencv5
# Conflicts: # docs/requirements.md |
||
|
|
629d698ad9 | Merge branch 'feature/dump-provenance' into feature/opencv5 | ||
|
|
71354e862a |
docs: VR-013 and VR-014 results; AR-002 raised to 40px
Records study results and the requirement change that follows from them. VR-013 measures minimum face size end to end — gallery from one recording, probes from another — rather than by degrading an already-aligned crop. Holding 90% of the plateau needs ~50 px that way against VR-005's ~22 px, the gap being detection and landmark error rather than the embedder. AR-002 therefore takes 40 px, not 32: VR-005 isolates the embedder and is an upper bound, and 32 admits faces in the falling region. FPI stayed 0.0% at every scale, and the ceiling is cross-view rather than resolution. VR-014 exercises audio-signature offset recovery on real film audio instead of the synthetic golden tone. Forty random in-cap offsets, every one recovered to the nearest frame, worst error 46 ms against a 500 ms budget — and 46 ms is the quantisation floor rather than a result, since offsets land on whole 92.88 ms frames. The runtime/2 anchor is confirmed through head-trimmed files. The soft spot VR-014 found is tier labelling, not accuracy: the score drops with sub-frame misalignment, so 27 of 40 correct alignments were demoted to `loose`. One frame of slack in the score restores all forty to `audio` with false matches unmoved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: AR-002, VR-005, VR-013, VR-014 | SR-002, SR-003 |
||
|
|
2a8ee3660b |
feat(audio): bind the v1 signature and validate offset recovery on real content
sae_audio exposes the shipped signature to Python. It compiles audio_signature.cpp directly against FFmpeg rather than linking sae_gallery: the signature needs no model, no OpenCV and no HDF5, so a module that dragged those in would make `import sae_audio` depend on a GPU-capable build of a path that is pure CPU DSP. The point of binding rather than porting is that a fingerprint is only useful if every implementation agrees byte for byte. A numpy port would be a third implementation, and the one nobody checks against the golden vector. VR-014 then recovers a known trim from real film audio rather than from the synthetic tone: 40 random in-cap offsets, every one recovered to the nearest frame, worst error 46 ms against a 500 ms budget — and 46 ms is the quantisation floor, not a result, since offsets land on whole 92.88 ms frames. The soft spot is tier labelling rather than accuracy. Sub-frame misalignment drags the score down (0.94-0.99 near a frame boundary, 0.69-0.73 at half a frame), demoting 27 of 40 correct alignments to `loose`. Allowing +/-1 frame of slack in the score fixes it: all 40 back to `audio` at min 0.906, false matches unmoved at 0.12-0.16, for 81 ms of the budget. The module stops at the producer's edge. Sliding one signature against another is the consumer's algorithm (server SPEC §3, and the jRay plugin implements it), so a caller writing that slide in numpy is not duplicating anything this repo owns. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: IR-004, IR-005 | VR-014 | UT-105, UT-106, UT-107, UT-108 | SR-003 |
||
|
|
b4318f8d9e |
fix: belief accumulates across frames (lazy-OR), not once
A track recognised on 318 of 385 frames was owned on none, so the truth file named nobody while the matcher was accepting almost continuously. The correlation discount was an annihilator rather than an attenuator. Weight was 1 - P(same view), so once a track had one stored view every later frame of that same face scored ~0.01 and the belief stopped moving. One observation just over the accept threshold is logit(0.78) ~ 1.27, under the ownership bar — hence recognised always, owned never. Two changes, in the order they were found. Correlated evidence is now attenuated by effective sample size, n_eff = n / (1 + (n-1)·rho), each frame contributing the marginal gain. That has the right shape at both ends: uncorrelated evidence accumulates linearly, and a held pose converges on 1/rho rather than growing without bound. A constant floor was tried first and rejected — it grows linearly forever, so a long shot could out-argue genuinely varied evidence purely by lasting longer. Combination is now weighted lazy-OR: P = 1 - (1-P_old)·(1-p)^w, stored as log(1-P) so the update is additive and precision stays where it matters as P approaches 1. Each frame is new evidence that this track is that actor, and the belief is the probability that at least one sighting was right. It converges faster than summing log-odds at the same effective count — 2.98 vs 2.53 after two observations at p=0.78 — which is what a real clip needs. Note that summing log-odds was already a correct sequential Bayesian update: the matcher fits with prior 0.5, so logit(p) IS the per-frame log-likelihood ratio and the running sum carries the prior forward. It was not wrong, it was slow. What blocked ownership was the discount, not the combination rule. Also fixes a real correctness bug: the observation count lived on the discounter, which is shared by every track, so tracks pooled into one effective sample and each was discounted by how many others happened to be on screen. It is now a per-track parameter. The registry's frame scope holds its lock for its lifetime and the mutex is not recursive, so calling observe() inside a scope self-deadlocks. The pipeline never does — separate nodes — but the test did, and hung rather than failing. Documented at the call site. Verified end to end: the same clip that produced zero actors now identifies Bing Crosby and Dorothy Lamour with belief 0.97. Suite: 96 cases, 6142 assertions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: AR-025 | SR-002 |
||
|
|
af5208035e |
docs: the RANSAC aligner was a defect, measured
AR-005 replaced cv::estimateAffinePartial2D(..., RANSAC, 3.0) with Umeyama least squares over all five points — the estimator InsightFace aligns with, and so the one the ArcFace/LVFace training crops were produced by. The first note here assumed the two agree wherever RANSAC keeps all five points, leaving a small divergence on non-frontal faces. Measured on 400 gallery headshots with the model held fixed, that was wrong: the crops disagree by a median 17 source px and 83.5% embed below cos 0.99 of their Umeyama counterpart. A 4-DoF similarity is exactly determined by two points, so every minimal sample fits its own pair perfectly and is scored on the other three; real landmarks sit a median 2.74 canonical px from any similarity fit, so a landmark outside the 3 px band is the common case and RANSAC returns an under-determined transform. How much that cost in accuracy is a separate question, and the honest answer is less than those numbers suggest. Rebuilding the full gallery moved the intra/inter separation the AR-023 calibration is fitted from by 0.583 to 0.590: the old warp was wrong but self-consistent, gallery and probe both went through it, and the embedder tolerates framing variation. The sharper evidence is duplicate detection — the rebuild dropped 1614 near-duplicates against the original build's ~100, because unstable two-point fits gave near-identical images visibly different vectors. That instability, not a headline accuracy delta, is what a tracker accumulating evidence across frames was paying for. Also records the AR-030 residual's real-data floor: on the most cooperative images the pipeline sees, it runs a median 2.74 px, so landmark noise occupies the first few pixels and the synthetic foreshortening ladder is optimistic about the low end. Any discount curve has to treat that range as uninformative rather than as mild pose, and VR-012 must set thresholds against the measured distribution. Tests carry the tag they verify: the residual's roll/scale invariance and monotonicity under foreshortening are what make it a pose measure rather than a pose-and-everything-else measure. TRACES: AR-005, AR-030 | SR-002 |
||
|
|
d3ab598434 |
feat(artifacts): push and pull the VR-013 corpus
The cross-source study needs two 4K recordings and a hand-sorted set of face crops, neither of which belongs in git. Adds an xsource target to both artifact scripts. Push uploads the clips as-is (already compressed) and zips labelling/. Pull fetches both and regenerates frames with ffmpeg rather than downloading them: ~320 MB of PNG that is deterministic from the clips. The extraction settings are pinned in the script, not left to the caller, because the manifests key on frame filenames and on detection order within each frame — verify_labels.py runs afterwards and fails loudly if they drift. Pull refuses to overwrite an existing labelling/. It is human ground truth: somebody looked at 167 crops and placed each one, and silently replacing that with a remote copy would destroy the expensive half of the study. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: VR-013 |
||
|
|
66c9ca0a0c |
refactor(VR-005): drive the study off the sae_embed bindings
Deletes the Python ports of SCRFDDecoder, ArcFaceEmbedder, align_face, enhance_for_retry and calibrate_gallery, and calls the shipped C++ instead. 297 lines removed, 108 added. The ports existed because sae_embed only exposed embed(path), so a caller could not embed a crop it had degraded. That gap is closed: detect(), align_face(), enhance_for_retry(), embed_crop()/embed_crops() and GalleryCalibration are bound now, so there is no longer a reason to keep a second implementation of any of them. The calibration is the one that mattered. A parallel copy of the sigmoid is precisely where "always the calibrated probability, never a raw cosine" (AR-024) breaks without anyone noticing — the copy goes on returning plausible numbers after the original has moved. Scoring through the binding makes the rule structural rather than remembered. Verified against the committed run: same shape, FPI 0.0% at every size, same operating point of 32 px. Absolute rates differ by 1-2 points because this check sampled 100 actors / 574 crops against the original's 258 / 999, not because anything regressed. Also: --providers and --batch are gone, since provider selection and batching belong to the backend; embeds are chunked at its max_batch, because the engine does not split an oversized request and a whole gallery in one call asks CUDA for a multi-gigabyte buffer. DEDUP_SIM and MIN_EMB_FOR_POSITIVE stay as mirrored constants — used only to report the population the C++ fitted on, not to refit it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: VR-005 | AR-024 |
||
|
|
81ec77625c | Merge branch 'feature/gallery-report' into feature/opencv5 | ||
|
|
1dfd6fea11 |
feat: gallery build report
GR-003 — the calibration fit already computed per-actor dedup counts, how many actors are eligible for positive pairs, and a 200-bin histogram of the intra and inter distributions, then discarded all of it to stderr. Nothing persisted, so nobody could audit whether a gallery was any good. The report is written alongside the gallery at build time. That is the right moment: the matcher fits the same sigmoid at analysis time, but by then the answer is per-run and nobody is looking, whereas build time is when a gallery's quality is actually decided. What it surfaces, in order of usefulness: - actors with no usable image — a silent recall ceiling, since the pipeline can never name them and nothing else says why - actors below the positive-pair threshold — not broken, so nothing complains; they just quietly weaken every threshold downstream - near-duplicate references removed, per actor and total - the fitted calibration AND the two distributions behind it That last one is the point. Every threshold in the pipeline is expressed in the probability space this sigmoid defines, so if the distributions overlap heavily the calibration is weak and every downstream decision inherits it — while the gallery still looks fine from the outside. The gallery-derived prior, intra/(intra+inter), is computed and reported but the shipped default of 0.5 is deliberately left alone. The spec records these as disagreeing; now the real value is visible, so the decision can be made on evidence rather than argument. Three tests: a zero-image actor is visible in the report, an under-referenced actor is counted, and the report round-trips through JSON. Suite: 95 cases, 6142 assertions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: GR-003 | SR-001 |
||
|
|
6aabeb9897 |
feat: provenance attributes on the embedding dump
VR-010 — a dump made with one detector/embedder pair was byte-indistinguishable from one made with another, except for the two attributes GR-004 added. Replayed against a gallery from a different model, cosine similarities are meaningless but look entirely plausible. The register states the principle directly: a fixture whose provenance is unknown is worse than no fixture, because it will be trusted. Sixteen attributes now record everything that determines the dump's content: detector model and thresholds, min_face_px, max_faces, cut_threshold, dense_scale, bbox_upscale, start/end, track_assoc_min_prob, and scene_detect. scene_detect is the one that matters most. is_scene_boundary is all-zero both when the detector found nothing and when it never ran, and those mean completely different things to a consumer — without the flag they are indistinguishable. No schema_version bump: new root attributes are additive and replay.py already reads attributes with a default, so older dumps stay readable and the committed fixtures — which predate this — still load. Also corrects SCHEMA.md, which claimed bbox was already mapped to original resolution at dump time. It is not; the upscale is applied downstream in the matcher, after the dump tap. Harmless while dense_scale is 1 and silently wrong otherwise, so bbox_upscale is now recorded and the doc says what the code does. Verified end to end: all sixteen attributes present and correct on a freshly generated dump. Suite: 92 cases, 6136 assertions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: VR-010, VR-001 | PR-002 |
||
|
|
01d7ead1e7 |
study(VR-013): cross-source identification probe over input resolution
Gallery from one recording, probes from another, sweeping the probe's input resolution end to end. VR-005 asked the same question over gallery mugshots but degraded an already-aligned 112x112 crop with alignment held perfect, so it isolates the embedder. Here the whole frame is downscaled before the detector, so detection and landmark regression degrade with it — which is most of the difference. Corpus is two 4096x2160 clips of one shoot, four people, hand-sorted. Ground truth is sorted by hand and gated by verify_labels.py; labels carried down the scales geometrically by box position, never by embedding similarity, which would keep only the faces the embedder already gets right and drop the ones the sweep exists to find. Findings, all scored through the production gallery sigmoid at prob_threshold 0.754 — never a raw cosine: - Holding 90% of the plateau needs ~50 px end to end, against VR-005's ~22 px. min_face_px at 40 looks right; 32 would admit faces in the falling region. - FPI is 0.0% at every scale. Resolution loss goes entirely to TBI. - 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, threshold 0.335). Only the subject with frontal *gallery* references identified reliably, whatever their probe pose — so the lever is gallery pose coverage, not a better landmark source. - Averaging SCRFD's overlapping detections instead of discarding them at NMS lifts cross-recording TPI 41% -> 49%, for one forward pass and no extra model. Four identities and one shoot, so the shape is the result and the absolute rates are not. Both clips contain all four people, so there is no out-of-gallery class and the 10x-weighted out-of-cast misID is untested here. Clips, frames, hand-sorted crops and results are gitignored and belong in the artifact registry — the sorting is human ground truth and expensive to redo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: VR-013 | AR-002, AR-005, AR-024 |
||
|
|
042e424961 |
study(VR-005): minimum face size from downscaled gallery mugshots
Holds out one mugshot per actor, degrades that probe to each candidate
face size and matches it against a gallery held at native resolution,
reporting TPI/FPI per size. Replaces AR-002's 66x66 px working estimate
with a measurement. Needs no video and no ground truth beyond the
mugshot cache already on disk.
LVFace-B over 258 actors, 999 gallery embeddings, threshold 0.754:
px 12 16 20 24 32 40 48+
TPI 6.6% 46.5% 81.8% 93.4% 98.1% 99.2% 99.2%
FPI is 0.000 at every size — a face too small to identify degrades to
unidentified, never to a wrong name. rank-1 holds at >=99.6% from 24 px
up, so what fails first is the calibrated probability crossing
threshold, not the ranking.
Two limits on reading this. FPI grows with the number of actors
competing, so 258 understates it against a production library. And
detection and alignment run on the native image with only the resulting
112x112 crop degraded, so landmark error at small face sizes is excluded
by construction and the curve is an upper bound — VR-010 measures the
same question end to end, and lands well above these numbers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TRACES: VR-005 | AR-002
|
||
|
|
9fc2763096 |
feat: expansion promotion gated on all three discontinuity signals
AR-019 — promotion may only borrow same-identity evidence from a span where identity is certain, so every discontinuity signal now clears the buffers rather than just the histogram cut. is_scene_boundary was already named in the gate but never set by anything, so that half of it was dead until AR-010 gave it a producer. It now does what the spec always said. The third signal, an identity contradiction, needs no code here: AR-015 closes a track whose belief swapped, so it can no longer promote. Ownership now comes from the registry rather than a second tally. TrackGallery was computing its own plurality vote over accepted frames, which meant two different answers to "who is this track" could coexist in one run — and the expansion one ignored the Bayesian accumulation entirely, weighting thirty near-identical looks the same as thirty distinct ones. The local tally survives only as a fallback for callers with no registry attached, which is the unit tests and the replay harness. Suite: 92 cases, 6133 assertions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: AR-019, AR-010, AR-015 | SR-005 |
||
|
|
c843c4abe3 |
feat: banded admission for the per-subject embedding store
AR-018 — an embedding joins a track's store only if its similarity to something already there falls inside a band, rather than merely being far from the gallery. Above the upper bound it is redundant: another look at a pose the store already covers, teaching the annex nothing while costing a slot a novel view could have used. Below the lower bound it is suspect: within one track every face is the same person by construction, so an embedding unlike everything else on the track is evidence that construction failed — a track-ID collision or a bad detection. Admitting it is exactly how an actor's annex gets poisoned with someone else's face. The old gate had only the upper half of that idea, expressed as a raw cosine against the gallery. Both bounds are now calibrated probabilities (AR-024), so the same number means the same thing here as in association and evidence weighting rather than three different things. This catches track-ID collisions EARLIER than the spread gate did — at the door rather than at promotion — so the buffer never becomes two-person in the first place. The spread gate stays as a second line for a track that drifts gradually instead of jumping. The existing test was asserting the mechanism rather than the outcome, so it was rewritten to assert what actually matters: whichever gate fires, the outsider must not reach the annex. Rejections are counted. A store that admits nothing is as broken as one that admits everything, and neither is visible otherwise. Band defaults 0.90-0.95 are working values pending VR-007; the two bounds fail in opposite directions and must be swept separately. Suite: 92 cases, 6133 assertions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: AR-018, AR-024 | SR-005 |
||
|
|
cc1bed92d8 |
perf: back the CPU similarity GEMM with OpenBLAS
The CPU path was a scalar triple loop. It is the correctness oracle for the GPU backends, but it is also what CI runs — there is no GPU on the N100 host — and since AR-003 removed the per-frame face cap, a crowded frame now scores many faces against a library-scale gallery. Scoring one face against 5000 embeddings is 2.6 MFLOP; in scalar that does not hold up (AR-027). S(g,f) viewed as row-major [n_faces x n_gallery] is exactly query * gallery^T, so the loop nest collapses into a single cblas_sgemm. OpenBLAS is optional in the build: found via pkg-config, and the scalar path remains when it is absent so no hard dependency is added and the two can be diffed when a similarity looks wrong. The configure step warns rather than failing, since a developer without it should still get a working tree. The test target links it too. Without that the suite compiles the scalar fallback while the builder image ships CBLAS, so CI would be verifying a kernel that is not the one running in production — the same class of mistake as testing a path the gate never executes. Recorded as required (not optional) in the DP-007 image, for the same reason. Suite: 92 cases, 6136 assertions, with CBLAS compiled in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: AR-026, AR-027, DP-007 | SR-001 |
||
|
|
13bdc27566 |
feat: join the decode butterfly so scene boundaries reach the face branch
AR-010 — is_scene_boundary had no producer: SceneDetectorFunc was a terminal sink writing scenes.json and never annotating the frames flowing to face detection. The flag was permanently false, so the boundary half of AR-007's frame-dependent association was dead code that a test could still exercise synthetically and appear to verify. The topology already forks after decode — dense frames to TransNetV2, sampled frames to face detection — so this is a fork-join. SceneBoundaries is the join: the detector publishes each window's verdict with a watermark, and an annotator on the sampled branch stamps the flag. The watermark is the part that matters. TransNetV2 buffers 100 frames before it can score any of them, so at any instant it has an opinion up to some time T and none after. Without recording T a consumer cannot tell "no boundary" from "not scored yet", and those demand opposite behaviour — treating unscored frames as boundary-free is exactly what makes a downstream check pass while verifying nothing. Buffering alone does not work, which was my first attempt. Channel depth creates lag only when the consumer is slower, and the face branch runs four orders of magnitude faster per frame than TransNetV2 (0.01ms vs 400ms), so its channels drain instantly and no lag accumulates. Measured: 106 of 364 frames outran the detector. The annotator therefore waits on the watermark explicitly. The detector signals completion so the tail cannot deadlock, and publishes from flush_remaining too — without that the final frames arrive with no verdict. Boundaries are deduped on publish, matching what scenes.json does at write time. A run of adjacent high-scoring frames is one boundary, not several; leaving them raw made this view report 357 where the file said 13. Now the two agree exactly. Frames past the detector's last scored window remain unverified and are counted as such rather than silently marked boundary-free. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: AR-007, AR-010 | SR-002 |
||
|
|
fa1c494825 |
docs: AR-010 is blocked on a design decision, not an implementation gap
Making SceneDetectorFunc a pass-through does not work. TransNetV2 buffers 100 dense frames before it can score any of them and trusts only each window's centre, so a boundary at time T is not known until roughly 3.3s after T at 30 fps. The face pipeline runs on a parallel branch and has long since passed T. An association hint that arrives after the association is worthless. Three options recorded with their costs: two-pass (correct, doubles the decode that already dominates runtime), delaying the face branch (couples the two branches' timing, which invites heisenbugs under backpressure), or leaving it unwired. Leaving it unwired costs less than it looks, which is what makes this a decision rather than a defect. The redesign made cuts and boundaries do the same thing — both say "spatial continuity is broken, associate on embedding" — so TransNetV2 adds nothing over the histogram except on transitions the histogram cannot see: slow dissolves and fades. That gap is real but narrow. Where TransNetV2 still earns its cost is AR-019, whose promotion gate wants a span free of cuts and boundaries. A late answer is fine there, because promotion happens on track confirmation rather than per frame — so it can be wired offline against the collected boundary list, off the hot path entirely. Recommendation: leave the association path on is_cut alone, wire boundaries into AR-019, and revisit if dissolve-heavy material shows association failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: AR-010, AR-019 | SR-002 |
||
|
|
f99f1c5ccc |
feat: no fixed cap on faces per frame
AR-003 — max_faces defaults to 0, meaning no cap. A fixed cap discards the SMALLEST faces first, which are exactly the background cast X-Ray still credits with scene membership, so the pipeline was systematically losing the people it is supposed to find in crowded scenes. This is only safe now that AR-004 landed. Previously an uncapped frame would have pushed more work into channels that dropped on overflow, trading a visible cap for silent loss. With backpressure the producer slows instead, so per-frame cost is contained rather than discarded. The matcher's kMaxFaces used to throw above 32, which made it an accidental second cap. It sizes the similarity engine's preallocated buffer, so it bounds memory rather than face count — the frame is now scored in batches of that size. Memory stays bounded; faces do not. Largest-first ordering is kept even without the cap, and the comment now says why: the Hungarian solver tie-breaks on index order, so that ordering is load-bearing for the replay determinism test rather than a leftover of the cap. Verified end to end on a real clip: identical output to the capped run (385 frames, 693 faces), which is expected since that footage peaks at 4 faces per frame — the point is the absence of a regression. The committed fixtures remain byte-identical and valid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: AR-003 | SR-002 |
||
|
|
3605b8da78 |
fix: point the KPN submodule at the merged commit
The recorded pointer was be6e922 — the backpressure fix as originally committed, before it was rebased onto KPN master. That commit exists on no pushed branch, so a fresh clone of this branch could not fetch the submodule at all. Now 6595e6e, the same change on KPN master. Worth noting for next time: rebasing a submodule commit after the superproject has already recorded it silently invalidates the pointer. Nothing in the superproject's status shows it, because the submodule working tree is clean and at a valid commit — just not the one recorded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: AR-004 | SR-002 |
||
|
|
61e487fbee |
test: replay the real tracker and registry from committed fixtures
Tier T2 — composition rather than units. The registry tests construct awkward states directly; these feed the pieces real 480x360 footage with the cuts, gaps and crowded frames that synthetic input does not produce. Six cases: - fixture integrity: exact frame and face counts, contiguous face_offset, and the embedder identity each dump carries (GR-004). The counts are asserted exactly rather than approximately, which was impossible before AR-004 — what a lossy run dropped depended on timing. - determinism: replaying a fixture twice gives identical track ids and windows. This is the property the whole fixture strategy rests on; without it every golden output derived from a fixture is unreliable and the CI replay tier is worthless. - every face is assigned a track, and flush leaves nothing open — a track still live at EOF is a window that never reaches the output. - windows are well-formed and inside the clip. A window ends at the last sighting, so it can never extend past the footage that produced it. - a longer extinction window yields fewer, longer tracks. On the sparse fixture (140 faces over 385 frames) that is the difference the constant actually makes: absorbing a gap versus splitting a window. - the cut-heavy fixture still contains cuts. This guards the corpus, not the code: a regeneration that produced cut-free fixtures would leave the association tests passing while silently testing nothing. Driving the functors directly rather than through a KPN network is deliberate — no threads, no channels, no scheduling, so the same input gives the same output. Suite: 86 cases, 6106 assertions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: AR-004, AR-012, AR-013, VR-001, VR-002 | SR-002 |
||
|
|
c12838b9fd |
fix: dropped frames fail the run instead of printing a footer
A drop was reported to stderr and the process exited 0, so a run that discarded 320 frames "succeeded" and produced a truth file that looked complete. The output in that case is a claim about footage that was never analysed, and nothing in the file says so. Now exits 2 and says why. Distinct from 1 (node crash) because the failures are different: a crash produced no output, a drop produced output that cannot be trusted. This is also the regression test for AR-004 that otherwise did not exist. The backpressure fix is one line in the KPN submodule — easy to lose in an update — and with data pushes blocking, a drop can no longer occur on the data path. So any drop now means either that fix regressed or a channel was disabled mid-run, and both are worth stopping for. Verified: a clean run still exits 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: AR-004 | SR-002 |