From 9f35c8d659e85d5a904b3499f89767c0565dcd7e Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Thu, 17 Sep 2026 07:53:08 -0700 Subject: [PATCH] booth: four arms, one beat, one author-neutral prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six beats through voices-base, lv-bronte, lv-yarros and lv-hemingway, all served from the same process on fv-ml1 :8027 so only the adapter varies. Operator-requested side-by-side. http://10.100.10.50:8090/b/lv-voices-four-arms/ (24h TTL; also on the link board) THE PROMPT NAMES NO AUTHOR, deliberately. Each adapter trained under a prompt naming its own, so driving all four with any one of those hands that arm a hint the others do not get and the page would be measuring the prompt rather than the voice. The shared task skeleton is kept and the author clause removed. One asymmetry is disclosed on the page: Brontë and Hemingway trained on "a SHORT PASSAGE ... may run to several paragraphs" while Yarros trained on "ONE paragraph", so the neutral prompt sits slightly off-distribution for all three rather than for one. THE CONTROL GETS A 4x LARGER TOKEN BUDGET, and publishing it any other way would have been dishonest. Measured at the gate's 320-token budget: voices-base median 26 prose words, 181-257 words of planning first, and 5 of 12 cells never reach the prose at all the adapters 0 of 12 failures each, empty think block in 12 of 12, median 97-105 words The adapters learned to skip the reasoning phase; the carrier has not. Showing the starved control would conflate voice with budget discipline, so the control runs at 1200 tokens and finishes every time, median 121 words. Both numbers are on the page. Two seeds per cell behind a toggle, because one sample of a sampled process is an anecdote, and a blind-mode toggle that hides which column is which. Sampler matches the gate harness (temperature 0.9, top_p 0.95, "BEAT: " prefix). Checked before publishing rather than after: all 36 adapter generations scored for verbatim 8-gram reuse, each arm against ITS OWN corpus. Brontë 0, Yarros 0, Hemingway 2 of 12 with a longest run of 8 words, that run being "i don t know i don t know". Layout verified by rendering it, not by reading the CSS: four equal 374px columns at 1600px wide, no horizontal overflow, 24 cards, 48 panes. ⚠ nh3-dev's shared /opt/ms-playwright tops out at chromium-1234, so playwright must be pinned to 1.61.0; a bare `npm i playwright` pulls 1.63 and asks for a browser build that is not there. --- scripts/r49-corpus/build_booth_four_arms.py | 185 ++++++++++++++++++++ scripts/r49-corpus/gen_booth_four_arms.py | 67 +++++++ 2 files changed, 252 insertions(+) create mode 100644 scripts/r49-corpus/build_booth_four_arms.py create mode 100644 scripts/r49-corpus/gen_booth_four_arms.py 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.
  • +
+ + {rows}
armmedian prosemedian planningnever wrote prose
+
+
+ + + 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)