From ca4661763c0c24e61a3eee708bef3866b23fb9c7 Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 12 Aug 2026 19:16:30 +0400 Subject: [PATCH] Stop a background extra's action reaching narration build_scene already dropped an unassigned detection from `characters` and `present`, so an extra never reached the cast list. Its ACTION did. `actions` was built from every detection, and that list is what the script prompt renders and what the verifier uses as evidence, so "standing at the window" arrived as a fact about the panel with no character attached and the verifier confirmed it, because the action really was in the blob. Skip has_face is False, the same gate and the same fail-open semantics as enrollment. Self-check covers all three cases: a real cast member's action survives, a faceless one's does not, and a detection from a panel where the detector never ran keeps its action. decisions/identity-bbox.md#extras-gate-consumers Co-Authored-By: Claude Opus 5 --- decisions/CLAUDE.md | 1 + decisions/identity-bbox.md | 30 ++++++++++++++++++++++++++++++ worker_scene.py | 22 +++++++++++++++++++++- 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/decisions/CLAUDE.md b/decisions/CLAUDE.md index ba8fc24..d7ef2da 100644 --- a/decisions/CLAUDE.md +++ b/decisions/CLAUDE.md @@ -44,3 +44,4 @@ still live belongs in `caveats/`. | [A stage result proves nothing until the worker is newer than the edit](identity-bbox.md#stale-worker-invalidates) | closed | | [A detection with no detected face never enrolls or binds](identity-bbox.md#face-gates-enrollment) | closed | | [A resolver NONE mints an anonymous character, it does not clear the crop](identity-bbox.md#none-mints-an-anonymous-character) | closed | +| [The extras gate runs at enrollment and at narration, not at the speaker prompt](identity-bbox.md#extras-gate-consumers) | closed | diff --git a/decisions/identity-bbox.md b/decisions/identity-bbox.md index c2308a9..46083b7 100644 --- a/decisions/identity-bbox.md +++ b/decisions/identity-bbox.md @@ -153,3 +153,33 @@ changed in the same session. Forbids: treating an absent `character_id` as one outcome. A resolver that answered and a resolver that failed are different facts. + +## The extras gate runs at enrollment and at narration, not at the speaker prompt {#extras-gate-consumers} + +**Closed, 2026-08-12. Written and self-checked, not yet proven on a GPU run.** + +`#face-gates-enrollment` stops a faceless detection taking an identity. It does not stop the detection +being narrated, because three places read the raw vision character list and only one of them consults an +assignment. Panel 7 is the worked example: six detections, four of them extras or scenery. + +`worker_scene.build_scene` already drops an unassigned detection from `characters` and `present` +(`worker_scene.py:63`), so an extra never reached the cast list. Its **action** did. `actions` was built +from every detection, and that list is what the script prompt renders and what the verifier uses as +evidence. So "standing at the window" arrived as a fact about the panel with no character attached, and +the verifier confirmed it because the action really was in the blob. Both now skip `has_face is False`. + +`service._beat` picks the cinematographer's "who" from the first three detections, falling back to a +detection's action when it has no name. An extra could take a slot and steer the camera. Also gated. + +`service._present_characters` is deliberately **not** gated. It builds the dialogue stage's candidate +speaker list and the set-of-mark boxes. Two reasons. The failure is already contained: an extra chosen as +the speaker has no identity assignment, so `normalize_dialogue` resolves it to unknown rather than to a +wrong name. And the gate's own cost lands hardest here, because a character drawn from behind has no face +box, so gating would delete a real speaker from the only list that can attribute their line. + +All three gates test `is False`, never falsiness. A vision blob written before the gate existed carries no +`has_face` key, and a panel whose detector failed is marked `True` by the fail-open path. Both keep their +previous behaviour. + +Forbids: adding a fourth consumer of `vision["characters"]` without deciding which side of this line it is +on. The blob keeps every detection on purpose, so the audit can still see what was gated. diff --git a/worker_scene.py b/worker_scene.py index 11f0110..66064e9 100644 --- a/worker_scene.py +++ b/worker_scene.py @@ -91,7 +91,13 @@ def build_scene(data: SceneInput): vchars = data.vision_result.get("characters", []) # `actions` is the list the orchestrator's beat builder reads for verifier evidence; `action` is the # joined string the script prompt renders. Emitting only the string left verification blind. - actions = [c["action"].strip() for c in vchars if str(c.get("action") or "").strip()] + # `characters` above already drops an unassigned detection, but its ACTION used to survive, so a + # background extra standing at a window became a sentence the narrator read out. A detection with no + # detected face is an extra, a figure on a poster, or scenery gemma called a person + # (`decisions/identity-bbox.md#face-gates-enrollment`). Gate on `is False`, like the enrollment gate, + # so a blob written before the gate existed and a panel whose detector failed both behave as before. + actions = [c["action"].strip() for c in vchars + if str(c.get("action") or "").strip() and c.get("has_face") is not False] action = "; ".join(actions) return {"panel_id": data.panel_id, "characters": characters, "dialogue": dialogue, "action": action, "actions": actions, @@ -132,6 +138,20 @@ if __name__ == "__main__": "description": '{"hair":"black","features":["glasses"]}'}], )) assert outu["characters"][0]["name"] == "" and outu["characters"][0]["label"] == "the one with black hair and glasses" + # a faceless detection's ACTION never reaches the script prompt or the verifier's evidence, but a + # real cast member's does, and so does one on a panel where the detector never ran (no key). + p7 = build_scene(SceneInput( + panel_id="p007", + vision_result={"characters": [ + {"local_id": "person_1", "action": "reading a report", "has_face": True}, + {"local_id": "person_2", "action": "standing at the window", "has_face": False}, + {"local_id": "person_3", "action": "pointing"}, + ]}, + identity_assignments=[{"local_id": "person_1", "character_id": "c1"}], + characters_registry=[{"character_id": "c1", "name": "Seonho"}], + )) + assert p7["actions"] == ["reading a report", "pointing"], p7["actions"] + assert "window" not in p7["action"], p7["action"] assert _describe({}) == "" # bare appearance -> empty, script falls back to Person X # animals are named as their species (color + species), never by clothing/"the one with..." assert _describe({"species": "cat", "hair": "black"}) == "the black cat"