Commit Graph
83 Commits
Author SHA1 Message Date
dtourolle 960a7c4eed chore: bump KPN to 454f72c (ignore generated ORT cache) 2026-08-04 14:08:13 +02:00
dtourolle 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.
2026-08-04 14:04:50 +02:00
dtourolle 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.
2026-08-04 14:04:21 +02:00
dtourolle 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
2026-08-04 13:49:11 +02:00
dtourolleandClaude Opus 5 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 35e7033, whose trailer names AR-004
only, so git log --grep=AR-018 will not surface them. And
docs/traceability.md is left uncommitted on purpose: regenerating it now
would bake in VR-013 rows for two experiment scripts that are not yet
committed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

TRACES: AR-018, AR-024 | SR-005
2026-07-31 22:48:07 +02:00
dtourolleandClaude Opus 5 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
2026-07-31 22:47:59 +02:00
dtourolle 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
2026-07-31 22:42:19 +02:00
dtourolleandClaude Opus 5 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
2026-07-31 17:02:45 +02:00
dtourolle 5f6daefc40 Merge branch 'feature/quality-knee' into feature/opencv5
# Conflicts:
#	docs/requirements.md
2026-07-31 16:54:09 +02:00
dtourolle 629d698ad9 Merge branch 'feature/dump-provenance' into feature/opencv5 2026-07-31 16:53:58 +02:00
dtourolleandClaude Opus 5 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
2026-07-31 16:53:58 +02:00
dtourolleandClaude Opus 5 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
2026-07-31 16:52:11 +02:00
dtourolleandClaude Opus 5 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
2026-07-31 16:51:08 +02:00
dtourolle 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
2026-07-31 16:39:12 +02:00
dtourolleandClaude Opus 5 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
2026-07-31 15:58:24 +02:00
dtourolleandClaude Opus 5 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
2026-07-31 15:51:50 +02:00
dtourolle 81ec77625c Merge branch 'feature/gallery-report' into feature/opencv5 2026-07-31 15:31:07 +02:00
dtourolleandClaude Opus 5 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
2026-07-31 15:30:46 +02:00
dtourolleandClaude Opus 5 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
2026-07-31 15:24:06 +02:00
dtourolleandClaude Opus 5 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
2026-07-31 15:20:18 +02:00
dtourolleandClaude Opus 5 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
2026-07-31 15:17:39 +02:00
dtourolleandClaude Opus 5 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
2026-07-31 15:11:56 +02:00
dtourolleandClaude Opus 5 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
2026-07-31 15:10:31 +02:00
dtourolleandClaude Opus 5 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
2026-07-31 15:04:29 +02:00
dtourolleandClaude Opus 5 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
2026-07-31 14:56:38 +02:00
dtourolleandClaude Opus 5 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
2026-07-31 14:27:05 +02:00
dtourolleandClaude Opus 5 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
2026-07-31 14:22:20 +02:00
dtourolleandClaude Opus 5 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
2026-07-31 11:53:27 +02:00
dtourolleandClaude Opus 5 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
2026-07-31 11:23:17 +02:00
dtourolleandClaude Opus 5 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
2026-07-31 10:45:39 +02:00
dtourolleandClaude Opus 5 d31526cfaf test: committed replay fixtures from the public-domain corpus
Five HDF5 embedding dumps from bali/ — Road to Bali (1952) — 3.6 MB total,
generated at 5 fps with a 32 px minimum face. CI never calls a model, so
inference happens on a GPU host and CI replays these as data; everything
downstream of embedding is cheap CPU maths.

Public domain is the reason this corpus rather than a convenient one: derived
fixtures can be committed, where anything cut from a copyrighted title could not
live in the repository at all.

The set covers distinct behaviours rather than being five of the same thing:
bali_28 has 9 cuts, so it exercises shot/reverse-shot association (AR-007);
bali_46 is sparse at 140 faces over 385 frames, so it exercises gaps and
extinction (AR-013); bali_13 is the busiest at 4 faces per frame; bali_31 is
short at 29s. All five recorded zero drops.

Both pinned parameters are consequences of measurements, not defaults: 5 fps
because 1 fps over a 77s clip is 77 frames, too thin for an extinction window
measured in tens of seconds; 32 px because that is the VR-005 floor, and the
corpus is 480x360 so a stricter value would reject most of what is there.

make_fixtures.sh regenerates them. Reproducibility is the requirement — a
fixture whose provenance is unknown is worse than none, because it will be
trusted. These are byte-reproducible only because of AR-004: before node
outputs blocked rather than dropped, the same command produced different dumps
run to run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

TRACES: VR-001 | PR-002
2026-07-31 10:41:51 +02:00
dtourolleandClaude Opus 5 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
2026-07-31 10:35:29 +02:00
dtourolleandClaude Opus 5 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
2026-07-31 10:29:53 +02:00
dtourolleandClaude Opus 5 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
2026-07-31 10:22:38 +02:00
dtourolleandClaude Opus 5 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
2026-07-31 10:19:10 +02:00
dtourolleandClaude Opus 5 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
2026-07-31 10:10:51 +02:00
dtourolleandClaude Opus 5 fe29d014da feat: identity evidence reaches the registry
Closes the link that made AR-012 inert: the tracker was maintaining registry
state, but nothing called observe(), so no belief accumulated, no track was ever
owned, and no presence claim could be emitted. Tracking worked and presence did
not.

The matcher now feeds every scored face to the registry as a calibrated
posterior plus its embedding. Deliberately every scored face, not only the ones
clearing prob_threshold: a run of near-misses for one actor is evidence, and
discarding it would leave ownership depending on the per-frame threshold this
redesign exists to stop relying on. The registry discounts for correlation and
decides ownership from the accumulated posterior (AR-025).

The registry is an optional dependency of the matcher. Without one it behaves
exactly as before, which keeps the replay harness and the unit tests working
unchanged rather than forcing every caller to construct a registry it does not
need.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

TRACES: AR-012, AR-025 | SR-002
2026-07-31 10:05:59 +02:00
dtourolleandClaude Opus 5 be5f67fa96 feat: tracker on the registry — one pool, calibrated, frame-dependent
Merges feature/tracker-registry. See e9aea3f for the detail; in summary the
tracker no longer owns track state, association weights embedding over position
whenever position is uninformative, and every similarity comparison is a
calibrated probability.

Four constants retired: track_max_embed_dist, cut_revive_sim,
cut_inactive_max_frames, track_max_frames_missing.

Suite: 80 cases, 3250 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

TRACES: AR-007, AR-008, AR-024 | SR-002
2026-07-31 10:01:31 +02:00
dtourolleandClaude Opus 5 e9aea3fc41 feat: tracker owns no state; association is frame-dependent and calibrated
Three requirements land together because they cannot be separated. The
cross-cut revival branch was the only user of cut_revive_sim, so retiring that
raw cosine forces the pool collapse, and collapsing the pool removes the only
caller of the constant. Splitting them would have produced an intermediate
commit whose only purpose was to be split.

AR-008 — FaceTrackerFunc no longer keeps its own tracks_/inactive_ maps; it
holds a shared_ptr<TrackRegistry> and operates on it directly. Two parallel
copies of track state could disagree, and every divergence would surface as a
wrong presence window with nothing to indicate it. There is now ONE candidate
pool: last_seen alone says whether IoU is meaningful. The park/revive path is
deleted outright — matching a dormant track is ordinary inter-frame
association, and continuity falls out of the embedding comparison the tracker
already did rather than being a mechanism of its own.

AR-007 — track_alpha becomes the base weight for ordinary frames only.
Association drops to embedding-only when position carries no information:
on is_cut or is_scene_boundary, because the viewpoint changed, and for a
dormant track, because time has passed since its box was last valid. The second
case matters as much as the first and had no equivalent before.

AR-024 — association cost is a calibrated probability, never a raw cosine. The
tracker takes the calibration belonging to the active embedder, the same
function object EvidenceDiscounter uses. track_max_embed_dist becomes
track_assoc_min_prob, which means the same thing for every model, gallery and
face size, where a bare cosine threshold did not.

Retired: track_max_embed_dist, cut_revive_sim, cut_inactive_max_frames, and
track_max_frames_missing — the last superseded by the registry's extinction
window. That one is worth naming: a frame count silently changed meaning with
sample_fps, so the same configuration behaved differently at 1 fps and 5 fps.
Extinction is in seconds and lives in one place.

Tests rewritten rather than deleted. The old cases asserted revival by raw
cosine; the same behaviours are now asserted through the registry — a face lost
across a cut and re-associated is the SAME track, one unbroken window, and a
face returning past the extinction window is not. Added the case AR-007 exists
for: two people swap screen positions across a cut while keeping their faces,
and identity must follow the embedding rather than the box.

Suite: 80 cases, 3250 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

TRACES: AR-007, AR-008, AR-024 | SR-002
2026-07-31 09:58:27 +02:00
dtourolleandClaude Opus 5 b7c96641a9 docs: no exact tier — the file-hash tier was withdrawn
A stale reference to 'the runtime/exact tiers' as the fallback for media too
short to carry an audio signature. The exact tier keyed on a file hash and was
withdrawn on legal grounds: it fingerprinted the individual copy a user holds
rather than the cut the timings describe.

The pipeline never emitted a video_hash, so nothing in the code changes — but a
spec that still names a withdrawn tier is what makes the withdrawal look like an
oversight to the next reader, which is exactly how it nearly got re-added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 09:55:50 +02:00
dtourolleandClaude Opus 5 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
2026-07-31 09:15:44 +02:00
dtourolleandClaude Opus 5 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
2026-07-31 09:08:39 +02:00
dtourolleandClaude Opus 5 b35d49c772 docs: tag the implemented core with its requirement IDs
Adds TRACES tags to code that already satisfies a Done requirement, so coverage
reflects what exists rather than starting from zero:

AR-001 face detection, AR-005 ArcFace alignment, AR-023 calibration fit,
DP-001/DP-002 the single analysis core behind the CLI, IR-001 truth-file
emission, IR-006 the Jellyfin round trip, GR-001/GR-002 gallery build and
incremental merge, VR-001 the embedding dump, VR-002 replay through the real
nodes, VR-003 per-second scoring.

Only Done requirements are tagged. A tag on Planned work would inflate coverage
with fiction that looks plausible — the same failure family as a gate that
cannot fail, and harder to spot.

GR-005 (gallery never leaves the instance) stays untagged deliberately: it is a
prohibition satisfied by the absence of an egress path, so there is no unit that
decides it. Same shape as PR-005 in the system spec, which has no software row
for the same reason. A goal held only by prohibitions cannot be verified by
pointing at code.

Coverage 5/63 to 14/63. The three VR tags are reported as tagged-but-unexecuted
and excluded from the numerator, since their tier cannot run on the CI host —
tagging deliberately cannot raise the number on its own.

Suite still 64 cases, 3199 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

TRACES: AR-001, AR-005, AR-023, DP-001, DP-002, IR-001, IR-006, GR-001, GR-002, VR-001, VR-002, VR-003
2026-07-30 21:20:27 +02:00
dtourolleandClaude Opus 5 62396fce75 docs: record why exclude_dirs stays unset, refresh matrix
The tool's defaults already exclude `vendor`, which covers the submodule at
scripts/vendor/jray-project. Setting the key explicitly is a trap worth
documenting: it REPLACES the defaults rather than extending them, and matching
is on path components rather than prefixes — so ["scripts/vendor"] matches
nothing while silently dropping __pycache__, node_modules, build and the rest.
Verified: the submodule's source is not scanned, and the only vendored path in
the report is the system spec it reads for PR/SR orphan checking.

Coverage after the merges: 5/63, 0 orphans. Every tag names a real requirement,
and nothing claims a requirement that is still Planned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:16:15 +02:00
dtourolleandClaude Opus 5 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
2026-07-30 19:04:07 +02:00
dtourolleandClaude Opus 5 662a469870 feat: v1 content-derived audio signature
Implements the server spec §3 construction: a 120 s window centred on the media
midpoint, downmixed and resampled to mono 11025 Hz, a 4096/1024 Hann STFT, 32
log-spaced bands over 300-3000 Hz, one byte per frame carrying a 5-bit peak band
and a 2-bit energy class, base64 with a v1: prefix.

IR-004 — the signature itself, in src/audio_signature.{hpp,cpp}. Decode reuses
the already-linked FFmpeg libraries; libswresample was missing from ffmpeg_libs
and is added. The FFT is written out rather than taken from a library: the
output must be bit-identical against a separate C# implementation, so a
dependency whose version can change the numerics is a liability.

IR-005 — a golden fixture at tests/fixtures/audio/, verified against an
independent Python implementation producing identical bytes. FLAC rather than
WAV because 120 s of 11025 Hz PCM is 2.6 MB and does not compress in git; both
decode to identical samples. The PCM checksum is asserted separately from the
signature so a codec-level divergence is distinguishable from a DSP one.

IR-007 — media under 120 s emits no signature at all, since the centred window
underflows. The rule must be identical in both producers or signatures never
match on exactly the short items most likely to be misidentified.

IR-008 — the v1: prefix is emitted and honoured, so a future change to the DSP
chain is detectable rather than silently non-matching.

Six parameters the spec left undefined had to be pinned to reproduce a byte
stream at all: periodic Hann, band value as the mean of linear magnitudes, ties
to the lowest band, the energy-class definition and its thresholds, byte layout,
and the base64 alphabet. These are now normative in the server spec — left only
in a C++ header, the C# side would have guessed and diverged.

Not yet emitted into the truth file; that is the coordinated schema_version bump
under IR-002/IR-003.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

TRACES: IR-004, IR-005, IR-007, IR-008 | SR-003
2026-07-30 19:00:08 +02:00
dtourolle 28e3bd9496 docs: generated traceability matrix
Committed rather than ignored, matching house precedent: coverage becomes
visible to anyone browsing the repo, and its movement over time is real history
worth having in the log.
2026-07-30 18:59:06 +02:00
dtourolleandClaude Opus 5 d9aaf8fa4e build: consume the shared traceability tooling via submodule
jray-project is added at scripts/vendor/jray-project and the extractor is used
from there. Only two files are repo-local: traceability.toml, which carries
everything repo-specific, and the CI workflow that invokes the vendored gate.

The extractor is deliberately NOT copied in. One implementation, parameterised
by config — a second copy would drift from the first, and the tool already
proves it works unchanged against all three registers.

Enables system_spec so PR/SR orphan checking runs: previously uncheckable,
because the system spec lived outside every component's checkout.

Gate is green at 0.0% of 63 requirements, which is correct — nothing is tagged
yet. Eleven are flagged unverifiable on this CI host and excluded from the
numerator rather than counted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:58:40 +02:00
dtourolleandClaude Opus 5 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
2026-07-30 18:37:07 +02:00
Claude 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>
2026-07-30 18:35:46 +02:00