Files
manga-recap-pipeline/decisions/audit-phase1.md
T
kami 0cc6302245 Audit Phase 1: correctness and scheduling safety
Implements every P0 from AUDIT.md plus four P1s, across both halves of the
pipeline. Verified by CPU-only self-checks and the orchestrator test suite.
No GPU work ran and no pipeline ran.

workpc:
- worker_scene: read speaker_ref, not the rewritten speaker field. Every line
  narrated as "Someone" before this. Emit `actions` for the verifier.
- worker_script: declare beat + verifier_feedback (pydantic dropped both, so
  the retry was blind) and render them as a repair prompt.
- worker_vision: gate face->identity pairing on containment, assign globally
  shortest-first, map an out-of-range resolver index to `unresolved` instead
  of minting a character, parse JSON with raw_decode.
- session_manager: tear down a server whose lease vanished mid-load, and spawn
  the supervisor respawn unlocked.

orchestrator (edited in place, NOT committed there):
- tracklets: canonicalize gender, add co-presence cannot-links, block
  transitive bridges across a hard constraint.
- correctness: stop failing valid narration on sentence-initial capitals and
  short quotes; read action evidence from the singular key.
- db: stop orphan flags leaking into every chapter; resolve by flag id.
- service: TTS returns instead of raising under GATES, auto-resolves under
  autonomous mode; job admission control; registry names on dialogue resume.
- session_proxy: queue on 409 instead of stealing the lease; run heartbeats.

Docs restructured per the repo-structure layout: CLAUDE.md is a pointer table,
NEXT.md replaces HANDOFF.md, plus ROADMAP.md, JOURNAL.md, decisions/ and
caveats/. AUDIT.md now points at those instead of restating them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XD7cAy81MZrc7gCr6aZGWr
2026-08-11 10:16:24 +04:00

8.0 KiB

Audit Phase 1: correctness and scheduling safety

Settled 2026-08-11 from AUDIT.md. Every entry was verified by a runnable check in the same commit. No GPU work ran and no pipeline run was executed. Each claim rests on source and on the CPU-only self-checks named below.

Files: worker_scene.py, worker_script.py, worker_vision.py, session_manager.py on workpc, and tracklets.py, correctness.py, db.py, service.py, session_proxy.py in the homesrv orchestrator (/mnt/server/home/kami/docker-apps/manga-infra/orchestrator/).

Speaker ref is canonical

Closed. A dialogue row's speaker is speaker_ref when its kind is character_id. The flat speaker field is a compatibility value. The orchestrator already rewrote it from a panel-local id to a character_id.

Evidence: correctness.py:normalize_dialogue rewrites row["speaker"]. worker_scene.build_scene mapped that value through id_by_local, whose keys are panel-local ids. Every lookup returned None, so worker_script._render_line narrated every line as Someone.

Forbids: reading speaker as a panel-local id anywhere downstream of the dialogue stage. Check: python worker_scene.py, the p007 case.

The same field leaked into the dialogue resume path, where a stored character_id was pushed into recent as a speaker name. It now renders the registry name (service.py, run_stage_dialogue).

Verifier false positives

Closed. verify_script must stay quiet on valid narration. A failure is not soft. The script stage retries once and then raises, so one false positive halts the whole chapter.

Two rules fired on correct output.

  1. unsupported-proper-noun flagged any capitalized token outside a 20-word stop list. That caught ordinary sentence openers (Suddenly, Behind, Inside, Both, Everyone, After, Two) and Someone. A capital opening a sentence is now grammar unless the word falls outside a real narration vocabulary. A capital in the middle of a sentence still counts as name evidence.
  2. misquote compared each quoted span against a WHOLE source line at ratio 0.82. The narration prompt asks for a short quote of the actual words. A 15-character quote from a 40-character line scored about 0.55. Grounding now matches the longest contiguous span instead.

Forbids: adding a verifier rule without a test that a correct beat passes it. Check: pytest test_script_verify.py in the orchestrator.

Verifier feedback now reaches the retry. worker_script.ScriptInput declares beat and verifier_feedback. Pydantic v2 dropped both silently before, so the retry was another blind sample. The repair prompt names each failure and lists the exact quotable lines from the beat.

Tracklet hard constraints

Closed. Tracklet linking is transitive over similarity evidence and never across a hard constraint.

The hard constraints are gender and co-presence. Gender is canonicalized first. Vision emits m|f|unknown while the registry says male|female, and the old gate tested only the second spelling, so it never fired on real data. Co-presence means two detections in one panel are two people by construction. A merge is rejected when any cross pair between the two groups violates either rule. A weak chain can no longer bridge two people seen together.

Forbids: comparing a raw gender string against a literal enum anywhere in the identity path. Check: python tracklets.py and pytest test_tracklets.py.

Gated face pairing

Closed. A detected face takes a character's identity only when its centre falls inside that character's gemma bbox. The box is grown by 25% first. An unpaired face stays unknown.

This matters because the label becomes speaker_method="som_face", the highest-trust provenance the pipeline records. Unconditional nearest-neighbour pairing laundered a geometric guess into evidence. Pairs are now taken globally shortest-first, so the first face processed cannot claim a character that fits a later face better.

Set-of-mark attribution stays ON by default now that the pairing is gated. The docstring said "off by default" while the flag read SOM_ATTRIBUTION with default "1". The code and the comment now agree.

Forbids: trusting a som_face speaker without the containment gate. Check: python worker_vision.py.

No lease stealing

Closed. A 409 from /session/open means another job legitimately holds the GPU. The proxy queues on it. Only the session manager's TTL reaper clears a dead lease, because only it can tell a dead lease from a busy one.

session_proxy.open_session previously closed the active lease and retried, so two jobs terminated each other's models. Job admission control now bounds concurrent pipelines through MAX_CONCURRENT_JOBS, default 1. A lease queue alone still lets two pipelines interleave stages against one GPU.

heartbeat_session is finally called. open_session starts a beat task and close_session cancels it, so a stage running past its TTL is no longer reaped mid-run. Beating from open and close keeps all nine stage call sites unchanged.

Forbids: closing a session the caller did not open.

Flag resolution

Closed. Correctness flags resolve by flag id, and unresolved flags never wedge an autonomous run.

Two defects combined into a permanent block. get_correctness_flags accepted rows whose panel no longer existed. A flag raised against a panel that a crop rerun deleted became visible to every chapter and could never be resolved. resolve_correctness_flags could only touch flags whose panel still existed. The view no longer admits orphans, and resolution takes explicit flag ids.

The TTS block also had no reachable exit. It refused to start on four flag kinds that only /review/approve clears, and GATES defaults to off. With gates on, TTS now returns rather than raises, so awaiting_review survives instead of being overwritten by failed in the pipeline's catch-all. With gates off, the flags are logged and cleared, because no reviewer exists to clear them.

Forbids: raising out of a stage that has just set awaiting_review. Check: pytest test_name_binding.py in the orchestrator.

Hallucinated index

Closed. An out-of-range choice from /vision/resolve is unresolved, like a parse failure. Only an explicit 0 means NONE and mints a new character. Mapping a bad index to NONE created a brand new entry in the permanent registry from a hallucination.

Check: python worker_vision.py.

Unlocked model load {#unlocked-model-load} [#203]

Closed. The session manager never holds _lock across a model load or a health wait.

open_session was already written that way, but it could orphan the server it spawned. A /session/close arriving during the load found proc = None, tore down nothing, and cleared _active. The finished server then held its VRAM unreferenced, and the next open spawned a second one on the same port. Open now tears down its own process and reports 409 when its lease vanished mid-load.

_supervise_once had the opposite asymmetry. It called _start_subprocess while holding _lock, which blocked /session/active, /session/close, and /session/open for the full health wait. It now claims the respawn by clearing proc, spawns unlocked, and tears the new process down if the lease disappeared meanwhile.

Check: python session_manager.py.

_extract_json in both worker_vision.py and worker_script.py now uses json.JSONDecoder().raw_decode from the first brace. The greedy \{.*\} ran to the LAST brace in the reply. A second object or trailing braced prose burned a repair call on a response that parsed fine.

worker_scene.build_scene emits actions as a list beside the joined action string, because correctness.build_beat_artifact reads the plural key. The verifier was receiving no action evidence at all. The beat builder also falls back to splitting the singular string.