Files
scene-actor-extraction/docs/requirements.md
T
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

38 KiB
Raw Blame History

scene-actor-extraction — requirements register

Stable IDs for every requirement in SPEC.md, which holds the prose. This file is the authoritative list; the CI gate reads its denominators from here (see ../../SPEC.md §6).

IDs are permanent. A withdrawn requirement is marked Withdrawn and its number is never reused — renumbering is what produces orphan TRACES tags. This register replaces the earlier thematic A1…E8 scheme, which had already produced an A1a and an out-of-order E6.

Tag code with // TRACES: AR-012 | SR-002.

Type Scope
AR Algorithm — the extraction pipeline itself
DP Deployment — how it runs
IR Integration — contracts with other components
GR Gallery — building and maintaining actor references
VR Validation — parameter studies and benchmarks
UT / IT Unit / integration tests

Status: Done · In Progress · Planned · TBD · Withdrawn


Algorithm (AR)

ID Requirement Traces to Priority Status
AR-001 Detect faces in sampled frames; emit bbox, confidence, 5-point landmarks in original pixel space SR-002 High Done
AR-002 Minimum face size 40×40 px (VR-013 measured end to end; VR-005's 32 px is an embedder-only upper bound), expressed in original resolution (decoupled from dense_scale) SR-002 High DoneFaceDetectorFunc::drop_undersized(). The threshold is divided by bbox_upscale rather than every box multiplied, which keeps the comparison on the detector's own numbers and means turning dense_scale on cannot silently raise the minimum face the pipeline accepts. Verified at the threshold and at dense_scale 0.5 (UT-002), and end to end on the fixture (IT-001) — the superhero dump's smallest side is exactly its recorded 32 px, so the filter is binding there rather than vacuously satisfied
AR-003 No fixed per-frame face cap — crowd scenes must not lose background cast SR-002 Medium Donemax_faces defaults to 0 (no cap); the matcher batches through its GEMM buffer instead of throwing
AR-004 Backpressure: unbounded faces/frame absorbed by slowing, never by dropping or throwing SR-002 High Mostly — node outputs park on a full channel: the value is held, the worker released, and a channel space-callback resumes the node. Replaces push_blocking, which parked a scheduler worker inside the push and, with one thread per node, stopped that node draining its own input. Verified: 385/385 frames, 0 drops. Two remaining holes now closed: (a) FanoutNode dropped on overflow rather than waiting, so the AR-010 scene join shed frames exactly when the dense branch fell behind — measured at 9 of 2192 items delivered to the slower of two branches, now lossless with the fast branch throttled to within its buffering; (b) the residual hang, recorded as ~1 run in 20 at a 300 s timeout, was a startup lost wake, not a mid-stream one — start() enables a node's inputs several statements before it installs the push callback, and a producer firing into that gap is accepted by the ring while waking nobody, since Channel::push signals only the empty→non-empty edge. Signature is zero items delivered, never a partial stall. Reproduced 7 times in 24 under CPU contention and 0 in 10 without; start() now closes with the level-triggered on_input_ready(), giving 0 in 24 on the same harness. Consequence to hold onto: a lossless fanout makes join depth a correctness precondition — one branch can now run ahead of another only by the slower branch's buffering, so kSceneJoinDepth must exceed the TransNetV2 window. Gap: capacity is still counted in items, not bytes, so a crowd frame carrying 60 crops occupies one slot exactly as an empty one does — the memory ceiling the plan asks for is unenforced
AR-005 Align to 112×112 via ArcFace 5-point similarity transform, fitted by Umeyama least squares over all five points (as InsightFace does) — never a robust fit, which would discard the landmarks AR-030 reads SR-002 High Doneumeyama_similarity(). The RANSAC fit it replaces disagreed by a median 17 source px on 400 headshots, 83.5% of crops embedding below cos 0.99, and was unstable and RNG-driven: rebuilding caught 1614 near-duplicates against the original build's ~100. All galleries rebuilt (2456 actors, 10254 embeddings); measured separation gain is small (0.583 → 0.590), so recorded accuracy figures should be re-run but are not expected to move far
AR-006 512-d L2-normalised embeddings, batched SR-002 High Done
AR-007 Associate detections by IoU + embedding, with frame-dependent weighting SR-002 High Donetrack_alpha is the base for ordinary frames; drops to embedding-only on cut/boundary and for dormant tracks
AR-008 One track pool keyed on last_seen; no separate revival path SR-002 High Done — one pool keyed on last_seen; park/revive branch deleted
AR-009 Camera-cut detection (histogram) as an association hint SR-002 High Done
AR-010 Scene-boundary detection (TransNetV2) as an association hint SR-002 Medium Done — decode butterfly joined via SceneBoundaries; the sampled branch waits for the detector's watermark. Frames past its last scored window are counted as unverified, never assumed boundary-free
AR-011 Every model is fed the input it was trained for — cost reduced by running less often, never by degrading one inference SR-002 High Done — both violations SPEC.md named are closed. (1) scene_decode_fps defaults to 0 (native): at 12 fps a 100-frame kWindow spanned ~8.3 s instead of the ~4 s TransNetV2 was trained on, half-speed motion over twice its temporal context. (2) The boundary dedup window is derived from the cadence the detector was actually fed (SceneDetectorFunc::dedup_window_sec(), median observed interval, halved) rather than the literal 0.04 s — one frame at 25 fps, and at 30 fps wider than a frame, so two cuts on consecutive frames merged into one and the loss was invisible: the file simply had fewer boundaries. Derivation checked at 24/25/30 fps and under a seek (UT-003). Consequence, not a gap: scene_threshold 0.60 was fitted against the 12 fps input and is now certainly wrong — VR-006 re-fits it, and until then boundary recall at native rate is untuned rather than better. Dense decode is the cost driver, so this is not free; dense_scale and scene_stride remain the reductions that do not run the model off-distribution
AR-012 Presence follows track extent, not per-frame recognition SR-002 High Donesrc/track_registry.hpp; window is [first_seen, last_seen] of an owned track
AR-013 last_seen optional state machine; window ends at last sighting, never after SR-002 High Donelast_seen optional is the whole state machine; interior gaps absorbed, trailing cool-down never claimed
AR-014 Belief swap A→B terminates the track and starts a new one SR-002 Medium Done — swap closes at last_seen and opens a successor at the swap frame; counted
AR-015 Two live tracks owned by one actor ⇒ treat as a detected cut, re-associate SR-002 Medium Done — reverse index detects it on the causing update; counted
AR-016 All tracks closed at EOF — a film ends with faces on screen SR-002 High Doneflush(), idempotent, closes at last sighting or final tick
AR-017 Every presence claim carries its belief and identification route SR-002 High DoneDeadTrack carries belief and observation count
AR-018 Per-subject embedding store with banded admission (novel enough, safe enough) SR-005 Medium Done — banded admission in probability space, bounds from expand_band_lo/hi; the lower bound re-asked pairwise at promotion, since admit compares only against the nearest member and a drifting track can chain past it. Retires expand_novelty_sim and expand_track_spread_max — novelty is now the eviction ordering, not a threshold. Rejections counted. Bounds unswept (VR-007)
AR-019 Per-film gallery annex from owned tracks; acquires the non-frontal views TMDB lacks SR-005 Medium Done — all three discontinuity signals clear the buffers; ownership comes from the registry, not a second local tally
AR-020 Deferred re-identification of unknown tracks against the final expanded gallery SR-005 High Planned
AR-021 Cluster unknown tracks into one entity per person, under temporal cannot-link constraints SR-005 Medium Planned
AR-022 Capture still-unidentified tracks: embeddings, metadata, context crops for human review §4 Medium Planned
AR-023 Fit sigmoid calibration from intra/inter similarity distributions SR-002 High Done
AR-024 Always the calibrated probability, never a raw cosine — exceptions recorded SR-002 High Done — association, accumulation and expansion all in probability space; track_max_embed_dist, cut_revive_sim, expand_novelty_sim, expand_track_spread_max retired
AR-025 Per-track Bayesian accumulation in log-odds, with correlated-observation discounting SR-002 High Done — log-odds accumulation with correlation discounting owned by the registry, src/evidence_discount.hpp
AR-026 All similarity computed as GEMM, including annex and deferred pass SR-001 High In Progress — two of the three call sites done. Baked gallery was already GEMM; the annex now is too — it is a contiguous row-major matrix (track_gallery.hpp) whose promoted rows are appended to the engine's resident matrix (ISimilarityEngine::append_rows), so one multiply covers baked and promoted references and the host-side cosine loop is gone. CPU path requires OpenBLAS (scalar fallback now opt-in behind SAE_ALLOW_SCALAR_GEMM). Remaining: the deferred pass, which does not exist until AR-020
AR-027 Throughput acceptable for arbitrary gallery size SR-001 High Planned
AR-028 Embedding input quality assessed and carried — every face scored on size, sharpness and visibility before its embedding is used as identity evidence; the vector travels with the face and reaches the VR-001 dump SR-002 High Done — filled in by FaceAlignerFunc, where both measured axes come free from the warp; carried on DetectedFace and written to the dump as faces/sharpness + faces/alignment_residual, taking it to schema_version 2. Size is bbox, not duplicated into a field that would drift. No face is admitted unscored (-1 sentinel), and the degenerate-fit case is now counted and reported rather than silently dropped. Carried, not consumed — no discount and no threshold, which is AR-030 and VR-012. Verified UT-137, UT-138 (aligner) and UT-139…UT-141 (dump round-trip, version, sentinel). The committed fixtures are still v1, so they carry no vector until make_fixtures.sh is re-run on a GPU host
AR-029 Sharpness measure on the aligned crop (scale-normalised, so it cannot re-measure size) SR-002 Medium Donecrop_sharpness(): variance of the Laplacian over variance of the crop, so contrast cannot leak in the way it does for the raw textbook measure. Both blur ladders monotone, Gaussian and motion. Three properties recorded on the function for VR-012 rather than corrected here: the contrast invariance is exact in the algebra but bends at the 8-bit quantisation floor (a dim and soft crop reads sharper than it is — 148% high at σ 2.5), BORDER_CONSTANT fill from a frame-edge face adds a step edge, and the measure conflates focus with intrinsic texture. Verified UT-130…UT-136
AR-030 Visibility measure from the AR-001 5-point landmarks — extreme pose or occlusion discounts the observation, never deletes the detection SR-002 Medium In Progress — measure is the AR-005 alignment residual (estimate_alignment()), carried on DetectedFace; roll/scale invariance and monotonicity under foreshortening asserted. Nothing consumes it as a discount yet

Deployment (DP)

ID Requirement Traces to Priority Status
DP-001 One analysis core; modes are front-ends and must not fork pipeline logic PR-004 High Done
DP-002 Batch CLI over one title PR-004 High Done
DP-003 On-demand resident service with bounded, observable queue PR-004 Medium Planned
DP-004 Opportunistic/idle mode: external trigger, hard stop, implicit re-queue PR-004 Medium Planned
DP-005 Native installer, no Docker; Fedora + Arch PR-004 Medium Planned
DP-006 Background incremental gallery refresh on a timer PR-003 Medium Planned
DP-007 CI builder image, CPU-only, pinned by tag in the Gitea container registry PR-004 High Planned
DP-008 Builder images + release jobs per backend (cpu / cuda / rocm); ship binaries, not engines PR-004 Medium Planned

Integration (IR)

ID Requirement Traces to Priority Status
IR-001 Emit the JRay truth format as sibling .jray.json SR-003 High Done
IR-002 Windows carry belief + route; extraction.* carries extinction_sec, gallery_scope SR-003 High Doneschema_version: 2; windows are objects with belief + route; extraction.* carries extinction_sec and gallery_scope; anneal_sec removed
IR-003 Output written after the deferred pass, not at EOF SR-003 High In Progress — sink builds windows from registry claims and flushes at EOF; the deferred pass (AR-020) does not exist yet, so output is still final at EOF
IR-004 Compute the audio signature exactly per server spec §3 SR-003 Medium Donesrc/audio_signature.*; not yet emitted into the truth file (IR-002)
IR-005 Golden-vector fixture shared with the plugin repo to prove bit-exactness SR-003 High Donetests/fixtures/audio/; v1 parameters now normative in server spec §3
IR-007 Media < 120 s: emit no signature, apply no sync offset — identical rule in both producers SR-003 Low Done
IR-008 Emit and honour the signature's own v1: version prefix SR-003 Low Done
IR-006 Jellyfin round-trip: pull pending queue, push complete results only SR-001 High Done
ID Requirement Traces to Priority Status
GR-001 Build gallery from Jellyfin library cast, TMDB profile fallback SR-001, SR-005 High Done
GR-002 Incremental --merge refresh without re-embedding known actors PR-003 High Done
GR-003 Report coverage: zero-image actors, under-referenced actors, dedup, calibration PDFs SR-001 Medium Donegallery/gallery_report.hpp, written next to the gallery by build_gallery. Zero-usable-image actors come from the build audit, which a stored gallery cannot reconstruct; also distinct_references, duplicates_removed, and the intra/inter distributions the calibration fits and would otherwise discard
GR-004 Stamp embedder identity into the gallery; hard startup error on mismatch SR-001 High Done — basename + SHA-256 + embed_dim; mismatch fatal with no bypass, unstamped warns unless --require-gallery-stamp; scripts/stamp_gallery.py migrates in place
GR-005 Gallery data never leaves the instance SR-005 High Done
GR-006 Provenance tiers: baked / harvested / confirmed, distinguishable per embedding SR-005 High Planned
GR-007 Persist harvested embeddings flagged and reviewable, never silently equal to baked SR-005 Medium Planned
GR-008 Flag distributional outliers among an actor's references (poisoning guard) — EXCEPTION: AR-024 SR-005 Medium Planned
GR-009 Human-confirmed associations persist and improve future extractions §4 Medium TBD

Validation (VR)

ID Requirement Traces to Priority Status
VR-001 HDF5 post-inference dump at the embedded-frame boundary PR-002 High Done
VR-002 Replay drives the real KPN nodes, not a reimplementation PR-002 High Done — replay driven from committed fixtures in tests/test_replay_fixtures.cpp; determinism asserted
VR-003 Scoring: micro-F1 against X-Ray, precision/recall logged at every evaluation PR-002 High Done
VR-004 Reproducible validation corpus with ground truth PR-002 High Done
VR-005 Minimum face size study — TPI/FPI vs probe size, gallery held at native res PR-002 Medium Done — knee at 2432 px; 32 px gives 98.1% TPI, 0.0 FPI at every size. Degrades an already-aligned 112×112 crop, so it isolates the embedder and is an upper bound; VR-013 measures the same question end to end and AR-002 takes its number, not this one
VR-006 Re-tune scene_threshold once native-rate decode lands PR-002 Medium Planned, now unblocked — native-rate decode landed with AR-011, so the prerequisite is met and the current 0.60 is a value fitted against input the pipeline no longer produces. Raised from Low for that reason: it is no longer a refinement, it is a stale constant
VR-007 Expansion band, clustering threshold, and deferred-pass ablation PR-002 Medium Planned
VR-008 Gallery scaling benchmark — throughput vs gallery size PR-002 Medium Planned
VR-009 Verify accumulated posteriors are calibrated against held-out tracks PR-002 High Planned
VR-010 Dump provenance attributes — embedder model, detector settings, dense_scale, scene_detect, sample rate PR-002 High Planned
VR-011 Rewrite the replay harness for the post-AR-012 output contract PR-002 High Planned
VR-012 Quality-knee study — TPI/FPI vs sharpness and vs pose, as VR-005 did for size; also settles whether the 5-point pose proxy needs a dedicated landmark model PR-002 Medium Planned
VR-014 Audio-signature offset recovery on real content — a known trim recovered from film audio, not from the synthetic golden tone PR-002 Medium Done — 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 floor rather than a result, since the offset is quantised to whole 92.88 ms frames. The runtime/2 anchor confirmed through real head-trimmed files (a delta trim moves the window by delta/2). The one soft spot is tier labelling, not accuracy: the score falls with sub-frame misalignment (0.940.99 near a frame boundary, 0.690.73 at half a frame), so 27/40 correct alignments were demoted to loose. ±1 frame of slack in the score fixes it — measured, all 40 back to audio (min 0.906), false matches unmoved at 0.120.16, costing 81 ms of the budget
VR-015 Per-node cost and bottleneck attribution for a run — where the time actually goes PR-004 High Done--benchmark <path> on scene_analyze; src/benchmark.hpp. Reports cumulative CPU and wall time per node, and locates the pacing node from sampled channel occupancy rather than from time-in-node, which backpressure inflates. Verified UT-120…UT-124
VR-013 Cross-source identification probe — gallery from one recording, probes from another, swept over input resolution end to end PR-002 Medium In Progress — holding 90% of the plateau needs ~50 px end to end against VR-005's ~22 px, the gap being detection and landmark error; min_face_px 40, since 32 admits faces in the falling region (AR-002). FPI 0.0% at every scale. Ceiling is cross-view, not resolution

Verification strategy

CI runs on an Intel N100 with no discrete GPU. That is a hard constraint on how each requirement can be verified, and it shapes the test design rather than merely limiting it.

Four tiers, in decreasing order of preference:

Tier Runs in CI What it covers
T1 — Functor unit Yes A KPN node's operator() driven directly with hand-built inputs
T2 — Replay Yes The composed pipeline driven from an HDF5 fixture — no GPU, no video
T3 — CPU inference Yes, slowly ORT CPU provider over a handful of frames; smoke tests only
T4 — GPU No Throughput, TRT engines, large-gallery GEMM

T1 is the primary tier, and KPN is why

Node functors are plain callable structs, constructed independently of the network that wraps them (main.cpp:186-207 builds them as stack objects; ObjectNode merely adapts them). So a node is testable by constructing it and calling operator() — no channels, no threads, no network, no fixture.

This is already the established pattern, not a proposal: tests/test_face_tracker.cpp "drives the node's operator() with hand-built EmbeddedSceneFrames and inspects the emitted track_ids", and does so "pure, GPU-free, model-free".

The consequence is that most of the redesign is verifiable without any fixture at all: construct exactly the awkward state — a belief swap, two live tracks converging on one actor, a film ending mid-track, a gap one frame under the timeout — rather than hunting for a clip that happens to exhibit it.

Four hazards this removes outright:

  • No fixture-provenance risk for these tests — the inputs are synthetic and explicit.
  • No "fixture must be replayed from frame 0" concern — state is constructed directly.
  • No cross-test state leakage (e.g. a tracker's next_id_ persisting) — each test constructs a fresh functor.
  • No replay-harness nondeterminism — no channels, so no EOF-tail heuristics or silent drops.

It also means a dead upstream producer does not block testing a downstream consumer. is_scene_boundary currently has no producer (see AR-010), which would make a replay test of the frame-dependent track_alpha pass vacuously — but a T1 test simply constructs a frame with is_scene_boundary = true and asserts the weighting changes. The producer gap is a pipeline defect to fix, not a verification blocker.

T2 covers what T1 cannot

Replay remains necessary for composition — that the nodes wired together behave as the sum of their parts — and for realistic data at scale, which synthetic inputs cannot honestly imitate. It is the tier that would catch a wiring error, a channel-capacity problem, or an ordering assumption that only appears under concurrency.

The HDF5 dump (VR-001) captures state after decode → detect → align → embed, so replay needs no GPU and no video. That was built for the optimizer; it doubles as CI, which is a strong argument for keeping the schema honest and for replay driving the real nodes rather than a reimplementation (VR-002).

Fixtures and studies are generated locally, on the development machine where the models, galleries and media already exist. CI consumes them; it never produces them.

Small committed fixtures are required. A few HDF5 dumps covering the awkward cases — a cut, a belief swap, two live tracks converging, a film ending mid-track, an unknown track that only resolves after expansion — are worth more than a large corpus, and they are small enough to commit.

T4 requirements cannot pass in CI, and the gate must not pretend otherwise. For these, CI verifies that a test exists and is tagged, not that it passes; the run happens on a GPU host, nightly or manually, and reports separately. A requirement whose only evidence is a test that never executes should be visible as such rather than counted as covered.

Requirement Tier Note
AR-001, AR-005, AR-006 T3 Smoke only — correctness of detection/embedding is a model property, not ours
AR-002 T2 Size filtering is arithmetic on dumped bboxes
AR-003, AR-004 T1 + T4 Backpressure logic is unit-testable; saturation behaviour needs real load
AR-007 … AR-017 T2 The core of the redesign — fully replayable
AR-018 … AR-022 T2 Expansion, deferred pass, clustering: all post-embedding
AR-023 … AR-025 T1 Calibration fit and log-odds accumulation are pure maths
AR-026, AR-027 T4 GEMM throughput and scaling — GPU host only
DP-* T1 + manual Lifecycle logic unit-tested; install paths are manual
IR-001 … IR-003 T1 Serialisation against a golden truth file
IR-004, IR-005 T1 Audio signature is CPU DSP — the golden-vector fixture runs anywhere, which is precisely why it is the right cross-repo check
GR-001 … GR-005 T1 + T3 Gallery assembly is I/O and bookkeeping; embedding is T3 smoke
GR-006 … GR-008 T1 Tiering and outlier detection operate on stored embeddings
VR-* Out of CI Studies are run deliberately and their results committed as documents
VR-014 T2 The exception, and the reason the blanket row above is not the whole story: its fixture is committed and its signature is CPU-only DSP, so the study is a test a CI host can run — not a measurement someone has to remember to repeat

One consequence worth stating: AR-027 (arbitrary gallery scale) is structurally unverifiable on the CI host. It needs a GPU host and a synthetic large gallery, so it is the requirement most likely to silently regress. Its benchmark (VR-008) should run on a schedule rather than on demand.

CI never calls a model

Not "should not" — cannot. The N100 has no GPU, and even the ONNX Runtime CPU provider is impractical: a measured run of the embedder on this hardware sits at ~930 ms per frame, so a 77 s clip at 5 fps would take roughly six minutes of inference alone. Every model invocation therefore happens locally, ahead of time, and CI consumes the result as data.

This is what makes the T1/T2 split load-bearing rather than a preference: T1 and T2 are the only tiers that can exist in CI at all.

Fixture corpus — hero/

Five clips of SuperHero (1952), ~77 s each, 480×360, 30 fps, 42 MB total.

Public domain, and that is the reason to use it rather than a convenience: derived fixtures — dumps, crops, golden outputs — can be committed without the rights question that rules out sharing gallery data (SR-005). A fixture cut from a copyrighted title could not live in the repository at all.

Two properties to design around rather than discover:

  • 480×360 means small faces. At this resolution a face is often 4080 px, so the AR-002 minimum of 40 px (original resolution) sits at the very bottom of that range: the filter is close to binding, and anything shot wider is lost. Fixture generation must set --min-face-px explicitly and record it, or the dumps will be sparse for reasons unrelated to what is being tested.
  • 77 s is short. At 1 fps that is 77 frames — too thin to exercise an extinction window measured in tens of seconds. Generate at 5 fps (≈385 frames, ~1 MB) and record the rate in provenance, since the behaviour under test changes with it.

AR-004 blocks reproducible fixture generation. A trial run of one clip produced 49 frames of an expected ~385, ending at 51 s of 77 s, with the diagnostics reporting 285 frames dropped at camera_pos and 51 at face_aligner. Channels overflow and drop rather than blocking, and what gets dropped depends on timing — so the same command run twice can produce different dumps. Golden fixtures cannot be built on that. AR-004 is therefore a prerequisite for VR-001 fixtures, not merely a throughput concern for crowd scenes.

Fixtures — precomputed inference, pulled by CI

The N100 cannot run inference at any useful rate, so inference output is precomputed on a GPU host and consumed by CI as data. This converts most of what looks like GPU work into pure CPU replay.

Fixture Contents Size Storage
Edge-case dumps ~6 short clips (3060 s), one per awkward behaviour ~0.11 MB each Committed in-repo
Corpus dumps Full-length titles from the validation corpus ~2138 MB each Gitea package registry, pinned by version + checksum
Synthetic gallery Random unit-norm embeddings, fixed seed small Generated at test time
Golden truth files Expected output for each edge-case dump KB Committed
Audio golden vectors FLAC + expected signature + parameter contract ~600 KB Committed, shared with the plugin repo

Edge-case dumps are small enough to commit, and being in-repo means they version with the code that reads them.

Corpus dumps go to the Gitea package registry, not Git LFS. Both are available — the models already use LFS — but their fetch semantics differ in a way that matters here. LFS objects are pulled on clone unless a developer explicitly skips them, so ~38 MB per title behind LFS taxes everyone who clones, forever, for data that only CI and the optimizer ever read. Registry artifacts are fetched on demand by the job that needs them.

Rule of thumb: LFS for what the build needs; the package registry for what a particular job needs. Models are the former; corpus dumps and the CI image (DP-007) are the latter.

Pin by version and verify by checksum on fetch. A fixture that changes silently under CI is worse than a missing one, because the failure presents as a code regression.

Generation must be reproducible and versioned. A script, run on a GPU host, regenerates every fixture from source clips; it is re-run when the VR-001 schema version bumps. A fixture whose provenance is unknown is worse than no fixture, because it will be trusted.

The limitation that must stay visible: replay fixtures freeze upstream behaviour. A test driven from a dump verifies AR-007 onward given those embeddings — it cannot detect a regression in detection, alignment or embedding, because those produced the fixture. Nothing in CI can. That gap is covered only by the T3 smoke test and the scheduled GPU run, and it should not be papered over by a high replay-coverage number.

Per-requirement verification plan

ID Tier Test asserts Edge cases to cover
AR-001 T3 Detector returns plausible boxes on a known frame — smoke only
AR-002 T2 Faces below 40 px (original res) are dropped Exactly at threshold; with dense_scale 0.5 — the interaction that motivated the requirement
AR-003 T2 No cap applied; a 40-face frame yields 40 Crowd frame
AR-004 T1 Saturated input blocks rather than drops or throws Bounded queue at capacity; byte-based limit with large crops; SIGTERM mid-block. Two cases the KPN suite now pins, both of which failed before being written: a fanout feeding an unequal pair loses nothing and throttles the fast branch (either assertion alone passes on a broken implementation); and a node started with data already in its input still fires — the startup lost wake, which needs no contention to reproduce once the state is constructed directly
AR-005 T1 Known landmarks → expected 112×112 warp; the fit never mirrors Landmarks near frame edge; degenerate/collinear points; a mirrored set — SVD returns a reflection unless the determinant guard rejects it
AR-006 T3 Embeddings are unit-norm Batch smaller than, equal to, larger than embed_batch_size
AR-007 T2 Association picks the right track Two faces crossing paths; one leaving frame as another enters
AR-008 T2 One pool; dormant tracks match on embedding, not IoU Dormant track whose old bbox overlaps a different new face — must not match on position
AR-009/010 T2 Cut/boundary shifts weighting toward embedding Cut with same people; cut with all-new people
AR-011 T1 TransNetV2 receives native-rate frames Source at 24/25/30 fps — dedup window derived, not assumed
AR-012 T2 Window spans full track extent, not first recognition Actor recognised only at track end — window must still start at first_seen
AR-013 T2 last_seen set/unset; window ends at last sighting Gap just under vs just over timeout; reappearance after timeout → two windows
AR-014 T2 Belief swap closes one window, opens another No blended window; no overlap at the swap frame
AR-015 T2 Two live tracks on one actor trigger re-association Counter increments
AR-016 T2 Every track closed at EOF Film ending mid-shot — window ends at final frame, not dropped
AR-017 T1 Claim carries posterior and route Deferred and pooled routes distinguishable
AR-018 T1 Band admits only within bounds At each bound exactly; store never admits below lower bound
AR-019 T2 Promotion only when all three signals quiet Cut mid-track blocks promotion
AR-020 T2 Unknown resolved after expansion Track failing at minute 12, resolved at EOF — the ordering-independence claim
AR-021 T2 Clustering merges same person, respects cannot-link Temporally overlapping tracks never merge; measure how many merges the constraint rejects
AR-022 T1 Context crops retained, bounded per track Track running for minutes
AR-023 T1 Sigmoid fit on synthetic separable data Too few positive pairs → valid=false, fallback engages
AR-024 Static check No bare cosine outside a tagged EXCEPTION Grep-based; this is the invariant's enforcement
AR-025 T1 Log-odds accumulate; correlated frames discounted 30 identical frames must not reach the certainty of 30 diverse ones
AR-026 T1 + T4 GEMM path produces same result as reference loop Equivalence on small input in CI; throughput on GPU host
AR-027 T4 Throughput at 10²…10⁵ actors Scheduled, not on-demand
AR-028 T2 No embedding reaches the matcher unscored; the vector survives into the dump Face failing exactly one axis; all three healthy; a face whose landmarks are degenerate — dropped for want of a crop to score, but counted rather than silently vanished (UT-138)
AR-029 T1 Synthetic blur ladder → monotonically falling sharpness Gaussian vs motion blur; small sharp face vs large soft one — size must not leak into this axis. The blur ladder must be measured on a 1/f texture: on a flat-spectrum one the motion ladder rises, since an anisotropic smear takes energy out of numerator and denominator together (UT-131). Contrast must not leak either — exact in the algebra, and the 8-bit floor that bends it is pinned by UT-133
AR-030 T1 Alignment residual rises monotonically with foreshortening In-plane roll, scale and translation must leave it at zero — the property that makes it a pose measure rather than a pose-and-everything-else measure; face size must not shift it; degenerate landmarks report not-ok rather than a number
VR-012 T4 Knee located per axis on held-out films Report each candidate threshold's cost in lost true presence, not only its gain in precision — a gate that improves misID by discarding half the cast has not helped
VR-013 T4 Identification holds across two recordings of the same people, and degrades to TBI rather than to a wrong name as input resolution falls Gallery and probes must come from different recordings — a hold-one-out over one recording measures a much easier problem and will not surface the cross-view failure. Ground truth is hand-sorted; labels propagated by embedding similarity would keep only the faces the embedder already gets right
IR-001/002 T1 Serialised output matches golden file Zero-length window; actor with many windows
IR-003 T1 Output written after deferred pass Not at EOF
IR-004/005 T1 Signature matches golden vector bit-for-bit Identical result in both producer repos
VR-014 T2 A known trim offset is recovered from real film audio, to the nearest frame An offset past the ±600-frame cap and unrelated content must both be declined, never given a best-effort alignment. Fixture and signature are both CPU-only, so unlike the other VR rows this one is CI-executable — though the repo's only workflow today is the traceability gate, so nothing runs it there yet. The signature comes from the shipped C++ through sae_audio; a numpy port would be a third implementation nobody checks against the golden vector
IR-006 T1 + manual Queue pull and result push against a stubbed Jellyfin API Partial result never pushed; push only after the deferred pass
IR-007 T1 Media < 120 s emits no signature at all Exactly 120 s; just under; zero-length audio. Must match the plugin's cutoff exactly — a caller-varying window length is what SR-004 forbids
IR-008 T1 v1: prefix emitted and honoured on read Unknown prefix rejected, not guessed
GR-009 T1 Human-confirmed associations persist and are tier-tagged Survives a gallery rebuild; distinguishable from baked and harvested
GR-004 T1 Mismatched embedder → hard startup error Error names both sides; unstamped warns, and errors under SAE_REQUIRE_GALLERY_STAMP; same filename + different SHA-256 must still be a mismatch
GR-008 T1 Outlier flagged among an actor's references Injected poisoned embedding detected
VR-009 T1 Posterior calibration holds A 0.99 posterior is wrong ~1% of the time on held-out tracks

Three of these are worth singling out because they verify claims that would otherwise be assertions: AR-012 (window starts at first_seen even when recognition comes late) is the entire point of the redesign; AR-020 (a track failing mid-film resolves at EOF) is the claim that ordering stops mattering; and AR-025 (30 identical frames ≠ 30 diverse ones) is what stops the Bayesian accumulation from being decoration.


Withdrawn

ID Requirement Reason
anneal_sec window merging Superseded by AR-012/AR-013: a track survives its own gaps, so there is nothing to anneal
extinction_sec actor keep-alive Superseded by AR-013: windows end at last sighting, which is what this over-claimed

Both were deleted rather than retained at zero — a field naming a mechanism the pipeline no longer has is actively misleading (see SPEC.md A6.6).


Notes on coverage

  • VR-* traces to PR-002 (scene-granularity answers) rather than to a system requirement: parameter studies are single-repo work serving accuracy, and this is correct rather than a gap.
  • PR-005 (leak nothing) has no AR/DP row. It is satisfied structurally by SR-004 and GR-005 — the server holds no binary, the gallery never leaves the instance — not by any component doing something. It cannot be verified by pointing at code, and it dies the moment either prohibition is relaxed.