edf19ab798aa6920d706350786a37d8b99edbdf9
32
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
24d35cbde3 |
fix(AR-013): reap tracks on the evidence watermark, not the tracker's clock
The registry closed a track when the *tracker's* timestamp passed `track_extinction_sec`. But votes arrive from the matcher, which is a separate KPN node behind a channel, and much the slower of the pair. Backpressure — working exactly as AR-004 intends — turns that channel's depth into lag, so the tracker's clock can be far ahead of the last frame anybody has voted on. Tracks were therefore closed before their evidence arrived: the votes landed on ids that no longer existed, were counted as dropped, and the track was emitted unowned or not at all. The symptom is the part worth remembering: **a deeper channel produced fewer identifications, from identical input.** On the SuperHero fixture, 5 actors / 16 windows at depth 32 against 3 actors / 5 windows at depth 10322; through the replay harness, capacity 32 gave 5 actors and 10322 gave 0. A throughput knob was silently changing the answer, which makes every sweep tuned against it suspect. The fix is not to bound the channel against `track_extinction_sec` — that makes an algorithm constant police a throughput knob and leaves the result a function of scheduling. It is to reap on an evidence watermark: the matcher advances it as it folds each frame in, and a track is only finished once everything up to its extinction point has actually been voted on. Same device `SceneBoundaries::scored_through()` uses for the AR-010 join — a consumer past that point is asking about frames nobody has looked at yet, and the honest answer is to wait rather than guess. Association keeps the tracker's clock, and separating the two is the other half. They answer different questions: "may this detection link to that track?" is asked now, about a box seen `track_extinction_sec` ago; "is that track finished?" cannot be answered until every vote is in. Deferring association to the evidence clock — which deferring the erase alone did — left retired tracks associable for as long as the matcher lagged, so a new face re-associated onto a long-dead track and two people merged into one window. The watermark is monotonic and only ever *delays* a reap, so no window is extended by it: AR-013's "a window ends at the last sighting, never after" is a property of `emit_locked`, which takes `last_seen` and never `now`. `dropped_votes` is exposed and reported — by main at shutdown and through the replay bindings — because this failed silently for as long as it did precisely because nothing counted it. It warns rather than aborts: a dropped frame means the output describes footage nobody analysed and is always wrong, while a dropped vote degrades a claim without falsifying it, and there is no measurement yet of how often it happens on real content. replay.py's channel capacity stops being the whole film. It was sized that way to dodge a PyNode overflow drop that AR-004 has since replaced with parking, and removing backpressure that way is what made the defect above so extreme. Tag separators in kpn_bindings.cpp corrected to pipes between requirement types, which the traceability gate was reporting as diagnostics; the matrix is regenerated and reports 0 orphan tags. 149/149. TRACES: AR-004, AR-012, AR-013, AR-025 | VR-011 | SR-002 | PR-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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
09a4650fd9 |
feat: backpressure — the pipeline slows instead of losing frames
Picks up the KPN fix: node data outputs block on a full channel rather than dropping. Sentinels stay out-of-band, so EOF can always overtake a stalled data path and the hold-and-wait deadlock that comment warns about is not reachable. Verified on a 77s clip at 5 fps, which should yield 385 sampled frames: before 65 written, 320 dropped, 29s, two runs differ after 385 written, 0 dropped, 17s, two runs byte-identical The determinism is the part that matters. Golden fixtures were impossible while what got dropped depended on timing; VR-001 fixture generation is unblocked by this, and so is the CI replay strategy that depends on it. Faster rather than slower, which is worth recording because the intuition runs the other way: a dropped frame has already cost its decode, and the overflow exception cost more still. AR-004 is not fully closed. Channel capacity remains a count of items, while a face carries a 112x112 crop and a 512-float embedding — so a crowded frame occupies far more memory per slot than a sparse one. Bounding by bytes in flight is the remaining half, and it matters once max_faces is removed (AR-003). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: AR-004, VR-001 | SR-002 |
||
|
|
b98372bad8 |
docs: AR-004 is a KPN change, with the measurement behind it
Backpressure cannot be implemented in this repository. Every node output in KPN uses the dropping push() (pool_node.hpp:404 and :710, plus branch, fanout and interrupt_node). A lossless push_blocking() already exists on both Channel and OutputPort — "wait for the consumer to drain instead of dropping; the producer just runs slower" — and nothing calls it. The fix is a per-channel policy or a network default in KPN, and this pipeline should select lossless: a dropped frame here does not degrade a result, it silently changes one. Measured rather than inferred. One 77s clip at 5 fps should yield ~385 sampled frames. On CPU it produced 49, ending at 51s, with 285 dropped at camera_pos and 51 at face_aligner. Rebuilt with CUDA the same clip ran in 29s and reached EOF correctly, and still dropped 320 at camera_pos, yielding 65. Faster hardware moves where the queue backs up; it does not change what happens when it does — which is why this is a correctness requirement rather than a throughput one. Raising channel capacity is therefore a stopgap: it lowers the probability of overflow without changing the behaviour on overflow, and the failure it hides is silent corruption of the output. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: AR-003, AR-004, VR-001 | SR-002 |
||
|
|
e0f9c95689 |
docs: minimum face size is 32px, measured (VR-005)
Replaces the 66px working estimate with the sweep result. 258 probes degraded to each size and matched against a native-resolution gallery: 16px 46.5% TPI, 24px 93.4%, 32px 98.1%, 40px 99.2%, flat to 112px. The knee is 24-32 and 32 sits within about a point of the ceiling. The estimate was roughly twice too strict. At 66px a large share of usable faces would have been discarded, and on 480x360 sources most of them — which is exactly the resolution of the fixture corpus. The more useful finding: false identification was 0.0 at every size, including 12px. Small faces fail by becoming unidentified, never by being attributed to the wrong actor. That asymmetry is what makes a low threshold safe — the cost of admitting a marginal face is a miss, not a false claim. Caveat recorded rather than assumed: FPI grows with gallery size, so 258 actors understates it against a full library. Treat 0.0 as an observation at this scale, not a property. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: AR-002, VR-005 | SR-002, PR-002 |
||
|
|
9e4cdc4efc |
docs: bali fixture corpus, and AR-004 blocks reproducible fixtures
Records `bali/` — five ~77s clips of Road to Bali (1952) — as the fixture source. Public domain, which is the point rather than a convenience: derived fixtures can be committed, where anything cut from a copyrighted title could not live in the repository at all. Makes explicit what the tier table only implied: CI never calls a model. Not a preference — the embedder measures ~930 ms/frame on the CPU provider, so a 77s clip at 5 fps is six minutes of inference. Every model invocation happens locally and CI consumes the result as data, which is what makes the T1/T2 split load-bearing rather than stylistic. Two properties of the corpus to design around: 480x360 puts many faces below the AR-002 66px minimum, so generation must set and record --min-face-px; and 77s at 1 fps is too thin to exercise an extinction window measured in tens of seconds, so fixtures want 5 fps. The finding that matters: a trial dump produced 49 frames of an expected ~385, stopping at 51s of 77s, with 285 frames dropped at camera_pos and 51 at face_aligner on channel overflow. Channels drop rather than block, and what drops depends on timing, so the same command twice can yield different dumps. Golden fixtures cannot be built on that — AR-004 is a prerequisite for VR-001 fixtures, not just a throughput concern for crowd scenes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: AR-004, VR-001 | PR-002 |
||
|
|
08941540cb |
feat: presence windows come from registry claims (schema_version 2)
The sink no longer reconstructs presence from per-frame detections. A reaped track already IS a window — [first_seen, last_seen] of a track an actor owned — so it is pushed straight to the aggregator when it dies and written out as-is. AR-012 completed end to end. The annealing pass is deleted, not disabled: anneal_sec existed only to bridge gaps between isolated accepted frames, and a track that survives its own gaps leaves it nothing to do. The field is REMOVED from the output rather than zeroed — a field naming a mechanism the pipeline no longer has is actively misleading to anyone reading a manifest, and would outlive everyone who remembers why it reads 0. IR-002 — schema_version 2, matching jRay/SPEC.md JR-002. Windows become objects carrying `belief` and `route` rather than bare float pairs, so a consumer can caveat or filter instead of treating every window as equally certain. The new `extraction` block carries `extinction_sec` (the successor to anneal_sec, and what a consumer actually needs to interpret a window) and `gallery_scope` — global vs limited being the strongest single quality signal when two manifests compete for one cut, since identical gallery_size can mean very different recall. AR-016 wired: a pre-write hook flushes the registry with the last timestamp seen, so tracks still live at EOF are emitted. A film ends with faces on screen and those tracks have not timed out; without this the closing scene's cast is silently dropped, which reads as a recognition miss rather than a bookkeeping bug. IR-003 stays In Progress deliberately: the sink now writes after the flush, but the deferred re-identification pass (AR-020) does not exist yet, so output is still final at EOF rather than after it. This is a BREAKING format change and part of the coordinated SR-003 bump — it must ship together with the jRay reader and the server's acceptance of the new shape, not ahead of them. Suite: 80 cases, 3250 assertions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: AR-012, AR-016, IR-002, IR-003 | SR-002, SR-003 |
||
|
|
be5f67fa96 |
feat: tracker on the registry — one pool, calibrated, frame-dependent
Merges feature/tracker-registry. See
|
||
|
|
843852e19c |
feat: registry owns correlation discounting (AR-024, AR-025)
Moves two responsibilities inside the registry that callers should never have been trusted with. AR-025 — per-frame evidence is discounted for correlation by the registry itself, via EvidenceDiscounter. Log-odds accumulation is only valid for independent observations, and consecutive frames of one track are anything but: near-identical pose, lighting and expression. Accumulated naively, thirty frames of the same face at the same angle drive the posterior to certainty on what is effectively one measurement. Each observation is weighted by how much it adds — a view already contributed counts for ~nothing, a genuinely new pose counts in full. This reuses the novelty judgement gallery expansion already makes rather than inventing a second one. The discounter is a separate class the registry holds, so it stays testable and swappable, but it is a constructor argument rather than an option: there is no correct way to accumulate without it. AR-024 — observe() takes a calibrated probability and converts to log-odds internally. A caller can no longer hand it a raw cosine, which would have been silently wrong rather than obviously so. Retiring the remaining raw-cosine constants in the tracker is still open. DeadTrack now reports effective_obs alongside observations: the raw count and the evidence that actually counted. A large gap between them is a track the camera stared at, and worth seeing. Three tests, one of which is the point: two tracks given the same number of observations at the same posterior, one repeating a single view and one seeing eight distinct ones, must not end up equally confident. Without discounting they would be identical. Fixed a test that asserted a belief swap on tied evidence. A tie leaves ownership where it is — a challenger must out-accumulate the incumbent, since one contrary observation is noise. The original test passed only because it fed raw log-odds directly. Suite: 78 cases, 3245 assertions. Coverage 20/63 to 22/63. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: AR-024, AR-025 | SR-002 |
||
|
|
f0c7126f80 |
feat: TrackRegistry — presence follows track extent
The spine of the redesign. Presence is now the extent of a track an actor owns, [first_seen, last_seen], rather than the subset of frames in which recognition happened to succeed. An actor recognised only at the end of a long track is present for all of it, which is what the scene-scoped ground truth actually records. AR-013 — `last_seen` as an optional carries the entire liveness state: unset means on screen, set means went off at that timestamp and still revivable, reaped means emitted and erased. No missing-frame counter, no expired flag. It subsumes the tracker's existing two-pool split, so there is no separate revival path — matching a dormant track is ordinary inter-frame association. The asymmetry is the point: interior gaps are claimed, the trailing cool-down is not. A face lost and re-associated within the timeout never closed its track, so the gap is presence — someone briefly occluded has not left the scene. But a track that dies ends at its last sighting, never at the death time. That is precisely the over-claim the retired extinction_sec keep-alive produced, where presence ran on into the closing credits. AR-014 — a belief swap A→B closes the track and opens a successor at the swap frame. Not a correction: two non-twins both clearing the threshold on one face is not realistic, whereas a track_id carried across a viewpoint change onto a different person is. Treating it as a swap-and-continue would emit one window blending two people; treating it as a boundary yields two that are each right. AR-015 — two live tracks owned by one actor means at least one is wrong, since a person cannot be in two places at once. A reverse index catches it on the update that causes it rather than by scanning. This makes identity a third cut detector, independent of the histogram and TransNetV2 and firing where those failed. AR-016 — flush() closes tracks still live at EOF. Without it a film ending mid-shot silently drops its closing cast, which presents as a recognition miss rather than a bookkeeping bug. Reaping hands the dead track to the aggregator and erases it, so the registry holds only live tracks and its size is bounded by concurrent on-screen faces rather than growing with the film. Locking: a frame's association pass is atomic as a unit via FrameScope, since per-call locking would let another thread observe a half-updated frame. owner() reads tally and verdict under one lock — separately, a track could be both unowned and owned within a single promotion decision. A vote for an already-reaped track is dropped and counted, because a nonzero count means the timeout is shorter than the matcher's lag. 11 unit tests, driven directly against the registry with no network and no fixture — the awkward cases are constructed rather than hunted for. Suite: 75 cases, 3236 assertions. Coverage 14/63 to 20/63. Not yet wired into FaceTrackerFunc; that is AR-007/AR-008. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: AR-012, AR-013, AR-014, AR-015, AR-016, AR-017 | SR-002 |
||
|
|
908d166173 |
feat: bind galleries to the embedder that built them
GR-004 — a gallery built with one embedding model is meaningless with another. Cosine similarities across models are garbage but look entirely plausible, so this fails silently and expensively; every measurement taken against a mismatched pair would have been quietly wrong. The stamp is the model basename plus a SHA-256 of its bytes, with embed_dim as a cheap extra guard. The hash decides and the name explains, because neither works alone: a name is a promise rather than a fact — models get re-exported in place under an unchanged filename, which is exactly the case where the weights differ and nothing else does — while a bare hash mismatch tells an operator nothing actionable. Mismatch is fatal in every mode with no bypass. Unstamped only warns, because unstamped is unknown rather than known-bad, and an error firing on every legacy gallery trains people to reach for the bypass reflexively. scripts/stamp_gallery.py binds an existing gallery in place with no re-embedding, so the warning is a migration step rather than a permanent state; --require-gallery-stamp promotes it to an error once a site has migrated. Two gaps found that would have defeated the requirement outright: - Embedding dumps carried no stamp, so a replay — which has no live embedder — had nothing to check the gallery against. Dumps now carry embedder_model and embedder_sha256 as root attributes. Additive; schema_version stays 1. This is the same gap the dump audit identified independently. - --merge produced one file holding two embedding spaces, which no later check can untangle. Merge paths now verify before writing. The stamp also survives identity_matcher's calibration write-back, which would otherwise have stripped it on the first analysis run — the check would have worked exactly once. Conflicts resolved additively: both branches appended a source to sae_gallery and to the test target, and both edited the GR-004 register row. Merged suite: 64 cases, 3199 assertions, passing on CPU with no GPU. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: GR-004, VR-001 | SR-001 |
||
|
|
a2ebdc4cdd |
docs: GR-004 done — gallery/embedder binding
Stamp is model basename + SHA-256 + embed_dim: the hash decides, the name explains. A name alone is a promise rather than a fact — models get re-exported in place under an unchanged filename, which is exactly the case where weights differ and nothing else does. A hash alone is unactionable in an error message. Mismatch is fatal in every mode with no bypass. Unstamped only warns, because unstamped is unknown rather than known-bad, and an error that fires on every legacy gallery trains people to reach for the bypass. A migration script binds existing galleries in place with no re-embedding, so warn is not permanent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: GR-004 | SR-001 |
||
|
|
7db40f430d |
GR-004: bind galleries to the embedder that built them
A gallery is only valid for the embedder that produced its vectors. Cosine
similarities across models are meaningless but *look* plausible, so the mistake
is silent and every measurement taken afterwards is suspect. Stamp the embedder
identity into the gallery at build; verify it at every load.
The stamp is the model file's basename plus the SHA-256 of its bytes (plus
embed_dim). The hash decides, the name explains. A name alone is a promise
rather than a fact — models get re-exported and overwritten in place under an
unchanged filename, which is exactly the case where the weights differ and
nothing else does. A hash alone is correct but unactionable in an error message.
SHA-256 is derived from the artefact, needs no registry kept current, and costs
~0.1s for a 250MB ONNX, memoised per process.
Mismatch is a hard error in every mode, with no bypass, naming both sides.
Unstamped legacy galleries warn loudly and proceed: unknown is not known-bad,
and hard-failing every pre-existing gallery would turn the check into something
people disable rather than trust. --require-gallery-stamp (or
SAE_REQUIRE_GALLERY_STAMP=1, which propagates to subprocesses) promotes that to
a hard error — the mode measurement work should run in. scripts/stamp_gallery.py
re-binds an existing gallery with no re-embedding, so "warn" is a cheap state to
leave rather than a permanent one.
Embedding dumps carry the same stamp: a replay has no live embedder, so the dump
is the embedder as far as the gallery is concerned. Derived galleries inherit
their source's stamp; --merge and the JSON gallery merge check before writing,
since one file holding two embedding spaces cannot be untangled afterwards.
Verified in: scene_analyze, scene_preview, the sae_kpn matcher binding,
replay.py, optimize.py (once per film at startup, before the first evaluation),
movienet_eval.py and both merge paths.
Stamp logic lives in src/gallery/embedder_stamp.{hpp,cpp} and its Python twin
scripts/sae_stamp.py, kept dependency-light so replay subprocesses do not pay
sae_gallery's requests/Pillow import to ask whether two models match.
Tests: 12 new cases in test_gallery_store.cpp covering the comparison logic,
both round trips, and the SHA-256 vectors that guarantee the C++ and hashlib
stamps agree. No ONNX or GPU required.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
020306c94f |
docs: builder images and per-backend release binaries (DP-008)
Adds the build/deploy story that was missing: containerised builder images for cpu / cuda / rocm, and release jobs producing prebuilt binaries so a first install need not compile. States explicitly that this does not reverse DP-005. That requirement rejects Docker as a *runtime* — GPU passthrough is fragile and exists only because of the container. Using it as a *build* environment is the opposite case, and lets one machine produce binaries for backends it cannot itself run. Build in a container, run natively. Two things deliberately cannot ship, and the installer must not imply otherwise: TensorRT engines are GPU-architecture and TRT-version specific, so build_trt_engines.sh still runs on the target; and models are ~725 MB in LFS, orthogonal to the binary. The base image is chosen by the OLDEST glibc to be supported, not by convenience — a binary built in a container runs against the host's glibc, and getting this wrong fails at load with GLIBC_2.xx not found. Accelerator runtimes have the same shape of problem, so each image documents its compatible CUDA/ROCm range and the installer checks it rather than discovering a mismatch at first inference. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: DP-005, DP-007, DP-008 | PR-004 |
||
|
|
2919ed68d1 |
docs: audit findings, verification tiers, CI image, artifact storage
Corrections from the dump audit and the completed agent work: - AR-010 is not started, not in progress: is_scene_boundary has no producer anywhere. SceneDetectorFunc is a terminal sink writing scenes.json and never annotates the frame, so the field is permanently false and the dump column a constant 0. A replay test of the frame-dependent track_alpha would pass vacuously — the worst failure mode for a verification gate. - T1 (functor-level) becomes the primary verification tier, not T2. KPN node functors are plain callables constructed outside the network, so a node is tested by calling operator() with hand-built inputs. That removes four hazards at once: fixture provenance, replay-from-frame-0, cross-test state leakage, and replay-harness nondeterminism. It also means a dead upstream producer no longer blocks testing its consumer. - VR-010 (dump provenance) and VR-011 (replay harness rewrite) added. A dump made with LVFace is currently byte-indistinguishable from one made with w600k-R50 — the GR-004 problem again, in the dump. - Four requirements had no verification tier at all; the traceability gate found them. - DP-007: CI builder image, CPU-only, pinned by tag in the Gitea container registry. Corpus fixtures go to the package registry rather than LFS: LFS is pulled on clone and would tax every developer for data only CI reads. - IR-004/005/007/008 marked done; the v1 DSP parameters they had to pin are now normative in the server spec. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: AR-010, DP-007, IR-004, IR-005, IR-007, IR-008, VR-001, VR-010, VR-011 | SR-002, SR-003 |
||
|
|
a5299daf6e |
docs: software spec, requirements register, and implementation plan
Adds the requirements baseline for the pipeline redesign: - SPEC.md — software requirements with Current/Gap deltas per item, so the document doubles as a work list. - requirements.md — stable flat IDs (AR/DP/IR/GR/VR) with parent traces, priorities, statuses, and a per-requirement verification plan. Replaces the thematic A1..E8 scheme, which had already produced an A1a and an out-of-order E6; IDs are now permanent and never reused. - IMPLEMENTATION-PLAN.md — phased work. The central change is AR-012: presence follows track extent rather than per-frame recognition, so a window starts when an actor appears rather than when the recogniser first succeeded. anneal_sec and extinction_sec are withdrawn rather than retuned — a track that survives its own gaps leaves them nothing to do. Verification is shaped by CI running on an N100 with no dGPU: the existing HDF5 dump makes everything downstream of embedding replayable on CPU, which covers the bulk of the redesign. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |