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
This commit is contained in:
2026-08-05 19:40:25 +02:00
parent 1141172b04
commit 48332d2041
6 changed files with 414 additions and 229 deletions
+3 -4
View File
@@ -79,10 +79,9 @@ def main():
"--dump", str(dump), "--gallery", str(gallery),
"--out", str(pred_path),
"--prob-threshold", str(cfg["prob_threshold"]),
# anneal_sec is replay-local now (it configures replay.py's
# own windowing, not the pipeline). extinction_sec is gone
# entirely with SceneTrackerFunc -- see AR-012/AR-013.
"--anneal-sec", str(cfg.get("anneal_sec", 10.0)),
# anneal_sec and extinction_sec are both gone: presence is
# the registry's, built from track extents (AR-012/AR-013), and
# replay.py no longer windows anything itself (VR-011).
"--expand-gallery",
]
print(f"RUN {model}/{film['slug']}...", file=sys.stderr)
+2 -2
View File
@@ -17,7 +17,7 @@ point from the trajectory (--trajectory).
Usage:
python scripts/optimizer/optimize.py --manifest films.json \
--gallery gallery_arcface_w600k_r50.json \
--params prob_threshold:0.5:0.999 anneal_sec:1:30 track_alpha:0:1 \
--params prob_threshold:0.5:0.999 ownership_logodds:0.5:4 track_alpha:0:1 \
--popsize 20 --maxiter 25 --trajectory traj.json
"""
from __future__ import annotations
@@ -239,7 +239,7 @@ def main():
rec = {"eval": evals[0], "config": cfg, **m, "t": round(time.time() - t0, 1)}
traj.append(rec)
print(f"[opt] eval {evals[0]:3d} thr={cfg['prob_threshold']:.2f} "
f"ann={cfg.get('anneal_sec', float('nan')):.0f}"
f"own={cfg.get('ownership_logodds', float('nan')):.2f}"
f"F1={m['f1']*100:.1f}% P={m['precision']*100:.1f}% R={m['recall']*100:.1f}% "
f"agree={m.get('agreement', 0)*100:.1f}% misID={m.get('FPI_misid', 0)}",
file=sys.stderr)
+129 -123
View File
@@ -2,18 +2,22 @@
"""
replay.py — replay a dumped embedding HDF5 through the real KPN downstream nodes.
TRACES: VR-002 | PR-002
TRACES: VR-002, VR-011 | PR-002
Reads an embedding dump (scripts/optimizer/SCHEMA.md), feeds each frame as an
EmbeddedSceneFrame into a Python-assembled KPN network wiring the *real* C++
face_tracker → identity_matcher → frame_annotation, and returns the same presence-window
JSON that scene_analyze's result_sink produces (minimal schema). No decode, no GPU
embedding — only the cheap downstream tail runs, so a sweep can vary Config knobs
freely. See [[kpn-python-replay-optimizer]].
face_tracker → identity_matcher → frame_annotation → result_sink, and reads back
the truth file that sink wrote. No decode, no GPU embedding — only the cheap
downstream tail runs, so a sweep can vary Config knobs freely.
The sink is part of the network, not a Python reimplementation of it. That is
VR-011: presence comes from TrackRegistry claims, so a replayed window and a
scene_analyze window are produced by the same code rather than by two functions
that agreed once. See [[kpn-python-replay-optimizer]].
CLI:
python scripts/optimizer/replay.py --dump film.h5 --gallery gallery.json \
--out replayed.json [--prob-threshold 0.99] [--anneal-sec 10] ...
--out replayed.json [--prob-threshold 0.99] [--track-extinction-sec 5] ...
"""
from __future__ import annotations
@@ -108,17 +112,32 @@ def load_frames(dump_path: str, min_conf: float = 0.0):
return frames, str(movie), fps
def replay(dump_path: str, gallery: str, cfg: dict, build_dir: str, stop: bool = True,
raw_out: str | None = None) -> dict:
"""Run the dump through the real KPN chain; return minimal-schema presence JSON.
def replay(dump_path: str, gallery: str, cfg: dict, build_dir: str,
out_path: str, stop: bool = True, raw_out: str | None = None,
eof_timeout: float = 300.0) -> dict:
"""Run the dump through the real KPN chain and return the truth file it wrote.
cfg may include "detector_conf" to prune dumped detections below that confidence
(upward-only from the 0.5 dump floor) before matching.
TRACES: VR-011, VR-002 | PR-002
raw_out: if set, also write the raw per-frame annotations (timestamp, actor_idx,
name, bbox, similarity — one entry per input frame, before merging into windows)
as JSON lines to this path. Needed to draw bounding boxes on extracted frames;
the merged window schema returned by this function has no per-frame bbox."""
`out_path` is where the C++ sink writes. That is the change VR-011 makes:
the presence windows in that file are built by ResultSinkFunc from
TrackRegistry claims -- the extent of a track an actor owned (AR-012),
ending at the last sighting (AR-013) -- and are byte-for-byte the same
construction scene_analyze ships. This function used to build them itself,
in Python, by annealing gaps between per-frame detections, which is what the
pipeline did BEFORE AR-012. A sweep tuned against that was tuning a contract
the shipped code had stopped honouring.
cfg may include "detector_conf" to prune dumped detections below that
confidence (upward-only from the 0.5 dump floor) before matching.
raw_out: if set, also write per-frame annotations as JSON lines for the
montage renderers. Derived from the truth file's own `frames` array rather
than tapped separately out of the network -- see write_raw_frames.
eof_timeout: how long to wait for the sink to write. A replay that never
reaches EOF is a wedged pipeline, and returning an empty result would look
like a film with no cast rather than like a failure."""
sys.path.insert(0, build_dir)
import sae_kpn
@@ -161,120 +180,104 @@ def replay(dump_path: str, gallery: str, cfg: dict, build_dir: str, stop: bool =
# correctness is not. Generous slack on top.
cap = len(frames) * 2 + 64
sae_kpn.add_node_python(net, "replay", source, [], ["EmbeddedSceneFrame"], cap)
sae_kpn.add_face_tracker(net, "tracker", cfg, cap)
sae_kpn.add_identity_matcher(net, "matcher", gallery, cfg, cap,
stamp["model_name"], stamp["model_sha256"])
sae_kpn.add_frame_annotation(net, "scene", cap)
# TRACES: VR-011, VR-002 | DP-001 | PR-002
# One call builds tracker -> matcher -> annotation -> sink in the only order
# that works (the matcher fits the calibration the tracker needs, and the
# sink needs the registry's claims). This used to be three factory calls
# assembled here, which is how the seam broke: the ordering constraint could
# not be expressed, so the tracker was built from a Config alone long after
# it had started requiring a registry and a calibration.
cfg = dict(cfg)
cfg["output_path"] = out_path
cfg["movie_path"] = movie
cfg["sample_fps"] = fps
# Verbosity 1 (standard) adds the per-frame array; only pay for it when the
# caller wants raw frames, since it retains every annotation in memory.
cfg["verbosity"] = 1 if raw_out else 0
sae_kpn.add_pipeline(net, gallery, cfg, cap,
stamp["model_name"], stamp["model_sha256"])
net.connect("replay", 0, "tracker", 0)
net.connect("tracker", 0, "matcher", 0)
net.connect("matcher", 0, "scene", 0)
net.connect("matcher", 0, "annotation", 0)
net.connect("annotation", 0, "sink", 0)
net.build()
net.start()
# Read exactly one annotation per input frame. The source emits EOF as an ordinary
# value AFTER the last frame, but the concurrent pipeline lets that EOF OVERTAKE
# the last few real frames still flowing tracker→matcher→scene. Breaking on the
# first eof therefore dropped a random tail (~0.51%, race-dependent). Instead we
# keep reading past eof until we've collected all n_frames annotations (or hit a
# run of consecutive eofs meaning the pipeline is genuinely drained).
n_expected = len(frames) - 1 # excludes the trailing eof frame
annotations = []
eof_streak = 0
max_reads = n_expected * 2 + 32
for _ in range(max_reads):
sa = net.read("scene", 0)
if sa.get("eof"):
eof_streak += 1
# stragglers can still arrive after an eof; only stop once we've either
# got everything or seen several eofs in a row (truly drained).
if len(annotations) >= n_expected or eof_streak >= 8:
break
continue
eof_streak = 0
annotations.append(sa)
if len(annotations) >= n_expected:
break
# The sink writes on the EOF annotation. Wait for it rather than reading
# anything back through the seam: presence is the registry's answer, and the
# registry lives entirely on the C++ side.
#
# This replaces a read loop that pulled one SceneAnnotation per input frame
# and rebuilt windows in Python. That loop needed a heuristic -- "keep
# reading past eof until we've collected all n_frames annotations, or hit a
# run of 8 consecutive eofs" -- to work around a tail it was losing. None of
# that exists now: nothing is read per frame, so nothing can be lost per
# frame.
deadline = time.time() + eof_timeout
while not sae_kpn.pipeline_done(net):
if time.time() > deadline:
sae_kpn.release_pipeline(net)
raise TimeoutError(
f"replay did not finish within {eof_timeout}s "
f"({len(frames) - 1} frames); the sink never saw EOF")
time.sleep(0.02)
if raw_out:
with open(raw_out, "w") as f:
for sa in annotations:
f.write(json.dumps(sa) + "\n")
result = build_minimal(annotations, movie, fps, cfg)
if stop:
net.stop()
sae_kpn.release_pipeline(net)
with open(out_path) as f:
result = json.load(f)
if raw_out:
write_raw_frames(result, raw_out)
return result
def build_minimal(annotations, movie, fps, cfg) -> dict:
"""Per-actor [start,end] windows, built by annealing per-frame detections.
def write_raw_frames(truth: dict, raw_out: str) -> None:
"""Per-frame annotations as JSONL, for the montage/error-frame renderers.
TRACES: VR-011 | PR-002
This NO LONGER mirrors ResultSinkFunc, and the docstring used to claim it
did. The sink builds a window from a TrackRegistry claim -- the extent
[first_seen, last_seen] of a track an actor owned (AR-012) -- so a window
starts when the actor appeared rather than when recognition first
succeeded, and interior gaps are absorbed by the track surviving them.
This function still bridges gaps between isolated accepted frames, which is
what anneal_sec did before AR-012/AR-013 withdrew it.
Derived from the truth file's own `frames` array (verbosity 1) rather than
from a second stream tapped out of the network. One producer, one set of
numbers: a bbox drawn on a montage is now provably the bbox the sink
recorded, which it was not when Python read annotations separately.
So a replayed window and a pipeline window are answers to different
questions, and a sweep tuned against this one is not tuning the shipped
behaviour. That is VR-011's job -- "rewrite the replay harness for the
post-AR-012 output contract" -- and it is a rewrite, not an edit, because
the registry's claims do not cross the Python seam at all today.
`anneal_sec` is therefore replay-local now: it configures THIS function and
is no longer forwarded to the C++ Config, which has no such field.
The shape is the legacy one -- {timestamp_sec, visible_actors:[...]} with
actor_idx/bbox/name/similarity -- because dump_scene_montage.py and
dump_error_frames.py read exactly those fields, and rewriting them is not
what this requirement is about.
"""
anneal = float(cfg.get("anneal_sec", 10.0))
info = {} # actor_idx -> identity fields
times = {} # actor_idx -> [timestamps]
for sa in annotations:
for a in sa["visible_actors"]:
if a["actor_idx"] < 0:
continue
info[a["actor_idx"]] = a
times.setdefault(a["actor_idx"], []).append(sa["timestamp_sec"])
actors = []
for idx, ts in times.items():
ts.sort()
scenes = []
ws = we = ts[0]
for t in ts[1:]:
if t - we > anneal:
scenes.append([ws, we])
ws = t
we = t
scenes.append([ws, we])
a = info[idx]
actors.append({
"name": a["name"], "imdb_id": a["imdb_id"], "tmdb_id": a["tmdb_id"],
"jellyfin_id": a["jellyfin_id"], "scenes": scenes,
})
return {"schema_version": 1, "movie": movie, "sample_fps": fps,
"anneal_sec": anneal, "actors": actors}
with open(raw_out, "w") as f:
for fr in truth.get("frames", []):
visible = []
for a in fr.get("identified", []):
visible.append({
"actor_idx": 0, # >= 0 means "known"; the renderers
# test the sign, never the value
"name": a.get("name", ""),
"imdb_id": a.get("imdb_id", ""),
"tmdb_id": a.get("tmdb_id", ""),
"jellyfin_id": a.get("jellyfin_id", ""),
"similarity": a.get("similarity", 0.0),
"track_id": a.get("track_id", -1),
"bbox": a.get("bbox", [0, 0, 0, 0]),
})
for u in fr.get("unknowns", []):
visible.append({
"actor_idx": -1,
"name": "",
"similarity": u.get("confidence", 0.0),
"track_id": u.get("track_id", -1),
"bbox": u.get("bbox", [0, 0, 0, 0]),
})
f.write(json.dumps({"timestamp_sec": fr.get("t", 0.0),
"visible_actors": visible}) + "\n")
# TRACES: AR-024 | SR-002
# Keys the C++ Config actually still has. Seven names were removed here, all of
# them accepted silently for months after the fields behind them were deleted:
#
# match_threshold, match_ratio, match_ratio_ceil — the raw-cosine accept
# fallback, retired with AR-024's enforcement.
# track_max_embed_dist, cut_revive_sim — raw cosines, retired
# earlier by AR-024 when association moved into probability space.
# track_max_frames_missing, cut_inactive_max_frames — frame counts whose
# meaning changed with sample_fps, retired by AR-008/AR-013 in favour of
# track_extinction_sec.
#
# A sweep that varied one of these was measuring nothing, and reported a
# perfectly ordinary-looking F1 for its trouble. kpn_bindings.cpp reads config
# keys with a contains() check, so an unknown key is not an error — which makes
# a stale entry here silently inert rather than loudly wrong.
CFG_KEYS = ["detector_conf", "prob_threshold", "match_prior",
"track_alpha", "track_min_iou", "track_assoc_min_prob",
"track_extinction_sec",
@@ -284,10 +287,12 @@ CFG_KEYS = ["detector_conf", "prob_threshold", "match_prior",
"ownership_logodds", "evidence_rho_max", "evidence_admit_below",
"evidence_max_views"]
# Swept like a Config key but consumed entirely in Python, by build_minimal.
# Kept separate so nobody has to guess which of these the pipeline actually
# reads: everything in CFG_KEYS crosses the seam, and nothing here does.
REPLAY_LOCAL_KEYS = ["anneal_sec"]
# TRACES: VR-011 | PR-002
# REPLAY_LOCAL_KEYS is gone with build_minimal. It held anneal_sec, the last
# parameter this harness applied itself -- and the only reason it needed a
# separate list was that the harness was still doing windowing the pipeline had
# stopped doing. Every key is a Config key now, because every decision is the
# pipeline's.
def main():
@@ -298,7 +303,7 @@ def main():
p.add_argument("--out", required=True, help="output presence JSON")
p.add_argument("--raw-out", help="also write raw per-frame annotations (JSONL, with bboxes) here")
p.add_argument("--build-dir", default=str(REPO / "build"))
for k in CFG_KEYS + REPLAY_LOCAL_KEYS:
for k in CFG_KEYS:
p.add_argument(f"--{k.replace('_','-')}", type=float, default=None)
# per-film gallery expansion: promotes pose-varied views of confidently-identified
# actors into an in-memory annex, recovering ~+4 recall at no precision cost.
@@ -310,10 +315,7 @@ def main():
p.add_argument("--require-gallery-stamp", action="store_true")
args = p.parse_args()
# Both lists go into one dict: config_from_dict reads C++ keys with a
# contains() check and ignores the rest, and build_minimal reads its own.
cfg = {k: getattr(args, k)
for k in CFG_KEYS + REPLAY_LOCAL_KEYS if getattr(args, k) is not None}
cfg = {k: getattr(args, k) for k in CFG_KEYS if getattr(args, k) is not None}
if args.expand_gallery:
cfg["expand_gallery"] = True
if args.require_gallery_stamp:
@@ -322,9 +324,13 @@ def main():
# thread's run_loop actually exits. stop=False skips that, leaving stop_flag_
# false forever — the PyNode destructor's jthread.join() then blocks forever
# (verified via gdb: stuck in the source node's run_loop, not the GEMM path).
result = replay(args.dump, args.gallery, cfg, args.build_dir, stop=True,
raw_out=args.raw_out)
Path(args.out).write_text(json.dumps(result, indent=2))
result = replay(args.dump, args.gallery, cfg, args.build_dir,
out_path=args.out, stop=True, raw_out=args.raw_out)
# NOT rewritten here: the sink already wrote args.out, and that file is the
# artifact. Dumping `result` back over it would make this script the last
# writer of a file it did not produce -- and any formatting difference would
# be a diff between the replayed truth file and a scene_analyze one that is
# this script's doing rather than the pipeline's.
print(f"[replay] {len(result['actors'])} actors → {args.out}", file=sys.stderr)
+74 -31
View File
@@ -1,17 +1,30 @@
#!/usr/bin/env python3
"""
Smoke test for the sae_kpn module: assemble the real downstream pipeline nodes
(face_tracker → identity_matcher → frame_annotation) in a Python-driven KPN network,
fed by a no-input Python source node, and verify SceneAnnotations flow out.
Smoke test for the sae_kpn module: assemble the real downstream pipeline
(tracker → matcher → annotation → sink) in a Python-driven KPN network, fed by a
no-input Python source node, and verify the sink writes a truth file.
TRACES: VR-011 | PR-002
Proves the KPN-native replay path works without any numpy port of node logic.
Rewritten for `add_pipeline`. It previously called three node factories and read
SceneAnnotations back through the seam, asserting on what came out per frame.
Neither half of that survives VR-011: the factories are gone because the chain
has a construction order Python could not express, and presence is now the C++
sink's answer, derived from TrackRegistry claims. Nothing is read per frame, so
the assertions are on the file the sink writes.
Run: python scripts/optimizer/test_sae_kpn.py [gallery.json] [build_dir]
"""
import json
import sys
import queue
import numpy as np
import tempfile
import time
from pathlib import Path
import numpy as np
REPO = Path(__file__).resolve().parent.parent.parent
GAL = sys.argv[1] if len(sys.argv) > 1 else str(REPO / "gallery_arcface_w600k_r50.json")
BUILD = sys.argv[2] if len(sys.argv) > 2 else str(REPO / "build")
@@ -31,7 +44,6 @@ def make_frame(t, n):
def main():
net = sae_kpn.Network()
sae_kpn._register_types(net)
cfg = {"prob_threshold": 0.99, "track_extinction_sec": 5.0}
frames = [make_frame(float(t), 1) for t in range(3)]
frames.append({"timestamp_sec": 3.0, "eof": True})
@@ -39,36 +51,67 @@ def main():
eof_frame = {"timestamp_sec": 3.0, "eof": True}
def source():
# Emit each frame once, then keep returning EOF (never block) so the node
# thread stays responsive to stop() after the sink has seen EOF.
# Emit each frame once, then keep returning EOF so the node thread stays
# responsive to stop(). The sleep matters: a no-input source is called in
# a tight loop, and hot-spinning EOFs pegs a core and floods the channel.
i = idx[0]
idx[0] += 1
return frames[i] if i < len(frames) else eof_frame
if i < len(frames):
return frames[i]
time.sleep(0.05)
return eof_frame
sae_kpn.add_node_python(net, "replay", source, [], ["EmbeddedSceneFrame"], 8)
sae_kpn.add_face_tracker(net, "tracker", cfg, 16)
sae_kpn.add_identity_matcher(net, "matcher", GAL, cfg, 16)
sae_kpn.add_frame_annotation(net, "scene", 16)
net.connect("replay", 0, "tracker", 0)
net.connect("tracker", 0, "matcher", 0)
net.connect("matcher", 0, "scene", 0)
net.build()
net.start()
with tempfile.TemporaryDirectory() as tmp:
out_path = str(Path(tmp) / "truth.json")
cfg = {
"prob_threshold": 0.99,
"track_extinction_sec": 5.0,
"output_path": out_path,
"movie_path": "sae_kpn smoke test",
"sample_fps": 1.0,
# Standard verbosity emits the per-frame array this test asserts on.
# At 0 the file carries only the actor epochs, and three random
# embeddings against a real gallery need not produce any.
"verbosity": 1,
}
got = []
for _ in range(4):
sa = net.read("scene", 0)
got.append(sa)
if sa.get("eof"):
break
net.stop()
sae_kpn.add_node_python(net, "replay", source, [], ["EmbeddedSceneFrame"], 16)
# No embedder stamp: these embeddings are random, not the output of any
# model, so there is nothing truthful to claim. That warns rather than
# failing, and would be fatal under SAE_REQUIRE_GALLERY_STAMP — which is
# correct, since an unverifiable binding is exactly what it guards.
sae_kpn.add_pipeline(net, GAL, cfg, 16)
non_eof = [g for g in got if not g.get("eof")]
assert len(non_eof) == 3, f"expected 3 annotations, got {len(non_eof)}"
assert got[-1].get("eof"), "expected trailing EOF"
assert [g["timestamp_sec"] for g in non_eof] == [0.0, 1.0, 2.0], "timestamps wrong"
assert all("visible_actors" in g for g in non_eof), "missing visible_actors"
print(f"OK: {len(non_eof)} annotations through the real KPN chain, EOF received")
net.connect("replay", 0, "tracker", 0)
net.connect("tracker", 0, "matcher", 0)
net.connect("matcher", 0, "annotation", 0)
net.connect("annotation", 0, "sink", 0)
net.build()
net.start()
# The sink writes on the EOF annotation. Wait for that rather than
# reading anything back: presence lives entirely on the C++ side.
deadline = time.time() + 30.0
while not sae_kpn.pipeline_done(net):
if time.time() > deadline:
sae_kpn.release_pipeline(net)
raise TimeoutError("sink never saw EOF within 30s")
time.sleep(0.02)
net.stop()
sae_kpn.release_pipeline(net)
with open(out_path) as f:
truth = json.load(f)
per_frame = truth.get("frames", [])
assert "actors" in truth, "truth file has no actors array"
assert len(per_frame) == 3, f"expected 3 frames, got {len(per_frame)}"
# EOF is a control token, not an observation: the sink flushes on it and does
# not record it, so three inputs give three frames and never four.
assert [f["t"] for f in per_frame] == [0.0, 1.0, 2.0], "timestamps wrong"
assert all("identified" in f for f in per_frame), "missing identified"
print(f"OK: {len(per_frame)} frames through the real KPN chain, sink wrote its truth file")
if __name__ == "__main__":