method-figure
Generate a publication-grade method / architecture / pipeline / workflow figure (a paper or README 'Figure 1') as an AUDITABLE object, not a one-shot prompt. A deterministic JSON blueprint LOCKS the content; an image model (gpt-image-2, baked by the agent via mcp__codex__codex —
Install
npx skills add https://github.com/wanshuiyin/ARIS-Movie-Director/tree/main/skills/method-figure
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install wanshuiyin-aris-movie-director@llmmart
git clone https://github.com/wanshuiyin/ARIS-Movie-Director.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole wanshuiyin/aris-movie-director collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
method-figure
Turn "draw our method figure" from a one-shot gamble into the same audited spiral the framework uses for comics: a blueprint is the source of truth, the image model bakes the look, a cross-model panel + a deterministic diff keep it honest, and the loop converges to a publication-grade figure that is reproducible (re-run the blueprint) and auditable (a trace of every round).
Two things are simultaneously true: (a) gpt-image-2 CAN render a clean Figure-1 with legible labels when conditioned on a labeled blueprint — do not assume it garbles text; (b) on a free prompt it DRIFTS (renames phases, invents nodes, garbles a token, leaves pasted-looking floating labels). The blueprint + blind-transcribe-then-hard-diff loop turns (a) into a reliable result and catches (b) every round.
system description ─▶ ① BLUEPRINT (JSON content-lock) ── validate_blueprint.py
▼
② CONDITION (white-bg labeled SVG → PNG) + identity sheet (real chibi, optional) ── render_condition.py --png
▼
③ BAKE — agent: mcp__codex__codex(prompt+abs ref paths+out_path, workspace-write, gpt-5.5, config{xhigh}) → gpt-image-2 native PNG ── pickup_image.py --out-existing (sig+size+dims, mtime-bound, HARD-VETO struct/zlib/PIL/SVG, fail-closed)
▼
④ PANEL — Gemini ‖ Codex BLIND-transcribe → content_diff.py (observed ⊖ blueprint) → Claude structural sign-off
▼
⑤ agent reads the diff + the panel blockers → re-bake re-asserting the locked labels
▼
converged? ─ no ─▶ ③ (bounded: max_rounds → escalate to human)
│ yes
▼
⑥ APPROVE → figure.png + blueprint.json + trace.jsonl
Constants
- GENERATOR = Codex
gpt-5.5,config: {model_reasoning_effort: xhigh, include_image_gen_tool: true}→ the nativeimage_generationtool (gpt-image-2). Thegpt-5.5pin is a single hardcoded COMPAT DEFAULT in the bake sidecar payload (run_spiral.pymirrorsrun_comic.py's canonical bake plan; a config-driven model override is PLANNED, not yet implemented). It pins the BAKE only — the panel's Codex reviewer is un-pinned (see PANEL below). CRITICAL: image_gen is produced ONLY viamcp__codex__codex(the agent tool), NOTcodex exec.codex exec/ over-specified / forbid-list prompts make Codex hand-draw a code fallback (struct+zlib PNG or SVG/matplotlib) — visually indistinguishable for trivial shapes, useless for a real method-figure. The working invocation ismcp__codex__codexwith a dead-simple prompt +sandbox: "workspace-write"(it must WRITE the out_path) +model: "gpt-5.5"+config: {model_reasoning_effort: "xhigh", include_image_gen_tool: true}(the schema has NO top-level effort param;config{xhigh}shorthand below ALWAYS expands to both these keys — withoutinclude_image_gen_toolcodex won't fire its native image tool, it falls back to descriptive text / an SVG renderer) +cwd: <project>. Reference images are passed by absolute file path inside the prompt (the schema has NO-i); the output path is a deterministic abs path in the prompt. Pick it up withpickup_image.py --out-existing(verifies the EXPLICIT out_path: PNG sig + size + dims,mtime >= request.created_at) which HARD-VETOES struct/zlib/PIL/<svg>/matplotlib markers in the agent transcript (fail-closed; there is no 'native sig wins' override). Honesty caveat: as of Jun 2026 native headless persistence is unreliable, so this fail-closed verifier — not anysandboxsetting — is the first guard against a non-native bake. But the HARD-VETO is a BEST-EFFORT denylist against the known codex-exec hand-draw fallback (struct/zlib/PIL/ SVG/matplotlib markers), NOT a complete security boundary — a novel fallback that emits a sig-valid PNG without those markers can slip past it. The load-bearing faithfulness gate is the cross-model blind-transcribe panel + the deterministiccontent_diff(the pixels are what reviewers transcribe), with this denylist as a cheap upstream filter. - PANEL (automated blind-transcribe) = the orchestrator SHELLS the
gemini+codexCLIs as subprocesses (both must be on PATH; MCP is ONLY the bake seam): Gemini =gemini --model auto-gemini-3; Codex =codex exec -i <png>with NO model pin (it follows the local codex config — currentlygpt-5.6-sol) at effortxhigh— so the reviewer model ≠ the bake's pinnedgpt-5.5. Plus the deterministiccontent_diff. Claude (this agent) is the post-pass STRUCTURAL sign-off, not a blind transcriber — the loop converges on Gemini-approve + Codex-approve + empty-diff, then Claude signs off. - CROSS-MODEL ACQUITTAL — Codex is the generation family, so a Codex
approvecan only diagnose/veto, never be the sole acquitter. ACCEPT requires Gemini approve + Claude structural approve + the hard-diff empty. - MAX_ROUNDS = 4, then escalate to human with best-so-far + open blockers.
- LABEL_POLICY =
bakedonly in v0 — the image model renders ALL text; nothing is hand-pasted. (hybrid/overlay— lock structure + vector-overlay the labels for paper zero-tolerance text — are on the v1 roadmap; do NOT use a vector overlay as an ad-hoc patch on a finished bake, it reads as pasted.) - OUTPUT_DIR =
figures/method_figure/<figure_id>/(figure.png, blueprint.json, condition.svg, trace.jsonl). - NATIVE-IMAGE FAIL-CLOSED — accept a bake ONLY if a real native PNG exists at the explicit out_path,
sha/size/dims check out and
mtime >= request.created_at, and the agent transcript shows no struct/zlib/ PIL/<svg>/matplotlib fallback (pickup_image.py --out-existing, HARD-VETO — a clean sig never overrides a fallback marker). This veto is a BEST-EFFORT denylist against the known codex-exec hand-draw fallback, NOT a complete security boundary (a novel marker-free fallback could evade it). The load-bearing faithfulness gate remains the cross-model blind-transcribe panel + the deterministiccontent_diff; the denylist is a cheap upstream filter that matters because native headless persistence is currently unreliable. - SERIALIZE BAKES — never run two image generations at once. The default
--bake-mode=agentwrites each native PNG to its explicit per-roundout_path(no shared dir), so concurrent agent bakes still risk a request/status sidecar race — keep one runner per figure. (The global~/.codex/generated_imagesdir + newest-after-marker pickup that could cross-pollinate concurrent bakes is a hazard of the LEGACY--bake-mode=execpath ONLY, which is retired for real bakes.)
Input contract / ARIS hand-off (who decides WHAT, who only renders)
This skill is pure render + verify. Ownership:
- Upstream owns the semantics — what to depict, the labels, the graph, the grouping, the headline claim/number, the identity refs. method-figure does NOT choose content and must not invent a node, claim, number, or method structure (if one is missing it ESCALATES, it does not make it up).
- Step-0 is now DETERMINISTIC —
run_spiral.pycallsscripts/compile_brief.pyto map amethod_figure_brief.json→ a schema-validblueprint.json+traceability.json, fail-closed (an object that can't trace to a brief field, or a missing claim/number/trait, is refused — not invented). It is no longer a manual LLM hop. Full field map + the guards:references/blueprint_authoring.md. - method-figure owns validation → condition render → image bake → cross-model panel → diff → retry, and
has VETO power: it returns
FAILED / Logic Driftrather than ship a figure whose pixels contradict the blueprint.
The default single input = a method_figure_brief.json (schemas/method_figure_brief.schema.json) —
ONE ARIS-format file; the blueprint, the coordinates, and the identity wiring are all derived. The identity
sheet is resolved from the brief's identity_refs[].path (no separate --identity to manage). Where the
input comes from, in authority order:
- a
method_figure_brief.json— the canonical ARIS hand-off (whatpaper-planemits); auto-detected (by itsschema_version: "method-figure/brief/v1") + compiled. · - an existing hand-tuned
blueprint.json— power-user override (--from-blueprint, used as-is). · - an
experiment-plan/paper-writemethod section / free-text — no brief yet: the agent first DRAFTS amethod_figure_brief.jsonfrom it (claims/numbers verbatim; anything missing → Refuse-and-Escalate), then compiles.
ARIS integration: the canonical producer is paper-plan — after its claims_matrix it emits the
method_figure_brief (components, flows, phases, the headline claim/number, identity refs, forbidden_tokens).
You feed that one file to run_spiral.py; Step-0 compiles it and the traceability is enforced by the
compiler (an un-traceable object is a Refuse-and-Escalate, not a render). The identity sheet is created once
upstream and locked; method-figure only reads it.
Fast path — one command (single input: a brief)
Feed ONE method_figure_brief.json; the whole loop is one command (all commands below run from the repo
root; the panel shells the gemini + codex CLIs, so both must be on PATH):
python3 skills/method-figure/scripts/run_spiral.py your_method_figure_brief.json --out-dir figures/method_figure/<id>
# auto-detects a brief → Step-0 compile_brief.py → blueprint.json + traceability.json (deterministic, fail-closed)
# → validates → renders condition(+png) → [bake (agent: mcp__codex__codex --bake-mode=agent, workspace-write,
# gpt-5.5 config{xhigh} → gpt-image-2 native PNG via the .bakereq.json sidecar) → pickup_image.py --out-existing
# verify (fail-closed, HARD-VETO over the status file's mcp_output) → Gemini + Codex blind-transcribe → content_diff → blockers] × rounds
# → on PANEL-CLEAN writes figure.png + blueprint.json + traceability.json + trace.jsonl.
# input auto-detect: a brief is detected ONLY by schema_version "method-figure/brief/v1" vs a blueprint (version);
# a bare components+flows JSON with NO schema_version is REFUSED — it REQUIRES --from-brief (fail-closed, no guessing)
# --identity is OPTIONAL (resolved from the brief's identity_refs[0].path); --dry-run prints the round-1 bake
# prompt; --p0-only runs the zero-credit gate (validate+compile+render+prompt-lint) then stops; --max-rounds N.
# There is NO --effort knob (the flag is removed) — bake + review effort are hardcoded xhigh by design.
# --gemini-cmd overrides how the google-family reviewer is shelled (default: the legacy `gemini` CLI). Legacy
# CLI dead (IneligibleTierError, 2026-07)? pass --gemini-cmd "python3 cli/gemini_agy_shim.py" — the shipped
# Antigravity shim pins a Gemini model (the second-reviewer slot must stay google-family for quorum honesty).
Power-user / override: already have a hand-tuned blueprint?
run_spiral.py blueprint.json --identity sheet.png --out-dir … --from-blueprintruns the legacy path unchanged. A worked example brief lives atexamples/method_figure/method_figure_brief.json. Long-running (each bake ~3-8 min) — run it in the background; watchtrace.jsonl. It converges to PANEL-CLEAN — BOTH reviewers returned parseable JSON, Gemini approve AND Codex approve, the deterministiccontent_diffempty, core scores (incl.character_identitywhen an identity sheet is given) ≥ threshold, and no anomalies/blockers — then STOPS and hands to the calling agent (Claude) for the final structural sign-off (the generator family never self-acquits). The manual steps below are exactly whatrun_spiral.pyautomates (run them to debug one stage).
Who runs --bake-mode=agent (the agent-wrapper SOP) — REQUIRED for the default mode to function
The bake is a synchronous sidecar handshake and the skill agent is its fulfiller (without it, every bake
polls to --bake-timeout and escalates with failure_kind="other" — fail-closed, not a hang, never a false throttle):
- Launch the orchestrator in the BACKGROUND (from the repo root):
python3 skills/method-figure/scripts/run_spiral.py your_brief.json --out-dir figures/method_figure/<id> --bake-mode agent. - Loop until it prints PANEL-CLEAN / escalates / exits:
- watch
<out-dir>/for a new*.bakereq.json(the orchestrator writesround<N>.png.bakereq.json); - read it; call
mcp__codex__codexwith exactly its{prompt: <prompt_text>, model:"gpt-5.5", config:{include_image_gen_tool:true, model_reasoning_effort:"xhigh"}, sandbox:"workspace-write", cwd:<cwd>}(codex writes the native PNG to the sidecar'sout_path). TheconfigMUST carry bothinclude_image_gen_tool:trueANDmodel_reasoning_effort:"xhigh": withoutinclude_image_gen_toolCodex will not fire its nativegpt-image-2tool (it falls back to a struct/zlib/SVG hand-draw), andxhighis the required reasoning tier; - then read
request_idfrom the*.bakereq.jsonand write<out>.bakestatus.jsoncarrying the status, a bounded rawmcp_output, AND thatrequest_idVERBATIM —mcp_outputso the HARD-VETO can scan it (the core feeds this file topickup --transcript; anokstatus with no raw output makes the veto INERT), andrequest_idbecausepickup_image.py --out-existing --request-idfail-closes the bake if the statusrequest_idis missing or mismatched (write it on BOTH ok and fail):{"status":"ok","mcp_output":"<raw>","request_id":"<verbatim from bakereq>"}, or{"status":"fail","failure_kind":"throttle","mcp_output":"<raw>","request_id":"<verbatim from bakereq>"}on a 429 /MODEL_CAPACITY_EXHAUSTED/ overloaded error (else{"status":"fail","failure_kind":"other","mcp_output":"<raw>","mcp_error":"<raw>","request_id":"<verbatim from bakereq>"}).
- watch
- The core proceeds to verify ONLY on
status:"ok", viapickup_image.py --out-existing(sig + dims + size > 500000 +mtime >= created_at, HARD-VETO overmcp_output, and--request-idfail-close if the statusrequest_idis absent/mismatched).--bake-mode=execis the legacy/CI non-image path and RAISES if it reaches a real bake.
Workflow (what run_spiral.py automates — or run by hand)
① Author the BLUEPRINT (content lock)
Write blueprint.json per schemas/blueprint.schema.json. The *_exact fields (label_exact, desc_exact,
group/edge/callout *_exact, rail.label_exact) are the LOCKED text re-asserted verbatim every round;
expected_tokens[] are what the panel must blind-transcribe and the diff checks. Then:
python3 skills/method-figure/scripts/validate_blueprint.py blueprint.json # jsonschema (if installed) + unique ids · edges resolve · box/group/callout bounds · no dup labels
② Render the CONDITION
python3 skills/method-figure/scripts/render_condition.py blueprint.json --out condition.svg --png condition.png # white-bg labeled layout → rasterized
Prepare identity_sheet.png from the project's REAL characters if the figure has any (never invent robots).
The condition PNG + the identity sheet are the two image references.
③ BAKE (round N) — agent seam
Call mcp__codex__codex (sandbox workspace-write — it must WRITE the out_path; model: gpt-5.5,
config: {model_reasoning_effort: xhigh, include_image_gen_tool: true}, cwd: <project>) with the prompt from
references/prompt_templates.md §A — it RE-ASSERTS every *_exact label + the round-N blockers + the carried
positive_invariants, with condition.png + identity_sheet.png referenced by absolute path inside the
prompt (the schema has no -i) and the exact out_path to save the native PNG. Write the bake status to
round<N>.png.bakestatus.json carrying the raw mcp_output (so the HARD-VETO can scan it) AND the
request_id copied VERBATIM from round<N>.png.bakereq.json (pickup --request-id fail-closes if it's
missing/mismatched), then verify the explicit out_path (no marker/glob):
python3 skills/method-figure/scripts/pickup_image.py --out-existing --out figures/method_figure/<id>/round<N>.png --min-bytes 500000 --aspect <W/H> --created-at <epoch> --request-id <uuid4 hex from round<N>.png.bakereq.json> --transcript figures/method_figure/<id>/round<N>.png.bakestatus.json
④ PANEL — blind transcribe, then hard diff
Ask each of the TWO blind transcribers — Gemini + Codex (references/prompt_templates.md §B) — for the STRICT
JSON of references/reviewer_protocol.md: they transcribe observed_tokens / observed_edges /
identity_audit and an anomalies list (the Negative-Space Audit), NOT shown the expected labels.
Claude is NOT a transcriber — it never produces a blind round<N>.cc.json; its structural sign-off comes
post-pass in ⑤/⑥. Save as round<N>.{gemini,codex}.json, then:
python3 skills/method-figure/scripts/content_diff.py blueprint.json round<N>.gemini.json round<N>.codex.json
# → missing_tokens / unaccounted_tokens / anomalies ; empty == content-accurate
⑤ Decide (stop rule) — the agent consolidates
Read the diff report + the two transcribers' blockers. The executing agent itself merges blockers only
(ignore nice_to_have — chasing polish makes it oscillate), carries the union of positive_invariants
forward, and writes the round-N+1 bake prompt.
- ACCEPT iff: diff has no
missing_tokens/anomalies· Geminiapprove· Codexapprove(required, but never the sole acquitter) · Claude structuralapprove· every core score ≥acceptance.min_core_score(default 4). - RETRY iff: blockers are prompt/condition-fixable and
round < MAX_ROUNDS→ back to ③. - ESCALATE to human iff: same root failure 2 rounds · irreconcilable reviewers · MAX_ROUNDS hit · or a non-prompt-fixable failure (throttle / identity drift / no native image).
⑥ Finalize + trace
On ACCEPT: copy the approved PNG to figures/method_figure/<id>/figure.png, keep blueprint.json, and append
to trace.jsonl per round: {round, blueprint_sha, condition_sha, generated_sha, reviewers:{...verdicts}, hard_diff:{missing_tokens,anomalies}, fixes:[...], decision} + a final {final_approve, image, blueprint, accepted_round, verdicts}. Failures are kept — the fixes that were needed are the memory (the figure-wiki).
Hard do / don't (earned lessons)
- DO lock content in the blueprint and RE-ASSERT every
*_exactlabel in every regeneration — image models drift content every round; the blueprint is the anchor. - DO bake via the agent (
mcp__codex__codex, workspace-write) and fail-closed if no real native PNG at the explicit out_path (pickup_image.py --out-existing, HARD-VETO struct/zlib/PIL/<svg>/matplotlib in the status file'smcp_output). - DO use the project's real identity refs; anchor each character to the identity sheet. For a character figure, every reviewer ENUMERATES each chibi's visible hands — a wrong count / 3rd / floating / merged limb is a single-reviewer veto (the literal-diff is blind to anatomy).
- DO run the zero-credit P0 gate before the first metered bake:
run_spiral.py brief.json --out-dir … --p0-only(validate brief → compile blueprint → render condition → confirm the bake prompt carries ALL locked labels, the identity path resolves, the background is white). A blocker caught here costs zero image credits. - DON'T regenerate when the score-signature is IDENTICAL across rounds — that means the judge is broken
(gone design-blind), not the figure. Stop and audit the rubric (
feedback_gate_identical_scores_judge_broken). - DON'T hand-paste text onto a finished bake (reads as pasted/fake) — that is what burned us; the whole figure, text included, is generated. (Engineered vector overlay is a future policy, not a patch.)
- DON'T use a dark theme for a paper/README figure — light/pastel on white.
- DON'T let one model (especially the generator's family) self-acquit; the panel is cross-model.
Scope
| Figure type | Fit |
|---|---|
| method overview / pipeline / architecture / workflow | excellent |
| conceptual / taxonomy / comparison diagrams | good |
| statistical plots | no → plotting tool |
| exact-topology deterministic vector figures | prefer a pure-vector renderer |
| photo-realistic scenes / long narrative comics | no (comics use the framework's spiral engine) |
A converged worked example ships in examples/method_figure/: the ARIS-Movie-Director Figure 1 — blueprint +
figure.png + condition.svg + the real 4-round trace.jsonl (Gemini approve + Codex approve + empty diff, then
Claude's structural sign-off). PROMPTS.md there
publishes the exact, unedited prompt sequence that baked it (all 4 gpt-image-2 bakes + the cross-model
critiques, paths redacted) — the canonical exhibit of how detailed a condition must be; copy its shape.
Implemented / roadmap
- ✅
scripts/compile_brief.py— Step-0 automation: deterministicmethod_figure_brief.json→blueprint.json+traceability.json(the ADJ-4 field map,auto_layout, fail-closedvalidate_traceability). This is what makes the skill single-input —run_spiral.py brief.jsonauto-detects- compiles, so you never hand-write a blueprint or hand-place coordinates.
- ✅
scripts/run_spiral.py— the one-command orchestrator (sniff input → [Step-0 if brief] → bake→pickup→ panel→diff→consolidate→decide loop to PANEL-CLEAN).--p0-onlyruns the zero-credit gate;--from-brief/--from-blueprintdisambiguate. Folds blocker-consolidation + invariant-carry inline. - 🔭
scripts/overlay_labels.py+label_policy: hybrid/overlay— vector-overlay the structured labels on the bake for paper zero-tolerance text. Default staysbaked(fully generated). - 🔭 a Claude-vision reviewer inside the orchestrator (currently the automated panel is Gemini + Codex + the deterministic diff; Claude — the calling agent — gives the structural sign-off on the converged figure).
Protocols (governance contracts this skill honors)
reviewer-independence— reviewers blind-transcribe from the image only; the generator (Codex image_gen) ≠ the visual judges.acceptance-gate— the loop drives, can't acquit: ACCEPT needs the deterministic content-diff clean + Gemini approve + Codex no-veto + Claude structural sign-off.artifact-integrity— the baker doesn't judge its own figure's numbers; the blueprint is ground truth, verified by the blind diff.reviewer-routing— bake sidecar pins Codexgpt-5.5+xhigh(a hardcoded compat default; config-driven override is planned); the CLI reviewers pin NO model (they follow the local codex config — currentlygpt-5.6-sol) atxhigh; Geminiauto-gemini-3; never downgrade effort.review-tracing— every round's reviewer verdicts are logged totrace.jsonl.
Files (aris-movie-director)
-
examples
-
method_figure
-
blueprint.json 14.3 KB
{ "version": "method-figure/blueprint/v1", "figure_id": "aris_movie_director_method", "compiled_from_brief": { "type": "aris_movie_director_method" }, "canvas": { "width": 1495, "height": 1318, "background": "#FFFFFF" }, "render_policy": { "label_policy": "baked", "target_profile": "readme", "max_rounds": 4 }, "nodes": [ { "id": "researcher", "label_exact": "researcher", "shape": "character", "must_render": true, "source": "brief:components/researcher", "expected_tokens": [ "hands", "night", "over", "researcher", "the" ], "forbidden_tokens": [ "self-judge", "LLM-as-a-judge" ], "desc_exact": "hands over the night", "semantic_role": "human author", "asset_ref": "cast", "pos": { "x": 212, "y": 223 }, "size": { "w": 185, "h": 66 } }, { "id": "brief", "label_exact": "story brief", "shape": "document", "must_render": true, "source": "brief:components/brief", "expected_tokens": [ "24h", "brief", "deadline", "story" ], "forbidden_tokens": [ "self-judge", "LLM-as-a-judge" ], "desc_exact": "24h deadline", "semantic_role": "input document", "pos": { "x": 212, "y": 335 }, "size": { "w": 185, "h": 66 } }, { "id": "outline", "label_exact": "outline", "shape": "document", "must_render": true, "source": "brief:components/outline", "expected_tokens": [ "beats", "outline", "story" ], "forbidden_tokens": [ "self-judge", "LLM-as-a-judge" ], "desc_exact": "story beats", "semantic_role": "authored layer", "group": "p1", "accent": "blue_accent", "pos": { "x": 540, "y": 275 }, "size": { "w": 210, "h": 78 } }, { "id": "storyboard", "label_exact": "storyboard", "shape": "process", "must_render": true, "source": "brief:components/storyboard", "expected_tokens": [ "dialogue", "literals", "scene", "storyboard" ], "forbidden_tokens": [ "self-judge", "LLM-as-a-judge" ], "desc_exact": "scene + dialogue + literals", "semantic_role": "authored layer", "group": "p1", "accent": "blue_accent", "pos": { "x": 540, "y": 399 }, "size": { "w": 210, "h": 78 } }, { "id": "comicjson", "label_exact": "comic.json", "shape": "document", "must_render": true, "source": "brief:components/comicjson", "expected_tokens": [ "comic.json", "of", "single", "source", "truth" ], "forbidden_tokens": [ "self-judge", "LLM-as-a-judge" ], "desc_exact": "single source of truth", "semantic_role": "contract artifact", "group": "p1", "accent": "blue_accent", "pos": { "x": 540, "y": 534 }, "size": { "w": 250, "h": 100 } }, { "id": "blueprint", "label_exact": "content-SVG", "shape": "document", "must_render": true, "source": "brief:components/blueprint", "expected_tokens": [ "SVG", "blueprint", "content-SVG", "exact" ], "forbidden_tokens": [ "self-judge", "LLM-as-a-judge" ], "desc_exact": "exact SVG blueprint", "semantic_role": "deterministic condition", "group": "p2", "accent": "peach_accent", "pos": { "x": 900, "y": 275 }, "size": { "w": 210, "h": 78 } }, { "id": "bake", "label_exact": "image_gen BAKE", "shape": "process", "must_render": true, "source": "brief:components/bake", "expected_tokens": [ "BAKE", "blueprint", "identity", "image_gen" ], "forbidden_tokens": [ "self-judge", "LLM-as-a-judge" ], "desc_exact": "blueprint + identity", "semantic_role": "generator", "group": "p2", "accent": "peach_accent", "pos": { "x": 900, "y": 410 }, "size": { "w": 250, "h": 100 } }, { "id": "gate", "label_exact": "panel_gate", "shape": "character", "must_render": true, "source": "brief:components/gate", "expected_tokens": [ "CC", "Codex", "Gemini", "panel_gate" ], "forbidden_tokens": [ "self-judge", "LLM-as-a-judge" ], "desc_exact": "CC | Gemini | Codex", "semantic_role": "cross-model review", "group": "p2", "accent": "peach_accent", "asset_ref": "cast", "pos": { "x": 900, "y": 556 }, "size": { "w": 250, "h": 100 } }, { "id": "verdict", "label_exact": "verdict", "shape": "diamond", "must_render": true, "source": "brief:components/verdict", "expected_tokens": [ "diff", "literal", "verdict" ], "forbidden_tokens": [ "self-judge", "LLM-as-a-judge" ], "desc_exact": "literal diff", "semantic_role": "decision", "group": "p2", "accent": "peach_accent", "pos": { "x": 900, "y": 691 }, "size": { "w": 210, "h": 78 } }, { "id": "keep", "label_exact": "KEEP", "shape": "process", "must_render": true, "source": "brief:components/keep", "expected_tokens": [ "KEEP", "accepted", "panel" ], "forbidden_tokens": [ "self-judge", "LLM-as-a-judge" ], "desc_exact": "panel accepted", "semantic_role": "accept state", "group": "p2", "accent": "peach_accent", "pos": { "x": 900, "y": 815 }, "size": { "w": 210, "h": 78 } }, { "id": "wiki", "label_exact": "research-wiki", "shape": "datastore", "must_render": true, "source": "brief:components/wiki", "expected_tokens": [ "failures", "logged", "research-wiki" ], "forbidden_tokens": [ "self-judge", "LLM-as-a-judge" ], "desc_exact": "failures logged", "semantic_role": "memory", "group": "p2", "accent": "peach_accent", "pos": { "x": 900, "y": 939 }, "size": { "w": 210, "h": 78 } }, { "id": "assembly", "label_exact": "page assembly_gate", "shape": "gate", "must_render": true, "source": "brief:components/assembly", "expected_tokens": [ "assembly_gate", "check", "coherence", "drift", "page" ], "forbidden_tokens": [ "self-judge", "LLM-as-a-judge" ], "desc_exact": "coherence + drift check", "semantic_role": "page review", "group": "p3", "accent": "green_accent", "pos": { "x": 1240, "y": 275 }, "size": { "w": 210, "h": 78 } }, { "id": "viewer", "label_exact": "release", "shape": "output", "must_render": true, "source": "brief:components/viewer", "expected_tokens": [ "HTML", "PNG", "panels", "release" ], "forbidden_tokens": [ "self-judge", "LLM-as-a-judge" ], "desc_exact": "PNG panels + HTML", "semantic_role": "output", "group": "p3", "accent": "green_accent", "pos": { "x": 1240, "y": 399 }, "size": { "w": 210, "h": 78 } } ], "title": { "main": "ARIS-Movie-Director", "sub": "A baked panel that looks right can still be wrong; only a blind cross-model literal-diff passes it." }, "assets": [ { "id": "cast", "path": "identity_sheet.png", "role": "identity_sheet", "source": "brief:identity_refs/cast", "lock_traits": [ "researcher: black hair, glasses, black tee", "executor: brown hair, blue hoodie, no beard", "reviewer: black hair, beard, green hoodie" ] } ], "groups": [ { "id": "p1", "label_exact": "1 · Authored Source of Truth", "bounds": { "x": 385, "y": 190, "w": 310, "h": 454 }, "tone": "blue", "order": 0, "source": "brief:phases/p1" }, { "id": "p2", "label_exact": "2 · The Audited Spiral (per panel)", "bounds": { "x": 745, "y": 190, "w": 310, "h": 848 }, "tone": "peach", "order": 1, "source": "brief:phases/p2" }, { "id": "p3", "label_exact": "3 · Assembly + Release", "bounds": { "x": 1105, "y": 190, "w": 270, "h": 308 }, "tone": "green", "order": 2, "source": "brief:phases/p3" } ], "edges": [ { "from": "researcher", "to": "brief", "kind": "flow", "must_render": true, "source": "brief:flows/researcher->brief" }, { "from": "brief", "to": "outline", "kind": "flow", "must_render": true, "source": "brief:flows/brief->outline" }, { "from": "outline", "to": "comicjson", "kind": "flow", "must_render": true, "source": "brief:flows/outline->comicjson" }, { "from": "storyboard", "to": "comicjson", "kind": "flow", "must_render": true, "source": "brief:flows/storyboard->comicjson" }, { "from": "comicjson", "to": "blueprint", "kind": "flow", "must_render": true, "source": "brief:flows/comicjson->blueprint", "label_exact": "source", "expected_tokens": [ "source" ] }, { "from": "blueprint", "to": "bake", "kind": "flow", "must_render": true, "source": "brief:flows/blueprint->bake", "label_exact": "render", "expected_tokens": [ "render" ] }, { "from": "bake", "to": "gate", "kind": "flow", "must_render": true, "source": "brief:flows/bake->gate" }, { "from": "gate", "to": "verdict", "kind": "flow", "must_render": true, "source": "brief:flows/gate->verdict" }, { "from": "verdict", "to": "keep", "kind": "keep", "must_render": true, "source": "brief:flows/verdict->keep", "label_exact": "keep", "expected_tokens": [ "keep" ] }, { "from": "verdict", "to": "bake", "kind": "retry", "must_render": true, "source": "brief:flows/verdict->bake", "label_exact": "RETRY (max 4)", "expected_tokens": [ "4", "RETRY", "max" ], "direction": "back" }, { "from": "gate", "to": "wiki", "kind": "write", "must_render": true, "source": "brief:flows/gate->wiki", "label_exact": "write", "expected_tokens": [ "write" ] }, { "from": "keep", "to": "assembly", "kind": "flow", "must_render": true, "source": "brief:flows/keep->assembly" }, { "from": "assembly", "to": "bake", "kind": "repair", "must_render": true, "source": "brief:flows/assembly->bake", "label_exact": "repair drift", "expected_tokens": [ "drift", "repair" ], "direction": "back" }, { "from": "assembly", "to": "viewer", "kind": "flow", "must_render": true, "source": "brief:flows/assembly->viewer", "label_exact": "accept", "expected_tokens": [ "accept" ] } ], "callouts": [ { "id": "punchline", "title_exact": "Looks right != pass", "lines_exact": [ "A baked panel that looks right can still be wrong; only a blind cross-model literal-diff passes it.", "result: +6.2" ], "accent": "red", "source": "brief:headline_claim+headline_number", "expected_tokens": [ "+6.2", "Looks", "baked", "be", "blind", "can", "cross-model", "it.", "literal-diff", "looks", "only", "panel", "pass", "passes", "result", "right", "still", "that", "wrong" ], "pos": { "x": 747, "y": 1123 }, "size": { "w": 420, "h": 150 } } ], "forbidden_tokens": [ "self-judge", "LLM-as-a-judge" ], "style": { "theme": "academic_flat", "font_family": "Inter, Arial, sans-serif", "palette": { "text": "#1F2937", "muted": "#6B7280", "blue_fill": "#E5EEFB", "blue_stroke": "#9DBDEB", "blue_accent": "#2563EB", "peach_fill": "#FDEBD8", "peach_stroke": "#F4C18A", "peach_accent": "#EA580C", "green_fill": "#DEF5E6", "green_stroke": "#9BD9B0", "green_accent": "#0E9F6E", "violet_fill": "#EDE9FE", "violet_stroke": "#C4B5FD", "violet_accent": "#7C3AED", "amber_fill": "#FEF3C7", "amber_stroke": "#FCD34D", "amber_accent": "#D97706", "red": "#DC2626", "node_fill": "#FFFFFF", "node_stroke": "#CBD2DC" }, "arrow": { "stroke": "#4B5563", "width": 2.6 } }, "rail": { "label_exact": "max 4 rounds | blind cross-model diff | human backstop", "source": "framework:rail_constant" }, "acceptance": { "min_core_score": 4, "required_transcribers": [ "gemini", "codex" ], "codex_policy": "required_not_sole", "claude_structural_signoff_required": true, "required_approvers": [ "gemini", "claude" ], "veto_fields": [ "anomalies", "missing_tokens", "wrong_edges" ], "max_repeated_failure": 2 } } -
condition.svg 11.1 KB · in bundle
-
figure.png 132 B · in bundle
-
method_figure_brief.json 3.7 KB
{ "schema_version": "method-figure/brief/v1", "figure_id": "aris_movie_director_method", "figure_purpose": "ARIS-Movie-Director", "headline_claim": "Looks right != pass", "headline_number": "+6.2", "caption_thesis": "A baked panel that looks right can still be wrong; only a blind cross-model literal-diff passes it.", "topology_constraint": "left_to_right_phases", "target_profile": "readme", "forbidden_tokens": ["self-judge", "LLM-as-a-judge"], "identity_refs": [ { "id": "cast", "path": "identity_sheet.png", "traits": [ "researcher: black hair, glasses, black tee", "executor: brown hair, blue hoodie, no beard", "reviewer: black hair, beard, green hoodie" ] } ], "phases": [ {"id": "p1", "label": "1 · Authored Source of Truth", "members": ["outline", "storyboard", "comicjson"]}, {"id": "p2", "label": "2 · The Audited Spiral (per panel)", "members": ["blueprint", "bake", "gate", "verdict", "keep", "wiki"]}, {"id": "p3", "label": "3 · Assembly + Release", "members": ["assembly", "viewer"]} ], "components": [ {"id": "researcher", "label": "researcher", "one_line": "hands over the night", "role": "human author", "visual_priority": "secondary", "identity_ref": "cast"}, {"id": "brief", "label": "story brief", "one_line": "24h deadline", "role": "input document", "visual_priority": "secondary"}, {"id": "outline", "label": "outline", "one_line": "story beats", "role": "authored layer", "phase": "p1"}, {"id": "storyboard", "label": "storyboard", "one_line": "scene + dialogue + literals", "role": "authored layer", "phase": "p1"}, {"id": "comicjson", "label": "comic.json", "one_line": "single source of truth", "role": "contract artifact", "phase": "p1", "visual_priority": "core"}, {"id": "blueprint", "label": "content-SVG", "one_line": "exact SVG blueprint", "role": "deterministic condition", "phase": "p2"}, {"id": "bake", "label": "image_gen BAKE", "one_line": "blueprint + identity", "role": "generator", "phase": "p2", "visual_priority": "core"}, {"id": "gate", "label": "panel_gate", "one_line": "CC | Gemini | Codex", "role": "cross-model review", "phase": "p2", "visual_priority": "core", "identity_ref": "cast"}, {"id": "verdict", "label": "verdict", "one_line": "literal diff", "role": "decision", "phase": "p2"}, {"id": "keep", "label": "KEEP", "one_line": "panel accepted", "role": "accept state", "phase": "p2"}, {"id": "wiki", "label": "research-wiki", "one_line": "failures logged", "role": "memory", "phase": "p2"}, {"id": "assembly", "label": "page assembly_gate", "one_line": "coherence + drift check", "role": "page review", "phase": "p3"}, {"id": "viewer", "label": "release", "one_line": "PNG panels + HTML", "role": "output", "phase": "p3"} ], "flows": [ {"from": "researcher", "to": "brief", "kind": "flow"}, {"from": "brief", "to": "outline", "kind": "flow"}, {"from": "outline", "to": "comicjson", "kind": "flow"}, {"from": "storyboard", "to": "comicjson", "kind": "flow"}, {"from": "comicjson", "to": "blueprint", "kind": "flow", "label": "source"}, {"from": "blueprint", "to": "bake", "kind": "flow", "label": "render"}, {"from": "bake", "to": "gate", "kind": "flow"}, {"from": "gate", "to": "verdict", "kind": "flow"}, {"from": "verdict", "to": "keep", "kind": "keep", "label": "keep"}, {"from": "verdict", "to": "bake", "kind": "retry", "label": "RETRY (max 4)", "direction": "back"}, {"from": "gate", "to": "wiki", "kind": "write", "label": "write"}, {"from": "keep", "to": "assembly", "kind": "flow"}, {"from": "assembly", "to": "bake", "kind": "repair", "label": "repair drift", "direction": "back"}, {"from": "assembly", "to": "viewer", "kind": "flow", "label": "accept"} ] } -
PROMPTS.md 21.5 KB
# The real prompts that baked `docs/method_figure.png` (the README Figure 1) This is the **actual, unedited prompt sequence** that produced the README's "Figure 1 — method overview" (`docs/method_figure.png`). It is published verbatim (paths redacted to repo-relative) so you can see *how detailed an image-generation condition has to be* — this skill's whole thesis is **"condition it exhaustively, don't hand-paste."** Nothing in that figure was overlaid by hand; every box, arrow, and character was rendered by the model from the text below. **How it was run.** Each bake is one call to **codex (`gpt-5.5`, `model_reasoning_effort: xhigh`)** producing the native `gpt-image-2` image. In the **current** doctrine the real bake is the **agent `mcp__codex__codex` sidecar** (`--bake-mode=agent`, `sandbox: workspace-write` so it WRITEs the explicit `out_path`); `codex exec` is **retired** for real bakes. What keeps the bake honest is **not** any `sandbox` setting but a **fail-closed verifier** (`pickup_image.py --out-existing`: native PNG sig + dims + `mtime` check, HARD-VETO on any struct/zlib/SVG/matplotlib fallback marker). Between bakes a **cross-model panel** critiques the rendered pixels; the consolidated fix-list becomes the next bake. It converged in **4 rounds**; v4 was approved and shipped. The round-by-round verdicts are in `trace.jsonl`. *(The verbatim round-by-round prompts below still say `sandbox: read-only` — that was the earlier exec-path mechanism; they are preserved as-is as a truthful record, not rewritten.)* > **Process note — the skill has since evolved (kept honest).** This figure was baked under an earlier > *3-model* critique (Codex ‖ Gemini ‖ Claude all reading the pixels), which the round-by-round prompts below > still name verbatim. The **current** `run_spiral.py` panel is **Gemini + Codex blind-transcribe → a > deterministic `content_diff`** (the automated acquittal), with **Claude doing a separate *structural* > sign-off afterward** — Claude is no longer one of the blind-transcribe panelists. The historical prompts are > preserved as-is (not rewritten) so this stays a truthful record of how the shipped figure was actually made. Two reference images were attached to every bake (read-only): - `docs/figassets/aris_identity_sheet.png` — the **exact** chibi cast (don't invent robots/mascots). - `docs/figassets/condition_cond.png` — a rough layout draft, used only for the left→right 3-phase arrangement. > Reuse note: the four BAKE prompts are the **condition** exemplars; the round-1 CRITIQUE and the final > APPROVAL are the reusable **review** templates. Copy the shape, swap the content. See also > `references/prompt_templates.md` (the generic templates) and `references/reviewer_protocol.md`. --- ## Round 1 — BAKE (the full initial condition: this is "how detailed") ``` TASK: produce ONE publication-quality "Figure 1 — method overview" image for a GitHub README, by USING YOUR IMAGE GENERATION TOOL (gpt-image-2) to render the final PNG. This is an image-generation task — do NOT write or edit code, do NOT output SVG, do NOT touch repo files. Just generate the best image you can, iterate it in your head, and output the final raster image. You have two reference images to read first (read-only): - docs/figassets/aris_identity_sheet.png → the EXACT character cast you must use (do NOT invent robots/mascots): researcher = black hair, rectangular glasses, black tee; executor = brown hair, blue hoodie, NO beard; reviewer = black hair, full beard, green hoodie. All are cute pixel-art chibi. - docs/figassets/condition_cond.png → a ROUGH layout draft. Use it ONLY for the overall left→right 3-phase arrangement. IMPROVE on it: its arrows are messy and its little white floating label-pills look pasted-on — replace those with clean arrows whose labels sit naturally on the line; and its boxes are too hollow — make each box genuinely informative. STYLE: the visual language of a top ML-paper Figure 1 (think PaperBanana / AutoFigure): PURE WHITE background, three soft pastel phase panels (pale blue, pale peach, pale green), rounded white node cards with subtle soft shadows, clean thin connector arrows with small inline labels, a confident title top-left. Crisp legible sans-serif text; monospace for code/number tokens. It must look hand-designed by a researcher, not like a game screenshot. CONTENT — render this pipeline richly (every box gets a short, substantive description, not one vague word): TITLE (top-left): "ARIS-Movie-Director" · subtitle: "Audited, cross-model spiral generation for narrative comics". PHASE 1 — "Authored Source of Truth" (pale blue): • Asset Library — one canonical source for every recurring visual token (DDL clock, stamps, mugs, charts, star-map) → "one visual dialect, never two". • Outline — the 13 story beats. • Storyboard — per-panel spec: world · scene · dialogue · expected_literals. • These compile into comic.json — {content_svg, expected_literals, identity_ref}, the single source of truth the gate later checks against. The researcher chibi stands at the far left, exhausted, handing a "story brief (24h deadline)" document into Phase 1. PHASE 2 — "The Audited Spiral (per panel)" (pale peach) — the hero of the figure: • content-SVG blueprint — exact numbers & labels drawn deterministically. • image_gen BAKE — codex bakes a pixel-art comic panel from (blueprint + identity ref). • panel_gate — THREE independent cross-model reviewers: CC (narrative) ‖ Gemini (visual) ‖ Codex (visual). They blind-transcribe the panel's literals and a deterministic token-diff compares them to expected_literals; content_corruption is a single-vote veto. Draw the blue executor and the green bearded reviewer here, inspecting a freshly baked panel (the reviewer with a magnifier) — they ARE the gate. • verdict (deterministic) → KEEP, or RETRY (re-bake with the failed attempt's repair note; max 4 / panel). • research-wiki — every attempt / review / decision is logged; failures are kept as memory. • A highlighted callout "looks right ≠ passes": a gorgeous baked panel that still fails — expected +6.2 vs observed +6.25 → content_corruption → verdict RETRY. (the punchline: aesthetic beauty never bypasses the deterministic check.) PHASE 3 — "Assembly + Release" (pale green): • page assembly_gate — cast-aware page coherence; if a panel drifts, re-bake the named panels (max 6 / run). • Release — the kept panels become comic.json + a single-file clickable HTML viewer (show a tiny finished comic page). BOTTOM RAIL: "bounded retries (≤4/panel) · localized repair (≤6/run) → human backstop · the loop drives, never self-acquits". Arrows: left→right main flow through the three phases; a RETRY loop-back from verdict to BAKE inside Phase 2; a dashed "repair drift" loop from assembly_gate back to BAKE; gate writes to research-wiki. Keep arrows clean and non-crossing; labels small and ON the arrows. Output the single finished figure image. Make the text accurate and legible, the characters on-model (our chibi only), and the composition genuinely beautiful. ``` ## Round 1 — CRITIQUE (reusable review template: blind, pixel-level, prioritized fix-list) ``` PURE-ANALYSIS visual critique. Open and VIEW this image file, then critique it (read-only; do NOT edit/generate/code now): docs/figassets/method_codexmcp_v1.png You (codex, gpt-5.5) generated it via gpt-image-2 from a method-figure brief. We're now running an iterative refine loop: each round YOU will regenerate an improved version via image generation, after a 3-model panel (you, Gemini, Claude) critiques. This is the CRITIQUE step. Inspect the actual rendered pixels and give a CONCRETE, prioritized fix-list: 1. TEXT fidelity: list any label that is misspelled / garbled / blurry / illegible, with the correct text. (must be exact: comic.json, content-SVG, image_gen BAKE, panel_gate, research-wiki, page assembly_gate, "expected +6.2 / observed +6.25", "CC | Gemini | Codex".) 2. ARROWS/FLOW: broken/crossing/ambiguous arrows? Is left→right + the RETRY loop (verdict→bake) + repair-drift loop (assembly→bake) + gate→wiki clear? 3. LAYOUT: crowding, dead space, misalignment, uneven phase panels, box-size inconsistency. 4. CHARACTERS: are our 3 chibi on-model (researcher black-hair+glasses; executor blue-hoodie no-beard; reviewer green-hoodie beard) and correctly placed (duo AT panel_gate)? any duplication/weirdness? 5. The 3 highest-impact changes to make it look like a real top-ML-paper Figure 1. If you genuinely cannot see the image pixels, say so explicitly. Otherwise return a tight numbered fix-list, each item a concrete instruction usable as an image-gen prompt edit. ``` *Panel verdict v1 → RETRY. Consolidated fixes: floating pasted-looking edge labels (`source`/`keep`/ `accept`) → labels on the line; put BOTH chibi at panel_gate; shields on both gates; single-direction repair-drift arrow; highlight +6.2/+6.25.* --- ## Round 2 — BAKE (apply the consolidated v1 fix-list) ``` REGENERATE the method figure — round 2. Use your IMAGE GENERATION tool (gpt-image-2) to output ONE improved PNG. This is image generation, NOT coding: do not write/edit code or files, only generate the image. Read these (read-only): - docs/figassets/method_codexmcp_v1.png → YOUR previous version. Improve it; keep its good parts (white bg, 3 pastel phases, the researcher/duo chibi style, the overall left→right story). - docs/figassets/aris_identity_sheet.png → the exact character cast (researcher: black hair+glasses; executor: brown hair, blue hoodie, NO beard; reviewer: black hair, full beard, green hoodie). Use ONLY these. A 3-model panel (you + Gemini + Claude) reviewed v1. Apply ALL these consolidated fixes in the regeneration: TEXT (render EXACT, crisp, no extra spaces, no garble): - "comic.json" (NOT "comic .json"), "content-SVG", "image_gen BAKE", "panel_gate", "research-wiki", "page_assembly_gate" (with underscore), "verdict", "KEEP", "Release". - panel_gate subtitle EXACTLY "CC | Gemini | Codex" (drop the long "narrative/visual" form), plus one short line "blind token-diff · single-vote veto". - bottom callout EXACTLY "looks right ≠ passes" and "expected +6.2 / observed +6.25". - improve sharpness of all small text so nothing is blurry. ARROWS / FLOW (clean, non-crossing): - MAIN flow = a single thicker left→right path: content-SVG → image_gen BAKE → panel_gate → verdict → KEEP → page_assembly_gate → Release. - RETRY loop = a clear curved ORANGE arrow starting at the verdict diamond and returning to image_gen BAKE, labelled "RETRY · repair note · max 4/panel". - repair-drift = ONE single-direction dashed BLUE arrow from page_assembly_gate back to image_gen BAKE, labelled "repair drift · max 6/run". - audit trace = a thin grey arrow from panel_gate to research-wiki, labelled "write audit trace". CHARACTERS (this is important): - EXACTLY three character placements, nowhere else: (1) the researcher chibi at the far-left input handing over the "story brief (24h deadline)"; (2) BOTH the blue-hoodie executor AND the green-hoodie bearded reviewer standing together AT panel_gate, inspecting a freshly baked panel (executor holding the panel, reviewer with a magnifier). - Do NOT duplicate the chibi anywhere else. The kept-panel, the bottom "beautiful panel" callout, and the Release thumbnail must show COMIC ARTWORK / scenes (e.g. a little pixel landscape or city), NOT the duo again. LAYOUT / AESTHETIC (make it a clean top-ML-paper Figure 1): - Balance the three phases: spread Phase 2 vertically so panel_gate / verdict / research-wiki / callout are not cramped; enlarge the Release content in Phase 3 so the green panel isn't mostly empty. - Uniform node widths, equal margins, consistent font sizes and line weights; add a subtle soft drop-shadow to each of the 3 pastel phase panels; pure white background, lighten/remove any background grid noise. - Give BOTH gates (panel_gate, page_assembly_gate) a small consistent shield/gate icon. - Highlight the "+6.2 vs +6.25" mismatch with a small yellow highlight so the content_corruption point is the visual centerpiece. - Compress the bottom legend to ONE short line: "bounded retries (max 4/panel) · localized repair (max 6/run) · human backstop". Keep all the box descriptions substantive (one short informative line each) — do not make boxes hollow. Output the single improved figure image. ``` *Panel verdict v2 → RETRY. Flagged: top title dropped, Asset Library too bare, callout/Release used generic landscapes instead of comic panels.* --- ## Round 3 — BAKE (restore title + density + character-driven callout) ``` REGENERATE — round 3. Use your IMAGE GENERATION tool (gpt-image-2) to output ONE improved PNG. Image generation only, no code/files. Base to improve (read-only): docs/figassets/method_codexmcp_v2.png — keep ALL of v2's good parts (3 pastel phases, exact text, the duo at panel_gate, shields on both gates, the +6.2/+6.25 yellow highlight, the compressed legend, the clean left→right flow). Characters (read-only): docs/figassets/aris_identity_sheet.png — our exact chibi. The 3-model panel (you + Gemini + Claude) reviewed v2 and asked for ONE more round. Apply EXACTLY these deltas, change nothing else: 1. TITLE: restore a strong top-left header — "ARIS-Movie-Director" in large bold, with the muted subtitle "Audited, cross-model spiral generation for narrative comics" beneath it. (v2 lost the title; the top is too empty.) 2. RETRY arrow: draw a clear ORANGE curved arrow that STARTS at the "verdict" diamond and ENDS at "image_gen BAKE", labelled "RETRY · repair note · max 4/panel". Its origin must unambiguously be the verdict diamond (not research-wiki, not the gate). 3. repair-drift arrow: make it ONE dashed BLUE arrow with a SINGLE arrowhead, going FROM "page_assembly_gate" TO "image_gen BAKE" (a drift sends panels back to be re-baked), labelled "repair drift · max 6/run". Remove the second/extra arrowhead — no bidirectional ambiguity. 4. Fix the "single-vote veto" text under panel_gate: render it crisp BLACK, no red strike-through, no ghosting. 5. Phase 1 "Asset Library" is too bare — enrich it with a row of small pixel-art icons (a DDL clock, a stamp, a coffee mug, a tiny chart, a star-map) and the small motto "one visual dialect, never two". Bring back v1's density here. 6. SEMANTICS: the bottom "looks right ≠ passes" callout panel and the "Release" thumbnails currently show generic landscapes — replace them with CHARACTER-DRIVEN pixel-art COMIC panels (a tiny research-desk / city-at-night scene with our chibi style), so it reads as "narrative comics", not stock scenery. Inside the callout's comic panel, show a small score/number readout where "+6.2" is highlighted and conflicts with the observed "+6.25" — make the literal mismatch visible in the art. 7. CHARACTERS — keep EXACTLY three placements, no more, no fewer: (1) the researcher chibi at the FAR LEFT handing over "story brief (24h deadline)" — this is intentional (the human hands the night over), DO NOT remove it and it is NOT a duplicate; (2) the blue-hoodie executor AND green-hoodie bearded reviewer together inside panel_gate. No chibi anywhere else. 8. Give "write audit trace" clean spacing so it doesn't crowd the arrow. Everything else stays as in v2. Output the single improved figure. ``` *Panel verdict v3 → RETRY. The aesthetics jumped — but the model **drifted the content**: it renamed the phases to PLAN/BAKE/RELEASE, invented `self_critique` / `VLM pass-fail` / `(≤24h)` time-tags / `8 panels`, and moved research-wiki into Phase 1. This is the canonical failure mode: **a prettier round silently rewrites your labels.** The fix is to LOCK the exact text in round 4.* --- ## Round 4 — BAKE (lock exact labels, keep v3's look) → this produced `docs/method_figure.png` ``` REGENERATE — round 4 (final polish). Use your IMAGE GENERATION tool (gpt-image-2) to output ONE PNG. Image generation only. Base to improve (read-only): docs/figassets/method_codexmcp_v3.png Characters (read-only): docs/figassets/aris_identity_sheet.png KEEP v3's visual style EXACTLY — it's good: the big top-left "ARIS-Movie-Director" title + subtitle, the Asset Library icon row, the bottom-left LEGEND box, the "looks right ≠ passes" callout showing the two comic panels comparing +6.2 vs +6.25, and the "Release (example panels)" row of comic panels, the soft pastel phase panels, drop shadows, clean academic look. BUT v3 DRIFTED the text content. A 3-model panel says: restore the CANONICAL pipeline text EXACTLY. The box labels are LOCKED — use these exact words, do NOT rename, do NOT invent any extra node/term/time-tag: PHASE 1 header: "1 · Authored Source of Truth" (NOT "PLAN") box: "Asset Library" — sub: "one visual dialect, never two" box: "Outline" — sub: "13 beats" box: "Storyboard" — sub: "world · scene · dialogue · expected_literals" box: "comic.json" — sub: "content_svg · expected_literals · identity_ref" (flow: Asset Library → Outline → Storyboard → comic.json) PHASE 2 header: "2 · The Audited Spiral (per panel)" (NOT "BAKE") box: "content-SVG blueprint" box: "image_gen BAKE" — sub: "blueprint + identity ref" box: "panel_gate" — sub line 1: "CC | Gemini | Codex" — sub line 2: "blind token-diff · single-vote veto" (with a small shield icon; the blue executor + green bearded reviewer chibi inspecting a baked panel here) diamond: "verdict" — sub: "KEEP / RETRY" box: "research-wiki" — sub: "attempts · reviews · decisions · failures" (flow: content-SVG blueprint → image_gen BAKE → panel_gate → verdict → KEEP) PHASE 3 header: "3 · Assembly + Release" (NOT "RELEASE" alone) box: "page_assembly_gate" — sub: "cast-aware coherence · repair drift → re-bake" (small shield icon) box: "Release" — sub: "PNG panels + single-file HTML viewer" DELETE everything invented in v3: the "(≤24h)" / "(≤3×24h)" time tags, "self_critique", "VLM pass/fail", "storyboard_plan / 8 panels", "PDF", "pass/retry/escalate", and the misplaced research-wiki in Phase 1. research-wiki belongs ONLY in Phase 2. ARROWS (fix direction, single arrowhead each): - main flow: thicker left→right through the three phases. - "RETRY · max 4/panel": ONE orange arrow, arrowhead ONLY at image_gen BAKE, starting at the verdict diamond (verdict → image_gen BAKE). - "repair drift · max 6/run": ONE dashed blue arrow, arrowhead ONLY at image_gen BAKE, from page_assembly_gate → image_gen BAKE. - thin grey "write audit trace": panel_gate → research-wiki. TEXT: make all small subtitles + bottom comic captions slightly LARGER and crisp — no blurry/garbled characters. CHARACTERS: exactly the researcher chibi at the far-left input (handing the story brief — keep it), and the executor+reviewer duo inside panel_gate. No chibi anywhere else. Output the single final figure. ``` ## Final — APPROVAL check (reusable acceptance template; codex is diagnostic, not sole acquitter) ``` FINAL approval check. VIEW v4 (read-only, no gen): docs/figassets/method_codexmcp_v4.png Round 4 after the content-accuracy fixes. Canonical it must show: Phase 1 "Authored Source of Truth" (Asset Library/"one visual dialect, never two" → Outline 13 beats → Storyboard → comic.json content_svg·expected_literals·identity_ref); Phase 2 "The Audited Spiral (per panel)" (content-SVG blueprint → image_gen BAKE → panel_gate "CC | Gemini | Codex · blind token-diff · single-vote veto" → verdict KEEP/RETRY → research-wiki); Phase 3 "Assembly + Release" (page_assembly_gate cast-aware coherence·repair drift→re-bake → Release "PNG panels + single-file HTML viewer"). Arrows: RETRY max 4/panel verdict→bake (single head), repair drift max 6/run assembly→bake (single head). Answer crisply: 1. Did all content corrections land? Any leftover invented terms (self_critique / VLM / time-tags / PDF / 8 panels / misplaced research-wiki)? 2. Are the two loop arrows now single-headed and correctly directed? 3. Any text still garbled/blurry (name the label)? 4. VERDICT: APPROVED (ship) or ONE-MORE-ROUND with the few exact remaining image-gen instructions. Be decisive and tight. ``` **v4 → APPROVED by all three (Codex ‖ Gemini ‖ Claude).** Shipped as `docs/method_figure.png`. --- ## What this example teaches (the transferable lessons) 1. **Detail is the product.** The round-1 condition is ~4.3k chars and names every box, sub-line, arrow color/direction, and the exact character placement. image_gen renders figure text *legibly* when you condition it this hard — it garbles when you're vague. 2. **The agent seam + a fail-closed verifier keep the bake native.** The real bake is the agent `mcp__codex__codex` sidecar (`--bake-mode=agent`, `sandbox: workspace-write`); `codex exec` is retired. Codex can still "help" by writing an SVG/struct fallback, so the load-bearing safeguard is the fail-closed verifier (`pickup_image.py` HARD-VETO on fallback markers), **not** any sandbox setting. 3. **Condition, never paste.** Every literal in the figure was rendered by the model from text above; we pasted nothing on top. 4. **Lock exact labels or a prettier round will rewrite them** (the v3 drift → v4 lock). This is exactly what the blueprint's `*_exact` fields and the deterministic content-diff enforce in the automated loop. 5. **Cross-model critique drives convergence; the generator is diagnostic, not the sole acquitter** — v4 shipped only on a 3-model APPROVE. 6. **Use OUR identity sheet, never a generic mascot.** The cast was anchored to `aris_identity_sheet.png` on every round. -
trace.jsonl 2 KB · in bundle
-
-
-
references
-
blueprint_authoring.md 10.2 KB
# Step-0 — brief → blueprint, the compile-first reference method-figure renders + verifies a `blueprint.json`; it does not decide content. **Step-0** turns an upstream brief into that blueprint. As of the single-input upgrade, **Step-0 is a deterministic compile, not an LLM hop**: when a `method_figure_brief.json` exists, `run_spiral.py` calls [`scripts/compile_brief.py`](../scripts/compile_brief.py) (map → `validate_traceability` → write `blueprint.json` + `traceability.json`) and proceeds. You do **not** hand-author a blueprint or hand-place coordinates. The LLM's judgement moves to where it belongs: **upstream** (authoring the brief, when only prose exists) and **downstream** (the structural sign-off after the cross-model panel passes). ## Source, by authority order 1. an existing `blueprint.json` → use as-is: `run_spiral.py blueprint.json --identity sheet.png --out-dir DIR --from-blueprint` (skip Step-0; `--out-dir` is required). 2. a **`method_figure_brief.json`** ([`schemas/method_figure_brief.schema.json`](../schemas/method_figure_brief.schema.json), what **paper-plan** emits after its claims_matrix) → **the canonical single input.** `run_spiral.py brief.json --out-dir …` auto-detects it and compiles. **You feed one file.** 3. an `experiment-plan` / `paper-write` method section / free-text description → no brief yet: the agent FIRST drafts a `method_figure_brief.json` (NOT a blueprint), copying every claim/number/name **verbatim**, then runs the command above. A missing claim/number/identity-trait is a **Refuse-and-Escalate**, never invented. ## The deterministic mapping (ADJ-4 — what `compile_brief.py` does; authoritative) Every brief field maps to a blueprint object, and every blueprint object records the brief field it came from (`source`). This table supersedes any older partial mapping. | brief field | → blueprint | notes | |---|---|---| | `components[]` `.label` / `.one_line` | `nodes[]` `label_exact` / `desc_exact` | locked text | | `components[].role` | `node.semantic_role` | (was previously dropped) | | `components[].visual_priority` | `node.size` via `auto_layout` (bigger = higher priority) | **size only — NO center-weighting** | | `components[].identity_ref` | `node.asset_ref` | anchors a character to its sheet | | `components[].phase` | `node.group` + the group `tone`/`accent` | grouping is driven by `component.phase`; `phases[].members` is **consistency-checked** against it (must agree), not the grouping source | | `flows[]` | `edges[]` `from/to/kind/label_exact/direction` | every `kind` ∈ the schema edge enum | | `phases[]` | `groups[]` `label_exact` + `bounds` | bounds computed by `auto_layout`, never hand-set | | `headline_claim` + `headline_number` | a `callouts[]` punchline: `title_exact` ← claim; the **number lands in `lines_exact` AND `expected_tokens`** | so the blind-diff catches a garbled number (the `+6.2`/`+6.25` failure) | | `callouts[]` | additional `callouts[]` | | | `caption_thesis` | `title.sub` **AND** the punchline callout's `lines_exact` | composition intent + the felt thesis line | | `identity_refs[]` | `assets[]` (`role: identity_sheet`, `lock_traits` ← `traits`) | the single pointer to the sheet | | `symbol_registry[]` | the relevant node's `expected_tokens` (figure_symbol kept **==** paper_variable) | anti-drift | | `topology_constraint` | drives `auto_layout` (linear_flow / feedback_loop / hierarchical_stack / left_to_right_phases / free) | don't free-style topology | | `target_profile` | `render_policy.target_profile` — **passed explicitly** | brief default `paper` must not fall to the blueprint default `readme` | | `forbidden_tokens` | blueprint **top-level** `forbidden_tokens` + every node's `forbidden_tokens` + the per-round DELETE-list | injected into every bake prompt's DELETE-list | | `figure_id` | `blueprint.figure_id` + `compiled_from_brief.type` | | | `figure_purpose` | `title.main` | | | `inputs[]` / `outputs[]` | leading `document` / trailing `output` nodes (only when not already a component id) | | | `callouts[].anchor` | `callout.anchor` | passed through | The compiler also stamps `compiled_from_brief: {type}` (which makes `validate_blueprint.py` require a `source` on every object) and an honest `acceptance` block (`required_transcribers:["gemini","codex"]`, `codex_policy:"required_not_sole"` — Codex approve is required but never the sole acquitter, `claude_structural_signoff_required:true`) matching what `run_spiral.py` actually runs. ## The guards (enforced by the compiler + the gate + the panel — not by a prompt) 1. **Traceability is a gate (GUARD-10).** Every node/edge/group/callout/asset/locked-token maps to `brief:*`. No untraced `self_critique` / invented time-tags / renamed phases. `compile_brief.py --strict` (the default) refuses to emit and names the offending field — a missing number/claim/trait is an escalation, not license. 2. **White background (GUARD-1).** For `target_profile ∈ {readme,paper,slide}` the canvas is white/light. A dark base is a prohibited dead end (see the R1 fossil below). Enforced on BOTH sides: the render/policy sets white, AND the reviewer panel's `style_fit` vetoes a dark/low-contrast bake — one check is not enough. 3. **Condition, not paste (GUARD-2).** `condition.svg/png` is a LAYOUT CONTRACT + prompt reference; the final image is natively baked from it. No post-hoc vector overlay, no pasted edge-pills, no `*_overlay.svg` repair path. `label_policy` stays `baked` in v0 (`hybrid`/`overlay` is a future *policy*, not a fix for a bad bake). 4. **Native tool via the agent seam, fail-closed (GUARD-4).** The real bake is the **agent** `mcp__codex__codex` sidecar in **`--bake-mode=agent`** (Codex `gpt-5.5`, `config: {model_reasoning_effort: xhigh}`, `sandbox: workspace-write` so it can WRITE the explicit `out_path`). `codex exec` is **retired** for real bakes (`--bake-mode=exec` RAISES if it reaches one). No `sandbox` setting forces the native tool — the **load-bearing safeguard is the fail-closed verifier**: `pickup_image.py --out-existing` verifies a real native PNG (sig + sha + dims + `mtime >= created_at`) and **HARD-VETOES** any struct/zlib/PIL/`<svg>`/matplotlib fallback marker in the agent transcript (no 'clean sig wins' override). 5. **Exact locks re-asserted EVERY round (GUARD-3).** `run_spiral.locked_labels(bp)` re-emits every `*_exact` (group/node/edge/callout/rail) + every exact numeric token into every bake prompt — a regeneration may not silently drop or rename a label. 6. **Identity + anatomy (GUARD-5).** Identity-lock is active ONLY when `identity_refs[]` exists. For character figures every reviewer **ENUMERATES each chibi's visible hands**; a wrong count / a 3rd hand / a floating or merged limb is a **single-reviewer veto** (the literal-diff is blind to anatomy). Contradictory pose specs (crossed arms + raised hand) must fail before the bake, not after. 7. **Blind-transcribe → deterministic diff (GUARD-7).** The panel (Gemini + Codex) transcribes what it SEES without being shown the expected values; `content_diff.py` does `observed ⊖ blueprint`. "Looks right" never passes — only an empty literal-diff + the visual panel + Claude's structural sign-off does. 8. **Round repair = blockers-only + a DELETE-list (GUARD-8).** Each retry carries blockers + positive invariants + an explicit DELETE-list (`brief.forbidden_tokens ∪ prior-round unaccounted_tokens`). Nice-to-have is never carried. **Identical score-signatures across rounds ⇒ `judge_audit_required`** — stop regenerating and audit the rubric (it has gone design-blind), the lesson from `feedback_gate_identical_scores_judge_broken`. 9. **Zero-credit P0 gate (GUARD-9).** Before any metered `image_gen`: validate the brief → compile the blueprint → render the condition → lint that the bake prompt contains ALL locked labels → identity paths resolve → background is white → the DELETE-list is present. `run_spiral.py … --p0-only` runs exactly this and stops. A blocker here costs zero credits; a blocker found after baking costs a regeneration. 10. **Honest trace (GUARD-11).** Failed rounds STAY in `trace.jsonl`. Manual post-run polish is logged in `docs/GENERATION_RETRO.md`, **never** back-written as a synthetic spiral node (that would fake the audit). ## The real R1→R4 journey (the history this crystallizes — see `examples/method_figure/PROMPTS.md` + `trace.jsonl`) The figure was iterated many times; the dead ends are the lesson, not noise: - **R1 — dark base.** The first bakes used a dark/moody background. It looked like a product splash, not a paper Figure-1: low label contrast, "pasted"-looking floating text. → GUARD-1 (white) was born here. - **R2 — vector-overlay repair (abandoned).** To "fix" the labels we tried compositing a vector overlay over the bake. The result read as pasted-on, and it broke the single-image contract. We abandoned overlay as a repair path → GUARD-2 (condition, not paste): the condition is a reference the model bakes FROM, not a layer glued ON. (Overlay survives only as a hypothetical future *policy*, never as a fix for a bad bake.) - **R3 — white + condition + locked labels.** White canvas, the labeled condition as reference image 1, the identity sheet as image 2, every `*_exact` re-asserted in the prompt. Close — but a number drifted (`+6.2`→`+6.25`) and a chibi came back with a miscounted hand, which "looks right" sailed past a lazy glance. → GUARD-7 (blind diff catches the number) + GUARD-5 (enumerate hands) were born here. - **R4 — converged.** Re-assert exact labels + the number in `expected_tokens` + per-hand enumeration + white bg → empty literal-diff + clean visual panel + structural sign-off. `PROMPTS.md` holds the literal prompts and critiques of all four rounds; `trace.jsonl` holds the per-round verdicts. That history is the spec, crystallized. ## Power-user / legacy: hand-authoring a blueprint You rarely need this (the brief path is the default). If you DO hand-tune a blueprint, run `run_spiral.py blueprint.json --identity sheet.png --out-dir … --from-blueprint`. The same guards apply; the only difference is you own the field map yourself. Run `scripts/validate_blueprint.py` before baking. Do not hand-author a blueprint when a brief exists — compile the brief so traceability is enforced for free. -
paper_to_brief.md 4.4 KB
# paper → `method_figure_brief.json` (the input-generation SOP) `run_spiral.py` consumes a **`method_figure_brief.json`** (the single input). If you only have a **paper / method section** and no brief yet, this is the repo-local SOP your coding agent follows to PRODUCE that brief. It is an **agent procedure** (the agent reads the paper + fills the brief), not a CLI — the same shape as ARIS's `paper-plan`, which emits this brief after its `claims_matrix`. ## Prerequisites (the bake + review stages need these on PATH) - **`codex` CLI**, logged in + on PATH — the orchestrator SHELLS it (`codex exec`, NO model pin: it follows your local codex config, currently gpt-5.6-sol; effort xhigh) as the Codex blind-transcribe reviewer. The real bake is a SEPARATE seam: the calling agent's `mcp__codex__codex` tool services the `.bakereq.json` sidecar (`--bake-mode=agent`, `sandbox: workspace-write`, model `gpt-5.5` + `xhigh` — a hardcoded compat default) → the native image tool; a fail-closed verifier (not any sandbox setting) keeps it honest. (`codex exec` is retired for real bakes.) - **`gemini` CLI**, logged in + on PATH — the Gemini blind-transcribe reviewer. REQUIRED for convergence: a round is panel-clean only with a parseable Gemini `approve`, so a missing/broken `gemini` CLI fails every round (there is NO Codex-only fallback). - **headless Chrome / Chromium** — the condition SVG is rasterized to PNG for the bake. - Python ≥ 3.9. Run **`run_spiral.py … --p0-only`** first: it's the zero-credit gate (compile + render + lint), so you confirm the brief is sound *before* spending any image credit. (`--dry-run` also needs no credit.) ## The contract Output ONE file conforming to [`../schemas/method_figure_brief.schema.json`](../schemas/method_figure_brief.schema.json). Required: `schema_version` (`"method-figure/brief/v1"` — `run_spiral.py` auto-detects a brief ONLY by this key), `figure_id`, `figure_purpose`, `components[]`, `flows[]`. Everything is **copied VERBATIM from the paper** — a number, name, or claim that is not in the source is an **escalation, not invention** (the compiler re-checks this: `compile_brief.py --strict` refuses an un-traceable blueprint). ## The prompt (paste to your coding agent, with the paper/method section attached) ``` You are Step −1 of method-figure: turn the attached paper / method section into ONE method_figure_brief.json conforming to skills/method-figure/schemas/method_figure_brief.schema.json. Rules: - schema_version: "method-figure/brief/v1" VERBATIM — run_spiral.py's auto-detect keys on it; a brief without it is refused unless --from-brief is passed. - figure_id: a slug; figure_purpose: e.g. "Figure 1 — <method> overview". - components[]: every box the method needs — {id, label (exact on-figure text), one_line (≤1 line), role, phase (if the method has phases), visual_priority (core|primary|secondary|background)}. Use the paper's OWN component names verbatim. - flows[]: every arrow — {from, to, kind (flow|keep|retry|repair|write|audit|human), label?, direction?}. from/to MUST be component ids. - phases[] (optional): {id, label, members:[component ids]} — members MUST match each component's `phase`. - headline_claim + headline_number: the ONE contribution + its featured result number, VERBATIM (e.g. "+1.4", "0.89"); leave both unset if the figure features no single number. caption_thesis: the one sentence the figure should make a reader feel. - symbol_registry[]: every figure symbol ↔ its paper variable; keep figure_symbol == paper_variable. - forbidden_tokens[]: wrong/competing names the figure must NOT contain. - identity_refs[]: ONLY if the project has a locked visual identity (mascot/persona) — {id, path, traits:[≥1]}. Most papers have none → omit. target_profile: paper | readme | slide. Copy every number/label/name verbatim; invent NOTHING. If a load-bearing piece is missing from the paper, STOP and ask. Output only the JSON. ``` ## Then From the **repo root**: `python3 skills/method-figure/scripts/run_spiral.py your_method_figure_brief.json --out-dir figures/method_figure/<id> --from-brief` (`--from-brief` is belt-and-braces — the `schema_version` above already auto-detects) — Step-0 (`compile_brief.py`) auto-compiles the brief → blueprint (fail-closed traceability) and the spiral bakes + cross-model-verifies it. Worked example of a finished brief: [`../examples/method_figure/method_figure_brief.json`](../examples/method_figure/method_figure_brief.json). -
prompt_templates.md 3.9 KB
# method-figure — prompt templates ## A. Generation prompt (to `mcp__codex__codex`, sandbox: workspace-write, effort xhigh) Fill `{...}` from the blueprint + the round-N consolidated fixes. The bake is the **agent `mcp__codex__codex` sidecar** with `sandbox: workspace-write` so Codex can WRITE the native PNG to the explicit out_path. Honesty is NOT enforced by any sandbox setting — it is enforced by `pickup_image.py --out-existing`'s fail-closed HARD-VETO (a hand-drawn struct/zlib/SVG fallback is rejected even with a valid PNG signature). `codex exec` is retired for real bakes; refs + out_path are embedded as absolute paths in the prompt (the MCP schema has no `-i`). ``` Use your IMAGE GENERATION tool to output ONE PNG. Image generation only — do NOT write or edit code/SVG/files. References (read-only): - {condition_png} → the EXACT layout to reproduce (every box already shows its title + one description line; arrows are labeled; pale phase panels). Reproduce this layout faithfully. - {identity_sheet_png} → the ONLY characters allowed. Lock traits: {lock_traits}. Never invent robots/mascots. STYLE: top ML-paper "Figure 1" — pure WHITE background, soft pastel phase panels, rounded white node cards, soft shadows, clean thin labeled arrows, crisp sans-serif (monospace for code/number tokens). Target: {target_profile}. TEXT IS LOCKED — render these strings VERBATIM and crisp, no extra spaces, no garble, no rename, no invented node/term/time-tag. The exact labels (do not change any character): {for each node} "{label_exact}" — "{desc_exact}" {for each group} "{label_exact}" {for each edge} arrow "{from}"→"{to}" labelled "{label_exact}" (direction {direction}) {callout} "{title_exact}" / {lines_exact} CHARACTERS: place {asset_ref characters} exactly at {their nodes}; e.g. researcher at the input handing the brief; the duo together at panel_gate inspecting a baked panel. No character anywhere else; if art would cover a label/arrow, shrink the character. APPLY THIS ROUND'S FIXES: {consolidated_blockers} KEEP (do not regress): {positive_invariants} Output the single finished figure. Same wide aspect as the condition. White background. ``` ## B. Reviewer prompt (to each panel model — blind; do NOT reveal expected labels) ``` Visual QA of a generated academic figure. Look ONLY at the image: {round_png} You are the {Claude structure | Gemini visual/identity | Codex arrow/diagnostic} reviewer. Do NOT assume what the labels "should" say — transcribe what you ACTUALLY see, and hunt for what should NOT be there. Return STRICT JSON (the schema in references/reviewer_protocol.md): verdict, scores{text_fidelity, arrow_topology, layout_readability, character_identity, style_fit} 0-5, observed_tokens[] (verbatim), observed_edges[], identity_audit[], anomalies[] (floating/pasted labels, artifacts, stray lines, duplicated characters, invented nodes), character_anatomy[] + anatomy_defect (if the figure has characters, ENUMERATE each one's visible hands and set anatomy_defect=true on any wrong count / 3rd / floating / merged hand — a single-reviewer veto), blockers[] (concrete image-gen-fixable), nice_to_have[], positive_invariants[]. A vague "looks good" is rejected — list the transcription, and COUNT the hands (don't eyeball them). ``` ## C. Notes - Re-assert the FULL locked-label list every round (image models drift); don't assume the prior round "kept" it. - After the bake, `pickup_image.py --out-existing --out <round.png> --transcript <round.png.bakestatus.json>` verifies the EXPLICIT out_path (PNG signature + size + dims + `mtime >= created_at`) and HARD-VETOes struct/zlib/PIL/`<svg>`/matplotlib markers in the agent transcript — fail-closed otherwise. - Serialize bakes per the agent SOP (one `mcp__codex__codex` call at a time). Each bake writes to its OWN explicit deterministic out_path, so there is no global-dir cross-pollination (the legacy `--bake-mode=exec` newest-after-marker glob is the only path with that hazard). -
reviewer_protocol.md 5.7 KB
# method-figure — cross-model review-panel protocol The panel is the gate. Its job is to make figure quality **objective**, not a vibe. Three rules make it work: 1. **Blind transcribe, then diff.** Reviewers are NEVER shown the blueprint's expected labels first. They transcribe what they actually SEE (tokens, edges, characters) and flag what shouldn't be there. A SCRIPT (`content_diff.py`) then diffs that transcription against the blueprint. This removes confirmation bias — a reviewer told "the box should say comic.json" will hallucinate reading it; a reviewer asked "what does that box say?" reports `comic .json` (the real, broken render). 2. **Negative-Space Audit.** Beyond "what's there", every reviewer must hunt for what does NOT belong: floating / pasted-looking labels, background artifacts, stray lines, duplicated characters, invented nodes. These go in `anomalies` and are a veto. - **Character anatomy (when the figure has characters/mascots).** Do NOT eyeball "looks fine" — for EACH character, ENUMERATE its visible hands one by one and report the count. A wrong hand count (≠2), a third / floating / duplicated / merged hand, or fused/extra fingers is an `anatomy_defect` and is a **single-reviewer veto** (one reviewer seeing it blocks ACCEPT, no matter how high the beauty scores). This exists because a token/label diff is blind to anatomy — a 3-handed chibi once shipped past a gate that "scored 2 hands" without counting. 3. **The maker can't acquit alone.** Codex is the generation family. A Codex `approve` may *diagnose* or *veto* but can NEVER be the sole acquitter. ACCEPT requires **Gemini approve + Claude structural approve + the hard-diff empty**. ## Roster — TWO blind transcribers + one structural sign-off (fixed — do not free-style) - **Gemini** (blind transcriber #1) — visual / identity / legibility / artifacts: `character_identity`, `text_fidelity` (is each token crisp & legible), `style_fit`, and the `anomalies` audit. Its core-score vector is the one the orchestrator enforces against `acceptance.min_core_score`. - **Codex** (blind transcriber #2) — second visual + code-native critique (arrow topology, exact-token spelling). Its `verdict` / `blockers` / `anatomy_defect` are enforced as VETO signals — diagnostic/veto only, never an acquitter (Codex is the generation family). - **Claude** (the calling agent) — **NOT a blind transcriber**: it never produces a `round<N>.cc.json` and nothing from it enters `content_diff`. After the loop converges (PANEL-CLEAN) it gives the post-pass STRUCTURAL sign-off: first-read order, phase grouping, does the flow tell the story L→R, is the hierarchy clear. ## Strict per-round output (each of the TWO blind transcribers returns THIS JSON) ```json { "verdict": "approve | retry | escalate", "scores": {"text_fidelity": 0, "arrow_topology": 0, "layout_readability": 0, "character_identity": 0, "style_fit": 0}, // 0-5 each "observed_tokens": ["...verbatim strings you can READ..."], "observed_edges": [{"from_label": "...", "to_label": "...", "direction": "forward|back", "label": "..."}], "identity_audit": [{"node_id": "gate", "status": "MATCH|DRIFT", "issue": "..."}], "character_anatomy": [{"char": "blue executor", "hands_visible": 2, "defect": "none|extra_hand|merged|wrong_count"}], // [] if no characters "anatomy_defect": false, // true if ANY character above has a defect — single-reviewer veto "anomalies": ["floating pasted-looking 'source' label", "duplicate reviewer chibi in Release", "..."], "blockers": ["concrete, image-gen-fixable instruction", "..."], "nice_to_have": ["non-blocking polish"], "positive_invariants": ["things that are RIGHT — keep them next round"] } ``` ## Consolidation (anti-oscillation) `run_spiral.py` consolidates INLINE (there is no separate consolidation script): it merges **`blockers` only** across the two transcribers (de-duplicated), folds the deterministic diff's findings in as concrete fixes (missing/forbidden/unsourced tokens, wrong edges, anatomy), and carries every `positive_invariant` forward into the next bake prompt so good parts aren't lost. **Never** act on `nice_to_have` during the loop — chasing polish makes it oscillate and never converge. ## Stop rule - **ACCEPT** iff: BOTH transcribers returned parseable JSON with `observed_tokens` AND `content_diff` empty (no missing_tokens / wrong_edges / anomalies) AND **no reviewer set `anatomy_defect`** AND Gemini `approve` with no `anomalies` AND Codex `approve` with no `blockers` (required — veto/diagnostic, never the sole acquitter) AND every core score ≥ `acceptance.min_core_score` (default 4) AND no timeout — then the calling agent (Claude) gives the final structural `approve`. - **RETRY** iff: blockers are prompt/condition-fixable AND `round < max_rounds` → re-bake re-asserting the locked `*_exact` labels + the consolidated blockers + the positive_invariants. - **ESCALATE** to human iff: the same root failure recurs `max_repeated_failure` rounds (default 2); reviewers give irreconcilable instructions; `max_rounds` reached; or the failure isn't prompt-fixable (image_gen throttle, persistent identity drift, no native image artifact). ## What "drift" looks like (catch it every round) Image models silently re-interpret content each regeneration. Watch for: phases renamed (e.g. "Authored Source of Truth" → "PLAN"), invented nodes ("self_critique", "VLM pass/fail"), invented time tags ("≤24h"), a token mutated (`comic.json` → `comic .json`), an edge reversed or dropped, a character duplicated into thumbnails. The blueprint's `*_exact` + `expected_tokens` are the anchor — re-assert them.
-
-
schemas
-
blueprint.schema.json 7.2 KB
{ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/wanshuiyin/ARIS-Movie-Director/skills/method-figure/blueprint.schema.json", "title": "method-figure blueprint (content lock)", "description": "The deterministic source of truth for a generated figure. *_exact fields hold the LOCKED text that every regeneration must re-assert verbatim; expected_tokens are what the reviewer panel must blind-transcribe and the hard-diff checks. style carries defaults only — it is NOT the content authority.", "type": "object", "required": ["version", "figure_id", "canvas", "render_policy", "nodes"], "properties": { "version": {"const": "method-figure/blueprint/v1"}, "figure_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]+$"}, "compiled_from_brief": {"type": "object", "properties": {"type": {"type": "string"}}, "description": "present iff Step-0 (compile_brief.py) produced this blueprint from a method_figure_brief. When present, validate_blueprint.py REQUIRES a 'source' on every node/edge/group/callout/asset (full traceability, GUARD-10)."}, "title": {"type": "object", "properties": {"main": {"type": "string"}, "sub": {"type": "string"}}}, "canvas": {"type": "object", "required": ["width", "height"], "properties": { "width": {"type": "number"}, "height": {"type": "number"}, "background": {"type": "string", "default": "#FFFFFF"}}}, "render_policy": {"type": "object", "properties": { "label_policy": {"enum": ["baked", "hybrid", "overlay"], "default": "baked", "description": "baked = image model renders all text; hybrid = vector-overlay the structured labels only; overlay = near-textless base + all labels vector."}, "target_profile": {"enum": ["readme", "paper", "slide"], "default": "readme"}, "max_rounds": {"type": "integer", "default": 4}}}, "assets": {"type": "array", "items": {"type": "object", "required": ["id", "path", "role"], "properties": { "id": {"type": "string"}, "path": {"type": "string"}, "role": {"enum": ["identity_sheet", "character_ref", "style_ref"]}, "lock_traits": {"type": "array", "items": {"type": "string"}, "description": "the must-preserve visual traits, e.g. 'blue hoodie, no beard'"}, "license": {"type": "string"}, "source": {"type": "string", "description": "traceability: the brief field this asset came from"}}}}, "groups": {"type": "array", "items": {"type": "object", "required": ["id", "label_exact", "bounds"], "properties": { "id": {"type": "string"}, "label_exact": {"type": "string"}, "bounds": {"type": "object", "required": ["x", "y", "w", "h"]}, "tone": {"type": "string"}, "order": {"type": "integer"}, "source": {"type": "string", "description": "traceability: the brief phase this group came from"}}}}, "nodes": {"type": "array", "minItems": 1, "items": {"type": "object", "required": ["id", "label_exact", "pos"], "properties": { "id": {"type": "string", "pattern": "^[A-Za-z0-9_-]+$"}, "label_exact": {"type": "string", "description": "LOCKED title text — re-asserted verbatim every round"}, "desc_exact": {"type": "string", "description": "LOCKED one-line description inside the box"}, "group": {"type": "string"}, "pos": {"type": "object", "required": ["x", "y"]}, "size": {"type": "object"}, "shape": {"enum": ["process", "document", "datastore", "diamond", "gate", "output", "character"], "default": "process"}, "accent": {"type": "string"}, "semantic_role": {"type": "string"}, "visual_hint": {"type": "string", "description": "art direction for image_gen, NOT rendered as text"}, "asset_ref": {"type": "string", "description": "id of an asset in assets[] — anchors a character to its identity"}, "must_render": {"type": "boolean", "default": true}, "source": {"type": "string", "description": "traceability: the upstream brief field this node came from (e.g. 'brief:components/bake'). An un-traceable node when a brief was given is a Refuse-and-Escalate — see references/blueprint_authoring.md"}, "expected_tokens": {"type": "array", "items": {"type": "string"}, "description": "tokens the panel must blind-transcribe from this node; the hard-diff fails if missing"}, "forbidden_tokens": {"type": "array", "items": {"type": "string"}}}}}, "edges": {"type": "array", "items": {"type": "object", "required": ["from", "to", "kind"], "properties": { "from": {"type": "string"}, "to": {"type": "string"}, "kind": {"enum": ["flow", "keep", "retry", "repair", "write", "human", "audit"]}, "label_exact": {"type": "string"}, "direction": {"enum": ["forward", "back"], "default": "forward"}, "route": {"type": "object"}, "must_render": {"type": "boolean", "default": true}, "source": {"type": "string", "description": "traceability: the brief flow this edge came from"}, "expected_tokens": {"type": "array", "items": {"type": "string"}}}}}, "callouts": {"type": "array", "items": {"type": "object", "required": ["id", "title_exact"], "properties": { "id": {"type": "string"}, "title_exact": {"type": "string"}, "lines_exact": {"type": "array", "items": {"type": "string"}}, "anchor": {"type": "string"}, "pos": {"type": "object"}, "size": {"type": "object"}, "accent": {"type": "string"}, "visual_hint": {"type": "string"}, "source": {"type": "string", "description": "traceability: the brief field this callout came from"}, "expected_tokens": {"type": "array", "items": {"type": "string"}}}}}, "forbidden_tokens": {"type": "array", "items": {"type": "string"}, "description": "figure-wide banned terms (wrong/competing names); also injected per-node and used as the per-round DELETE-list (GUARD-3)"}, "style": {"type": "object", "description": "palette / font / arrow DEFAULTS only — not content authority"}, "rail": {"type": "object", "properties": {"label_exact": {"type": "string"}}}, "acceptance": {"type": "object", "properties": { "min_core_score": {"type": "integer", "default": 4}, "required_transcribers": {"type": "array", "items": {"type": "string"}, "default": ["gemini", "codex"], "description": "the AUTOMATED blind-transcribe panel run_spiral.py actually runs (Gemini + Codex vision); the hard-diff is over these."}, "codex_policy": {"enum": ["required_not_sole", "veto_only", "scored"], "default": "required_not_sole", "description": "Codex's approve IS required for panel-clean (run_spiral.py needs gemini-approve + codex-approve + empty diff), but as the generator family it is NEVER the sole acquitter — the deterministic content_diff and Claude's structural sign-off are also required."}, "claude_structural_signoff_required": {"type": "boolean", "default": true, "description": "the calling agent (Claude) gives the final structural sign-off AFTER the automated panel — the loop can drive but can't acquit."}, "required_approvers": {"type": "array", "items": {"type": "string"}, "default": ["gemini", "claude"]}, "veto_fields": {"type": "array", "items": {"type": "string"}, "default": ["anomalies", "missing_tokens", "wrong_edges"]}, "max_repeated_failure": {"type": "integer", "default": 2}}} } } -
method_figure_brief.schema.json 4.1 KB
{ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/wanshuiyin/ARIS-Movie-Director/skills/method-figure/method_figure_brief.schema.json", "title": "method_figure_brief — the ARIS upstream hand-off (paper-plan → method-figure)", "description": "The SEMANTIC brief paper-plan emits (after its claims_matrix) for a method/architecture figure. It decides WHAT the figure depicts; Step-0 turns it into a blueprint.json; method-figure renders+verifies. method-figure never invents anything not in this brief.", "type": "object", "required": ["figure_id", "figure_purpose", "components", "flows"], "properties": { "schema_version": {"const": "method-figure/brief/v1", "description": "optional but recommended — pins the brief contract paper-plan emits and compile_brief.py consumes"}, "figure_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]+$"}, "figure_purpose": {"type": "string", "description": "e.g. 'Figure 1 method overview'"}, "headline_claim": {"type": "string", "description": "the contribution this figure must support"}, "headline_number": {"type": "string", "description": "the result/number to feature, verbatim (e.g. '+1.4', '0.89')"}, "caption_thesis": {"type": "string", "description": "the one-sentence thesis the figure should make a reader feel"}, "topology_constraint": {"enum": ["linear_flow", "feedback_loop", "hierarchical_stack", "left_to_right_phases", "free"], "default": "left_to_right_phases", "description": "lock the layout logic so the renderer doesn't free-style the topology"}, "components": {"type": "array", "minItems": 1, "items": {"type": "object", "required": ["id", "label", "one_line"], "properties": { "id": {"type": "string"}, "label": {"type": "string", "description": "exact display text"}, "role": {"type": "string"}, "one_line": {"type": "string", "description": "one-line description shown in the box"}, "phase": {"type": "string"}, "visual_priority": {"enum": ["core", "primary", "secondary", "background"], "default": "primary", "description": "core innovations get visual weight/center; background modules are quiet"}, "identity_ref": {"type": "string", "description": "id in identity_refs[] if a character/mascot lives here"}}}}, "flows": {"type": "array", "items": {"type": "object", "required": ["from", "to", "kind"], "properties": { "from": {"type": "string"}, "to": {"type": "string"}, "kind": {"enum": ["flow", "keep", "retry", "repair", "write", "audit", "human"]}, "label": {"type": "string"}, "direction": {"enum": ["forward", "back"], "default": "forward"}}}}, "phases": {"type": "array", "items": {"type": "object", "required": ["id", "label"], "properties": { "id": {"type": "string"}, "label": {"type": "string"}, "members": {"type": "array", "items": {"type": "string"}}}}}, "inputs": {"type": "array", "items": {"type": "string"}}, "outputs": {"type": "array", "items": {"type": "string"}}, "callouts": {"type": "array", "items": {"type": "object", "required": ["title", "lines"], "properties": { "title": {"type": "string"}, "lines": {"type": "array", "items": {"type": "string"}}, "anchor": {"type": "string"}}}}, "symbol_registry": {"type": "array", "description": "map every figure symbol to its paper/equation variable so they never disagree", "items": {"type": "object", "properties": {"figure_symbol": {"type": "string"}, "paper_variable": {"type": "string"}, "meaning": {"type": "string"}}}}, "identity_refs": {"type": "array", "items": {"type": "object", "required": ["id", "path"], "properties": { "id": {"type": "string"}, "path": {"type": "string"}, "traits": {"type": "array", "items": {"type": "string"}}}}, "description": "the project's locked visual identities (created upstream, read-only); most papers have none"}, "forbidden_tokens": {"type": "array", "items": {"type": "string"}, "description": "terms the figure must NOT contain (avoid wrong/competing names)"}, "target_profile": {"enum": ["readme", "paper", "slide"], "default": "paper"} } }
-
-
scripts
-
compile_brief.py 29 KB
#!/usr/bin/env python3 """compile_brief.py — Step-0: deterministically compile a method_figure_brief.json into a content-locked blueprint.json (+ traceability.json). Pure stdlib, fail-closed. This is the auto-Step-0 that makes method-figure single-input: the user feeds ONE ARIS-format artifact (the brief that paper-plan emits after its claims_matrix); this compiler turns it into the blueprint the spiral renders + cross-model verifies. It NEVER invents content — every blueprint object traces back to a brief field (recorded in traceability.json), and a missing scientific number / claim / identity-trait / component is a Refuse-and-Escalate (non-zero exit naming the field), not creative license (GUARD-10). CLI: compile_brief.py brief.json --out blueprint.json --trace traceability.json [--strict|--no-strict] Mapping (ADJ-4, verified against method_figure_brief.schema.json ↔ blueprint.schema.json): components[] -> nodes[] (label->label_exact, one_line->desc_exact, role->semantic_role, identity_ref->asset_ref, phase->group, visual_priority->size/weight) flows[] -> edges[] (from/to/kind/label->label_exact/direction) phases[] -> groups[] (auto-bounds enclose member nodes) identity_refs[]-> assets[] (role=identity_sheet, traits->lock_traits) callouts[] -> callouts[] (+ a synthesized punchline from headline_claim/headline_number) symbol_registry[] -> the relevant node's expected_tokens (figure_symbol kept == paper_variable) forbidden_tokens -> blueprint top-level + every node's forbidden_tokens topology_constraint + visual_priority -> auto_layout() positions/sizes/canvas (no hand-placed coords) target_profile -> render_policy.target_profile (passed through EXPLICITLY — brief default 'paper' must not silently fall to the blueprint default 'readme') """ import argparse import json import re import sys from pathlib import Path # --- deterministic layout constants (the renderer refines; this only needs to be valid + readable) --- SIZE_BY_PRIORITY = { # (w, h) per visual_priority — core gets the most visual weight "core": (250, 100), "primary": (210, 78), "secondary": (185, 66), "background": (155, 58), } DEFAULT_SIZE = SIZE_BY_PRIORITY["primary"] TONE_CYCLE = ["blue", "peach", "green", "violet", "amber"] # group tones, cycled by phase order MARGIN = 120 # canvas outer margin TITLE_H = 70 # reserved band at the top for the figure title GROUP_LABEL_H = 46 # reserved band at the top of each phase group for its label COL_GAP = 110 # horizontal gap between columns/phases V_GAP = 46 # vertical gap between stacked nodes in a column PAD = 30 # padding between a group's bound and its member boxes # shape heuristic: brief components carry no shape; derive deterministically from role/label keywords. SHAPE_KEYWORDS = [ ("character", ("mascot", "chibi", "persona", "researcher", "executor", "reviewer", "user", "human")), ("gate", ("gate", "review", "audit", "panel", "check", "verify")), ("diamond", ("verdict", "decision", "decide", "branch", "switch")), ("datastore", ("wiki", "memory", "store", "db", "database", "log", "cache", "buffer", "ledger")), ("document", ("doc", "json", "brief", "spec", "outline", "blueprint", "report", "file", "config")), ("output", ("release", "output", "viewer", "result", "figure", "render", "publish")), ] # the deterministic default style block (palette/arrow DEFAULTS only — NOT content authority). DEFAULT_STYLE = { "theme": "academic_flat", "font_family": "Inter, Arial, sans-serif", "palette": { "text": "#1F2937", "muted": "#6B7280", "blue_fill": "#E5EEFB", "blue_stroke": "#9DBDEB", "blue_accent": "#2563EB", "peach_fill": "#FDEBD8", "peach_stroke": "#F4C18A", "peach_accent": "#EA580C", "green_fill": "#DEF5E6", "green_stroke": "#9BD9B0", "green_accent": "#0E9F6E", "violet_fill": "#EDE9FE", "violet_stroke": "#C4B5FD", "violet_accent": "#7C3AED", "amber_fill": "#FEF3C7", "amber_stroke": "#FCD34D", "amber_accent": "#D97706", "red": "#DC2626", "node_fill": "#FFFFFF", "node_stroke": "#CBD2DC", }, "arrow": {"stroke": "#4B5563", "width": 2.6}, } TONE_ACCENT = { # phase tone -> the per-node accent key used for member nodes of that group "blue": "blue_accent", "peach": "peach_accent", "green": "green_accent", "violet": "violet_accent", "amber": "amber_accent", } _TOKEN_RE = re.compile(r"[\w.+\-]+", re.UNICODE) def tokenize(*texts): """Deterministic expected-token extraction matching the existing blueprints: split on whitespace + structural punctuation, keep tokens of length>=2 (or any containing a digit, so '+6.2' survives), preserve case, dedupe, sort. The reviewer panel blind-transcribes these and the hard-diff checks them.""" out = set() for t in texts: if not t: continue for tok in _TOKEN_RE.findall(str(t)): if not re.search(r"[A-Za-z0-9]", tok): continue if len(tok) >= 2 or re.search(r"\d", tok): out.add(tok) return sorted(out) def derive_shape(comp): if comp.get("identity_ref"): return "character" hay = f"{comp.get('id', '')} {comp.get('label', '')} {comp.get('role', '')}".lower() for shape, kws in SHAPE_KEYWORDS: if any(k in hay for k in kws): return shape return "process" def auto_layout(brief, nodes, groups): """Compute pos/size for every node, bounds for every group, and the canvas size — from topology_constraint + visual_priority. No coordinate is ever hand-authored in the brief. Supported precisely: 'left_to_right_phases' (default) and 'linear_flow'. 'hierarchical_stack' is the same column algorithm rotated top->bottom. 'feedback_loop' lays out like linear_flow (the back edge is drawn by the renderer from the edge's direction:'back'). 'free' falls back to a grid. The chosen mode is returned so the caller can log it (no silent topology free-styling).""" topo = brief.get("topology_constraint", "left_to_right_phases") by_id = {n["id"]: n for n in nodes} # column buckets: free-floating nodes first, then one bucket per declared phase (brief order). phase_ids = [p["id"] for p in brief.get("phases", [])] columns = [] free = [n for n in nodes if not n.get("group")] if free: columns.append(("_free", free)) for pid in phase_ids: members = [n for n in nodes if n.get("group") == pid] if members: columns.append((pid, members)) # any node whose group isn't a declared phase (shouldn't happen post-validation) -> trailing column placed = {n["id"] for _, col in columns for n in col} leftover = [n for n in nodes if n["id"] not in placed] if leftover: columns.append(("_misc", leftover)) horizontal = topo != "hierarchical_stack" # hierarchical = same algo, axes swapped cur = MARGIN group_bounds = {} for cid, col in columns: col_w = max((n["_w"] for n in col), default=DEFAULT_SIZE[0]) col_h = sum(n["_h"] for n in col) + V_GAP * (len(col) - 1) top = MARGIN + TITLE_H + (GROUP_LABEL_H if cid in phase_ids else 0) y = top for n in col: cx = cur + col_w / 2 if horizontal else top + col_w / 2 cy = y + n["_h"] / 2 if horizontal else cur + n["_h"] / 2 n["pos"] = {"x": round(cx if horizontal else cy), "y": round(cy if horizontal else cx)} y += n["_h"] + V_GAP if cid in phase_ids: if horizontal: group_bounds[cid] = {"x": cur - PAD, "y": top - GROUP_LABEL_H, "w": col_w + 2 * PAD, "h": col_h + GROUP_LABEL_H + 2 * PAD} else: group_bounds[cid] = {"x": top - GROUP_LABEL_H, "y": cur - PAD, "w": col_h + GROUP_LABEL_H + 2 * PAD, "h": col_w + 2 * PAD} cur += col_w + COL_GAP # write sizes onto nodes; compute canvas extent max_x = max_y = 0 for n in nodes: n["size"] = {"w": n.pop("_w"), "h": n.pop("_h")} max_x = max(max_x, n["pos"]["x"] + n["size"]["w"] / 2) max_y = max(max_y, n["pos"]["y"] + n["size"]["h"] / 2) for b in group_bounds.values(): max_x = max(max_x, b["x"] + b["w"]); max_y = max(max_y, b["y"] + b["h"]) for g in groups: if g["id"] in group_bounds: g["bounds"] = group_bounds[g["id"]] canvas = {"width": round(max_x + MARGIN), "height": round(max_y + MARGIN), "background": "#FFFFFF"} return canvas, topo COMP_PRIORITY = {"core", "primary", "secondary", "background"} FLOW_KIND = {"flow", "keep", "retry", "repair", "write", "audit", "human"} FLOW_DIR = {"forward", "back"} TOPOLOGY = {"linear_flow", "feedback_loop", "hierarchical_stack", "left_to_right_phases", "free"} TARGET_PROFILE = {"readme", "paper", "slide"} def brief_schema_errors(brief): """Explicit, stdlib-only brief-schema check (do NOT depend on optional jsonschema): required fields + enum values + figure_id pattern. 'FATAL:' = a field compile_brief would crash on; the rest are schema violations that --strict turns into a refusal. Closes the 'invalid kind/priority/profile silently bakes' hole.""" e = [] if not isinstance(brief, dict): return ["FATAL: brief is not a JSON object"] fid = brief.get("figure_id") if not fid: e.append("FATAL: missing required 'figure_id'") elif not re.match(r"^[A-Za-z0-9_-]+$", str(fid)): e.append(f"schema: figure_id '{fid}' must match ^[A-Za-z0-9_-]+$") if not brief.get("figure_purpose"): e.append("schema: missing required 'figure_purpose'") comps = brief.get("components") if not isinstance(comps, list) or not comps: e.append("FATAL: 'components' must be a non-empty array") else: for i, c in enumerate(comps): if not isinstance(c, dict) or not c.get("id") or not c.get("label"): e.append(f"FATAL: components[{i}] missing required 'id'/'label'"); continue if not c.get("one_line"): e.append(f"schema: components[{i}] ('{c.get('id')}') missing required 'one_line'") vp = c.get("visual_priority") if vp is not None and vp not in COMP_PRIORITY: e.append(f"schema: components[{i}].visual_priority '{vp}' not in {sorted(COMP_PRIORITY)}") flows = brief.get("flows") if not isinstance(flows, list): e.append("FATAL: 'flows' must be an array") else: for i, fl in enumerate(flows): if not isinstance(fl, dict) or not fl.get("from") or not fl.get("to") or not fl.get("kind"): e.append(f"FATAL: flows[{i}] missing required 'from'/'to'/'kind'"); continue if fl["kind"] not in FLOW_KIND: e.append(f"schema: flows[{i}].kind '{fl['kind']}' not in {sorted(FLOW_KIND)}") d = fl.get("direction") if d is not None and d not in FLOW_DIR: e.append(f"schema: flows[{i}].direction '{d}' not in {sorted(FLOW_DIR)}") for i, ph in enumerate(brief.get("phases", []) or []): if not isinstance(ph, dict) or not ph.get("id") or not ph.get("label"): e.append(f"FATAL: phases[{i}] missing required 'id'/'label'") for i, c in enumerate(brief.get("callouts", []) or []): if not isinstance(c, dict) or not c.get("title") or "lines" not in c: e.append(f"FATAL: callouts[{i}] missing required 'title'/'lines'") for i, r in enumerate(brief.get("identity_refs", []) or []): if not isinstance(r, dict) or not r.get("id") or not r.get("path"): e.append(f"FATAL: identity_refs[{i}] missing required 'id'/'path'") tc = brief.get("topology_constraint") if tc is not None and tc not in TOPOLOGY: e.append(f"schema: topology_constraint '{tc}' not in {sorted(TOPOLOGY)}") tp = brief.get("target_profile") if tp is not None and tp not in TARGET_PROFILE: e.append(f"schema: target_profile '{tp}' not in {sorted(TARGET_PROFILE)}") return e def compile_brief(brief, *, strict=True): """Pure brief->(blueprint, trace_errors) compile. Returns (blueprint_dict, traceability_dict, errors_list). Caller decides whether to fail on errors (strict).""" errors = brief_schema_errors(brief) if any(x.startswith("FATAL") for x in errors): # can't safely build — hand the schema errors back for the caller to fail on return ({"version": "method-figure/blueprint/v1", "nodes": []}, {"nodes": {}, "edges": {}, "groups": {}, "callouts": {}, "assets": {}, "symbols": {}}, errors) fid = brief["figure_id"] forbidden = brief.get("forbidden_tokens", []) or [] profile = brief.get("target_profile", "paper") # pass EXPLICITLY (don't fall to 'readme') trace = {"nodes": {}, "edges": {}, "groups": {}, "callouts": {}, "assets": {}, "symbols": {}} # --- assets <- identity_refs --- assets = [] for ref in brief.get("identity_refs", []) or []: a = {"id": ref["id"], "path": ref["path"], "role": "identity_sheet", "source": f"brief:identity_refs/{ref['id']}"} if ref.get("traits"): a["lock_traits"] = list(ref["traits"]) assets.append(a) trace["assets"][ref["id"]] = a["source"] asset_ids = {a["id"] for a in assets} # --- groups <- phases (bounds filled by auto_layout) --- groups = [] for i, ph in enumerate(brief.get("phases", []) or []): g = {"id": ph["id"], "label_exact": ph["label"], "bounds": {"x": 0, "y": 0, "w": 0, "h": 0}, "tone": TONE_CYCLE[i % len(TONE_CYCLE)], "order": i, "source": f"brief:phases/{ph['id']}"} groups.append(g) trace["groups"][ph["id"]] = g["source"] tone_by_group = {g["id"]: g["tone"] for g in groups} # --- nodes <- components (+ inputs/outputs as edge documents) --- nodes = [] for comp in brief["components"]: prio = comp.get("visual_priority", "primary") w, h = SIZE_BY_PRIORITY.get(prio, DEFAULT_SIZE) n = {"id": comp["id"], "label_exact": comp["label"], "_w": w, "_h": h, "shape": derive_shape(comp), "must_render": True, "source": f"brief:components/{comp['id']}", "expected_tokens": tokenize(comp["label"], comp.get("one_line")), "forbidden_tokens": list(forbidden)} if comp.get("one_line"): n["desc_exact"] = comp["one_line"] if comp.get("role"): n["semantic_role"] = comp["role"] grp = comp.get("phase") if grp and grp in tone_by_group: n["group"] = grp n["accent"] = TONE_ACCENT.get(tone_by_group[grp]) if comp.get("identity_ref"): n["asset_ref"] = comp["identity_ref"] nodes.append(n) trace["nodes"][comp["id"]] = n["source"] comp_ids = {c["id"] for c in brief["components"]} # free-standing inputs / outputs that aren't already components -> small document / output nodes for kind, key, shape in (("inputs", "in", "document"), ("outputs", "out", "output")): for j, txt in enumerate(brief.get(kind, []) or []): nid = f"{key}_{j}" if nid in comp_ids: continue w, h = SIZE_BY_PRIORITY["secondary"] n = {"id": nid, "label_exact": txt, "_w": w, "_h": h, "shape": shape, "must_render": True, "source": f"brief:{kind}/{j}", "expected_tokens": tokenize(txt), "forbidden_tokens": list(forbidden)} nodes.append(n) trace["nodes"][nid] = n["source"] # --- symbol_registry -> inject figure_symbol (kept == paper_variable) into the node that mentions it --- for sym in brief.get("symbol_registry", []) or []: fsym, pvar = sym.get("figure_symbol"), sym.get("paper_variable") if not fsym: continue trace["symbols"][fsym] = pvar or fsym hay_keys = [k for k in (fsym, pvar, sym.get("meaning")) if k] for n in nodes: text = f"{n.get('label_exact', '')} {n.get('desc_exact', '')}".lower() if any(str(k).lower() in text for k in hay_keys): toks = set(n["expected_tokens"]) | set(tokenize(fsym, pvar)) n["expected_tokens"] = sorted(toks) break # --- edges <- flows --- edges = [] node_ids = {n["id"] for n in nodes} for fl in brief.get("flows", []) or []: e = {"from": fl["from"], "to": fl["to"], "kind": fl["kind"], "must_render": True, "source": f"brief:flows/{fl['from']}->{fl['to']}"} if fl.get("label"): e["label_exact"] = fl["label"] e["expected_tokens"] = tokenize(fl["label"]) if fl.get("direction") and fl["direction"] != "forward": e["direction"] = fl["direction"] edges.append(e) trace["edges"][f"{fl['from']}->{fl['to']}"] = e["source"] # --- callouts <- brief.callouts[] + a synthesized punchline from headline_claim/headline_number --- callouts = [] hc, hn = brief.get("headline_claim"), brief.get("headline_number") if hc: lines = [brief["caption_thesis"]] if brief.get("caption_thesis") else [] if hn: lines.append(f"result: {hn}") co = {"id": "punchline", "title_exact": hc, "lines_exact": lines, "accent": "red", "source": "brief:headline_claim+headline_number", "expected_tokens": tokenize(hc, *lines, hn)} # hn MUST land in expected_tokens (GUARD-6) callouts.append(co) trace["callouts"]["punchline"] = co["source"] for k, c in enumerate(brief.get("callouts", []) or []): cid = f"callout_{k}" co = {"id": cid, "title_exact": c["title"], "lines_exact": list(c.get("lines", [])), "source": f"brief:callouts/{k}", "expected_tokens": tokenize(c["title"], *c.get("lines", []))} if c.get("anchor"): co["anchor"] = c["anchor"] callouts.append(co) trace["callouts"][cid] = co["source"] canvas, topo_used = auto_layout(brief, nodes, groups) # lay callouts out in a reserved bottom band so MULTIPLE callouts never stack on one default spot (the # renderer would otherwise put every callout at the same place). Each gets an explicit pos/size; the band # extends the canvas so callouts never overlap the node/group content above. if callouts: CW, CH, GAP = 420, 150, 40 total_w = len(callouts) * CW + (len(callouts) - 1) * GAP canvas["width"] = max(canvas["width"], total_w + 2 * MARGIN) band_top = canvas["height"] - MARGIN + 10 x0 = max(MARGIN, (canvas["width"] - total_w) // 2) for i, co in enumerate(callouts): co["pos"] = {"x": round(x0 + i * (CW + GAP) + CW / 2), "y": round(band_top + CH / 2)} co["size"] = {"w": CW, "h": CH} canvas["height"] = band_top + CH + MARGIN blueprint = { "version": "method-figure/blueprint/v1", "figure_id": fid, "compiled_from_brief": {"type": fid}, "canvas": canvas, "render_policy": {"label_policy": "baked", "target_profile": profile, "max_rounds": 4}, "nodes": nodes, } if brief.get("figure_purpose") or brief.get("caption_thesis"): blueprint["title"] = {} if brief.get("figure_purpose"): blueprint["title"]["main"] = brief["figure_purpose"] if brief.get("caption_thesis"): blueprint["title"]["sub"] = brief["caption_thesis"] if assets: blueprint["assets"] = assets if groups: blueprint["groups"] = groups if edges: blueprint["edges"] = edges if callouts: blueprint["callouts"] = callouts if forbidden: blueprint["forbidden_tokens"] = list(forbidden) blueprint["style"] = DEFAULT_STYLE blueprint["rail"] = {"label_exact": f"max {blueprint['render_policy']['max_rounds']} rounds | " "blind cross-model diff | human backstop", "source": "framework:rail_constant"} # framework boilerplate, NOT a paper claim (keeps traceability honest) blueprint["acceptance"] = { "min_core_score": 4, "required_transcribers": ["gemini", "codex"], # the AUTOMATED panel (match run_spiral.py) "codex_policy": "required_not_sole", # Codex approve required, but never the sole acquitter "claude_structural_signoff_required": True, "required_approvers": ["gemini", "claude"], "veto_fields": ["anomalies", "missing_tokens", "wrong_edges"], "max_repeated_failure": 2, } errors += validate_traceability(blueprint, brief) # keep the schema-enum errors collected above return blueprint, trace, errors def validate_traceability(blueprint, brief): """Every blueprint object must trace to a brief field, and every load-bearing brief element (claim, number, identity trait, component, flow) must appear in the blueprint. Returns a list of human-named errors; compile_brief's --strict turns any of these into a non-zero exit (Refuse-and-Escalate).""" errs = [] # 1) every object carries a source for n in blueprint.get("nodes", []): if not n.get("source"): errs.append(f"node '{n.get('id')}' has no source (un-traceable)") for e in blueprint.get("edges", []): if not e.get("source"): errs.append(f"edge '{e.get('from')}->{e.get('to')}' has no source") for g in blueprint.get("groups", []): if not g.get("source"): errs.append(f"group '{g.get('id')}' has no source") for c in blueprint.get("callouts", []): if not c.get("source"): errs.append(f"callout '{c.get('id')}' has no source") # callout anchor (if present) must resolve to a real node/group/callout id (a dangling anchor is a fail-open ref) _anchor_ids = ({n.get("id") for n in blueprint.get("nodes", [])} | {g.get("id") for g in blueprint.get("groups", [])} | {c.get("id") for c in blueprint.get("callouts", [])}) for c in blueprint.get("callouts", []): if c.get("anchor") and c["anchor"] not in _anchor_ids: errs.append(f"callout '{c.get('id')}' anchor '{c['anchor']}' does not resolve to a node/group/callout id") for a in blueprint.get("assets", []): if not a.get("source"): errs.append(f"asset '{a.get('id')}' has no source") # 2) the scientific number must survive into a callout's expected_tokens (GUARD-6: +6.2 vs +6.25) hn = brief.get("headline_number") if hn: toks = {t for c in blueprint.get("callouts", []) for t in c.get("expected_tokens", [])} if not any(hn in t or t in hn for t in toks) and hn not in toks: errs.append(f"headline_number '{hn}' did not land in any callout's expected_tokens") # 3) the headline claim must be a callout title hc = brief.get("headline_claim") if hc and hc not in {c.get("title_exact") for c in blueprint.get("callouts", [])}: errs.append(f"headline_claim '{hc[:40]}...' did not become a callout title_exact") # 4) every identity trait must be carried on the matching asset's lock_traits for ref in brief.get("identity_refs", []) or []: asset = next((a for a in blueprint.get("assets", []) if a["id"] == ref["id"]), None) if asset is None: errs.append(f"identity_ref '{ref['id']}' did not become an asset") continue for tr in ref.get("traits", []) or []: if tr not in (asset.get("lock_traits") or []): errs.append(f"identity trait '{tr}' (ref {ref['id']}) missing from asset lock_traits") # 5) every component became a node, every flow became an edge (nothing silently dropped) node_labels = {n.get("label_exact") for n in blueprint.get("nodes", [])} for comp in brief.get("components", []): if comp["label"] not in node_labels: errs.append(f"component '{comp['id']}' (label '{comp['label']}') was dropped") edge_keys = {(e.get("from"), e.get("to")) for e in blueprint.get("edges", [])} for fl in brief.get("flows", []) or []: if (fl["from"], fl["to"]) not in edge_keys: errs.append(f"flow {fl['from']}->{fl['to']} was dropped") # 6) asset_ref integrity (a node anchored to an identity must point at a real asset) asset_ids = {a["id"] for a in blueprint.get("assets", [])} for n in blueprint.get("nodes", []): if n.get("asset_ref") and n["asset_ref"] not in asset_ids: errs.append(f"node '{n['id']}' asset_ref '{n['asset_ref']}' has no matching asset") # 7) phase resolution — a component.phase not declared in brief.phases is silently dropped from grouping (fail-open) phase_ids = {p["id"] for p in brief.get("phases", []) or []} for comp in brief.get("components", []): if comp.get("phase") and comp["phase"] not in phase_ids: errs.append(f"component '{comp['id']}' references phase '{comp['phase']}' not declared in brief.phases") # 8) flow-endpoint resolution — every edge endpoint MUST resolve to a real node id (a dangling flow would # otherwise emit an invalid blueprint before validate_blueprint.py even runs) node_ids = {n.get("id") for n in blueprint.get("nodes", [])} for e in blueprint.get("edges", []): for end in ("from", "to"): if e.get(end) not in node_ids: errs.append(f"flow endpoint '{e.get(end)}' (edge {e.get('from')}->{e.get('to')}) is not a node id") # 9) an identity_ref MUST carry ≥1 trait (a locked identity with no traits cannot be verified against) for ref in brief.get("identity_refs", []) or []: if not (ref.get("traits") or []): errs.append(f"identity_ref '{ref['id']}' has no traits (a locked identity needs ≥1 trait to verify against)") # 10) symbol_registry — every figure_symbol must bind to a node's expected_tokens, and figure_symbol must # equal paper_variable (anti-drift); an unmatched symbol or a figure≠paper mismatch is an escalation all_tokens = {t for n in blueprint.get("nodes", []) for t in n.get("expected_tokens", [])} for sym in brief.get("symbol_registry", []) or []: fsym, pvar = sym.get("figure_symbol"), sym.get("paper_variable") if fsym and fsym not in all_tokens: errs.append(f"symbol '{fsym}' did not bind to any node's expected_tokens (unmatched — name it in a component)") if fsym and pvar and fsym != pvar: errs.append(f"symbol drift: figure_symbol '{fsym}' != paper_variable '{pvar}' (keep them identical)") # 11) phases.members consistency — a declared member must be a component whose phase == that phase comp_phase = {comp["id"]: comp.get("phase") for comp in brief.get("components", [])} for ph in brief.get("phases", []) or []: for m in ph.get("members", []) or []: if m not in comp_phase: errs.append(f"phase '{ph['id']}' lists member '{m}' that is not a component") elif comp_phase[m] != ph["id"]: errs.append(f"phase '{ph['id']}' member '{m}' has component.phase='{comp_phase[m]}' (mismatch)") return errs def main(): ap = argparse.ArgumentParser(description="Step-0: compile a method_figure_brief.json into blueprint.json") ap.add_argument("brief") ap.add_argument("--out", required=True, help="output blueprint.json path") ap.add_argument("--trace", help="output traceability.json path (default: alongside --out)") ap.add_argument("--strict", dest="strict", action="store_true", default=True, help="fail closed on any un-traceable object or missing claim/number/trait (default)") ap.add_argument("--no-strict", dest="strict", action="store_false", help="emit anyway and only warn (NOT recommended)") a = ap.parse_args() brief = json.loads(Path(a.brief).read_text(encoding="utf-8")) for req in ("figure_id", "components", "flows"): if req not in brief: sys.exit(f"[compile_brief] FATAL: brief missing required field '{req}'") blueprint, trace, errors = compile_brief(brief, strict=a.strict) if errors: tag = "FAIL" if a.strict else "WARN" print(f"[compile_brief] {tag} — {len(errors)} traceability issue(s):", file=sys.stderr) for e in errors: print(" -", e, file=sys.stderr) if a.strict: sys.exit("[compile_brief] refusing to emit a blueprint that is not fully traceable to the brief " "(a missing number/claim/trait/component is an escalation, not creative license). " "Fix the brief or pass --no-strict to override.") out = Path(a.out) out.write_text(json.dumps(blueprint, indent=2, ensure_ascii=False), encoding="utf-8") trace_path = Path(a.trace) if a.trace else out.with_name("traceability.json") trace_path.write_text(json.dumps(trace, indent=2, ensure_ascii=False), encoding="utf-8") print(f"[compile_brief] OK — {len(blueprint['nodes'])} nodes, {len(blueprint.get('edges', []))} edges, " f"{len(blueprint.get('groups', []))} groups, {len(blueprint.get('callouts', []))} callouts, " f"topology={brief.get('topology_constraint', 'left_to_right_phases')} " f"→ {out} (+ {trace_path.name})") if __name__ == "__main__": main() -
content_diff.py 6.9 KB
#!/usr/bin/env python3 """content_diff.py — deterministic content-accuracy diff: blueprint EXPECTED vs panel OBSERVED (pure stdlib). Reviewers BLIND-transcribe what they see (observed_tokens / observed_edges / anomalies) WITHOUT being shown the expected labels; this script diffs that against the blueprint's LOCKED text. Empty diff == content-accurate. HARD vetoes (any one fails acceptance): - missing_tokens : a locked token (from label_exact / desc_exact / lines_exact / rail / expected_tokens) that was NOT read by EVERY visual reviewer. (catches dropped / garbled text) - unaccounted_numeric : a NUMBER/code token EVERY visual reviewer read that the blueprint never declared. (a figure must not state a number the brief never authored — the unsourced-number veto) - forbidden_present : a DELETE-list term (a competing/wrong name) that ANY reviewer read anywhere. - wrong_edges : an expected arrow that EVERY reviewer saw REVERSED (and none forward) = wrong direction. - anomalies : the Negative-Space-Audit items any reviewer flagged (floating/pasted labels, artifacts). Informational (flagged, not a veto): word-level unaccounted_tokens (a legit paraphrase must not false-veto). Usage: python3 content_diff.py blueprint.json review1.json review2.json [review3.json ...] Prints a JSON report; exits non-zero if not content_accurate. """ import json, sys, re TOKEN_RE = re.compile(r"[a-z0-9_.+|/-]+") def keep_token(t): """meaningful tokens only — drop pure punctuation / single chars / bare digits / structure numbers.""" t = t.strip(".,;:()[]{}\"'") if len(t) <= 1: return False if t.isdigit(): return False # "1","2" phase numbers, bare counts if not re.search(r"[a-z]", t): return False # must contain a letter (keeps comic.json, panel_gate, content-svg; drops "+6", "6.25"? -> see keepnum) return True def keep_numeric(t): """keep meaningful numeric/code tokens like +6.2, +6.25, 24h (they carry the audit point).""" t = t.strip(".,;:()[]{}\"'") return bool(re.fullmatch(r"[+-]?\d[\d.]*[a-z]*", t)) and any(c.isdigit() for c in t) and len(t) >= 2 def tokenize(strings): out = set() for s in strings or []: for m in TOKEN_RE.findall(str(s).lower()): if keep_token(m) or keep_numeric(m): out.add(m) return out def main(): bp = json.load(open(sys.argv[1], encoding="utf-8")) reviews = [json.load(open(p, encoding="utf-8")) for p in sys.argv[2:]] visual = [r for r in reviews if isinstance(r.get("observed_tokens"), list)] if not visual: print(json.dumps({"error": "no visual reviewer returned observed_tokens"})); sys.exit(2) obs_sets = [tokenize(r.get("observed_tokens")) for r in visual] # EXPECTED = all locked text: node label_exact + desc_exact, group label_exact, edge label_exact, # callout title_exact + lines_exact, rail label_exact, plus explicit expected_tokens. exp_strings = [] for n in bp.get("nodes", []): exp_strings += [n.get("label_exact", n.get("label", "")), n.get("desc_exact", n.get("desc", ""))] exp_strings += n.get("expected_tokens", []) for g in bp.get("groups", []): exp_strings.append(g.get("label_exact", g.get("label", ""))) for e in bp.get("edges", []): exp_strings.append(e.get("label_exact", e.get("label", ""))); exp_strings += e.get("expected_tokens", []) for c in bp.get("callouts", []): exp_strings.append(c.get("title_exact", c.get("title", ""))); exp_strings += c.get("lines_exact", c.get("lines", [])) exp_strings += c.get("expected_tokens", []) exp_strings.append((bp.get("rail", {}) or {}).get("label_exact", (bp.get("rail", {}) or {}).get("label", ""))) expected = tokenize(exp_strings) # missing: a locked token not read by EVERY visual reviewer (no union — both must read it) missing = sorted(t for t in expected if not all(t in s for s in obs_sets)) # unaccounted: a token read by EVERY visual reviewer that the blueprint never declared (hallucinated text) common_observed = set.intersection(*obs_sets) if obs_sets else set() unaccounted = sorted(t for t in common_observed if t not in expected) # a hallucinated NUMBER/code token (read by both, authored by none) is a HARD veto — a figure must not state # a number the brief never authored (the analogue of the comic's expected_literals contract). unaccounted_numeric = sorted(t for t in unaccounted if keep_numeric(t)) # forbidden DELETE-list (a competing/wrong name): veto if ANY reviewer read it anywhere (union — stricter). forbidden = tokenize(bp.get("forbidden_tokens", [])) obs_union = set().union(*obs_sets) if obs_sets else set() forbidden_present = sorted(t for t in forbidden if t in obs_union) # edge topology: an expected arrow that EVERY reviewer saw REVERSED (and none forward) = wrong direction → veto. # conservative: exact normalized-label match only fires on a clear both-reviewer reversal, so a transcription gap # can't false-veto; a merely missing arrow stays informational (reviewers don't always transcribe every edge). nlabel = lambda s: re.sub(r"\s+", " ", str(s or "").strip().lower()) id2label = {n.get("id"): nlabel(n.get("label_exact", n.get("label", ""))) for n in bp.get("nodes", [])} exp_edges = set() for e in bp.get("edges", []): s, d = id2label.get(e.get("from", e.get("src"))), id2label.get(e.get("to", e.get("dst"))) if s and d: exp_edges.add((s, d)) def obs_edge_set(r): out = set() for e in (r.get("observed_edges") or []): if isinstance(e, dict): f, t = nlabel(e.get("from_label", "")), nlabel(e.get("to_label", "")) if f and t: out.add((f, t)) return out oe = [obs_edge_set(r) for r in visual] wrong_edges = sorted(f"{a} -> {b} (seen reversed)" for (a, b) in exp_edges if oe and all((b, a) in s for s in oe) and not any((a, b) in s for s in oe)) # anomalies (Negative-Space Audit) — union across all reviewers anomalies = sorted({str(a).strip().lower() for r in reviews for a in (r.get("anomalies") or []) if a}) # HARD vetoes; word-level unaccounted stays informational (a legit paraphrase must not false-veto). accurate = not (missing or anomalies or forbidden_present or unaccounted_numeric or wrong_edges) report = {"missing_tokens": missing, "unaccounted_tokens": unaccounted, "unaccounted_numeric": unaccounted_numeric, "forbidden_present": forbidden_present, "wrong_edges": wrong_edges, "anomalies": anomalies, "visual_reviewers": len(visual), "content_accurate": accurate, "note": "HARD vetoes: missing / anomalies / forbidden_present / unaccounted_numeric / wrong_edges; word-level unaccounted is informational"} print(json.dumps(report, ensure_ascii=False, indent=2)) sys.exit(0 if accurate else 1) if __name__ == "__main__": main() -
pickup_image.py 16.8 KB
#!/usr/bin/env python3 """pickup_image.py — fail-closed verify of a natively-generated image (pure stdlib). pickup_image.py is the single source of truth for the shared bake primitives. PRIMARY mode (`--out-existing`, the default): verify the EXPLICIT `--out` path that the agent's `mcp__codex__codex` bake wrote — PNG signature + IHDR dims + size floor (+ optional aspect band, + `mtime >= --created-at` so a stale prior bake at the same path is rejected). It runs a BEST-EFFORT denylist (the HARD-VETO) against the KNOWN codex-exec hand-draw fallback markers (struct/zlib/PIL/SVG/matplotlib) found in the agent `--transcript`: a clean PNG signature NEVER overrides a fallback marker, because a hand-written struct+zlib PNG can pass sig+dims and exceed the size floor. The denylist is NOT a complete security boundary — a sufficiently novel hand-draw recipe could evade it; the load-bearing faithfulness gate is the DOWNSTREAM cross-model blind-transcribe + content_diff, of which this scan is only a cheap first line of defense. LEGACY mode (`--legacy-marker-glob`, exec/CI path only): the old "newest PNG after `--marker` in ~/.codex/generated_images" glob, copied into `--out`. Retained only for the non-agent code path. This module is the SINGLE HOME of the shared bake primitives (contract-v2 §0a): build_bake_prompt / verify_existing_png / emit_bake_request / await_bake_status / _status_path. The two Python engines (run_comic.py + run_spiral.py) reuse these pickup_image.py primitives + the `--out-existing` CLI; packages/core/spiral_engine.js MIRRORS them and is held to a byte-parity test (tests/test_gates.py) so the in-process JS mirror can't drift from this Python source of truth. Usage: python3 pickup_image.py --out-existing --out path/round1.png [--min-bytes 500000] [--aspect W/H] [--created-at <epoch>] [--transcript bake.bakestatus.json] [--request-id <uuid4 hex from emit_bake_request>] python3 pickup_image.py --legacy-marker-glob --marker <epoch> --out path/round1.png [--dir ...] # prints a JSON {ok,path,sha256,bytes,width,height} """ import argparse, hashlib, json, os, re, struct, sys, time, glob, shutil, uuid def png_dims(path): with open(path, "rb") as f: sig = f.read(8) if sig != b"\x89PNG\r\n\x1a\n": return None f.read(4); ctype = f.read(4) if ctype != b"IHDR": return None w, h = struct.unpack(">II", f.read(8)) return w, h def bake_plan_digest(plan: dict) -> str: """Fingerprint the RESOLVED bake plan for P0 certificate binding (contract bakereq/v1): sha256 of the canonical JSON dump (sort_keys + tight separators) of the dict run_comic.get_bake_plan() returns. The p0_proof certificate (run_p0_proof.py) binds to THIS digest, so any change to the spend plan (model/effort/sandbox/min_bytes/aspect/timeout) invalidates a previously-minted certificate.""" return hashlib.sha256(json.dumps(plan, sort_keys=True, separators=(",", ":")).encode()).hexdigest() # ── shared bake primitives (contract-v2 §0a) — single source of truth, imported by the engines ── def build_bake_prompt(body, content_png_abs, identity_ref_abs, out_path_abs): """SHARED dead-simple bake prompt (contract-v2 §2). Reference + output paths are LITERAL in the text (the mcp__codex__codex schema has no -i image param). Contains NOTHING that triggers the hand-drawn fallback (no forbid-list, no escape hatch, no veto tokens) — see codex-gptimage-bake-recipe.""" lines = [ "Use your native image generation tool to produce ONE PNG. Generate an image — do not write or " "edit any code or files; only generate the single image.", body, f"Reference image 1 (absolute path): {content_png_abs}", ] if identity_ref_abs: lines.append(f"Reference image 2 (absolute path): {identity_ref_abs}") lines.append(f"Save the final native PNG to this exact path: {out_path_abs}") return "\n".join(x for x in lines if x) # code-context veto markers — NEVER a bare 'struct' (that would false-veto 'structural'/'reconstruct') # The trailing two are the FROM-IMPORT form of the struct/zlib markers (`from struct import` / `from zlib import`), # appended at the END in this FIXED order (struct then zlib); spiral_engine.js VETO_PATS appends the SAME two in the # SAME order to keep the byte-parity test (tests/test_gates.py) green → 14 entries. _VETO_PATS = [r"\bimport\s+struct\b", r"\bstruct\.pack\b", r"\bimport\s+zlib\b", r"\bzlib\.compress\b", r"\bfrom\s+pil\b", r"\bimport\s+pil\b", r"<svg", r"\bmatplotlib\b", r"\bdef\s+main\s*\(", r"\brsvg-convert\b", r"\bcairosvg\b", r"\bwritefile\b", r"\bfrom\s+struct\s+import\b", r"\bfrom\s+zlib\s+import\b"] def _veto_hits(scan_path): """Code-context fallback markers found in a transcript/log (empty list = clean).""" if not scan_path or not os.path.exists(scan_path): return [] low = open(scan_path, errors="ignore").read().lower() return [p for p in _VETO_PATS if re.search(p, low)] def _verify_png_bytes(out_path, min_bytes, aspect, request_created_at): """The pure PNG-bytes check (existence + size floor + sig/IHDR + optional aspect band + mtime>=request). Shared by both the normal post-denylist path and the allow_no_transcript=True legacy branch (one impl, no drift).""" if not os.path.exists(out_path): return {"ok": False, "reason": "out_path does not exist (agent bake never wrote it — fail-closed)"} b = os.path.getsize(out_path) # reject EXACTLY min_bytes too (b <= min_bytes), accept only strictly greater — matches the downstream # acceptance gate (run_comic.py rejects on bytes <= min_bytes), closing an off-by-one where a min_bytes PNG # would pass pickup but be rejected downstream. if b <= min_bytes: return {"ok": False, "reason": f"out_path too small ({b} <= {min_bytes}) — likely non-native fallback"} dims = png_dims(out_path) if not dims: return {"ok": False, "reason": "out_path is not a valid PNG (bad signature/IHDR)"} if aspect: ar = dims[0] / dims[1] if not (0.6 * aspect <= ar <= 1.6 * aspect): return {"ok": False, "reason": f"out_path aspect {ar:.3f} off blueprint {aspect:.3f}"} if request_created_at is not None and os.path.getmtime(out_path) < request_created_at - 1: return {"ok": False, "reason": "out_path older than the bake request (stale prior bake) — fail-closed"} sha = hashlib.sha256(open(out_path, "rb").read()).hexdigest() return {"ok": True, "path": out_path, "sha256": sha, "bytes": b, "width": dims[0], "height": dims[1]} def verify_existing_png(out_path, min_bytes=500000, aspect=None, request_created_at=None, transcript=None, allow_no_transcript=False): """SHARED verifier (contract-v2 §4). Verify the EXPLICIT out_path; run a BEST-EFFORT denylist (the HARD-VETO) over the TRANSCRIPT ONLY (never the prompt — it legitimately mentions 'image'/'PNG'). The denylist targets the KNOWN codex-exec hand-draw fallback (struct/zlib/PIL/SVG/matplotlib); it is NOT a complete security boundary — the load-bearing faithfulness gate is the downstream cross-model blind-transcribe + content_diff. A clean sig/dims/size does NOT override a fallback marker. FAIL-CLOSED by default: a None/missing/empty transcript is rejected (the denylist would be INERT), unless allow_no_transcript=True restores the legacy permissive behavior. Returns {ok:True, path, sha256, bytes, width, height} or {ok:False, reason}.""" # FAIL-CLOSED default: with no transcript to scan, the HARD-VETO denylist is INERT, so a code-drawn fallback PNG # with a clean sig+dims+size could sail through. Reject up front unless a legacy caller opts back in. if not transcript: if allow_no_transcript: return _verify_png_bytes(out_path, min_bytes, aspect, request_created_at) return {"ok": False, "reason": "empty/missing transcript — HARD-VETO inert, fail-closed"} # B-CONTRACT defense-in-depth: a GIVEN transcript path that resolves to missing/whitespace-only content also # leaves the HARD-VETO scan INERT — fail-closed rather than silently accept. if not os.path.exists(transcript) or not open(transcript, errors="ignore").read().strip(): return {"ok": False, "reason": "empty/missing transcript — HARD-VETO inert, fail-closed"} # BLOCKER-central: if the transcript is a JSON status dict (the agent's <png>.bakestatus.json carries # status + raw mcp_output), require mcp_output to be a NON-EMPTY string — an ok status whose mcp_output is # missing/empty/non-string makes the HARD-VETO scan vacuous (nothing to denylist), so fail-closed. A NON-JSON # transcript (a plain log) skips this check so the regex-denylist path still applies on its own. try: _tx_obj = json.loads(open(transcript, errors="ignore").read()) except (ValueError, OSError): _tx_obj = None if isinstance(_tx_obj, dict) and "status" in _tx_obj: _m = _tx_obj.get("mcp_output") if not isinstance(_m, str) or not _m.strip(): return {"ok": False, "reason": "bakestatus mcp_output missing/empty/non-string — HARD-VETO inert, fail-closed"} hits = _veto_hits(transcript) if hits: return {"ok": False, "reason": f"non-native fallback markers (HARD-VETO): {hits}"} return _verify_png_bytes(out_path, min_bytes, aspect, request_created_at) def _status_path(out_path): return out_path + ".bakestatus.json" def emit_bake_request(out_path, req): """Atomically write <out_path>.bakereq.json; pre-delete a stale out_path + status so a prior bake can't be silently reused (contract-v2 §3(i)). GENERATE a per-request nonce (uuid4 hex), stamp it into the bakereq.json payload as request_id, and RETURN it so the caller can require the matching id back in the bakestatus — a stale/foreign bake (mismatched id) is then fail-closed at pickup (--request-id).""" request_id = uuid.uuid4().hex req["request_id"] = request_id for p in (out_path, _status_path(out_path)): try: os.remove(p) except OSError: pass tmp = out_path + ".bakereq.json.tmp" with open(tmp, "w") as f: json.dump(req, f) os.replace(tmp, out_path + ".bakereq.json") return request_id def await_bake_status(out_path, timeout): """Poll <out_path>.bakestatus.json until it appears or timeout; return the parsed dict or {} on timeout. BLOCKER: return the parsed JSON ONLY when it is a dict — a non-dict payload (scalar/list/null, or a half-written file) is treated as NOT-READY (keep polling), so a malformed status file can never become a non-dict that a later status.get(...) would crash on. {} on timeout (callers' status.get(...) stays safe).""" sp = _status_path(out_path); deadline = time.time() + timeout while time.time() < deadline: if os.path.exists(sp): try: obj = json.load(open(sp)) if isinstance(obj, dict): return obj except (ValueError, OSError): pass time.sleep(2) return {} def main(): ap = argparse.ArgumentParser() ap.add_argument("--out", required=True) ap.add_argument("--out-existing", action="store_true", help="PRIMARY mode (default): verify the EXPLICIT --out path (no glob, no marker) — the contract-v2 agent-seam verifier") ap.add_argument("--created-at", type=float, help="request epoch; in --out-existing mode require mtime(out) >= this (rejects a stale prior bake)") ap.add_argument("--transcript", help="agent bakestatus/bakelog scanned for non-native fallback markers (HARD-VETO)") ap.add_argument("--request-id", help="expected bake nonce (emit_bake_request's uuid4 hex); in --out-existing mode the " "transcript MUST parse to a JSON bakestatus DICT whose request_id == this, else fail-closed " "(non-dict/plain-log/list or missing/mismatched id all rejected — stale/foreign bake)") ap.add_argument("--marker", type=float, help="LEGACY (exec path only): epoch; accept files with mtime >= this") ap.add_argument("--legacy-marker-glob", action="store_true", help="LEGACY: newest-PNG-after-marker glob over --dir (exec/CI only; default is --out-existing)") ap.add_argument("--min-bytes", type=int, default=500000) ap.add_argument("--dir", default=os.path.expanduser("~/.codex/generated_images")) ap.add_argument("--log", help="LEGACY: the codex bake log scanned for a non-native fallback (use --transcript in agent mode)") ap.add_argument("--aspect", type=float, help="expected width/height (e.g. blueprint W/H); rejects a wildly off-aspect image") a = ap.parse_args() if not a.out_existing and not a.legacy_marker_glob: a.out_existing = True # default to the explicit-out verifier if a.legacy_marker_glob and a.marker is None: print(json.dumps({"ok": False, "reason": "--legacy-marker-glob requires --marker"}), file=sys.stderr); sys.exit(2) # PRIMARY (contract-v2): verify the EXPLICIT out_path the agent's mcp__codex__codex bake wrote. if a.out_existing: # B-CONTRACT (pickup side): the HARD-VETO scans the agent transcript (status file's mcp_output) for # struct/zlib/PIL/SVG/matplotlib traces of a hand-drawn fallback. If the transcript is absent, missing, # or whitespace-only, the veto is INERT — a code-drawn PNG with a clean sig+dims+size would sail through. # Fail-closed BEFORE accepting the PNG: require a present, non-whitespace transcript in --out-existing mode. tx = a.transcript or a.log if not tx or not os.path.exists(tx) or not open(tx, errors="ignore").read().strip(): print(json.dumps({"ok": False, "reason": "empty/missing transcript — HARD-VETO inert, fail-closed"}, ensure_ascii=False), file=sys.stderr) sys.exit(1) # NONCE fail-closed (--request-id): when an expected request_id was supplied, the transcript MUST parse to a # JSON bakestatus DICT whose request_id == it — otherwise fail-closed BEFORE accepting the PNG. Opting into # --request-id is opting into nonce enforcement, so a NON-JSON (plain-log) or JSON-list transcript (which # carries no addressable request_id, leaving the nonce check INERT) is itself rejected — NOT silently passed: # a fresh valid PNG with a wrong/absent nonce on such a transcript would otherwise be accepted (rc=0). Reject # on parse-failure, non-dict, OR missing/mismatched request_id. (The no-request-id path is untouched.) if a.request_id: try: _st = json.loads(open(tx, errors="ignore").read()) except (ValueError, OSError): _st = None if not isinstance(_st, dict) or _st.get("request_id") != a.request_id: print(json.dumps({"ok": False, "reason": "bakestatus request_id mismatch/absent (non-dict or no match) — stale/foreign bake, fail-closed"}, ensure_ascii=False), file=sys.stderr) sys.exit(1) res = verify_existing_png(a.out, a.min_bytes, a.aspect, a.created_at, tx) if res.get("ok"): res.setdefault("src", os.path.basename(a.out)) print(json.dumps(res, ensure_ascii=False)); return 0 print(json.dumps(res, ensure_ascii=False), file=sys.stderr); sys.exit(1) # LEGACY (exec/CI only): HARD-VETO scan + newest-PNG-after-marker glob over --dir, copied into --out. hits = _veto_hits(a.transcript or a.log) if hits: print(json.dumps({"ok": False, "reason": f"non-native fallback markers (HARD-VETO): {hits}"}), file=sys.stderr) sys.exit(1) cands = [p for p in glob.glob(os.path.join(os.path.expanduser(a.dir), "**", "*.png"), recursive=True) if os.path.getmtime(p) >= a.marker - 1] cands.sort(key=os.path.getmtime, reverse=True) for p in cands: b = os.path.getsize(p) if b < a.min_bytes: continue dims = png_dims(p) if not dims: continue if a.aspect: ar = dims[0] / dims[1] if not (0.6 * a.aspect <= ar <= 1.6 * a.aspect): continue # wildly off the blueprint aspect → not our figure sha = hashlib.sha256(open(p, "rb").read()).hexdigest() os.makedirs(os.path.dirname(os.path.abspath(a.out)), exist_ok=True) shutil.copy(p, a.out) print(json.dumps({"ok": True, "path": a.out, "src": os.path.basename(p), "sha256": sha, "bytes": b, "width": dims[0], "height": dims[1]}, ensure_ascii=False)) return 0 print(json.dumps({"ok": False, "reason": "no valid native PNG after marker (fail-closed)", "checked": len(cands), "dir": a.dir, "min_bytes": a.min_bytes}), file=sys.stderr) sys.exit(1) if __name__ == "__main__": sys.exit(main()) -
render_condition.py 8.1 KB
#!/usr/bin/env python3 """render_condition.py — blueprint -> a clean WHITE-BG labeled CONDITION (SVG; pure stdlib). The condition is the image references' structural anchor: pale phase panels, labeled node cards (title=label_exact + one desc_exact line), labeled arrows, character PLACEHOLDER zones, the callout, the rail. It is NOT the final figure — image_gen redraws it into the aesthetic while keeping the locked text. Emit SVG (no deps); rasterize with headless Chrome: chrome --headless=new --screenshot=condition.png --window-size=W,H --force-device-scale-factor=2 file://<svg> Usage: python3 render_condition.py blueprint.json --out condition.svg """ import argparse, json, math def main(): ap = argparse.ArgumentParser(); ap.add_argument("blueprint"); ap.add_argument("--out", default="condition.svg") ap.add_argument("--png", help="also rasterize to this PNG via headless Chrome (the bake needs a PNG condition)") a = ap.parse_args() bp = json.load(open(a.blueprint, encoding="utf-8")) W = bp["canvas"]["width"]; H = bp["canvas"]["height"]; BG = bp["canvas"].get("background", "#FFFFFF") PAL = (bp.get("style") or {}).get("palette", {}) TONE = {"blue": ("#E5EEFB", "#9DBDEB", "#2563EB"), "peach": ("#FDEBD8", "#F4C18A", "#EA580C"), "green": ("#DEF5E6", "#9BD9B0", "#0E9F6E"), "grey": ("#F1F3F6", "#CBD2DC", "#6B7280")} INK = PAL.get("text", "#1F2937"); MUT = PAL.get("muted", "#6B7280"); RED = PAL.get("red", "#DC2626") FONT = (bp.get("style") or {}).get("font_family", "Inter, Arial, sans-serif") NODES = {n["id"]: n for n in bp["nodes"]} o = [] def esc(s): return str(s).replace("&", "&").replace("<", "<").replace(">", ">") def T(x, y, s, fill, sz, wt="700", anc="middle"): return f'<text x="{x}" y="{y}" fill="{fill}" font-size="{sz}" font-weight="{wt}" font-family="{FONT}" text-anchor="{anc}">{esc(s)}</text>' def R(x, y, w, h, fill, rx=12, st=None, sw=0, dash=None): e = f'<rect x="{x:.0f}" y="{y:.0f}" width="{w:.0f}" height="{h:.0f}" rx="{rx}" fill="{fill}"' if st: e += f' stroke="{st}" stroke-width="{sw}"' if dash: e += f' stroke-dasharray="{dash}"' return e + "/>" def lab(n, k): return n.get(k + "_exact", n.get(k, "")) def box(n): s = n.get("size", {}); w = s.get("w", 200); h = s.get("h", 76) return n["pos"]["x"] - w / 2, n["pos"]["y"] - h / 2, w, h def arrow(x1, y1, x2, y2, c, w=2.6, dash=None, curve=0): d = f' stroke-dasharray="{dash}"' if dash else "" if curve: mx, my = (x1 + x2) / 2, (y1 + y2) / 2 - curve p = f'<path d="M{x1:.0f},{y1:.0f} Q{mx:.0f},{my:.0f} {x2:.0f},{y2:.0f}" fill="none" stroke="{c}" stroke-width="{w}"{d}/>' ang = math.atan2(y2 - my, x2 - mx) else: p = f'<line x1="{x1:.0f}" y1="{y1:.0f}" x2="{x2:.0f}" y2="{y2:.0f}" stroke="{c}" stroke-width="{w}"{d}/>' ang = math.atan2(y2 - y1, x2 - x1) L = 11 head = (f'<path d="M{x2:.0f},{y2:.0f} L{x2-L*math.cos(ang-0.45):.0f},{y2-L*math.sin(ang-0.45):.0f} ' f'L{x2-L*math.cos(ang+0.45):.0f},{y2-L*math.sin(ang+0.45):.0f} Z" fill="{c}"/>') return p + head o.append(R(0, 0, W, H, BG, 0)) tt = bp.get("title", {}) if tt.get("main"): o.append(T(56, 78, tt["main"], INK, 40, "800", "start")) if tt.get("sub"): o.append(T(58, 110, tt["sub"], MUT, 17, "500", "start")) EK = {"flow": "#4B5563", "keep": "#0E9F6E", "retry": "#EA580C", "repair": "#2563EB", "write": MUT, "audit": MUT, "human": RED} # phase panels for g in bp.get("groups", []): b = g["bounds"]; t = TONE.get(g.get("tone", "grey"), TONE["grey"]) o.append(R(b["x"], b["y"], b["w"], b["h"], t[0], 18, t[1], 2)) o.append(T(b["x"] + 20, b["y"] + 32, lab(g, "label"), t[2], 18, "800", "start")) # edges + labels for e in bp.get("edges", []): af = NODES.get(e["from"]); at = NODES.get(e["to"]) if not af or not at: continue ax, ay, bx, by = af["pos"]["x"], af["pos"]["y"], at["pos"]["x"], at["pos"]["y"] c = EK.get(e["kind"], "#4B5563") dash = "8 5" if e["kind"] in ("retry", "repair", "write", "audit") else None curve = 120 if e["kind"] == "retry" else (-150 if e["kind"] == "repair" else 0) o.append(arrow(ax, ay, bx, by, c, 2.6, dash, curve)) el = lab(e, "label") if el: mx, my = (ax + bx) / 2, (ay + by) / 2 - (120 if e["kind"] == "retry" else (-150 if e["kind"] == "repair" else 0)) tw = len(el) * 7 + 14 o.append(R(mx - tw / 2, my - 11, tw, 19, "#FFFFFF", 4) + T(mx, my + 3, el, c, 11.5, "700")) # nodes for n in bp["nodes"]: x, y, w, h = box(n); shp = n.get("shape", "process") acc = PAL.get(n.get("accent", ""), n.get("accent", "")) or "#CBD2DC" fill = "#F8FAFD" if shp == "datastore" else "#FFFFFF" if shp == "diamond": cx, cy = n["pos"]["x"], n["pos"]["y"] o.append(f'<path d="M{cx},{y} L{x+w},{cy} L{cx},{y+h} L{x},{cy} Z" fill="{fill}" stroke="{acc}" stroke-width="2.4"/>') else: o.append(R(x, y, w, h, fill, 12, acc, 2, dash="6 5" if shp == "character" else None)) title = lab(n, "label"); desc = lab(n, "desc"); cx = n["pos"]["x"] if shp == "character": o.append(T(cx, y + 26, title, INK, 14, "800")); if desc: o.append(T(cx, y + 44, desc, MUT, 11, "600")) o.append(T(cx, y + h - 12, "〔ARIS chibi here〕", MUT, 10, "600")) else: ty = n["pos"]["y"] - (7 if desc else -5) o.append(T(cx, ty, title, INK, 18, "800")) if desc: for i, dl in enumerate(desc.split("\n")): o.append(T(cx, ty + 22 + i * 18, dl, MUT, 13, "600")) # callouts for c in bp.get("callouts", []): p = c.get("pos", {}); s = c.get("size", {}) cw, ch = s.get("w", 360), s.get("h", 140); cx0, cy0 = p.get("x", W / 2) - cw / 2, p.get("y", H - 120) - ch / 2 o.append(R(cx0, cy0, cw, ch, "#FDECEC", 12, RED, 2.4)) o.append(T(p.get("x", W / 2), cy0 + 26, lab(c, "title"), RED, 17, "800")) for i, ln in enumerate(c.get("lines_exact", c.get("lines", []))): o.append(T(p.get("x", W / 2), cy0 + 52 + i * 22, ln, INK, 12.5, "700")) rail = bp.get("rail", {}) if rail.get("label_exact") or rail.get("label"): o.append(T(W / 2, H - 22, lab(rail, "label"), MUT, 13, "600")) svg = f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" viewBox="0 0 {W} {H}">' + "".join(o) + "</svg>" svg_path = a.out if a.out.endswith(".svg") else a.out + ".svg" open(svg_path, "w", encoding="utf-8").write(svg) print(f"[render_condition] wrote {svg_path} ({len(svg)} bytes, {W}x{H})") if a.png: import shutil, subprocess, os png_path = a.png if a.png.endswith(".png") else a.png + ".png" chrome = next((c for c in [ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", shutil.which("google-chrome"), shutil.which("chromium"), shutil.which("chromium-browser")] if c and os.path.exists(c) if c), None) \ or shutil.which("google-chrome") or shutil.which("chromium") if not chrome: print(f"[render_condition] no headless Chrome found; rasterize manually:\n" f" <chrome> --headless=new --disable-gpu --screenshot='{os.path.abspath(png_path)}' " f"--window-size={int(W)},{int(H)} --force-device-scale-factor=2 'file://{os.path.abspath(svg_path)}'") else: subprocess.run([chrome, "--headless=new", "--disable-gpu", "--hide-scrollbars", f"--screenshot={os.path.abspath(png_path)}", f"--window-size={int(W)},{int(H)}", "--force-device-scale-factor=2", f"file://{os.path.abspath(svg_path)}"], timeout=60, capture_output=True) ok = os.path.exists(png_path) and os.path.getsize(png_path) > 1000 print(f"[render_condition] {'rasterized → ' + png_path if ok else 'rasterize FAILED (use the manual command)'}") if __name__ == "__main__": main() -
run_spiral.py 30.4 KB
#!/usr/bin/env python3 """run_spiral.py — one-command orchestrator for the method-figure spiral (stdlib + subprocess only). The executing agent only authors blueprint.json; this runs the whole loop: validate → render condition(+png) → [ bake (agent mcp__codex__codex sidecar seam) → pickup(verify) → panel: Gemini + Codex blind-transcribe → content_diff (deterministic) → decide ] × rounds → finalize + trace. Automated panel = Gemini-3 (visual) + Codex-5.5 (arrow/diagnostic) + the deterministic content_diff (the hard gate). Per the cross-model-acquittal rule the generator family (Codex) can't self-acquit, so a round is "panel-clean" iff: content_diff clean AND Gemini verdict==approve AND Codex has no blocker. The CALLING AGENT (Claude) gives the final STRUCTURAL sign-off on the converged figure — the orchestrator never claims Claude's acquittal for it (it prints a clear "awaiting Claude structural approve" on success). Long-running (each bake ~3-8 min × rounds) — run it in the background and watch run.log / trace.jsonl. Usage: python3 run_spiral.py blueprint.json --identity identity_sheet.png --out-dir figures/method_figure/<id> [--max-rounds 4] [--dry-run] """ import argparse, hashlib, json, os, re, shlex, subprocess, sys, time, shutil HERE = os.path.dirname(os.path.abspath(__file__)) # contract-v2 §0a: import the SHARED bake primitives from pickup_image.py (single source of truth — NEVER # re-define them here; an inlined copy re-introduces cross-engine drift). pickup_image.py lives in HERE. sys.path.insert(0, HERE) from pickup_image import build_bake_prompt, emit_bake_request, await_bake_status, _status_path # noqa: E402 def log(msg): print(f"[run_spiral {time.strftime('%H:%M:%S')}] {msg}", flush=True) class _Fail: def __init__(self, msg): self.returncode = 1; self.stdout = ""; self.stderr = msg def sh(cmd, timeout, **kw): try: return subprocess.run(cmd, timeout=timeout, capture_output=True, text=True, **kw) except subprocess.TimeoutExpired as e: return _Fail(f"timeout after {timeout}s: {e}") except Exception as e: return _Fail(f"subprocess error: {e}") def sha(path): return hashlib.sha256(open(path, "rb").read()).hexdigest()[:16] if os.path.exists(path) else "" def extract_json(text, required_key=None): """pull the first JSON object out of a CLI model's (prose/fence-wrapped) output — a real decoder (raw_decode, mirrors run_comic.py) so braces INSIDE string values (observed_tokens, blockers) can't truncate the payload. If required_key is given, skip fragments (e.g. a {"thought":...} preamble) that lack it — only return the real payload.""" dec = json.JSONDecoder() s = text.find("{") while s != -1: try: j, _ = dec.raw_decode(text[s:]) if isinstance(j, dict) and (required_key is None or required_key in j): return j except Exception: pass s = text.find("{", s + 1) return None # ---- blueprint → the locked-label re-assertion block (re-fed every round so the image model can't drift) ---- def locked_labels(bp): L = [] for g in bp.get("groups", []): L.append(f'phase "{g.get("label_exact", g.get("label",""))}"') for n in bp.get("nodes", []): t = n.get("label_exact", n.get("label", "")); d = n.get("desc_exact", n.get("desc", "")) L.append(f'box "{t}"' + (f' — "{d}"' if d else "")) for e in bp.get("edges", []): el = e.get("label_exact", e.get("label", "")) if el: L.append(f'arrow {e["from"]}→{e["to"]} "{el}"') for c in bp.get("callouts", []): L.append(f'callout "{c.get("title_exact", c.get("title",""))}": ' + " / ".join(c.get("lines_exact", c.get("lines", [])))) r = (bp.get("rail", {}) or {}).get("label_exact", "") if r: L.append(f'bottom rail "{r}"') return "\n".join(" - " + x for x in L) def bake_prompt(bp, condition_png, identity_png, fixes, invariants): tt = bp.get("title", {}); ident = "" if identity_png: traits = "; ".join(t for a in bp.get("assets", []) for t in a.get("lock_traits", [])) ident = (f"\nReference IMAGE 2 = the ONLY characters allowed ({traits}). Place them exactly where the " f"condition marks a character; never invent robots/mascots; shrink a character before it covers any label.") fixblock = ("\nAPPLY THIS ROUND'S FIXES (from the cross-model panel):\n" + "\n".join(" - " + f for f in fixes)) if fixes else "" keepblock = ("\nKEEP (do not regress):\n" + "\n".join(" - " + i for i in invariants)) if invariants else "" forbidden = bp.get("forbidden_tokens", []) or [] forbidblock = ("\nFORBIDDEN — NONE of these terms may appear ANYWHERE in the image (DELETE-list — a competing/wrong name):\n" + "\n".join(" - " + str(t) for t in forbidden)) if forbidden else "" return f"""Use your IMAGE GENERATION tool to output ONE PNG. Image generation only — do NOT write or edit code/SVG/files; only generate one image. Reference IMAGE 1 = the EXACT layout to reproduce ({os.path.basename(condition_png)}): every box already shows its title + one description line, arrows are labeled, three pale pastel phase panels on a WHITE background. Reproduce this layout faithfully.{ident} STYLE: top ML-paper "Figure 1" — pure WHITE background, soft pastel phase panels, rounded white node cards, soft shadows, clean thin labeled arrows, crisp sans-serif (monospace for code/number tokens). Hand-designed, not a screenshot. TEXT IS LOCKED — render every string below VERBATIM and crisp; do NOT rename, paraphrase, add, drop, garble, or insert spaces; do NOT invent any node/term/time-tag: {locked_labels(bp)}{forbidblock} {fixblock}{keepblock} Title top-left: "{tt.get('main','')}" / "{tt.get('sub','')}". Output the single finished figure, same wide aspect as IMAGE 1, white background.""" REVIEW_PROMPT = """Visual QA of a generated academic figure — look ONLY at the image: {png} Do NOT assume what labels SHOULD say; transcribe what you ACTUALLY see, and hunt for what should NOT be there. Return ONLY strict JSON (no prose) with these keys: {{"verdict":"approve|retry","scores":{{"text_fidelity":0,"arrow_topology":0,"layout_readability":0,"character_identity":0,"style_fit":0}}, "observed_tokens":["...verbatim strings you can read..."], "observed_edges":[{{"from_label":"","to_label":"","direction":"forward"}}], "identity_audit":[{{"node":"","status":"MATCH|DRIFT","issue":""}}], "character_anatomy":[{{"char":"","hands_visible":2,"defect":"none|extra_hand|merged|wrong_count"}}],"anatomy_defect":false, "anomalies":["floating/pasted-looking labels, artifacts, stray lines, duplicated characters, invented nodes"], "blockers":["concrete image-gen-fixable instruction"],"nice_to_have":[],"positive_invariants":["what is right, keep it"]}} Scores are 0-5. A vague "looks good" is rejected — you MUST list observed_tokens. If the figure contains characters/mascots, do NOT eyeball it: ENUMERATE each character's visible hands one by one, fill character_anatomy, and set anatomy_defect=true if ANY character has a wrong hand count (!=2), a third/floating/ duplicated/merged hand, or fused/extra fingers (character_anatomy=[] and anatomy_defect=false if there are no characters).""" def run_codex(prompt, images, effort, timeout, logf): # R4: run_codex now serves ONLY the read-only VISION REVIEW (with -i image attach). The bake NEVER routes # through it — codex exec hand-draws a non-native fallback. No surviving path passes image_gen. cmd = ["codex", "exec", prompt] for im in images: cmd += ["-i", im] cmd += ["--sandbox", "read-only", "-c", f"model_reasoning_effort={effort}", "--skip-git-repo-check"] r = sh(cmd, timeout) if logf: open(logf, "w").write((r.stdout or "") + "\n---STDERR---\n" + (r.stderr or "")) return (r.stdout or "") + (r.stderr or "") def review_gemini(png, timeout, gemini_cmd): p = REVIEW_PROMPT.format(png="the attached image") r = sh(shlex.split(gemini_cmd) + ["--model", "auto-gemini-3", "-p", f"@{png} {p}"], timeout) return extract_json((r.stdout or "") + (r.stderr or ""), required_key="verdict") def review_codex(png, timeout): # MUST attach the image with -i (a path in the prompt does NOT let Codex see the pixels). out = run_codex(REVIEW_PROMPT.format(png="the attached image"), [png], "xhigh", timeout, None) return extract_json(out, required_key="verdict") def resolve_input(a): """Step-0 input sniffing — the single-input entry. A *brief* is auto-compiled into a blueprint (so the user feeds ONE ARIS-format artifact and never hand-writes a blueprint or hand-places coordinates); a *blueprint* is used as-is (legacy/power-user). Returns (blueprint_path, identity_path). Fail-closed on ambiguity. The identity sheet is resolved from the brief's identity_refs[0].path unless --identity overrides it (so the sheet stops being a second hand-managed argument).""" if a.from_brief and a.from_blueprint: sys.exit("[run_spiral] pass at most one of --from-brief / --from-blueprint") raw = json.load(open(a.blueprint, encoding="utf-8")) is_bp = raw.get("version") == "method-figure/blueprint/v1" is_brief_sv = raw.get("schema_version") == "method-figure/brief/v1" # the AUTHORITATIVE brief signal is_brief_shape = ("components" in raw and "flows" in raw) # legacy shape (no schema_version) is_brief = is_brief_sv or is_brief_shape if is_bp and is_brief and not (a.from_brief or a.from_blueprint): sys.exit("[run_spiral] input looks like BOTH a blueprint (has version) and a brief (has components+flows) " "— pass --from-blueprint or --from-brief to disambiguate (fail-closed, no guessing).") if a.from_blueprint or (is_bp and not a.from_brief): return a.blueprint, a.identity # auto-detect a brief ONLY via schema_version; a components+flows JSON with NO schema_version must be opted in # explicitly (--from-brief), so a random non-method-figure JSON can't be silently compiled + baked. if is_brief_shape and not is_brief_sv and not is_bp and not a.from_brief: sys.exit("[run_spiral] input has components+flows but no `schema_version: method-figure/brief/v1` — " "pass --from-brief to compile it as a brief (fail-closed; refusing to guess).") if a.from_brief or (is_brief_sv and not is_bp): sys.path.insert(0, HERE) import compile_brief as cb blueprint, trace, errors = cb.compile_brief(raw, strict=True) if errors: print("[run_spiral] Step-0 traceability errors:", file=sys.stderr) for e in errors: print(" -", e, file=sys.stderr) sys.exit("[run_spiral] Step-0 REFUSED: the brief is not fully traceable (a missing " "number/claim/trait/component is an escalation, not creative license) — fix the brief.") os.makedirs(a.out_dir, exist_ok=True) bp_path = os.path.join(a.out_dir, "blueprint.json") tr_path = os.path.join(a.out_dir, "traceability.json") with open(bp_path, "w", encoding="utf-8") as f: json.dump(blueprint, f, indent=2, ensure_ascii=False) with open(tr_path, "w", encoding="utf-8") as f: json.dump(trace, f, indent=2, ensure_ascii=False) log(f"Step-0: compiled brief → {bp_path} ({len(blueprint['nodes'])} nodes) + traceability.json") identity = a.identity if not identity: refs = raw.get("identity_refs") or [] if refs and refs[0].get("path"): p = refs[0]["path"] if not os.path.isabs(p): p = os.path.join(os.path.dirname(os.path.abspath(a.blueprint)), p) if os.path.exists(p): identity = p log(f"Step-0: resolved identity sheet from brief.identity_refs[0] → {refs[0]['path']}") else: log(f"Step-0: brief identity_refs[0].path '{refs[0]['path']}' not found beside the brief " f"— bake will run without an identity ref (pass --identity to supply one)") return bp_path, identity sys.exit("[run_spiral] cannot tell if the input is a brief or a blueprint " "(no version, no components+flows) — pass --from-brief or --from-blueprint.") def main(): ap = argparse.ArgumentParser() ap.add_argument("blueprint"); ap.add_argument("--identity"); ap.add_argument("--out-dir", required=True) ap.add_argument("--max-rounds", type=int, default=0, help="0 = use blueprint render_policy.max_rounds") ap.add_argument("--dry-run", action="store_true") # no --effort: bake + review are hardcoded xhigh by design ap.add_argument("--bake-timeout", type=int, default=600); ap.add_argument("--review-timeout", type=int, default=300) # no --gemini-family here: run_spiral writes no reviewer_families provenance (trace carries verdicts only) ap.add_argument("--gemini-cmd", default="gemini", help='command for the SECOND visual reviewer — the legacy gemini CLI, or e.g. "python3 cli/gemini_agy_shim.py" ' "for Antigravity; MUST route to a google-family model (shlex-split; the " '["--model","auto-gemini-3","-p",<prompt>] tail is appended unchanged)') ap.add_argument("--from-brief", action="store_true", help="force: treat the input as a method_figure_brief (Step-0 compile)") ap.add_argument("--from-blueprint", action="store_true", help="force: treat the input as a ready blueprint (legacy/power-user)") ap.add_argument("--p0-only", action="store_true", help="run validate + render the condition (the zero-credit P0 gate), then stop") ap.add_argument("--bake-mode", choices=["agent", "exec"], default="agent", help="agent = real bake via the mcp__codex__codex sidecar seam (default); exec = legacy/CI non-image path that RAISES if it reaches a real bake (codex exec hand-draws a non-native fallback)") a = ap.parse_args() a.blueprint, a.identity = resolve_input(a) # Step-0: sniff brief vs blueprint; auto-compile a brief # ABSOLUTE-REF INVARIANT (mirror run_comic.py): the identity sheet must be an absolute, on-disk path before # it is embedded (as a literal) in the bake prompt — a relative/missing ref would silently desync the agent's # write target from our pickup probe. Absolutize, then existence-check fail-closed. if a.identity: a.identity = os.path.abspath(a.identity) if a.identity and not os.path.exists(a.identity): print(f"[run_spiral] identity sheet not found: {a.identity}", file=sys.stderr); sys.exit(2) bp = json.load(open(a.blueprint, encoding="utf-8")) fid = bp.get("figure_id", "figure"); cw = bp["canvas"]["width"]; ch = bp["canvas"]["height"] rounds = a.max_rounds or (bp.get("render_policy", {}).get("max_rounds", 4)) minscore = (bp.get("acceptance", {}) or {}).get("min_core_score", 4) # RELATIVE-PATH FIX (mirror run_comic.py's abspath(project)): resolve out_dir to a TRUE absolute path ONCE, # and derive EVERY downstream path (trace/cond_svg/cond_png + the per-round png/blog + the finalize copies) # from it. build_bake_prompt + emit_bake_request emit refs/out_path under the literal "(absolute path)" label, # so the embedded ref/out paths, the agent's write target, and our own pickup probe (--out png) must all be # the SAME absolute files — a relative out_dir would silently desync them against the agent's cwd. out_dir = os.path.abspath(a.out_dir) os.makedirs(out_dir, exist_ok=True) trace = os.path.join(out_dir, "trace.jsonl"); open(trace, "w").close() cond_svg = os.path.join(out_dir, "condition.svg"); cond_png = os.path.join(out_dir, "condition.png") log(f"validate {a.blueprint}") r = sh(["python3", os.path.join(HERE, "validate_blueprint.py"), a.blueprint], 60) print(r.stdout, r.stderr) if r.returncode != 0: sys.exit("blueprint invalid — fix it first") log("render condition") rc0 = sh(["python3", os.path.join(HERE, "render_condition.py"), a.blueprint, "--out", cond_svg, "--png", cond_png], 90) print(rc0.stdout, rc0.stderr) if rc0.returncode != 0 or (not a.dry_run and not os.path.exists(cond_png)): sys.exit("render_condition failed to produce condition.png (need headless Chrome to rasterize)") if a.p0_only: # zero-credit P0 gate: brief→blueprint validated + condition rendered (above) AND the would-be bake # prompt LINTED — background white, identity sheet resolved (if the blueprint declares one), every # forbidden token carried into the DELETE-list, locked labels present. A blocker here costs ZERO credits. prompt0 = bake_prompt(bp, cond_png, a.identity, [], []) p0 = [] if str((bp.get("canvas") or {}).get("background", "#FFFFFF")).upper() not in ("#FFFFFF", "#FFF", "WHITE"): p0.append(f"canvas background is not white: {(bp.get('canvas') or {}).get('background')}") if bp.get("assets") and not a.identity: p0.append("blueprint declares an identity asset but no identity sheet resolved (--identity / brief.identity_refs[].path)") for t in (bp.get("forbidden_tokens") or []): if str(t) not in prompt0: p0.append(f"forbidden token '{t}' not carried into the bake prompt's DELETE-list") if "TEXT IS LOCKED" not in prompt0 or not locked_labels(bp).strip(): p0.append("bake prompt carries no locked labels") log("p0-only: zero-credit gate (validate + compile + render + bake-prompt lint)") if p0: for b in p0: print(" ✗ P0 blocker:", b) sys.exit("[run_spiral] P0 gate FAILED — fix the blockers above before spending any image credit.") print(f" ✓ P0 clean — condition: {cond_svg}; identity={'resolved' if a.identity else 'none'}; " f"{len([x for x in locked_labels(bp).splitlines() if x.strip()])} locked labels + " f"{len(bp.get('forbidden_tokens') or [])} forbidden tokens carried into the prompt") return if a.dry_run: log("dry-run: printing the round-1 bake prompt then exiting") print("\n===== BAKE PROMPT (round 1) =====\n" + bake_prompt(bp, cond_png, a.identity, [], [])) return # FAIL-CLOSED: a blueprint that declares identity asset(s) must NOT bake without its identity sheet — otherwise # the character figure bakes unconstrained. (--p0-only enforces the same check; enforce it on the real bake too.) if bp.get("assets") and not a.identity: sys.exit("[run_spiral] blueprint declares identity asset(s) but no identity sheet resolved " "(--identity / brief.identity_refs[].path) — refusing to bake a character figure without its identity ref.") invariants, last_fixes = [], [] images = [cond_png] + ([a.identity] if a.identity else []) for rd in range(1, rounds + 1): log(f"=== round {rd}/{rounds} : BAKE ===") png = os.path.join(out_dir, f"round{rd}.png"); blog = os.path.join(out_dir, f"round{rd}.bakelog.txt") # R4: the exec path NEVER bakes (codex exec hand-draws a non-native fallback) — it RAISES. R3: in agent # mode the core does NOT hold /tmp/aris_imagegen.lock (the agent wrapper is the sole serializer; the # foreground core blocking on the agent that must service the sidecar would deadlock). if a.bake_mode != "agent": raise RuntimeError("exec bake retired — it hand-draws a non-native fallback; use --bake-mode=agent") # BAKE via the AGENT (mcp__codex__codex) over the sidecar seam: refs+out_path embedded as ABSOLUTE PATHS # inside prompt_text (the MCP schema has no -i); sandbox=workspace-write; model_reasoning_effort=xhigh via # config{}. The agent writes <png>.bakestatus.json carrying status + raw mcp_output (for the HARD-VETO). created_at = time.time() prompt_text = build_bake_prompt(bake_prompt(bp, cond_png, a.identity, last_fixes, invariants), cond_png, a.identity or "", png) # B-NONCE: capture the per-bake request_id RETURNED by emit_bake_request. The shared pickup_image.py # (single source of truth, contract-v2 §0a) mints a uuid4 nonce, stamps it into the bakereq.json payload, # and returns it; we forward it via --request-id so pickup fail-closes if the bakestatus carries a # mismatched id (a stale/foreign bake at <png>.bakestatus.json can't be silently honored). The id never # needs pre-seeding into the dict here — emit owns minting it. Fallback to created_at if a future/legacy # emit ever returns None (mirrors run_comic.py's guard) so request_id is always a usable string. request_id = emit_bake_request(png, {"prompt_text": prompt_text, "out_path": png, "content_png": cond_png, "identity_ref": a.identity or "", "model": "gpt-5.5", "config": {"model_reasoning_effort": "xhigh", "include_image_gen_tool": True}, "sandbox": "workspace-write", # the agent's cwd is a STABLE ABSOLUTE repo root (mirrors run_comic.py's cwd=paths["PROJ"]), # NOT the parent of out_dir — every ref/out_path is already an absolute path in prompt_text, # so the bake resolves the same files regardless of cwd, and the cwd never drifts per-run. "cwd": HERE.rsplit("/skills/", 1)[0], "created_at": created_at, "min_bytes": 500000, "aspect": cw / ch}) if not request_id: request_id = str(created_at) # defensive: emit always returns a uuid today; keep a usable id status = await_bake_status(png, a.bake_timeout) # polls <png>.bakestatus.json; {} on timeout if not isinstance(status, dict): status = {} # await guard: a non-dict return (foreign/future) → treat as timeout # FAIL-CLOSED: ONLY status=="ok" may proceed to verify (an empty/timeout/non-ok status must NOT fall # through to pickup — a stale/foreign PNG at png could otherwise be accepted). A timeout is "other", NOT # "throttle" (only an explicit wrapper failure_kind=="throttle" is a throttle — consistent with run_comic.py). if status.get("status") != "ok": kind = "throttle" if status.get("failure_kind") == "throttle" else "other" why = "agent bake timed out (no <png>.bakestatus.json — is the agent wrapper running?)" if not status \ else f"agent bake not ok [{kind}]: {str(status.get('mcp_error') or status.get('status'))[:300]}" log(f"BAKE not ok [{kind}] (round {rd}) — escalate") open(trace, "a").write(json.dumps({"round": rd, "decision": "escalate", "reason": "agent bake not ok", "failure_kind": kind, "detail": why}) + "\n") print("ESCALATE: image generation failed (throttle? non-native fallback?). See", _status_path(png)); sys.exit(2) # HARD-VETO ENFORCEMENT (status==ok): the HARD-VETO scans status.mcp_output (where a hand-draw leaves # struct/zlib/PIL/SVG/matplotlib traces). An ok status carrying an EMPTY/missing mcp_output would make the # veto INERT — a code-drawn fallback could then sail through pickup. Fail-closed exactly like a failed bake # (same escalate trace + sys.exit(2)), classified "other", BEFORE we ever probe the PNG. m = status.get("mcp_output") if not isinstance(m, str) or not m.strip(): # isinstance (not `(... or "").strip()`): blocks null/empty AND a non-string mcp_output that would # otherwise CRASH .strip() — fail-closed exactly like a failed bake (same escalate trace + sys.exit(2)). why = "agent status ok but mcp_output missing/empty/non-string — HARD-VETO inert, fail-closed" log(f"BAKE ok but EMPTY/non-string mcp_output (round {rd}) — HARD-VETO inert, escalate") open(trace, "a").write(json.dumps({"round": rd, "decision": "escalate", "reason": "agent status ok but mcp_output missing/empty/non-string — HARD-VETO inert, fail-closed", "failure_kind": "other", "detail": why}) + "\n") print("ESCALATE: image generation failed (throttle? non-native fallback?). See", _status_path(png)); sys.exit(2) pk = sh(["python3", os.path.join(HERE, "pickup_image.py"), "--out-existing", "--out", png, "--min-bytes", "500000", "--aspect", str(cw / ch), "--created-at", str(created_at), "--request-id", request_id, "--transcript", _status_path(png)], 60) if pk.returncode != 0: log(f"BAKE produced no valid native image (round {rd}) — escalate"); open(trace, "a").write(json.dumps({"round": rd, "decision": "escalate", "reason": "no native image", "detail": pk.stderr.strip()}) + "\n") print("ESCALATE: image generation failed (throttle? non-native fallback?). See", _status_path(png)); sys.exit(2) log(f"baked → {png} ({sha(png)}) : PANEL (Gemini + Codex)") rg = review_gemini(png, a.review_timeout, a.gemini_cmd); rc = review_codex(png, a.review_timeout) reviews = {} for name, rv in (("gemini", rg), ("codex", rc)): j = os.path.join(out_dir, f"round{rd}.{name}.json") json.dump(rv or {"verdict": "retry", "parse_error": True, "observed_tokens": []}, open(j, "w"), ensure_ascii=False, indent=1) reviews[name] = j df = sh(["python3", os.path.join(HERE, "content_diff.py"), a.blueprint, reviews["gemini"], reviews["codex"]], 60) diff = extract_json(df.stdout) or {"content_accurate": False, "missing_tokens": ["<diff failed>"], "anomalies": [], "unaccounted_tokens": []} # decide — fail-closed: BOTH reviewers must have returned parseable JSON with observed_tokens, both # must approve, the deterministic diff must be clean, core scores (incl. identity if a sheet was given) # >= threshold, and neither anomalies nor codex blockers present. gv = (rg or {}).get("verdict", "retry"); cv = (rc or {}).get("verdict", "retry") both_parsed = isinstance((rg or {}).get("observed_tokens"), list) and isinstance((rc or {}).get("observed_tokens"), list) gscores = (rg or {}).get("scores", {}) core_keys = ["text_fidelity", "arrow_topology", "layout_readability", "style_fit"] + (["character_identity"] if a.identity else []) core_ok = bool(gscores) and all(gscores.get(k, 0) >= minscore for k in core_keys) codex_block = (rc or {}).get("blockers", []); g_anom = (rg or {}).get("anomalies", []) anatomy_defect = (rg or {}).get("anatomy_defect") is True or (rc or {}).get("anatomy_defect") is True # single-reviewer veto (figures w/ characters) panel_clean = (both_parsed and diff.get("content_accurate") and gv == "approve" and cv == "approve" and core_ok and not codex_block and not g_anom and not anatomy_defect) # consolidate BLOCKERS only; carry positive_invariants anatomy_chars = [c for r in (rg, rc) for c in (r or {}).get("character_anatomy", []) if isinstance(c, dict) and c.get("defect") not in (None, "none")] last_fixes = sorted(set((rg or {}).get("blockers", []) + codex_block + [f"render the missing token exactly: {t}" for t in diff.get("missing_tokens", [])[:12]] + [f"remove the anomaly: {x}" for x in diff.get("anomalies", [])[:8]] + [f"DELETE the forbidden term (must not appear anywhere): {t}" for t in diff.get("forbidden_present", [])[:8]] + [f"REMOVE the unsourced number (not authored in the brief): {t}" for t in diff.get("unaccounted_numeric", [])[:8]] + [f"fix the arrow direction — {t}" for t in diff.get("wrong_edges", [])[:8]] + ([f"fix anatomy: give {c.get('char','a character')} exactly two hands ({c.get('defect')})" for c in anatomy_chars[:4]] if anatomy_defect else []))) invariants = sorted(set(invariants + (rg or {}).get("positive_invariants", []) + (rc or {}).get("positive_invariants", []))) [:12] rec = {"round": rd, "blueprint_sha": sha(a.blueprint), "condition_sha": sha(cond_png), "generated_sha": sha(png), "reviewers": {"gemini": gv, "codex": cv}, "core_scores_ok": core_ok, "hard_diff": {"missing_tokens": diff.get("missing_tokens", []), "anomalies": diff.get("anomalies", []), "unaccounted_tokens": diff.get("unaccounted_tokens", []), "unaccounted_numeric": diff.get("unaccounted_numeric", []), "forbidden_present": diff.get("forbidden_present", []), "wrong_edges": diff.get("wrong_edges", [])}, "fixes": last_fixes, "decision": "accept_candidate" if panel_clean else ("retry" if rd < rounds else "escalate")} open(trace, "a").write(json.dumps(rec, ensure_ascii=False) + "\n") log(f"round {rd}: gemini={gv} codex={cv} core_ok={core_ok} diff_clean={diff.get('content_accurate')} anatomy_ok={not anatomy_defect} → {rec['decision']}") if panel_clean: shutil.copy(png, os.path.join(out_dir, "figure.png")) bp_dst = os.path.join(out_dir, "blueprint.json") # skip the copy when src and dst are the SAME file (Step-0 already wrote it there) — samefile() also # catches a symlink/hardlink that abspath string-compare would miss (avoids a shutil SameFileError). same = os.path.exists(bp_dst) and os.path.samefile(a.blueprint, bp_dst) if not same: shutil.copy(a.blueprint, bp_dst) open(trace, "a").write(json.dumps({"final_approve": "panel_clean_awaiting_claude_structural", "image": "figure.png", "blueprint": "blueprint.json", "accepted_round": rd, "verdicts": {"gemini": gv, "codex": cv, "diff": "clean"}}, ensure_ascii=False) + "\n") log(f"PANEL-CLEAN at round {rd} → {out_dir}/figure.png") print(f"\n✅ PANEL-CLEAN (Gemini approve + Codex approve + deterministic diff empty) at round {rd}.") print(f" figure: {out_dir}/figure.png trace: {trace}") print(" NEXT: the calling agent (Claude) gives the final STRUCTURAL sign-off — the orchestrator") print(" does not self-acquit the generator family. Inspect the figure and approve or send one more fix.") return log("MAX_ROUNDS reached without panel-clean → escalate to human") print(f"\n⚠ ESCALATE: {rounds} rounds without convergence. Best-so-far + open blockers in {trace}.") sys.exit(3) if __name__ == "__main__": main() -
validate_blueprint.py 6.3 KB
#!/usr/bin/env python3 """validate_blueprint.py — structural validation of a method-figure blueprint (pure stdlib). Checks JSON Schema *shape* loosely (required top-level keys) plus the structural invariants a JSON Schema can't easily express: unique node/group ids, every edge endpoint resolves, node/group bounds inside the canvas, and no two LOCKED labels collide (which would make the hard-diff ambiguous). Exits non-zero on any failure so it can gate the loop. Usage: python3 validate_blueprint.py blueprint.json """ import json, os, sys def main(): if len(sys.argv) < 2: sys.exit("usage: validate_blueprint.py blueprint.json") bp = json.load(open(sys.argv[1], encoding="utf-8")) errs = [] # real JSON-Schema validation when the lib is available (shape/enum/types/pattern); structural checks always run. schema_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "schemas", "blueprint.schema.json") try: import jsonschema # type: ignore if os.path.exists(schema_path): for e in sorted(jsonschema.Draft202012Validator(json.load(open(schema_path))).iter_errors(bp), key=lambda e: list(e.path)): errs.append(f"schema: {'/'.join(map(str, e.path)) or '<root>'}: {e.message[:120]}") except ImportError: print("[validate_blueprint] note: jsonschema not installed — structural checks only") for k in ("version", "figure_id", "canvas", "render_policy", "nodes"): if k not in bp: errs.append(f"missing top-level key '{k}'") cw = (bp.get("canvas") or {}).get("width", 0); ch = (bp.get("canvas") or {}).get("height", 0) if not isinstance(cw, (int, float)) or not isinstance(ch, (int, float)) or cw <= 0 or ch <= 0: errs.append(f"canvas dimensions must be positive numbers (got {cw!r}x{ch!r})") nodes = bp.get("nodes", []); groups = bp.get("groups", []); edges = bp.get("edges", []) ids = [n.get("id") for n in nodes] if len(ids) != len(set(ids)): errs.append(f"duplicate node ids: {[i for i in ids if ids.count(i) > 1]}") gids = {g.get("id") for g in groups} if len(gids) != len([g.get('id') for g in groups]): errs.append("duplicate group ids") nodeset = set(ids) asset_ids = {a.get("id") for a in bp.get("assets", [])} # when Step-0 compiled this blueprint from a brief, EVERY object must trace back (GUARD-10). traced = bool(bp.get("compiled_from_brief")) for e in edges: if e.get("from") not in nodeset: errs.append(f"edge.from '{e.get('from')}' is not a node id") if e.get("to") not in nodeset: errs.append(f"edge.to '{e.get('to')}' is not a node id") if traced and not e.get("source"): errs.append(f"edge '{e.get('from')}->{e.get('to')}' missing 'source' (compiled_from_brief)") def in_canvas(x, y): return -2 <= x <= cw + 2 and -2 <= y <= ch + 2 for n in nodes: if "label_exact" not in n and "label" not in n: errs.append(f"node '{n.get('id')}' has no label_exact") p = n.get("pos") or {}; s = n.get("size") or {} if "x" in p and "y" in p: w = s.get("w", 0); h = s.get("h", 0) if not (in_canvas(p["x"] - w / 2, p["y"] - h / 2) and in_canvas(p["x"] + w / 2, p["y"] + h / 2)): errs.append(f"node '{n.get('id')}' box (pos {p}, size {s}) extends outside canvas {cw}x{ch}") if n.get("group") and n["group"] not in gids: errs.append(f"node '{n.get('id')}' group '{n['group']}' undefined") # asset_ref integrity — checked PER NODE (this block was previously misplaced in the callouts loop, # so it only ever saw the last node and never actually validated a character anchor). if n.get("asset_ref") and n["asset_ref"] not in asset_ids: errs.append(f"node '{n.get('id')}' asset_ref '{n['asset_ref']}' not in assets[]") if traced and not n.get("source"): errs.append(f"node '{n.get('id')}' missing 'source' (compiled_from_brief)") for g in groups: if traced and not g.get("source"): errs.append(f"group '{g.get('id')}' missing 'source' (compiled_from_brief)") b = g.get("bounds") or {} if not (in_canvas(b.get("x", 0), b.get("y", 0)) and in_canvas(b.get("x", 0) + b.get("w", 0), b.get("y", 0) + b.get("h", 0))): errs.append(f"group '{g.get('id')}' bounds {b} outside canvas {cw}x{ch}") callout_boxes = [] for c in bp.get("callouts", []): p = c.get("pos") or {}; s = c.get("size") or {} if "x" in p and "y" in p: w = s.get("w", 0); h = s.get("h", 0) if not (in_canvas(p["x"] - w / 2, p["y"] - h / 2) and in_canvas(p["x"] + w / 2, p["y"] + h / 2)): errs.append(f"callout '{c.get('id')}' box (pos {p}, size {s}) extends outside canvas {cw}x{ch}") if w and h: callout_boxes.append((c.get("id"), p["x"] - w / 2, p["y"] - h / 2, p["x"] + w / 2, p["y"] + h / 2)) if traced and not c.get("source"): errs.append(f"callout '{c.get('id')}' missing 'source' (compiled_from_brief)") # callouts must not overlap each other (the default-stack bug) for i in range(len(callout_boxes)): for j in range(i + 1, len(callout_boxes)): a, b = callout_boxes[i], callout_boxes[j] if a[1] < b[3] and b[1] < a[3] and a[2] < b[4] and b[2] < a[4]: errs.append(f"callouts '{a[0]}' and '{b[0]}' overlap (each callout needs its own band slot)") for a in bp.get("assets", []): if traced and not a.get("source"): errs.append(f"asset '{a.get('id')}' missing 'source' (compiled_from_brief)") # locked-label collision (would make the blind-transcribe diff ambiguous) labels = [(n.get("label_exact") or n.get("label", "")).strip() for n in nodes] dup = sorted({l for l in labels if l and labels.count(l) > 1}) if dup: errs.append(f"duplicate node label_exact (ambiguous for hard-diff): {dup}") lp = (bp.get("render_policy") or {}).get("label_policy", "baked") if lp not in ("baked", "hybrid", "overlay"): errs.append(f"render_policy.label_policy '{lp}' invalid") if errs: print(f"[validate_blueprint] FAIL ({len(errs)}):") for e in errs: print(" -", e) sys.exit(1) print(f"[validate_blueprint] PASS — {len(nodes)} nodes, {len(groups)} groups, {len(edges)} edges, " f"label_policy={lp}, canvas={cw}x{ch}") if __name__ == "__main__": main()
-
-
SKILL.md 24.3 KB
--- name: method-figure description: "Generate a publication-grade method / architecture / pipeline / workflow figure (a paper or README 'Figure 1') as an AUDITABLE object, not a one-shot prompt. A deterministic JSON blueprint LOCKS the content; an image model (gpt-image-2, baked by the agent via mcp__codex__codex — Codex GPT-5.5 xhigh, sandbox workspace-write) bakes the aesthetic from a labeled-condition render + the project's real identity refs; a cross-model panel (Gemini + Codex) blind-transcribes the result and a script hard-diffs it against the blueprint; the loop regenerates until Gemini AND Codex approve and the diff is empty — then the calling agent (Claude) gives the structural sign-off. NOT for statistical plots (use a plotting tool) or photo scenes." argument-hint: [method_figure_brief.json | blueprint.json] allowed-tools: Bash(*), Read, Write, Edit, Grep, Glob, mcp__codex__codex, mcp__codex__codex-reply, mcp__gemini-cli__ask-gemini, mcp__gemini__chat --- # method-figure Turn "draw our method figure" from a one-shot gamble into the **same audited spiral the framework uses for comics**: a blueprint is the source of truth, the image model bakes the look, a cross-model panel + a deterministic diff keep it honest, and the loop converges to a publication-grade figure that is **reproducible** (re-run the blueprint) and **auditable** (a trace of every round). Two things are simultaneously true: (a) gpt-image-2 CAN render a clean Figure-1 with legible labels when *conditioned on a labeled blueprint* — do not assume it garbles text; (b) on a free prompt it DRIFTS (renames phases, invents nodes, garbles a token, leaves pasted-looking floating labels). The blueprint + blind-transcribe-then-hard-diff loop turns (a) into a reliable result and catches (b) every round. ```text system description ─▶ ① BLUEPRINT (JSON content-lock) ── validate_blueprint.py ▼ ② CONDITION (white-bg labeled SVG → PNG) + identity sheet (real chibi, optional) ── render_condition.py --png ▼ ③ BAKE — agent: mcp__codex__codex(prompt+abs ref paths+out_path, workspace-write, gpt-5.5, config{xhigh}) → gpt-image-2 native PNG ── pickup_image.py --out-existing (sig+size+dims, mtime-bound, HARD-VETO struct/zlib/PIL/SVG, fail-closed) ▼ ④ PANEL — Gemini ‖ Codex BLIND-transcribe → content_diff.py (observed ⊖ blueprint) → Claude structural sign-off ▼ ⑤ agent reads the diff + the panel blockers → re-bake re-asserting the locked labels ▼ converged? ─ no ─▶ ③ (bounded: max_rounds → escalate to human) │ yes ▼ ⑥ APPROVE → figure.png + blueprint.json + trace.jsonl ``` ## Constants - **GENERATOR** = Codex `gpt-5.5`, `config: {model_reasoning_effort: xhigh, include_image_gen_tool: true}` → the native `image_generation` tool (gpt-image-2). The `gpt-5.5` pin is a single hardcoded COMPAT DEFAULT in the bake sidecar payload (`run_spiral.py` mirrors `run_comic.py`'s canonical bake plan; a config-driven model override is PLANNED, not yet implemented). It pins the BAKE only — the panel's Codex reviewer is un-pinned (see PANEL below). **CRITICAL**: image_gen is produced ONLY via **`mcp__codex__codex`** (the agent tool), NOT `codex exec`. `codex exec` / over-specified / forbid-list prompts make Codex hand-draw a code fallback (struct+zlib PNG or SVG/matplotlib) — visually indistinguishable for trivial shapes, useless for a real method-figure. The working invocation is **`mcp__codex__codex`** with a **dead-simple** prompt + `sandbox: "workspace-write"` (it must WRITE the out_path) + `model: "gpt-5.5"` + `config: {model_reasoning_effort: "xhigh", include_image_gen_tool: true}` (the schema has NO top-level effort param; `config{xhigh}` shorthand below ALWAYS expands to **both** these keys — without `include_image_gen_tool` codex won't fire its native image tool, it falls back to descriptive text / an SVG renderer) + `cwd: <project>`. Reference images are passed by **absolute file path inside the prompt** (the schema has NO `-i`); the output path is a **deterministic abs path** in the prompt. Pick it up with **`pickup_image.py --out-existing`** (verifies the EXPLICIT out_path: PNG sig + size + dims, `mtime >= request.created_at`) which **HARD-VETOES** struct/zlib/PIL/`<svg>`/matplotlib markers in the agent transcript (fail-closed; there is **no** 'native sig wins' override). **Honesty caveat:** as of Jun 2026 native headless persistence is unreliable, so this fail-closed verifier — not any `sandbox` setting — is the first guard against a non-native bake. But the HARD-VETO is a **BEST-EFFORT denylist** against the *known* codex-exec hand-draw fallback (struct/zlib/PIL/ SVG/matplotlib markers), **NOT a complete security boundary** — a novel fallback that emits a sig-valid PNG without those markers can slip past it. The **load-bearing faithfulness gate is the cross-model blind-transcribe panel + the deterministic `content_diff`** (the pixels are what reviewers transcribe), with this denylist as a cheap upstream filter. - **PANEL** (automated blind-transcribe) = the orchestrator SHELLS the `gemini` + `codex` CLIs as subprocesses (both must be on PATH; MCP is ONLY the bake seam): Gemini = `gemini --model auto-gemini-3`; Codex = `codex exec -i <png>` with NO model pin (it follows the local codex config — currently `gpt-5.6-sol`) at effort `xhigh` — so the reviewer model ≠ the bake's pinned `gpt-5.5`. Plus the deterministic `content_diff`. **Claude (this agent) is the post-pass STRUCTURAL sign-off, not a blind transcriber** — the loop converges on Gemini-approve + Codex-approve + empty-diff, then Claude signs off. - **CROSS-MODEL ACQUITTAL** — Codex is the generation family, so a Codex `approve` can only *diagnose/veto*, never be the sole acquitter. ACCEPT requires **Gemini approve + Claude structural approve + the hard-diff empty**. - **MAX_ROUNDS** = 4, then escalate to human with best-so-far + open blockers. - **LABEL_POLICY** = **`baked` only in v0** — the image model renders ALL text; nothing is hand-pasted. (`hybrid`/`overlay` — lock structure + vector-overlay the labels for paper zero-tolerance text — are on the v1 roadmap; do NOT use a vector overlay as an ad-hoc patch on a finished bake, it reads as pasted.) - **OUTPUT_DIR** = `figures/method_figure/<figure_id>/` (figure.png, blueprint.json, condition.svg, trace.jsonl). - **NATIVE-IMAGE FAIL-CLOSED** — accept a bake ONLY if a real native PNG exists at the **explicit out_path**, sha/size/dims check out and `mtime >= request.created_at`, and the agent transcript shows **no** struct/zlib/ PIL/`<svg>`/matplotlib fallback (`pickup_image.py --out-existing`, HARD-VETO — a clean sig never overrides a fallback marker). This veto is a **BEST-EFFORT denylist** against the *known* codex-exec hand-draw fallback, **NOT a complete security boundary** (a novel marker-free fallback could evade it). The load-bearing faithfulness gate remains the **cross-model blind-transcribe panel + the deterministic `content_diff`**; the denylist is a cheap upstream filter that matters because native headless persistence is currently unreliable. - **SERIALIZE BAKES** — never run two image generations at once. The default `--bake-mode=agent` writes each native PNG to its **explicit per-round `out_path`** (no shared dir), so concurrent agent bakes still risk a request/status sidecar race — keep one runner per figure. (The global `~/.codex/generated_images` dir + newest-after-marker pickup that could cross-pollinate concurrent bakes is a hazard of the **LEGACY `--bake-mode=exec` path ONLY**, which is retired for real bakes.) ## Input contract / ARIS hand-off (who decides WHAT, who only renders) This skill is **pure render + verify**. Ownership: - **Upstream owns the semantics** — what to depict, the labels, the graph, the grouping, the headline claim/number, the identity refs. method-figure does NOT choose content and **must not invent** a node, claim, number, or method structure (if one is missing it ESCALATES, it does not make it up). - **Step-0 is now DETERMINISTIC** — `run_spiral.py` calls [`scripts/compile_brief.py`](scripts/compile_brief.py) to map a `method_figure_brief.json` → a schema-valid `blueprint.json` + `traceability.json`, fail-closed (an object that can't trace to a brief field, or a missing claim/number/trait, is refused — not invented). It is no longer a manual LLM hop. Full field map + the guards: `references/blueprint_authoring.md`. - **method-figure owns** validation → condition render → image bake → cross-model panel → diff → retry, and has VETO power: it returns `FAILED / Logic Drift` rather than ship a figure whose pixels contradict the blueprint. **The default single input** = a **`method_figure_brief.json`** (`schemas/method_figure_brief.schema.json`) — ONE ARIS-format file; the blueprint, the coordinates, and the identity wiring are all derived. The identity sheet is resolved from the brief's `identity_refs[].path` (no separate `--identity` to manage). **Where the input comes from**, in authority order: 1. **a `method_figure_brief.json`** — the canonical ARIS hand-off (what `paper-plan` emits); auto-detected (by its `schema_version: "method-figure/brief/v1"`) + compiled. · 2. an existing hand-tuned `blueprint.json` — power-user override (`--from-blueprint`, used as-is). · 3. an `experiment-plan` / `paper-write` method section / free-text — no brief yet: the agent first DRAFTS a `method_figure_brief.json` from it (claims/numbers verbatim; anything missing → Refuse-and-Escalate), then compiles. **ARIS integration:** the canonical producer is **`paper-plan`** — after its `claims_matrix` it emits the `method_figure_brief` (components, flows, phases, the headline claim/number, identity refs, `forbidden_tokens`). You feed that **one file** to `run_spiral.py`; Step-0 compiles it and the traceability is enforced by the compiler (an un-traceable object is a Refuse-and-Escalate, not a render). The identity sheet is created once upstream and locked; method-figure only reads it. ## Fast path — one command (single input: a brief) Feed ONE `method_figure_brief.json`; the whole loop is one command (all commands below run from the **repo root**; the panel shells the `gemini` + `codex` CLIs, so both must be on PATH): ```bash python3 skills/method-figure/scripts/run_spiral.py your_method_figure_brief.json --out-dir figures/method_figure/<id> # auto-detects a brief → Step-0 compile_brief.py → blueprint.json + traceability.json (deterministic, fail-closed) # → validates → renders condition(+png) → [bake (agent: mcp__codex__codex --bake-mode=agent, workspace-write, # gpt-5.5 config{xhigh} → gpt-image-2 native PNG via the .bakereq.json sidecar) → pickup_image.py --out-existing # verify (fail-closed, HARD-VETO over the status file's mcp_output) → Gemini + Codex blind-transcribe → content_diff → blockers] × rounds # → on PANEL-CLEAN writes figure.png + blueprint.json + traceability.json + trace.jsonl. # input auto-detect: a brief is detected ONLY by schema_version "method-figure/brief/v1" vs a blueprint (version); # a bare components+flows JSON with NO schema_version is REFUSED — it REQUIRES --from-brief (fail-closed, no guessing) # --identity is OPTIONAL (resolved from the brief's identity_refs[0].path); --dry-run prints the round-1 bake # prompt; --p0-only runs the zero-credit gate (validate+compile+render+prompt-lint) then stops; --max-rounds N. # There is NO --effort knob (the flag is removed) — bake + review effort are hardcoded xhigh by design. # --gemini-cmd overrides how the google-family reviewer is shelled (default: the legacy `gemini` CLI). Legacy # CLI dead (IneligibleTierError, 2026-07)? pass --gemini-cmd "python3 cli/gemini_agy_shim.py" — the shipped # Antigravity shim pins a Gemini model (the second-reviewer slot must stay google-family for quorum honesty). ``` > **Power-user / override:** already have a hand-tuned blueprint? `run_spiral.py blueprint.json --identity > sheet.png --out-dir … --from-blueprint` runs the legacy path unchanged. A worked example brief lives at > [`examples/method_figure/method_figure_brief.json`](examples/method_figure/method_figure_brief.json). Long-running (each bake ~3-8 min) — run it in the background; watch `trace.jsonl`. It converges to **PANEL-CLEAN** — BOTH reviewers returned parseable JSON, **Gemini approve AND Codex approve**, the deterministic `content_diff` empty, core scores (incl. `character_identity` when an identity sheet is given) ≥ threshold, and no anomalies/blockers — then STOPS and hands to the calling agent (Claude) for the final **structural** sign-off (the generator family never self-acquits). The manual steps below are exactly what `run_spiral.py` automates (run them to debug one stage). ## Who runs `--bake-mode=agent` (the agent-wrapper SOP) — REQUIRED for the default mode to function The bake is a **synchronous sidecar handshake** and the **skill agent** is its fulfiller (without it, every bake polls to `--bake-timeout` and escalates with `failure_kind="other"` — fail-closed, not a hang, never a false throttle): 1. Launch the orchestrator in the **BACKGROUND** (from the repo root): `python3 skills/method-figure/scripts/run_spiral.py your_brief.json --out-dir figures/method_figure/<id> --bake-mode agent`. 2. **Loop** until it prints PANEL-CLEAN / escalates / exits: - watch `<out-dir>/` for a new `*.bakereq.json` (the orchestrator writes `round<N>.png.bakereq.json`); - read it; call `mcp__codex__codex` with **exactly** its `{prompt: <prompt_text>, model:"gpt-5.5", config:{include_image_gen_tool:true, model_reasoning_effort:"xhigh"}, sandbox:"workspace-write", cwd:<cwd>}` (codex writes the native PNG to the sidecar's `out_path`). The `config` MUST carry **both** `include_image_gen_tool:true` AND `model_reasoning_effort:"xhigh"`: without `include_image_gen_tool` Codex will **not** fire its native `gpt-image-2` tool (it falls back to a struct/zlib/SVG hand-draw), and `xhigh` is the required reasoning tier; - then read `request_id` from the `*.bakereq.json` and write `<out>.bakestatus.json` carrying **the status, a bounded raw `mcp_output`, AND that `request_id` VERBATIM** — `mcp_output` so the HARD-VETO can scan it (the core feeds this file to `pickup --transcript`; an `ok` status with no raw output makes the veto INERT), and `request_id` because `pickup_image.py --out-existing --request-id` **fail-closes the bake if the status `request_id` is missing or mismatched** (write it on BOTH ok and fail): `{"status":"ok","mcp_output":"<raw>","request_id":"<verbatim from bakereq>"}`, or `{"status":"fail","failure_kind":"throttle","mcp_output":"<raw>","request_id":"<verbatim from bakereq>"}` on a 429 / `MODEL_CAPACITY_EXHAUSTED` / overloaded error (else `{"status":"fail","failure_kind":"other","mcp_output":"<raw>","mcp_error":"<raw>","request_id":"<verbatim from bakereq>"}`). 3. The core proceeds to verify ONLY on `status:"ok"`, via `pickup_image.py --out-existing` (sig + dims + size > 500000 + `mtime >= created_at`, HARD-VETO over `mcp_output`, and `--request-id` fail-close if the status `request_id` is absent/mismatched). `--bake-mode=exec` is the legacy/CI non-image path and RAISES if it reaches a real bake. ## Workflow (what run_spiral.py automates — or run by hand) ### ① Author the BLUEPRINT (content lock) Write `blueprint.json` per `schemas/blueprint.schema.json`. The `*_exact` fields (`label_exact`, `desc_exact`, group/edge/callout `*_exact`, `rail.label_exact`) are the **LOCKED text re-asserted verbatim every round**; `expected_tokens[]` are what the panel must blind-transcribe and the diff checks. Then: ```bash python3 skills/method-figure/scripts/validate_blueprint.py blueprint.json # jsonschema (if installed) + unique ids · edges resolve · box/group/callout bounds · no dup labels ``` ### ② Render the CONDITION ```bash python3 skills/method-figure/scripts/render_condition.py blueprint.json --out condition.svg --png condition.png # white-bg labeled layout → rasterized ``` Prepare `identity_sheet.png` from the project's REAL characters if the figure has any (never invent robots). The condition PNG + the identity sheet are the two image references. ### ③ BAKE (round N) — agent seam Call `mcp__codex__codex` (sandbox **workspace-write** — it must WRITE the out_path; `model: gpt-5.5`, `config: {model_reasoning_effort: xhigh, include_image_gen_tool: true}`, `cwd: <project>`) with the prompt from `references/prompt_templates.md §A` — it RE-ASSERTS every `*_exact` label + the round-N blockers + the carried `positive_invariants`, with `condition.png` + `identity_sheet.png` referenced by **absolute path inside the prompt** (the schema has no `-i`) and the **exact out_path** to save the native PNG. Write the bake status to `round<N>.png.bakestatus.json` carrying the raw `mcp_output` (so the HARD-VETO can scan it) **AND the `request_id` copied VERBATIM from `round<N>.png.bakereq.json`** (pickup `--request-id` fail-closes if it's missing/mismatched), then verify the explicit out_path (no marker/glob): ```bash python3 skills/method-figure/scripts/pickup_image.py --out-existing --out figures/method_figure/<id>/round<N>.png --min-bytes 500000 --aspect <W/H> --created-at <epoch> --request-id <uuid4 hex from round<N>.png.bakereq.json> --transcript figures/method_figure/<id>/round<N>.png.bakestatus.json ``` ### ④ PANEL — blind transcribe, then hard diff Ask each of the TWO blind transcribers — Gemini + Codex (`references/prompt_templates.md §B`) — for the STRICT JSON of `references/reviewer_protocol.md`: they transcribe `observed_tokens` / `observed_edges` / `identity_audit` and an `anomalies` list (the **Negative-Space Audit**), NOT shown the expected labels. **Claude is NOT a transcriber** — it never produces a blind `round<N>.cc.json`; its structural sign-off comes post-pass in ⑤/⑥. Save as `round<N>.{gemini,codex}.json`, then: ```bash python3 skills/method-figure/scripts/content_diff.py blueprint.json round<N>.gemini.json round<N>.codex.json # → missing_tokens / unaccounted_tokens / anomalies ; empty == content-accurate ``` ### ⑤ Decide (stop rule) — the agent consolidates Read the diff report + the two transcribers' `blockers`. The executing agent itself merges **blockers only** (ignore `nice_to_have` — chasing polish makes it oscillate), carries the union of `positive_invariants` forward, and writes the round-N+1 bake prompt. - **ACCEPT** iff: diff has no `missing_tokens`/`anomalies` · Gemini `approve` · Codex `approve` (required, but never the sole acquitter) · Claude structural `approve` · every core score ≥ `acceptance.min_core_score` (default 4). - **RETRY** iff: blockers are prompt/condition-fixable and `round < MAX_ROUNDS` → back to ③. - **ESCALATE** to human iff: same root failure 2 rounds · irreconcilable reviewers · MAX_ROUNDS hit · or a non-prompt-fixable failure (throttle / identity drift / no native image). ### ⑥ Finalize + trace On ACCEPT: copy the approved PNG to `figures/method_figure/<id>/figure.png`, keep `blueprint.json`, and append to `trace.jsonl` per round: `{round, blueprint_sha, condition_sha, generated_sha, reviewers:{...verdicts}, hard_diff:{missing_tokens,anomalies}, fixes:[...], decision}` + a final `{final_approve, image, blueprint, accepted_round, verdicts}`. Failures are kept — the fixes that were needed are the memory (the figure-wiki). ## Hard do / don't (earned lessons) - **DO** lock content in the blueprint and RE-ASSERT every `*_exact` label in every regeneration — image models drift content every round; the blueprint is the anchor. - **DO** bake via the agent (`mcp__codex__codex`, workspace-write) and fail-closed if no real native PNG at the explicit out_path (`pickup_image.py --out-existing`, HARD-VETO struct/zlib/PIL/`<svg>`/matplotlib in the status file's `mcp_output`). - **DO** use the project's real identity refs; anchor each character to the identity sheet. For a character figure, every reviewer ENUMERATES each chibi's visible hands — a wrong count / 3rd / floating / merged limb is a single-reviewer veto (the literal-diff is blind to anatomy). - **DO** run the zero-credit P0 gate before the first metered bake: `run_spiral.py brief.json --out-dir … --p0-only` (validate brief → compile blueprint → render condition → confirm the bake prompt carries ALL locked labels, the identity path resolves, the background is white). A blocker caught here costs zero image credits. - **DON'T** regenerate when the score-signature is IDENTICAL across rounds — that means the judge is broken (gone design-blind), not the figure. Stop and audit the rubric (`feedback_gate_identical_scores_judge_broken`). - **DON'T** hand-paste text onto a finished bake (reads as pasted/fake) — that is what burned us; the whole figure, text included, is generated. (Engineered vector overlay is a future *policy*, not a patch.) - **DON'T** use a dark theme for a paper/README figure — light/pastel on white. - **DON'T** let one model (especially the generator's family) self-acquit; the panel is cross-model. ## Scope | Figure type | Fit | |---|---| | method overview / pipeline / architecture / workflow | **excellent** | | conceptual / taxonomy / comparison diagrams | good | | statistical plots | no → plotting tool | | exact-topology deterministic vector figures | prefer a pure-vector renderer | | photo-realistic scenes / long narrative comics | no (comics use the framework's spiral engine) | A converged worked example ships in `examples/method_figure/`: the ARIS-Movie-Director Figure 1 — blueprint + figure.png + condition.svg + the real 4-round `trace.jsonl` (Gemini approve + Codex approve + empty diff, then Claude's structural sign-off). `PROMPTS.md` there publishes the **exact, unedited prompt sequence** that baked it (all 4 `gpt-image-2` bakes + the cross-model critiques, paths redacted) — the canonical exhibit of *how detailed a condition must be*; copy its shape. ## Implemented / roadmap - ✅ `scripts/compile_brief.py` — **Step-0 automation**: deterministic `method_figure_brief.json` → `blueprint.json` + `traceability.json` (the ADJ-4 field map, `auto_layout`, fail-closed `validate_traceability`). This is what makes the skill single-input — `run_spiral.py brief.json` auto-detects + compiles, so you never hand-write a blueprint or hand-place coordinates. - ✅ `scripts/run_spiral.py` — the one-command orchestrator (sniff input → [Step-0 if brief] → bake→pickup→ panel→diff→consolidate→decide loop to PANEL-CLEAN). `--p0-only` runs the zero-credit gate; `--from-brief`/`--from-blueprint` disambiguate. Folds blocker-consolidation + invariant-carry inline. - 🔭 `scripts/overlay_labels.py` + `label_policy: hybrid/overlay` — vector-overlay the structured labels on the bake for paper zero-tolerance text. Default stays `baked` (fully generated). - 🔭 a Claude-vision reviewer inside the orchestrator (currently the automated panel is Gemini + Codex + the deterministic diff; Claude — the calling agent — gives the structural sign-off on the converged figure). ## Protocols (governance contracts this skill honors) - [`reviewer-independence`](../../protocols/reviewer-independence.md) — reviewers blind-transcribe from the image only; the generator (Codex image_gen) ≠ the visual judges. - [`acceptance-gate`](../../protocols/acceptance-gate.md) — the loop drives, can't acquit: ACCEPT needs the deterministic content-diff clean + Gemini approve + Codex no-veto + Claude structural sign-off. - [`artifact-integrity`](../../protocols/artifact-integrity.md) — the baker doesn't judge its own figure's numbers; the blueprint is ground truth, verified by the blind diff. - [`reviewer-routing`](../../protocols/reviewer-routing.md) — bake sidecar pins Codex `gpt-5.5` + `xhigh` (a hardcoded compat default; config-driven override is planned); the CLI reviewers pin NO model (they follow the local codex config — currently `gpt-5.6-sol`) at `xhigh`; Gemini `auto-gemini-3`; never downgrade effort. - [`review-tracing`](../../protocols/review-tracing.md) — every round's reviewer verdicts are logged to `trace.jsonl`.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.