Commit Graph
111 Commits
Author SHA1 Message Date
dtourolle d113c83189 feat(optimizer): sweep expansion bands + presence mode; tolerate scattered dropped votes
Make the flood-fill and expansion knobs reachable from the DE sweep:

- kpn_bindings: read expand_band_lo/hi and presence_mode from the replay
  cfg dict (presence_mode accepts "flood"/"track_extent" or a numeric
  >=0.5 toggle), and carry is_scene_boundary onto the replayed frame.
- replay.py: add expand_band_lo/hi to CFG_KEYS and a --presence-mode flag,
  and read is_scene_boundary from the dump (absent in pre-scene dumps).
- optimize.py: map the continuous presence_flood knob (0..1, >=0.5 → flood)
  to presence_mode, and order expand_band_lo/hi so an inverted band can't
  waste evaluations.

Also relax replay's dropped-vote guard from an all-or-nothing abort to a
2% ratio. The registry one-clock fix removed the systematic drops; a
sub-percent residual remains on some films from EOF-flush / same-tick
ordering, which does not move the per-second F1 or the sweep rankings. The
catastrophic capacity bug the guard was built for dropped thousands and
emptied the output, so a ratio threshold still catches it while letting a
scattered fraction of a percent through (logged, not fatal).
2026-08-09 10:21:45 +02:00
dtourolle 584f23546a feat(presence): flood-fill presence mode
Add PresenceMode::flood alongside the default track_extent. In flood mode
the result sink snaps each presence claim to the shot it sits in, so an
actor seen once anywhere in a shot is reported for the whole shot
[prev_boundary, next_boundary]. This trades precision for recall against
X-Ray's per-scene cast granularity and is a toggleable knob for the
optimizer to weigh rather than a default.

Boundaries come from the frame stream, now carried through SceneAnnotation
(is_cut and is_scene_boundary). Flood prefers TransNetV2 shot boundaries
when a scene detector populated them, otherwise falls back to the
always-on histogram cuts (camera_position_change_detector); with no
boundaries it degrades to track_extent per claim. The is_scene_boundary
path stays dormant so an out-of-process scene detector can be revived
later without re-wiring.

Selected with --presence-mode flood|track_extent (default track_extent),
so existing output is byte-for-byte unchanged. The dump_embeddings header
note records why TransNetV2 scene detection is not run in that process.
2026-08-09 10:21:13 +02:00
dtourolle de02e25e6a fix(registry): one clock for association and reaping
FrameScope::candidates() filtered associable tracks on the tracker clock
(now_) while reap_locked() retired them on the matcher's evidence
watermark (evidence_through_). Because the tracker runs ahead of the
matcher (backpressure turns channel depth into lag, AR-004), a track the
registry had already reaped on the watermark could still be offered for
association on the tracker clock; the matcher's later vote then landed on
an erased id and was counted as a dropped vote, silently under-reporting
presence. Rare live (small lag), routine in replay where the Python
source drives the tracker far ahead of the matcher.

candidates() now filters on the same clock reaping uses
(awaits_evidence_ ? evidence_through_ : now_) with the same
track_extinction_sec horizon, so the offered pool and the live pool are
identical: nothing is offered past its reap horizon, nothing is reaped
while still offerable. This also cannot reintroduce the older zombie-merge
bug (offering on a looser horizon than the reap), because the horizon is
now identical rather than looser.

track_extinction_sec is the tracker's association window, so it must be
evaluated on the horizon the registry retires tracks on.
2026-08-09 10:19:22 +02:00
dtourolle 7b73bf923a fix(AR-025): only evidence spends the evidence budget
The correlation discount is an effective-sample correction: with observations
correlated at rho, the n-th is worth n_eff(n+1) - n_eff(n) = 2/((n+1)(n+2))
at rho=0.5, so it decays quadratically and the total converges to 1/rho = 2.
That saturation is deliberate and stays — a long static shot must not
out-argue varied evidence purely by lasting longer.

What was not deliberate is that every scored face spent it. An observation at
p=0.02 contributes log(0.98) = -0.02 of belief, which is nothing, while
consuming the same increment as one at p=0.95. On SuperHero-2, track 3 carried
103 observations for an effective weight of 2.026 and a belief of 0.455
against a 0.881 threshold — with the 51 frames that *did* identify the actor
arriving when each was worth 0.0002. The budget had been spent by the frames
that recognised nobody.

It also made the answer depend on frame rate: deliver more frames, dilute the
budget with more non-matches, and a track that was owned stops being owned.
That is the defect AR-013 already had to fix once for reaping, still present
in accumulation. It is how this was found — SuperHero-2 identified Jeremy
before a KPN throughput fix and not after, from identical input, and bisect
put the change at the KPN bump in d98dc28 rather than anywhere in this repo.

The floor is 0.5, which is where the posterior stops favouring the hypothesis,
not a tuned threshold. Near-misses still count: the identity matcher
deliberately feeds every scored face rather than only accepted ones, and an
observation at 0.7 — under the matcher's 0.754 acceptance — still accumulates,
which the second test pins. Below 0.5 the observation argues *against*, which
noisy-OR cannot represent, so declining to spend a budget on it loses nothing.

Scored against the DVU knowledge-graph ground truth, all ten SuperHero scenes:

                 TP   FP   FN   precision   recall     F1
  before          9    0   15       1.000    0.375   0.545
  after          13    0   14       1.000    0.481   0.650

Four more true positives and no false positives — the recall gain is not
bought with precision. Scene 7's Isabelle and scene 10's Isabelle and Jeremy
are all in the ground truth. Apples-to-apples over scenes 1-9 (scene 10's
before-run timed out): TP 9 to 11, FN 15 to 13, FP 0 either way.

An earlier attempt at this gave each actor its own budget. It was a no-op:
measured on the same track, n_obs_for[best] equalled n_obs at 103, because
every observation votes for the best-matching actor. Reverted rather than
kept.

151/151.

TRACES: AR-025, AR-013 | SR-002
2026-08-08 14:48:40 +02:00
dtourolle 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
2026-08-08 12:08:15 +02:00
dtourolle 0e27339ab6 chore: update KPN — unconditional push wake, and the start() lock it exposed
Two commits on top of c9aa246.

6802328 fixes a lost wake in the channel itself: push fired push_callback_
only on the empty->non-empty edge, computed from a head_ sampled before the
item was published, so a pop landing in that window left the item in the ring
with the consumer idle and no wake outstanding. Absorbing, because every later
push then sees a non-empty ring and the edge never fires again. Firing
unconditionally is correct by construction — the callback runs after the
publishing store — and the redundant wakes are cheap, since on_input_ready
re-checks the level and the submit gate collapses a wake arriving mid-firing.

771b9f8 is the gap that change exposed. abbb2d4 guarded ThreadPool::submit
against stop(), which clears queues_, but missed that start() rebuilds the
same vector under no lock. A submission genuinely lands there — a network
starts nodes one at a time, and an already-started node fires into the next
one's channel, whose callback submits — and push_back can reallocate under a
reader that has already indexed it. Unobserved before; 4 races in one run
after callbacks became frequent enough during startup.

Verified: KPN 148/148, this repo 149/149, ThreadSanitizer clean across five
unit runs and two stress runs.

Not re-measured on a clip. The GPU has a driver/library version mismatch
(kernel module 610.43.03 against NVML 610.57 — an update without a reboot),
so scene_analyze aborts in cuInit before it decodes anything. The SuperHero-1
measurement recorded in c599e07 — exit 0, outran() 0, scenes.json and presence
byte-identical — stands against c9aa246; both commits added since are
concurrency fixes with no effect on pipeline semantics, but a re-run is worth
doing once the driver is sorted.

TRACES: AR-004 | SR-002
2026-08-06 22:59:18 +02:00
dtourolle c599e07d4b chore: update KPN — a re-offered sentinel is not data loss
Picks up c9aa246, which the SuperHero run turned up. 139bfbb made the channel
refuse a sentinel offered while one was still pending — right, and it fixes a
real use-after-free — but it recorded the refusal as a drop and reported it as
an overflow.

frame_source emits EOF once and then returns it forever
(frame_source_node.hpp:76), so the token is re-offered on every firing. This
pipeline's own loss detector therefore fired on a clean run:

    [main] ERROR: frames were dropped (channel overflow):
      frame_source: 2
      scene_annotate: 1
    [main] The output would describe footage that was never analysed.
           Refusing to report success.

exit 2, with nothing dropped and every frame analysed. A loss detector that
cries loss on a clean run is worse than none, because the next real one gets
ignored.

Measured on SuperHero-1 at --fps 5 --scene-detect after the fix: exit 0, no
drops, and outran() 0 — the AR-004 join-depth derivation (59a2927) holds at 95
slots where it was pinned at 256.

TRACES: AR-004 | SR-002
2026-08-06 20:54:45 +02:00
dtourolle 4dcef8d6c5 fix(AR-004): the TransNetV2 window stores the model's input, not the frame
The rolling window held frames as decoded — `images_.push_back(f.image)` — and
left the downscale to the backend. TransNetV2's input is 48x27, so the buffer
held roughly 590 MB at 1080p to feed a model that needs about 380 KB. The
config note for `dense_scale` says as much outright: "TransNetV2 downsamples to
48x27 regardless".

This is not a channel capacity, so no amount of tuning channel depths would
ever have found it. It is a `std::deque<cv::Mat>` member, and it is the single
largest allocation in the scene branch.

It is also redundant work. Windows overlap by `kWindow - stride`, so a frame
appears in several of them and was re-downscaled once per window it appeared
in; now it is downscaled once, on arrival.

**The risk here is the invariant, not the memory.** Every model gets the input
it was trained for — a model run off-distribution returns confident, plausible,
wrong output, and for a boundary detector that means fabricated cuts, which are
indistinguishable from real ones in the output. So this reproduces the
backends' preprocessing exactly rather than doing its own: both
ort_backend.cpp and trt_backend.cpp guard mis-sized input with
`convertTo(CV_8UC3)` and then
`cv::resize(..., {kFrameW, kFrameH}, 0, 0, cv::INTER_AREA)`, in that order, and
`to_model_input` performs the same two operations. The backend guard then sees
a correctly-sized frame and does nothing, so the tensor the model receives is
unchanged. The interface has always specified this as the caller's job — "Each
frame must already be kFrameW x kFrameH, BGR, CV_8UC3" — so the node now meets
a contract it was already given.

The tests assert equivalence, not size. They perform the backend's own two
operations independently and compare byte for byte, on a gradient rather than a
flat fill, since INTER_AREA averages and a constant image would compare equal
under almost any resize. Order is pinned too: converting a 4-channel frame
after downscaling averages alpha into the colour channels and gives different
pixels.

Verified in both directions. With INTER_LINEAR substituted for INTER_AREA —
the most plausible way to get this subtly wrong — three assertions fail. With
the backend's own operations, byte-identical at 1920x1080, 640x360 and 720x480.
149/149.

Still unmeasured on real content, as with the previous commit: the equivalence
argument says the model sees the same tensor, but a run comparing scenes.json
before and after on a real clip is what would settle it, and I could not launch
one here.

TRACES: AR-004, AR-010 | SR-002
2026-08-06 20:34:16 +02:00
dtourolle 59a2927a15 fix(AR-004): derive the scene join depth instead of pinning it at 256
`kSceneJoinDepth` was a constant 256, used for two different channels. Both
uses were wrong, in different ways.

**It is a span of film, not a count of slots.** The join needs the sampled
branch to trail the dense one by however long TransNetV2 takes to be able to
answer: its input queue (128) plus the window it must fill (100), over native
frame rate — about 9.5 s at 24 fps, which is the conservative floor since a
slower source makes the same frame count span more film. The slots needed to
hold that depend on `sample_fps`, so the constant meant ~256 s of lag at 1 fps
— 27x what the join needs — and nothing recomputed it when `sample_fps`
changed. The one number AR-010's correctness rests on drifted with an
unrelated knob. It is now derived, giving a constant 19 s of lag at any sample
rate: 19 slots at 1 fps, 95 at 5.

**The decimator's buffer was on the wrong side of the decimator.** Its *input*
carries the full-rate stream, so 256 slots held ~256 full decoded frames of
which, at 1 fps against 24 fps native, 23 in every 24 existed only to be
discarded by the predicate a moment later. Holding ~1.5 GB of images for
frames the very next node throws away is the worst available use of the
budget. A filter is a pass-through, not a reservoir: the lag belongs after
decimation, where a slot buys 1/sample_fps seconds instead of 1/native_fps.
Its input is now sized only to keep it fed.

Together, at 1080p and 1 fps, those two channels go from ~3.0 GB to ~206 MB
while the join keeps 2x margin over its requirement. Every message embeds
`Frame source`, so a slot on either branch holds a full decoded image — which
is visible now that the byte counter is honest (5e46f52), and was not before.

**Not verified against a running pipeline.** The check that matters is
`SceneBoundaries::outran()` — sampled frames that arrived before the detector
had scored them, which must stay 0 — and that needs a real clip through
`--scene-detect`. I could not launch it here. The arithmetic is stated above
so it can be checked by inspection, and the margin is deliberately 2x rather
than tight, but a run should confirm outran() is still 0 before this is
trusted on real content.

TRACES: AR-004, AR-010 | SR-002
2026-08-06 19:44:44 +02:00
dtourolle 5e46f52ad2 fix(AR-004): make the channel byte counter measure the payload
`kpn::ChannelDataSize<T>` is what a channel reports as bytes pushed, and its
primary template returns `sizeof(T)`. It was never specialised in this repo —
only in a KPN example — so every message type reported its header size. Each
of them is a few vectors and a `cv::Mat` header owning megabytes on the heap,
so a message carrying a full decoded frame was reported at roughly 200 bytes
against 5.9 MB at 1080p. Four orders of magnitude.

That is not a cosmetic stat. It is the one instrument for choosing channel
capacities against a memory ceiling — the open half of AR-004 — and anyone
who read the MB/s column to size a channel was reading fiction. The gap could
not be measured with the tool that exists to measure it.

Every message embeds `Frame source`, so this is not confined to the
crop-carrying channels: the full decoded image rides the whole chain, and the
byte figure now says so.

Two things worth stating about what the number means. `cv::Mat` is
reference-counted, so one frame referenced from several messages is counted
once per reference — an upper bound on distinct bytes, and the right bound for
"what would this channel keep alive if nothing else held it", which is the
question a capacity answers. And an eof sentinel carries no image, so it costs
only its header and is not charged for one.

Declared against a forward declaration of the primary template rather than by
including <kpn/channel.hpp>, so the message definitions keep no dependency on
the framework carrying them, and any translation unit that can see these types
also sees their sizes — which is what stops one channel being instantiated
with the default while another gets the specialisation.

Verified in both directions: with the specialisations removed, three of the
five cases fail, `SceneAnnotation` reporting 40 bytes against the 37,632 its
single 112x112 crop occupies. 145/145 with them.

No behaviour change — this only corrects what is reported. Choosing capacities
against the corrected numbers is the next commit.

TRACES: AR-004 | SR-002
2026-08-05 20:17:03 +02:00
dtourolle 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
2026-08-05 19:47:06 +02:00
dtourolle 48332d2041 feat(replay): the whole replay chain is C++, including the sink
The sae_kpn module has not compiled since the AR-007/AR-008 tracker redesign,
and was switched off at the build rather than patched because the fix is a
restructuring. Two failures, one cause.

It did not compile: `add_face_tracker` built FaceTrackerFunc from a Config
alone, and the tracker has required a TrackRegistry and a calibration since
association moved into probability space.

And presence was rebuilt in Python. `replay.py::build_minimal` merged
per-frame detections into windows by annealing gaps, which is what the
pipeline did before AR-012. The sink builds a window from a TrackRegistry
claim instead — the extent of a track an actor owned, starting when they
appeared rather than when recognition first succeeded. Those answer different
questions, so every sweep was tuning against a contract the shipped code had
stopped honouring.

Both follow from the seam being a factory per node. The chain has a
construction order — the matcher fits the calibration, the registry needs a
discounter built from it, the tracker needs both, and the sink needs the
registry's claims — and independent factories cannot express it, so the
tracker kept being built against a signature that no longer existed. One
`add_pipeline` mirrors main.cpp exactly and is now the only way to build the
chain, so the ordering cannot be got wrong again from Python. DP-001 is the
requirement behind it: a replay harness is a front-end, and its job is to
supply frames and read the result, not to re-derive presence.

Lifetimes needed a home. ResultSinkFunc holds `const Config&` and
`std::atomic<bool>&`, which under main() are locals in a frame outliving the
pipeline; there is no such frame when the network is built and torn down from
Python. ReplaySession owns both for the network's lifetime, keyed by network
and released explicitly — a sweep builds one network per replay and the sink
retains every annotation, so holding them forever would grow with films x
configs. Getting this wrong presented as an empty output_path: the sink
announced `[result_sink] writing ` and wrote nothing.

test_sae_kpn.py is ported rather than left behind. It called all three removed
factories and asserted on SceneAnnotations read back per frame; neither half
survives, so it now waits on pipeline_done and asserts on the file the sink
writes. Verified against gallery_lvface.h5: three frames through the real
chain, timestamps 0/1/2, truth file written. EOF is a control token the sink
flushes on and does not record, so three inputs give three frames, never four.

SAE_BUILD_KPN_BINDINGS goes back to ON.

TRACES: VR-011, VR-002 | DP-001 | PR-002
2026-08-05 19:40:25 +02:00
dtourolle 1141172b04 chore: update KPN to the wedging-audit fixes
Twenty commits, of which the one that matters for this repo is the filter
and router losslessness. FilterNode and RouterNode were the last nodes on a
data path still using the throwing push() and swallowing the exception, so
`decimate` — which passes EOF by predicate — discarded that token whenever
its output was full. Which it reliably is: the embedder is the slowest node
in the chain. Nothing downstream ever received EOF, `done` was never set,
and the run had to be killed. That is the wedge.

The rest, in rough order of how much they affect a run here:

- A sentinel could be delivered ahead of a value still queued behind it,
  losing that value to any consumer treating EOF as a hard stop.
- Two fire_once invocations for one node could overlap, which breaks the
  one-slot park: a parked value can be overwritten with no drop recorded.
- A node's push/space callbacks were written while a running neighbour read
  them — ten ThreadSanitizer races, and the root cause of the startup lost
  wake that AR-004 records as closed by a level-triggered re-check.
- shutdown() polled every channel in the graph with no deadline, and could
  fail to terminate outright on an index underflow in the fill calculation.
- Submitting to a stopped pool indexed a cleared vector: a segfault, which
  reproduced 12 runs in 20 once sources stopped before their consumers.
- stop() returned while a firing was still touching the node's members.
- Idle pool workers burned ~6.5 cores while one task ran (1991 ms of CPU
  against 0.4 ms). Latent here, since every node owns a private one-thread
  pool, but not for anything using a shared one.

Two consequences worth holding onto. `decimate` is now a backpressure point
rather than a relief valve, so the source throttles to the face branch
instead of quietly thinning it — the intended behaviour, but it changes the
shape of a loaded run and has not been benchmarked. And a channel now
carries at most one undelivered sentinel; a second is refused and reported
rather than silently overwriting the first.

Verified: KPN 148/148 with ThreadSanitizer clean across six runs, this repo
138/138 with every target building warning-free against the new headers.

TRACES: AR-004 | SR-002
2026-08-05 18:26:11 +02:00
dtourolle 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
2026-08-05 17:51:01 +02:00
dtourolle 06373817a2 refactor(config): give the tuned constants a real provenance, and make them reachable
Two problems, both of which made a number look more settled than it is.

The provenance was a dead link. config.hpp cited
docs/rep4-optimizer-results.md for prob_threshold, extinction_sec,
anneal_sec and the expansion default. That file was renamed to
model-bakeoff.md and then rewritten; the comments were never repointed,
so the most consequential constant in the pipeline appeared to have no
source at all.

Following it up produced something worse than a broken link.
prob_threshold=0.754 comes from the ORIGINAL rep4 document (still
readable at `git show d340da7:docs/rep4-optimizer-results.md`). The
rewrite that replaced it reports finding "a real scoring bug in
optimize.py: a candidate whose hardest film's replay timed out was
averaged over survivors instead of penalized, silently rewarding partial
coverage. Affected 3 of 16 training combos". So 0.754 was fitted under
scoring that was later found wrong, the corrected sweep converged
elsewhere, and no corrected prob_threshold is recorded anywhere. The
comment now says that, along with the surviving document's own verdict
that the optimum "generalizes unevenly -- strong on 3 of 5 held-out
films, badly broken on 2".

Four constants were unreachable. ownership_logodds lived on
TrackRegistry::Config, and max_views/admit_below/rho_max on
EvidenceDiscounter::Config, which main built with the one-argument
constructor -- so nothing short of a recompile could move any of them.
rho_max's own comment defers to "the sweep (VR-007)" for where it
belongs, and that sweep could not reach it.

They now live in Config with CLI flags and are exposed to the replay
harness. ownership_logodds is worth singling out: below it a track makes
no presence claim at all, so it decides whether an actor is reported
rather than how confidently -- arguably the most consequential constant
after prob_threshold, and until now unswept and unsettable.

No behaviour change: every default is the value that was compiled in.

TRACES: AR-025, AR-017 | SR-002
2026-08-05 17:43:51 +02:00
dtourolle 88c42573a5 fix(pipeline): finish three changes that had only been half applied
Each of these was recorded as done and was done in one place out of two.

AR-011 -- the TransNetV2 dedup window. The derived window
(dedup_window_sec, median observed interval halved) reached scenes.json
and nothing else. SceneBoundaries, the path that actually feeds
is_scene_boundary to the tracker, kept the literal 0.04 s under a
comment claiming it "matches the dedup scenes.json applies, so the two
views agree". They did not agree. 0.04 is one frame at 25 fps and wider
than a frame at 30, so two cuts on consecutive frames merged into one
and the loss was invisible: the pipeline simply saw fewer boundaries.
The detector now supplies the window it derived.

AR-019 -- ownership. The register says ownership "comes from the
registry, not a second local tally". Both existed: promotion fired on a
local accepted-frame count and fell back to a local per-actor plurality
when the registry had not yet claimed the track. That fallback was
reachable in the live pipeline, not just in tests -- three accepted
frames arrive well before a posterior crosses the ownership threshold --
so in practice the plurality usually decided, and it could not see the
AR-025 correlation discounting it was meant to defer to. The tally is
gone; promotion now requires the registry's verdict, with the accepted
-frame count demoted to an explicit evidence floor.

AR-017 -- the route. DeadTrack carried belief but no route, and the sink
wrote the literal string "live", so a field the schema publishes could
not distinguish anything. AR-017's own verification asks for "deferred
and pooled routes distinguishable". Route is now an enum on the claim.
Only `live` occurs today; `deferred` exists so AR-020's pass has
somewhere to write instead of a serialisation change to make.

Also: TrackGallery::forget had no callers, under a comment asserting the
matcher called it "on a cut or track disappearance". The cut half was
true by another route; the disappearance half was not, so a track that
died quietly kept its diversity buffer until the next cut cleared
everything. Replaced with prune_dead against the registry's own
liveness, the same shape as the tracker's prune_boxes -- a second
opinion about which tracks exist is a second thing that can be wrong.

Removes dead logistic/logit helpers and fixes five TRACES tags that used
a comma where a pipe separates requirement types, which the gate had
been reporting as diagnostics.

TRACES: AR-011, AR-017, AR-019 | IR-002 | SR-002, SR-005
2026-08-05 17:33:12 +02:00
dtourolle 7c7d4934ae refactor(presence): execute the extinction_sec/anneal_sec withdrawal
docs/SPEC.md specified this removal, listed its parts, and ended "grep
for both names and expect no survivors". There were about forty.
docs/requirements.md meanwhile recorded both constants as Withdrawn and
"deleted rather than retained at zero", on the grounds that a field
naming a mechanism the pipeline no longer has is actively misleading.
Neither statement was true of the code: Config still carried
extinction_sec 57.4 and anneal_sec 35.5, --extinction and --anneal still
parsed, and SceneTrackerFunc still ran its keep-alive in both shipped
pipelines, announcing its timeout at every startup.

SceneTrackerFunc is replaced by FrameAnnotationFunc, which is stateless:
same ports, same output type, no keep-alive. Presence belongs to
TrackRegistry (AR-012), where a window is the extent of a track an actor
owned and ends at the last sighting (AR-013). The keep-alive answered
that question a second time and answered it worse, by re-opening exactly
the trailing cool-down AR-013 refuses.

Visible change: --verbosity standard's frames[].identified listed every
actor inside the keep-alive, including ones absent from the frame. It
now lists what was matched in that frame. Minimal and xray output is
untouched -- both were already built from registry claims and never
consulted this node. No schema bump: the published extraction block
reports track_extinction_sec, a different knob that bounds
re-association and never extends a claim.

TrackRegistry::Config::extinction_sec is renamed track_extinction_sec to
match the Config field feeding it, so the grep SPEC.md asks for now
returns nothing rather than one confusing false positive.

Two targets turned out to have been silently dead, both since the
AR-007/AR-008 tracker redesign, and both for the same reason -- they
construct FaceTrackerFunc from a Config alone, a signature that stopped
existing when association moved into probability space:

- scene_preview is fixed here. It now mirrors main.cpp's construction
  order exactly (matcher, then registry, then tracker) and wires the
  registry's claims into the sink, which it was not doing. DP-001 says
  modes are front-ends that must not fork pipeline logic; this one had
  forked it and then rotted.
- sae_kpn is not fixed. Restructuring the seam so the tracker can reach
  a calibration that only exists once the matcher is built is VR-011's
  rewrite, not a patch, and presence claims do not cross the seam at all
  today. It is now behind SAE_BUILD_KPN_BINDINGS=OFF with the reason
  recorded, so `cmake --build` succeeds and the breakage is attributed
  rather than rediscovered.

That second one is worth stating plainly: VR-002 ("replay drives the
real KPN nodes, not a reimplementation") is marked Done, and the module
that makes replay possible has not compiled for some time. The .so in a
stale build/ predates the change.

Python side: the two names are gone from optimize.py, replay.py and
run_holdout_all_models.py as Config keys. anneal_sec survives as
REPLAY_LOCAL_KEYS -- it still configures replay.py's own windowing,
which is a Python reimplementation that no longer matches the sink and
is documented as such. That divergence is VR-011's.

TRACES: AR-012, AR-013 | DP-001 | SR-002
2026-08-05 16:21:15 +02:00
dtourolle e1de98e783 feat(ar-024): enforce the invariant statically, and delete the fallback it caught
AR-024's register row gives its verification tier as "Static check -- no
bare cosine outside a tagged EXCEPTION". No such check existed, so the
invariant was enforced by reading, and reading had missed a live
violation.

scripts/ci/check_raw_cosine.py is that check, wired into the
traceability workflow as a blocking step. It is honest about its reach:
it catches direct cosine_similarity() uses not routed through a
calibration, and it cannot follow a cosine through a variable across
statements. That limit is documented in the script rather than left for
someone to discover after trusting a pass.

What it caught, and what this commit removes with it:

The identity matcher's no-calibration fallback thresholded raw cosine
distance (match_threshold) plus a ratio test (match_ratio,
match_ratio_ceil). Worse than the invariant breach: it fed
max(0, cosine) into TrackRegistry::observe, whose contract reads
"posterior is a calibrated probability, never a raw cosine (AR-024) ...
so the accumulation cannot be fed an uncalibrated number by a careless
caller". It could, and did. And it disagreed with the rest of the
pipeline about what "the fit failed" means -- same_person_probability
answers that with the untuned default sigmoid and a loud warning, so
association stayed in probability space while matching alone left it.
One run, two policies, no announcement.

Now one rule: cal_.probability() always, with a warning when the fit is
not real. A worse answer than a fitted calibration, a better one than a
number whose units nothing else shares.

TrackGallery::set_calibration is mandatory for the same reason. Its
default was max(0, cosine), which made expand_band_lo = 0.90 mean
"cosine > 0.9" in a test and "P(same person) > 0.9" in production.
FaceTrackerFunc already threw without one; the expansion store now
matches.

One exception is recorded, in the calibration's own dedup. It is not a
close call: at 1 - 1e-7 it asks whether two vectors are the same vector,
and it runs on the fit's input, so a calibrated comparison there would
have to be calibrated by the fit it is feeding.

Also drops seven dead keys from the optimizer's CFG_KEYS. Config keys
are read with a contains() check, so each one had been silently inert
since the field behind it was deleted -- a sweep varying one of them
measured nothing and reported an ordinary-looking F1.

TRACES: AR-024, AR-023 | SR-002
2026-08-05 15:46:33 +02:00
dtourolle fd078c399f ci(tests): actually run the tier the verification strategy is built on
docs/requirements.md describes four verification tiers and argues that
T1 (functor unit) and T2 (replay) are "the only tiers that can exist in
CI at all". The traceability gate then reports a CI-scope coverage
fraction over exactly those tiers. Nothing ran them: the only workflow
was the gate itself, which reads source comments, and SAE_BUILD_TESTS
defaults to OFF. "Covered" meant a TRACES tag existed in a file.

That is the same failure the gate's own config warns about one level up
-- counting a test that cannot run -- and the gate cannot see it,
because a tag is all a static reader has.

Runs in the pinned DP-007 CPU builder image, which the image script
already expected this workflow to exist (it names unit-tests.yml and
asserts its tag). Nothing here calls a model: T1 constructs node
functors directly and T2 replays a precomputed dump, so the GPU-free
N100 runner is sufficient by construction rather than by concession.

Two deliberate hard failures. A missing replay fixture fails the job
instead of skipping, because pull_artifacts.sh warns-and-continues and a
T2 test whose input never arrived must not look like a pass. And the
image reporting a tag other than the pinned one fails rather than
building against an unknown toolchain.

It found a real bug on its first run: see the preceding commit. ctest
runs each case in its own process, which turned a 1-in-4 heap corruption
from noise in the aggregate binary into a reproducible failure.

TRACES: DP-007 | PR-004
2026-08-05 15:17:17 +02:00
dtourolle bedc859d3c fix(audio): zero-initialise the channel layouts before the resampler copy
av_channel_layout_copy() documents that it always uninitialises the
destination first, and av_channel_layout_uninit() calls av_freep() on
u.map. Declaring the layouts without {} therefore handed free() whatever
pointer-shaped garbage occupied that stack slot.

Not theoretical: UT-103 aborted with "free(): invalid pointer" in about
1 run in 4. Zero failures in 40 runs after the fix, against 10 in 40
before it.

Two things kept it hidden, both worth remembering:

- It is stack-dependent, so it disappears under an AddressSanitizer
  build and reads as a flake in the aggregate test binary, where the
  case usually passes. ctest, which runs each case in its own process,
  is what made it a reproducible failure rather than noise.
- UT-103 is the only test that reaches this branch, because it is the
  only one whose input is stereo. The golden-vector tests use a mono
  11025 Hz fixture chosen so the vector cannot depend on libswresample
  — which is right, and means bit-exactness against the golden vector
  is not evidence about the downmix path.

TRACES: IR-004 | SR-003
2026-08-05 15:16:46 +02:00
dtourolle 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
2026-08-05 14:38:42 +02:00
dtourolle a5ee3c05ce feat(benchmark): per-node cost and bottleneck attribution for a run
--benchmark <path> reports cumulative CPU and wall time per node and
names the node pacing the run. The pacing node is located from sampled
channel occupancy, not from time-in-node: backpressure inflates
time-in-node for everything downstream of the real bottleneck, so the
obvious measure names the victim rather than the cause.

Sampling starts with the network and stops before it is destroyed.
Channel fill is instantaneous and everything has drained by shutdown, so
a single read at the end reports an idle pipeline however congested it
was.

kill -USR1 dumps the table from a running or wedged process. Channel
occupancy identifies a stalled node -- full input, empty output --
without a debug build or a debugger, which is the difference between
diagnosing the AR-004 hang in seconds and reproducing it under gdb.

Two knobs this exposes for measurement rather than sets: SAE_CV_THREADS,
because OpenCV's TBB arena and KPN's thread-per-node are two schedulers
unaware of each other on the same cores; and SAE_CUDA_BLOCKING_SYNC,
because the default spin-wait held the embedder thread at 99.7% user
time while nvidia-powerd cut the GPU's clock from 1005 to 210 MHz.
Neither default changes until a measurement says it should.

TRACES: VR-015 | PR-004
2026-08-05 14:38:15 +02:00
dtourolle 777c98cb33 feat(quality): score every face on sharpness and alignment before it is evidence
Every embedding now carries the quality of the input it came from. Both
axes fall out of the AR-005 warp for free: crop_sharpness() is the
normalised Laplacian variance over the aligned 112x112, so contrast and
size cannot leak into it, and the alignment residual is the part of the
landmark deformation a similarity transform cannot explain, so in-plane
roll reads as zero and foreshortening does not.

Carried, not consumed. Nothing discounts or thresholds on either number
yet -- that is AR-030 and VR-012, and the knee has to be located against
recorded data before a gate is chosen. What this change buys is that the
data exists to locate it with.

No face is admitted unscored: the -1 sentinel is preserved rather than
clamped, and a degenerate landmark fit is counted rather than silently
dropped.

Takes the VR-001 dump to schema_version 2. The bump is not for readers,
which check for the datasets by name and replay a v1 dump unchanged; it
is so a consumer can tell "never scored" from "scored zero", which is
not recoverable from the arrays afterwards.

TRACES: AR-028, AR-029, AR-030 | VR-001 | SR-002
2026-08-05 14:37:30 +02:00
dtourolleandClaude Opus 5 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
2026-08-04 21:20:31 +02:00
dtourolleandClaude Opus 5 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
2026-08-04 21:17:57 +02:00
dtourolle 079b490ede Merge branch 'feature/ci-images' into feature/opencv5 2026-08-04 14:53:24 +02:00
dtourolle a3827646b9 feat(ci): CPU builder image for the unit-test workflow
TRACES: DP-007 | PR-004

Pinned by tag rather than :latest, so a workflow run is reproducible against
the image it was written for.
2026-08-04 14:53:12 +02:00
dtourolle 22758da118 docs: SuperHero benchmark — how to reproduce it and what it scores
Records the reference film end to end: fetching the annotations and mugshots,
fusing the scene clips into one stream, building the gallery at the 66 px face
floor, and the measured result (precision 1.00, recall 0.65, F1 0.79).

Three things are written down because each cost time to discover:

- Why Bali was withdrawn. Its reference crops have a median detected face of
  27 px against a 69 px maximum, so every reference was upscaled past what the
  embedder was trained for (AR-011). No threshold fixed it — at 66 px, 2 of 69
  references survived. Any accuracy figure recorded against Bali measures
  upscaling artifacts as much as the pipeline.
- Run it as one film. Per-scene clips defeat per-film gallery expansion
  (AR-019) and pay model load ten times over.
- Check you are on the GPU. ORT's CUDA provider fails to load here and falls
  back to CPU silently, so a build-ort timing is a CPU number wearing a GPU
  label — a 15x error whose only symptom is a number with no baseline.

TRACES: AR-011, AR-019 | VR-001, VR-005 | SR-002
2026-08-04 14:32:33 +02:00
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