Skip to content

feat(acceptance): a frame judge that refuses to guess - #728

Merged
ffroliva merged 2 commits into
developfrom
feature/frame-acceptance-judge
Sep 7, 2026
Merged

ffroliva merged 2 commits into
developfrom
feature/frame-acceptance-judge

Conversation

@ffroliva

@ffroliva ffroliva commented Sep 7, 2026 •

Copy link
Copy Markdown
Owner

Why

Every gate in clip_qa.py is a per-frame or signal property. On 2026-09-07 a beat whose background monolith tripled in size and materialised top-down passed all of them:

fluid rb04 onset=0.00s face=1.9/0.63 frame=1.88 sync=+0.000s r=-0.11 a/v=+0.000s

fluid, with the second-highest frame-motion of five beats — because a hallucinated object is motion, so it raises the score. The project owner caught it by watching. clip_qa.py's own docstring has said this for months; nothing acted on it.

This is the missing gate: a judge that looks at a 1 fps contact sheet and reports temporal findings.

The invariant

A judge that could not look never returns "nothing found".

Each of these raises JudgeUnavailableError rather than yielding a clean assessment:

Condition Why it would otherwise read as ACCEPT
finish_reason == "length" half a JSON object parses leniently to zero findings
unparseable body ditto
empty body ditto
verdict=accept with findings the model contradicted itself; picking a half is guessing
verdict=reject with no findings nothing to act on
failure name outside the taxonomy cannot be scored against a fixture
model pool exhausted silence is not a clean bill of health

This is the same failure this repo has met three times in one week — a 20 s timeout read as "the feature is absent" (#701), a swallowed click read as "the composer is video-only" (#692), an incident bundle photographed after the page was blanked and read as "the DOM was gone" (#722). Silence is not evidence. Here it is a type.

prove_sight() extends clip_qa.py --selftest's discipline to the eye: show the judge a flat colour, refuse to believe any verdict until it names it. The failure it catches is invisible otherwise — a gateway asked for auto may route to a text-only model, which answers plausibly having seen nothing. auto is refused at construction.

Design

The ubiquitous language already existed in skills/video-production/SKILL.md's temporal pass, so this takes it rather than inventing a parallel vocabulary. TemporalFailure mirrors the skill's seven names and a test pins them together — a rename there fails here loudly instead of the two drifting apart.

scripts/dev/acceptance/
  domain.py     TemporalFailure · Verdict · Finding · Assessment · ContactSheet
                FrameJudge (Protocol) · JudgeUnavailableError   — no transport, no config
  settings.py   JudgeSettings — the ONLY module that knows where a judge lives
  judge.py      VisionFrameJudge · ScriptedFrameJudge

No provider name appears in any type. Which gateway, which key, which model family is configuration, resolved from GFLOW_CLI_LLM_* — the same settings the prompt tools and the skillopt harness already use. FrameJudge is a port because there are genuinely two implementations: a model-backed one, and a scripted one so the things that use a judge can be tested offline with a known answer.

Why the judge lives outside clip_qa.py

clip_qa.py states its own contract in its docstring — "ffmpeg and ffprobe only — no model, no numpy, no network" — and that contract is what makes it runnable on any machine, in CI, with nothing installed. A judge is a model call over a network, so it goes in its own module and clip_qa.py stays dependency-free.

The two are complementary rather than competing: clip_qa measures signals, the judge reads pictures. Neither can do the other's job — which is exactly why the monolith defect slipped through carrying a fluid verdict.

Test plan

  • 14 new tests, all passing
  • Both core invariants mutation-tested — deleting the truncation guard and the self-contradiction guard fails exactly their two tests and nothing else, so neither is a test that would pass against any implementation
  • tests/scripts — 257 passed
  • pyright 0 errors · ruff clean · ruff format clean
  • repo hygiene · doc links · council memory clean

Not verified

No live model call has been made. Every test runs against a fake client. prove_sight() is unit-tested in both directions but has never been pointed at a real gateway, so the network path — real multimodal encoding, a real image_url part, real rotation across a real pool — is unproven. That needs GFLOW_CLI_LLM_* set against a vision-capable endpoint, and it is the first thing to run before this judges anything that matters.

Nothing consumes the module yet, deliberately. Wiring it to the temporal-01 fixture on test/temporal-acceptance-gate is the next step and belongs with that branch.

Refs #685


Live-verified since opening

The judge had never made a real call when this PR opened. It has now, and the live path broke four times before it scored anything — each break invisible to a fake client, each now fixed with a regression test naming the live failure:

# Defect Why offline testing could not see it
1 prove_sight() wrote into a directory it never created pytest's tmp_path always exists
2 JSON matched {.*} greedily to the last brace no fake emitted a stray trailing } — this discarded a correct verdict
3 response.choices[0] on choices=None a real gateway returned 200 with an error body
4 "no endpoints found" not a dead-model marker the router's phrasing for all-providers-offline

#2 is the one worth reading. The model answered correctly — right defect, right name, right cell — and closed with one brace too many. Greedy matching swallowed it and reported the answer as unparseable. Refusing to guess is the invariant; refusing to read was a bug. Extraction now counts brace depth, honouring strings and escapes.

The benchmark, live

scripts/dev/acceptance/bench.py scores a judge against a matched pair with known answers — same beat, same cast, same plate, same model, differing only in the defect. Both arms required; a benchmark with only the bad arm is won by rejecting everything, which fails in the direction that looks diligent. The sight proof runs first and is fatal, because a blind judge's two verdicts would look exactly like data.

[ OK ] sight proof passed on nemotron-3-nano-omni-30b-a3b-reasoning:free
[PASS] reject-arm: expected reject, got reject
        background_morph @ cell 3: "a rock appears in the top right corner that is
        not present in cell 0, indicating the background terrain rearranges"
[PASS] accept-arm: expected accept, got accept

BENCH: PASS (2/2 arms correct)

Cell 3 at 1 fps is frame 72 of a 24 fps clip — 3.0s, the exact moment the fragment materialises. The judge found what a human found, and named where.

The "Not verified" caveat above is therefore discharged: the network path, multimodal encoding, rotation and the sight proof have all run against a live endpoint. What remains unproven is breadth — one model, one defect class, one pair.

Every gate in clip_qa.py is a per-frame or signal property. On 2026-09-07 a beat
whose background monolith tripled in size and materialised top-down passed all of
them -- and RAISED the motion score, because a hallucinated object is motion. A
human watching caught it. This module is the missing gate.

WHY IT IS A MODULE AND NOT A FUNCTION

The bounded context is visual clip acceptance, and its ubiquitous language ALREADY
EXISTS: skills/video-production/SKILL.md's temporal pass names seven failures. I
took that vocabulary rather than inventing a parallel one, because a judge that
answers "the background looks odd" cannot be scored against a fixture whose known
answer is `background_morph`. `TemporalFailure` mirrors the skill exactly and a
test pins the two together, so a rename fails loudly instead of drifting quietly.

  domain.py    TemporalFailure, Verdict, Finding, Assessment, ContactSheet,
               FrameJudge (Protocol), JudgeUnavailableError. No transport, no
               config, no provider.
  settings.py  JudgeSettings, resolved from GFLOW_CLI_LLM_*. The ONLY module that
               knows where a judge lives.
  judge.py     VisionFrameJudge (asks a model) and ScriptedFrameJudge (canned
               answers, so the things that USE a judge can be tested offline).
               Two implementations, which is why FrameJudge is a port.

NAMING: the provider is a detail that GFLOW_CLI_LLM_* already resolves, so no
gateway, vendor or product name appears in any type. The types are named for the
feature.

THE INVARIANT, WHICH IS THE WHOLE POINT

    A judge that could not look never returns "nothing found".

Truncated completion (finish_reason=length -- half a JSON object parses leniently
to zero findings, which reads as ACCEPT), unparseable body, empty body, a verdict
contradicting its own findings, a failure name outside the taxonomy, an exhausted
model pool: ALL raise JudgeUnavailableError. None yields a clean assessment.

This is the same failure this project has now met three times in a week -- a 20s
timeout read as "the feature is absent", a swallowed click read as "the composer
is video-only", an incident bundle photographed after the page was blanked and
read as "the DOM was gone". Silence is not evidence. Here it is a type.

prove_sight() extends clip_qa.py's --selftest discipline to the eye: show the
judge a flat colour and refuse to believe any verdict until it names it. The
failure that catches is specific and invisible -- a gateway asked for `auto` may
route to a TEXT-ONLY model, which answers every question plausibly having seen
nothing. `auto` is therefore refused at construction.

BOTH CORE INVARIANTS ARE MUTATION-TESTED: deleting the truncation guard and the
self-contradiction guard fails exactly their two tests and nothing else, so
neither is a test that would pass on any implementation.

WHY THE JUDGE LIVES OUTSIDE clip_qa.py

clip_qa.py states its own contract in its docstring: "ffmpeg and ffprobe only --
no model, no numpy, no network". That contract is what makes it runnable on any
machine, in CI, with nothing installed. A judge is a model call over a network,
so it goes in its own module and clip_qa.py stays dependency-free. The two are
complementary: clip_qa measures signals, the judge reads pictures.

Gates: pyright 0 errors, ruff clean, ruff format clean, 14 new tests, 257
tests/scripts passing, hygiene / doc links / council memory clean.

Refs #685
@ffroliva
ffroliva force-pushed the feature/frame-acceptance-judge branch from a29b315 to ac83e1b Compare September 7, 2026 14:44
…could not

The judge had never made a real call. Pointing it at a live gateway broke it four
times before it scored anything, and every break was invisible to a fake client.
Each is now fixed with a regression test that names the live failure.

1. prove_sight() wrote into a directory it never created. Every unit test passed
   because pytest's tmp_path always exists; the first real caller handed over a
   path it had not made and got FileNotFoundError before a single request went
   out. Now mkdir(parents=True, exist_ok=True).

2. JSON extraction matched `{.*}` greedily to the LAST brace. THIS ONE DISCARDED A
   CORRECT VERDICT. The model answered:

     {"verdict": "reject", "findings": [{"failure": "background_morph",
      "first_cell": 3, "evidence": "the rock formation in the background shifts
      position between cell 0 and cell 3"}]}}

   -- right defect, right name, right cell -- and closed with one brace too many.
   Greedy matching swallowed the extra and produced invalid JSON, so a correct
   answer was reported as unparseable. Extraction now counts brace depth and
   stops at the object's real end, honouring strings and escapes. Refusing to
   guess is the invariant; refusing to READ was a bug.

3. `response.choices[0]` on a body with no choices. A gateway under load returned
   200 with an error payload and choices=None; indexing it raised TypeError deep
   in the parse, reading as a bug here rather than a hiccup upstream. It is
   transient, so it now raises a plain error and ROTATES, rather than
   JudgeUnavailableError, which is re-raised without rotating.

4. "no endpoints found" was not a dead-model marker. That is how the router says
   every provider behind a model is offline. Unrecognised, the dead id kept its
   turn in rotation and burned an attempt on every pass.

BENCH RUNNER, and the live result

scripts/dev/acceptance/bench.py scores a judge against a matched pair with known
answers: same beat, same cast, same plate, same model, differing only in the
defect. Both arms are required -- a benchmark with only the bad arm is won by
rejecting everything, which fails in the direction that looks diligent. The sight
proof runs first and is fatal, because a blind judge's two verdicts would look
exactly like data.

Live, against the two takes of the beat that started this:

  [ OK ] sight proof passed on nemotron-3-nano-omni-30b-a3b-reasoning:free
  [PASS] reject-arm: expected reject, got reject
          background_morph @ cell 3: "a rock appears in the top right corner that
          is not present in cell 0, indicating the background terrain rearranges"
  [PASS] accept-arm: expected accept, got accept
  BENCH: PASS (2/2 arms correct)

Cell 3 at 1 fps is frame 72 of a 24 fps clip -- 3.0s, the exact moment the
fragment materialises. The judge found what a human found, and named where.

Gates: pyright 0 errors, ruff clean, format clean, 18 acceptance tests, 261
tests/scripts passing.

Refs #685
@ffroliva
ffroliva merged commit cb3e9b7 into develop Sep 7, 2026
18 checks passed
@ffroliva
ffroliva deleted the feature/frame-acceptance-judge branch September 7, 2026 16:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant