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
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
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
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
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
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
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
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
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
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
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
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>
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.