Repository navigation
feat(acceptance): a frame judge that refuses to guess - #728
Merged
Merged
Conversation
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
force-pushed
the
feature/frame-acceptance-judge
branch
from
September 7, 2026 14:44
a29b315 to
ac83e1b
Compare
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Every gate in
clip_qa.pyis 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, 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
Each of these raises
JudgeUnavailableErrorrather than yielding a clean assessment:finish_reason == "length"verdict=acceptwith findingsverdict=rejectwith no findingsThis 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()extendsclip_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 forautomay route to a text-only model, which answers plausibly having seen nothing.autois 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.TemporalFailuremirrors the skill's seven names and a test pins them together — a rename there fails here loudly instead of the two drifting apart.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.FrameJudgeis 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.pyclip_qa.pystates 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 andclip_qa.pystays dependency-free.The two are complementary rather than competing:
clip_qameasures signals, the judge reads pictures. Neither can do the other's job — which is exactly why the monolith defect slipped through carrying afluidverdict.Test plan
tests/scripts— 257 passedpyright0 errors ·ruffclean ·ruff formatcleanNot 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 realimage_urlpart, real rotation across a real pool — is unproven. That needsGFLOW_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-01fixture ontest/temporal-acceptance-gateis 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:
prove_sight()wrote into a directory it never createdtmp_pathalways exists{.*}greedily to the last brace}— this discarded a correct verdictresponse.choices[0]onchoices=None"no endpoints found"not a dead-model marker#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.pyscores 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.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.