diff --git a/scripts/r49-corpus/build_booth_four_arms.py b/scripts/r49-corpus/build_booth_four_arms.py
new file mode 100644
index 0000000..88891c3
--- /dev/null
+++ b/scripts/r49-corpus/build_booth_four_arms.py
@@ -0,0 +1,185 @@
+"""Render the four-arm comparison as a self-contained booth page."""
+import json, html, re, sys, statistics as st
+from pathlib import Path
+
+SP = Path("/tmp/claude-1000/-home-lkraven-development-eshpfi-management/d4d5ad1b-76a2-4698-9684-ea6045592d22/scratchpad")
+d = json.loads((SP / "booth-cells.json").read_text(encoding="utf-8"))
+OUT = Path(sys.argv[1])
+
+COLS = [("voices-base-1200", "control", "unadapted carrier"),
+ ("lv-bronte", "Brontë", "lv-bronte · ckpt475"),
+ ("lv-yarros", "Yarros", "lv-yarros"),
+ ("lv-hemingway", "Hemingway", "lv-hemingway · ckpt850")]
+SEEDS = [1234, 5678]
+
+
+def split(t):
+ if "" in t and "" in t:
+ return t.split("", 1)[1].split("", 1)[0], t.split("", 1)[1].strip()
+ if "" in t:
+ return t.split("", 1)[1], ""
+ return "", t.strip()
+
+
+def paras(text):
+ out = []
+ for p in re.split(r"\n\s*\n", text.strip()):
+ p = " ".join(p.split())
+ if p:
+ out.append("
" + html.escape(p) + "
")
+ return "\n".join(out) or '
— the budget ran out before any prose was written —
'
+
+
+# headline numbers, computed not asserted
+short_base = [len(split(d["cells"][f"voices-base|{b}|{s}"]["text"])[1].split())
+ for b, _ in d["beats"] for s in SEEDS]
+stats = {}
+for model, label, _ in COLS:
+ w = [len(split(d["cells"][f"{model}|{b}|{s}"]["text"])[1].split())
+ for b, _ in d["beats"] for s in SEEDS]
+ th = [len(split(d["cells"][f"{model}|{b}|{s}"]["text"])[0].split())
+ for b, _ in d["beats"] for s in SEEDS]
+ stats[model] = (int(st.median(w)), int(st.median(th)), sum(1 for x in w if x == 0))
+
+blocks = []
+for bid, beat in d["beats"]:
+ cells = []
+ for model, label, sub in COLS:
+ panes = []
+ for s in SEEDS:
+ th, prose = split(d["cells"][f"{model}|{bid}|{s}"]["text"])
+ tw = len(th.split())
+ badge = (f'planned {tw}w first' if tw > 0
+ else 'straight to prose')
+ panes.append(
+ f'
'
+ f'
seed {s} · {len(prose.split())}w {badge}
'
+ f'
{paras(prose)}
')
+ cells.append(f'
'
+ f'
{html.escape(label)}'
+ f'{html.escape(sub)}
'
+ + "".join(panes) + "
")
+ blocks.append(f'
{bid}'
+ f'{html.escape(beat)}
' + "".join(cells) + "
")
+
+rows = "".join(
+ f"
{html.escape(l)}
{stats[m][0]}
{stats[m][1]}
"
+ f"
{stats[m][2]}/12
" for m, l, _ in COLS)
+
+HTML = f"""
+
+
+Four voices, one beat
+
+
+
Four voices, one beat
+
The same six beats and the same author-neutral prompt through the
+ unadapted carrier and the three lv-* LoRA adapters, all served from one
+ process on vllm-voices (fv-ml1 GPU 0 :8027). Only the adapter changes.
+
system — {html.escape(d["system"])}
user — BEAT: <the beat>
+
+
The prompt names no author, deliberately. Each adapter trained under a prompt
+ naming its own — driving all four with any one of those would hand that arm a hint the
+ others do not get, and the page would be measuring the prompt.
+
One disclosed asymmetry. Brontë and Hemingway trained on “a SHORT PASSAGE … may
+ run to several paragraphs”; Yarros trained on “ONE paragraph”. The neutral prompt uses
+ neither qualifier, so it sits slightly off-distribution for all three rather than for one.
+
The control gets a 4× larger token budget, and that is the fair thing to do.
+ At the gate's 320-token budget the carrier spends 181–257 words thinking and
+ 5 of 12 cells never reach the prose at all. The three adapters emit an empty
+ think block in 12 of 12 — they learned to skip it. Publishing the starved control would
+ conflate voice with budget discipline, so the control here runs at 1200 tokens and
+ finishes every time.
+
Two seeds per cell, because one sample of a sampled process is an anecdote.
+ Sampler matches the gate harness: temperature 0.9, top_p 0.95.
+
These 36 adapter generations were checked for verbatim reuse before this page went
+ up, each arm against its own training corpus (scoring one author against another
+ returns a clean zero that only means “different book”). Brontë 0, Yarros
+ 0, Hemingway 2 of 12, longest run 8 words — and the run is
+ “I don't know, I don't know”. Nothing on this page reproduces anything worth
+ reproducing.
+
This is a reading, not a measurement. The numbers that decide anything are in the
+ gate: persistent-memory.d/2026-09-17-lv-hemingway-gate.md.
+
+
arm
median prose
median planning
never wrote prose
+ {rows}
+
+
+
+
+ column order is fixed: control · Brontë · Yarros · Hemingway
+
+{''.join(blocks)}
+
+"""
+OUT.write_text(HTML, encoding="utf-8")
+print(f"wrote {OUT} ({len(HTML):,} bytes)")
+for m, l, _ in COLS:
+ print(f" {l:<11} median prose {stats[m][0]:>4}w · median planning {stats[m][1]:>4}w · empty {stats[m][2]}/12")
diff --git a/scripts/r49-corpus/gen_booth_four_arms.py b/scripts/r49-corpus/gen_booth_four_arms.py
new file mode 100644
index 0000000..bb585a7
--- /dev/null
+++ b/scripts/r49-corpus/gen_booth_four_arms.py
@@ -0,0 +1,67 @@
+"""Four arms, one prompt, one set of beats — harness-matched on the live seat.
+
+THE PROMPT IS DELIBERATELY AUTHOR-NEUTRAL. Each adapter was trained under a system
+prompt naming its own author ("in the manner of Charlotte Brontë — mid-nineteenth-century
+first-person retrospective ..."). Driving all four with any ONE of those would hand that
+author's arm a hint the others do not get, and the comparison would measure the prompt.
+So the author clause is removed and the shared task skeleton kept. What is left varies
+only by which LoRA is loaded.
+
+⚠ ONE DISCLOSED ASYMMETRY: Brontë and Hemingway trained on "a SHORT PASSAGE ... may run to
+several paragraphs"; Yarros trained on "ONE paragraph". The neutral prompt uses neither
+qualifier, so it sits slightly off-distribution for all three rather than for one.
+
+Sampler matches the gate harness exactly: temperature 0.9, top_p 0.95, "BEAT: " prefix.
+Two seeds per cell, because one sample of a sampled process is an anecdote — the second is
+rendered behind a toggle so the page stays readable but the variance is one click away.
+"""
+import json, urllib.request, sys, time
+
+SEAT = "http://10.251.50.54:8027/v1/chat/completions"
+ARMS = [("voices-base", "control"), ("lv-bronte", "Brontë"),
+ ("lv-yarros", "Yarros"), ("lv-hemingway", "Hemingway")]
+SEEDS = [1234, 5678]
+
+SYS = ("You expand a single story beat into a SHORT PASSAGE of prose. Render the beat "
+ "itself; do not move past it, do not begin a new scene, do not comment, do not "
+ "write a chapter heading. Output the prose only, 90–140 words.")
+
+BEATS = [
+ ("b1", "She tells him she is leaving in the morning, and he does not ask her to stay."),
+ ("b2", "He refuses the money a second time, and the other man sets it down on the table anyway."),
+ ("b3", "She finds him bleeding on the stairs and asks how long he has been sitting there."),
+ ("b4", "He waits past the hour they agreed, orders another drink, and watches the door."),
+ ("b5", "She reads the letter twice, then puts it in the fire without saying what it said."),
+ ("b6", "He tells her the truth about the accident, and she says nothing for a long time."),
+]
+
+
+def gen(model, beat, seed):
+ body = {"model": model, "seed": seed, "temperature": 0.9, "top_p": 0.95,
+ "max_tokens": 320,
+ "messages": [{"role": "system", "content": SYS},
+ {"role": "user", "content": "BEAT: " + beat}]}
+ req = urllib.request.Request(SEAT, data=json.dumps(body).encode(),
+ headers={"Content-Type": "application/json"})
+ for attempt in range(3):
+ try:
+ r = json.load(urllib.request.urlopen(req, timeout=180))
+ return r["choices"][0]["message"]["content"], r.get("model")
+ except Exception as e:
+ if attempt == 2:
+ raise
+ print(f" retry {attempt+1} after {e}", file=sys.stderr)
+ time.sleep(5)
+
+
+out = {"system": SYS, "beats": BEATS, "arms": ARMS, "seeds": SEEDS, "cells": {}}
+t0 = time.time()
+for model, label in ARMS:
+ for bid, beat in BEATS:
+ for seed in SEEDS:
+ txt, served = gen(model, beat, seed)
+ out["cells"][f"{model}|{bid}|{seed}"] = {"text": txt, "served": served}
+ print(f" {model:<14} {bid} seed={seed} {len(txt.split()):>4}w "
+ f"served={served}", flush=True)
+print(f"done in {time.time()-t0:.0f}s")
+json.dump(out, open(sys.argv[1], "w"), ensure_ascii=False, indent=1)