# Embedding-dump HDF5 schema (v2)

One file per analysed title. Captures the pipeline state at the `EmbeddedSceneFrame`
channel — i.e. after decode → detect → align → embed, but **before** tracking and
identity matching. Everything downstream (face tracker, identity matcher, scene
tracker/anneal) is cheap CPU math, so replaying from this file lets a parameter
sweep re-run the whole downstream tail thousands of times with no GPU and no video.

Written by the C++ dump sink (`--dump-embeddings out.h5`); read by
`scripts/optimizer/replay.py`.

## Layout

The dump is **flat/ragged**: all faces across all frames are concatenated into
per-face arrays, with a per-frame index table pointing into them. This avoids
variable-length HDF5 types and reads straight into numpy.

```
/                                     (root)
  attrs:
    schema_version : int   = 2
    embed_dim      : int   = 512

    # ── what produced the vectors (GR-004) ──────────────────────────────────
    embedder_model : str   basename of the embedding model
    embedder_sha256: str   SHA-256 of that model file

    # ── what produced the faces (VR-010) ────────────────────────────────────
    detector_model : str   basename of the detector .onnx
    detector_conf  : float score floor a detection had to clear to be dumped
    detector_nms   : float NMS IoU threshold
    min_face_px    : float minimum box side, ORIGINAL-resolution px (AR-002)
    max_faces      : int   per-frame cap; 0 = uncapped, the default (AR-003)

    # ── what produced the frames (VR-010) ───────────────────────────────────
    movie          : str   source video path
    sample_fps     : float frames analysed per second of movie
    start_sec      : float seek point
    end_sec        : float stop point; -1 = end of file
    cut_threshold  : float histogram correlation below which is_cut fires
    dense_scale    : float decoded-frame downscale in dense mode; 1 = off
    bbox_upscale   : float multiply faces/bbox and faces/landmarks by this to
                           reach original video pixels; 1 when dense_scale is 1
    scene_detect   : uint8 0/1 — was TransNetV2 running at all (see below)

    # ── downstream setting recorded for comparability (VR-010) ──────────────
    track_assoc_min_prob : float  the run's tracker admission probability

  frames/                             group — one row per sampled frame
    timestamp_sec  : float64 [F]
    frame_idx      : int64   [F]
    is_cut         : uint8   [F]      (histogram intra-scene cut)
    is_scene_boundary : uint8 [F]     (TransNetV2 boundary; 0 if scene_detect off)
    face_offset    : int64   [F]      start index into faces/* for this frame
    face_count     : int32   [F]      number of faces in this frame

  faces/                              group — one row per detected face, concatenated
    embedding      : float32 [N, 512] L2-normalised ArcFace embedding
    bbox           : float32 [N, 4]   x, y, w, h in DECODED-frame pixels
    landmarks      : float32 [N, 10]  5 (x,y) pairs, SCRFD/ArcFace order,
                                      same space as bbox
    confidence     : float32 [N]      detector confidence

    # ── embedding input quality (AR-028), v2 onward ─────────────────────────
    sharpness          : float32 [N]  normalised Laplacian variance on the
                                      112x112 aligned crop (AR-029)
    alignment_residual : float32 [N]  RMS landmark misfit in canonical px,
                                      after the AR-005 similarity fit (AR-030)
```

`F` = number of sampled frames, `N` = total faces (= sum of face_count).
Frame *i*'s faces are `faces/*[ face_offset[i] : face_offset[i]+face_count[i] ]`.

## Provenance (VR-010)

The attributes above are not documentation; they are the only thing that makes a
dump interpretable. Two dumps of the same film at `detector_conf` 0.5 and 0.7, or
at `dense_scale` 1.0 and 0.5, or with scene detection on and off, are different
measurements of different things — and they are byte-shaped identically. Without
provenance a consumer that mixes them gets a plausible number from an incoherent
input, and nothing anywhere reports a problem.

**`scene_detect` is the one that cannot be inferred.** `is_scene_boundary` is
all-zero both when TransNetV2 found no boundaries in the clip and when it was
never enabled, and those mean opposite things: the first says *this footage has
no shot changes*, the second says *nobody looked*. A consumer that reads the
array alone must guess. The flag is what removes the guess. (`dump_embeddings`
has no `--scene-detect`, so every dump it writes records `false` — which is
exactly the fact the committed fixtures needed to state.)

**`bbox_upscale` is recorded, not applied.** See the coordinate-space note below.

Reading is by name with a default or an existence check on **both** sides —
`replay.py` (`f.attrs.get(...)`) and `read_dump_provenance()` in
`src/nodes/embedding_dump_node.hpp` (`attrExists`). So the attributes are
additive and did not themselves move `schema_version` off 1: a pre-VR-010 dump
still loads, and a post-VR-010 dump still reads on old code. (AR-028 later took
it to 2 by adding *datasets* — see below.)

A missing attribute means **unknown**, never a default value. Substituting
`detector_conf = 0.5` for a dump that does not say so manufactures the provenance
the requirement exists to prevent — per `docs/requirements.md`, *"a fixture whose
provenance is unknown is worse than no fixture, because it will be trusted."*
The committed `tests/fixtures/dumps/*.h5` predate VR-010 and carry none of these
attributes; re-dump to bind them, as with GR-004.

## Embedding input quality (AR-028) — and why this one bumps the version

`sharpness` and `alignment_residual` are two of the three AR-028 quality axes,
written beside the embedding they describe. **The third axis, size, is already
here**: it is `bbox`, scaled by `bbox_upscale` to reach the original resolution
AR-002 thresholds in. It is not duplicated into a third column, because that
would put the same quantity in two coordinate spaces inside one file — the trap
the `bbox_upscale` note below records — and the copy is the one that drifts.

The vector is **carried, not consumed**. Nothing in the pipeline thresholds or
discounts on it yet; VR-012 locates the knees from these columns, which is only
possible if they were recorded at inference. A study cannot recover how sharp a
face was from an embedding, any more than it can recover which model produced it.

**This is the change that bumps `schema_version` to 2**, where VR-010's
attributes did not. The rule is unchanged — a bump is for the *datasets* — and
so is the reason behind it. Readers are fine either way: `replay.py` and
`test_replay_fixtures.cpp` take these datasets by name with an existence check,
so a v1 dump still replays and loses only what it never had. The version exists
for a *consumer of the quality vector*, which otherwise cannot tell **"this
film's faces were never scored"** from **"this film's faces scored zero"** —
sharpness 0 is a real reading, meaning a featureless crop. That is the same
distinction `scene_detect` exists to make, and it is equally unrecoverable from
the arrays.

A v1 dump reports the vector as **unknown, never as a default** — `load_frames`
omits the keys rather than filling zeros, and the C++ side leaves the
`DetectedFace` fields at their -1 "unscored" sentinel. Re-dump to acquire it;
there is no migration, for the same reason GR-004 has none.

> The committed `tests/fixtures/dumps/*.h5` are v1 and carry no quality vector.
> Re-dumping needs a GPU host (`scripts/make_fixtures.sh`), so until that runs,
> anything driven from the fixtures sees the sentinel.

## Model binding (GR-004)

`embedder_model` / `embedder_sha256` record which embedder produced every vector
in `faces/embedding`. A replay has no live embedder, so **the dump is the embedder
as far as the gallery is concerned**: `replay.py` checks these two attributes
against the gallery's own `/embedder` stamp and refuses to run on a mismatch,
naming both sides. Cross-model cosines are meaningless but look plausible.

The attributes are additive, not a format break — `schema_version` stays 1. Dumps
written before GR-004 simply lack them, which reports as *unverifiable* (a loud
warning, or a hard error under `SAE_REQUIRE_GALLERY_STAMP=1`) rather than as a
pass. Re-dump to bind an old dump; there is no in-place migration, because unlike
a gallery nobody can assert after the fact which model produced a vector.

## Coordinate space — `bbox`, `landmarks`, `bbox_upscale`

`bbox` and `landmarks` are in **decoded-frame pixels**: exactly the numbers SCRFD
produced, untransformed. To reach original video pixels, multiply by
`bbox_upscale`. With `dense_scale == 1` (the default, and every committed
fixture) `bbox_upscale == 1` and the two spaces coincide.

> Earlier revisions of this document claimed the upscale was applied at dump time.
> It never was. `embedding_dump_node.hpp` writes `f.bbox` raw; the upscale lives
> in `identity_matcher_node.hpp`, which is *downstream* of the dump tap. The
> claim was harmless only because `dense_scale` was 1 in practice.

The fix is to record the factor rather than to apply it, because the dump's whole
contract is to be a **faithful tap** at the `EmbeddedSceneFrame` channel — VR-002
requires replay to drive the real nodes, and a replay is only equivalent to the
live run if the tracker is fed the geometry the live tracker saw. Rescaling at
the tap would break that: the replayed tracker would associate on boxes the live
one never received. Two further reasons:

- The matcher's upscale is applied to `bbox` **only**, not to `landmarks`.
  Pre-multiplying at the tap would leave the two arrays in different coordinate
  spaces inside one file — a worse trap than the one being fixed.
- Pre-multiplying is lossy in the sense that matters: a dump that had been
  upscaled would be indistinguishable from one taken at `dense_scale == 1`, so
  you would have to record `bbox_upscale` anyway to know which you were holding.

## Invariants
- `embedding` rows are unit-norm (cosine == dot product against the gallery).
- `face_offset[0] == 0`; `face_offset[i+1] == face_offset[i] + face_count[i]`.
- `bbox` and `landmarks` share one coordinate space; `bbox_upscale` maps both to
  original resolution (see above).
- A frame with no faces has `face_count == 0` (still gets a row, so timestamps stay dense).
- EOF sentinel frames are NOT written.
- v2 onward: `sharpness` and `alignment_residual` are `[N]`, parallel to
  `confidence`, so face *i*'s quality indexes with the same slice as its
  embedding. Both are `>= 0` for any face the aligner admitted; a negative value
  means unscored and must never be read as a quality.
