Commit Graph
11 Commits
Author SHA1 Message Date
dtourolle 06373817a2 refactor(config): give the tuned constants a real provenance, and make them reachable
Two problems, both of which made a number look more settled than it is.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

TRACES: AR-028, AR-029, AR-030 | VR-001 | SR-002
2026-08-05 14:37:30 +02:00
dtourolle f891e579c5 chore(traces): put TRACES tags on their own line; regenerate the report
The parser reads a tag up to end of line, so `# TRACES: GR-004 | SR-001 —
prose` swallowed the prose into the tag and the row went unmatched. Splitting
the comment leaves the tag greppable by the same pattern as the code tags and
the commit trailers, which is the point of the house format.

Mechanical throughout; no logic touched. The regenerated report reflects this
session's new tags: 137 -> 148 found, and one more tagged-but-unexecuted, which
is the SuperHero accuracy assertion that is documented but not yet a test.
2026-08-04 14:04:21 +02:00
dtourolleandClaude Opus 5 6aabeb9897 feat: provenance attributes on the embedding dump
VR-010 — a dump made with one detector/embedder pair was byte-indistinguishable
from one made with another, except for the two attributes GR-004 added. Replayed
against a gallery from a different model, cosine similarities are meaningless
but look entirely plausible. The register states the principle directly: a
fixture whose provenance is unknown is worse than no fixture, because it will be
trusted.

Sixteen attributes now record everything that determines the dump's content:
detector model and thresholds, min_face_px, max_faces, cut_threshold,
dense_scale, bbox_upscale, start/end, track_assoc_min_prob, and scene_detect.

scene_detect is the one that matters most. is_scene_boundary is all-zero both
when the detector found nothing and when it never ran, and those mean completely
different things to a consumer — without the flag they are indistinguishable.

No schema_version bump: new root attributes are additive and replay.py already
reads attributes with a default, so older dumps stay readable and the committed
fixtures — which predate this — still load.

Also corrects SCHEMA.md, which claimed bbox was already mapped to original
resolution at dump time. It is not; the upscale is applied downstream in the
matcher, after the dump tap. Harmless while dense_scale is 1 and silently wrong
otherwise, so bbox_upscale is now recorded and the doc says what the code does.

Verified end to end: all sixteen attributes present and correct on a freshly
generated dump.

Suite: 92 cases, 6136 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

TRACES: VR-010, VR-001 | PR-002
2026-07-31 15:24:06 +02:00
dtourolleandClaude Opus 5 b35d49c772 docs: tag the implemented core with its requirement IDs
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
2026-07-30 21:20:27 +02:00
Claude 7db40f430d GR-004: bind galleries to the embedder that built them
A gallery is only valid for the embedder that produced its vectors. Cosine
similarities across models are meaningless but *look* plausible, so the mistake
is silent and every measurement taken afterwards is suspect. Stamp the embedder
identity into the gallery at build; verify it at every load.

The stamp is the model file's basename plus the SHA-256 of its bytes (plus
embed_dim). The hash decides, the name explains. A name alone is a promise
rather than a fact — models get re-exported and overwritten in place under an
unchanged filename, which is exactly the case where the weights differ and
nothing else does. A hash alone is correct but unactionable in an error message.
SHA-256 is derived from the artefact, needs no registry kept current, and costs
~0.1s for a 250MB ONNX, memoised per process.

Mismatch is a hard error in every mode, with no bypass, naming both sides.

Unstamped legacy galleries warn loudly and proceed: unknown is not known-bad,
and hard-failing every pre-existing gallery would turn the check into something
people disable rather than trust. --require-gallery-stamp (or
SAE_REQUIRE_GALLERY_STAMP=1, which propagates to subprocesses) promotes that to
a hard error — the mode measurement work should run in. scripts/stamp_gallery.py
re-binds an existing gallery with no re-embedding, so "warn" is a cheap state to
leave rather than a permanent one.

Embedding dumps carry the same stamp: a replay has no live embedder, so the dump
is the embedder as far as the gallery is concerned. Derived galleries inherit
their source's stamp; --merge and the JSON gallery merge check before writing,
since one file holding two embedding spaces cannot be untangled afterwards.

Verified in: scene_analyze, scene_preview, the sae_kpn matcher binding,
replay.py, optimize.py (once per film at startup, before the first evaluation),
movienet_eval.py and both merge paths.

Stamp logic lives in src/gallery/embedder_stamp.{hpp,cpp} and its Python twin
scripts/sae_stamp.py, kept dependency-light so replay subprocesses do not pay
sae_gallery's requests/Pillow import to ask whether two models match.

Tests: 12 new cases in test_gallery_store.cpp covering the comparison logic,
both round trips, and the SHA-256 vectors that guarantee the C++ and hashlib
stamps agree. No ONNX or GPU required.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:35:46 +02:00
dtourolle 0bd2747069 docs: full data-grounded rewrite of the performance report
Replaces narrative claims with verified numbers across all report pages:

- Cross-model held-out validation (LVFace/mbf/r18, all 5 held-out
  films): LVFace wins every film outright, not just "consistent with"
  the training-set pick. r50 dropped from the detailed comparison
  (gallery has ~30% fewer reference images per actor than the other
  three models on identical source photos).
- Per-film training breakdown: LVFace does not win every training
  film (mbf beats it on Lord of War); the 75.3% macro figure hides a
  10.7pp spread.
- Gallery coverage computed per film (20.3%-78.6%) instead of one
  flat 67%-missing average.
- Found and fixed a real scoring bug in optimize.py: a candidate
  whose hardest film's replay timed out was averaged over survivors
  instead of penalized, silently rewarding partial coverage. Affected
  3 of 16 training combos; corrected throughout, and optimize.py now
  scores an incomplete evaluation f1=0.0 instead of averaging over
  whichever films happened to finish.
- Every FPI frame in the deep dive now comes from the proper montage
  renderer (Onscreen/Offscreen panel, ghosts never drawn as boxes),
  never the bare-box debug overlay used earlier.
- Every distinct out-of-cast name across all 9 films gets its own
  frame at its first appearance (9 names, 4 films), not a
  single-example spot check: 2 ground-truth gaps, 1 photograph
  misread as a person, 6 genuine lookalike confusions.
- New methodology.md: the scene-level-vs-per-second scoring mismatch
  that the rest of the report assumes, written out once.
- Cut the deadlock/gdb debugging narrative from the experiment log;
  kept the one fact that matters (KPN's node/network split lets the
  expensive GPU stage run once and the cheap stage replay against
  cached embeddings).
- Plain declarative style throughout, no em dashes, no blog voice.
2026-07-21 08:55:57 +02:00
dtourolle b1efefac6f docs: richer report — data figures, success/failure frames, commit-pinned repo links
- experiment_charts.py generates 4 figures from experiments/ artifacts:
  held-out per-film F1, 16-combo ranking, DE search landscape, and the
  Downton detector-vs-tracker ghost timeline (replaces the blank
  title-card screenshot)
- new frames: 19-correct wedding shot (success case), Many Saints
  ghost-vs-unknown frame (three error classes in one image)
- rename rep4-optimizer-results.md -> model-bakeoff.md; rep4 kept only
  as the on-disk artifact prefix, explained once
- repo file references are now links via https://REPOLINK/<path>
  placeholders; build_site.sh pins them to the HEAD commit's raw URLs
  and fails the build if a linked path doesn't exist at HEAD
- drop references to removed scripts (scene_score.py, score_config.py)
  and to session-memory names; mark artifact-registry paths with their
  pull commands
- commit readme_example.jpg + pipeline_topology.svg so README renders
  on the plain Gitea repo view
- deploy_pages.sh: push built site/ to the gitea-pages branch
2026-07-19 22:06:56 +02:00
dtourolle 6f0ad83a55 feat(tooling): X-Ray threshold optimizer, gallery utilities, artifact registry, docs build
Optimizer (scripts/optimizer/): replay.py runs the real C++ tracker/matcher/
scene_tracker chain over a dumped-embeddings HDF5 via sae_kpn, so a threshold
sweep never re-decodes video or re-embeds faces. optimize.py drives scipy's
differential_evolution over the knob space, with DE-level parallelism
(multiple population candidates evaluated concurrently via a ThreadPoolExecutor)
on top of per-film replay parallelism. second_score.py is the per-second X-Ray
scoring metric (TPI/FPI/FN, out-of-cast misID weighted 10x, fair recall masked
to gallery-known cast) that superseded an earlier scene-union metric.
dump_error_frames.py / dump_scene_montage.py extract annotated video frames
(bounding boxes, TPI/FPI/FN captions, onscreen-vs-offscreen split) for visual
review of a replay against ground truth. Gallery utilities: cast_restrict.py,
gallery_membership.py, fetch_missing_actors.py, reembed_gallery.py.

scripts/validation/: X-Ray ground-truth loading and provider-agnostic identity
matching (identity.py's keys_for — an actor is the union of every id we can
derive, since pipeline output and ground truth don't share one id space).

scripts/artifacts/: push/pull scripts for the Gitea generic package registry —
galleries, montage frames, and experiment data (manifests/trajectories/results)
are pushed there instead of committed, since none are needed to run the app,
only benchmarks. Versioned by git short-SHA.

scripts/docs/: MkDocs site build (build_site.sh) and the calibration-curve
comparison chart (calibration_chart.py, matplotlib, reads each gallery's
embedded calibration).

Gallery-building scripts (make_jellyfin_gallery.py, make_gallery.py,
filter_gallery.py, run_from_jellyfin.py, movienet_eval.py, movienet_prep.py,
sae_gallery.py) updated to read/write HDF5 galleries exclusively, matching the
engine-side format switch. run_from_jellyfin.py and the optimizer no longer
carry movie source paths in shared manifests (some source filenames include
scene-release tags) — resolved locally via a gitignored file-lut.json instead.
2026-07-19 19:06:48 +02:00