Files
esh-pfi-infrastructure/scripts/r49-corpus/build_booth_yarros.py
T
vh 5558d9c7d3 R49/BabyYarros: voice + beat evaluation tooling and the seat-free delta_cb
Adds the Yarros-side evaluation the training exists to justify: does the adapter
move arbitrary prose toward Yarros, and can the instruct arm still expand a beat
to a paragraph on direction. Yarros-flavoured voice prompts (modern/neutral/
romantasy tiers so any Yarros voice in the modern tier is adapter-attributable,
not prompt-supplied) and a Yarros-register beat SYS on the chat generator.

voice_distance.py is the honest slice of adjudication that needs no seat: Burrows's
Delta over character bigrams against held-out Yarros. Its first cut mis-framed the
noise floor — it used the same-author distance (held-out vs itself) as the
between-arm significance threshold, which is the target, not the threshold. Fixed
to the measured floor: the within-arm seed spread, which is this metric's sampling
variance at this sample size, computed from the two seeds already generated rather
than assumed.

Result on the built corpus, ordering base-125-tuned < instruct-tuned <
base-unadapted, both adapters clearing the 0.046 measured floor (base +0.157,
instruct +0.076), and the ordering corroborating the independent held-out loss
ordering (Base below Instruct). One seed-pair per arm, so it corroborates rather
than settles; the full frozen adjudication still needs a romantasy control panel,
a second seed, and the gen seat for the beat-incumbent leg.
2026-09-11 15:59:41 -07:00

115 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Build the BabyYarros evaluation booth: voice A/B + beat->paragraph + delta_cb.
Reads the eval jsonls and the voice_distance summary, emits a self-contained
index.html for the Booth. Three panels:
A VOICE — each opening line, the three arms' continuations side by side, so the
operator can SEE whether the adapter pulls arbitrary prose toward Yarros.
B BEAT -> PARAGRAPH — the Skaldsong question: does the Instruct arm still take
direction (on-beat / in-band / ran-on) after training on raw Yarros text.
C delta_cb — the seat-free relative measure, with its A-vs-A noise floor.
"""
from __future__ import annotations
import html, json, re, sys
from pathlib import Path
D = Path(sys.argv[1]) # yarros-eval dir
DIST = Path(sys.argv[2]) if len(sys.argv) > 2 else None # distance stdout captured to a file
OUT = Path(sys.argv[3]) if len(sys.argv) > 3 else (D / "index.html")
def rows(f):
p = D / f
return [json.loads(l) for l in p.read_text(encoding="utf-8").splitlines()] if p.exists() else []
ARMS = [("base-unadapted", "Base · no adapter (control)"),
("base-125-tuned", "Base · Yarros LoRA (ckpt-125)"),
("instruct-tuned", "Instruct · Yarros LoRA")]
voice = {}
for key, _ in ARMS:
for r in rows(f"voice.{key}.jsonl"):
voice.setdefault((r["id"], r["seed"]), {})[key] = r
prompts = {}
for key, _ in ARMS:
for r in rows(f"voice.{key}.jsonl"):
prompts[r["id"]] = (r["tier"], r["prompt"])
def wc(t): return len(t.split())
def beat_flags(r):
kh, kws = r.get("keyword_hits", 0), r.get("beat_keywords", [])
ratio = kh / max(len(kws), 1)
on_beat = kh >= 1 and ratio >= 0.34 # at least a third of the beat's content words rendered
return on_beat, f"{kh}/{len(kws)}", r.get("in_band"), r.get("ran_on"), r.get("words", 0)
beats = rows("beats.instruct.jsonl")
esc = lambda s: html.escape(s or "")
parts = ["""<title>BabyYarros — voice & beats</title>
<style>
:root{--bg:#faf8f5;--fg:#1c1a17;--mut:#6b645c;--line:#e4ded4;--card:#fff;--acc:#8a5a2b;--good:#2e7d43;--bad:#b3402f}
:root:not([data-theme=light]) @media (prefers-color-scheme:dark){}
@media (prefers-color-scheme:dark){:root:not([data-theme=light]){--bg:#17150f;--fg:#ece7df;--mut:#a49a8c;--line:#332e26;--card:#201d16;--acc:#d69a5c;--good:#6ecb86;--bad:#e8836f}}
:root[data-theme=dark]{--bg:#17150f;--fg:#ece7df;--mut:#a49a8c;--line:#332e26;--card:#201d16;--acc:#d69a5c;--good:#6ecb86;--bad:#e8836f}
body{background:var(--bg);color:var(--fg);font:15px/1.55 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;margin:0;padding:2rem}
h1{font-size:1.5rem;margin:0 0 .2rem} .sub{color:var(--mut);margin:0 0 1.5rem}
h2{font-size:1.15rem;margin:2rem 0 .6rem;border-bottom:2px solid var(--acc);padding-bottom:.3rem}
.prompt{color:var(--acc);font-weight:600;margin:1.2rem 0 .4rem}.tier{color:var(--mut);font-size:.8rem;font-weight:400}
.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:.8rem}
@media(max-width:900px){.grid{grid-template-columns:1fr}}
.card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:.7rem .8rem}
.arm{font-size:.75rem;color:var(--mut);text-transform:uppercase;letter-spacing:.04em;margin-bottom:.35rem}
.txt{white-space:pre-wrap;font-size:.92rem}.wc{color:var(--mut);font-size:.75rem;margin-top:.4rem}
table{border-collapse:collapse;width:100%;margin:.5rem 0}td,th{border:1px solid var(--line);padding:.35rem .5rem;text-align:left;font-size:.9rem}
.beat{color:var(--acc);font-weight:600}.ok{color:var(--good);font-weight:600}.no{color:var(--bad);font-weight:600}
pre.dist{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:1rem;overflow-x:auto;font-size:.85rem}
</style>
<h1>BabyYarros — does the voice transfer, can it do beats?</h1>
<p class="sub">Qwen3-4B, one epoch on the leak-gated Yarros corpus. Base LoRA = ckpt-125 (its held-out minimum). Generated on gx10, same harness per arm. Not the frozen adjudication — the voice A/B and beat test the operator asked to see.</p>
"""]
# Panel A — voice
parts.append('<h2>A · Voice — arbitrary opening line, three arms continue it</h2>')
order = ["a1","a2","a3","b1","b2","b3","c1","c2","c3"]
seedpick = 1234
for pid in order:
if pid not in prompts: continue
tier, ptext = prompts[pid]
parts.append(f'<div class="prompt">{esc(ptext)} <span class="tier"{tier}</span></div><div class="grid">')
cell = voice.get((pid, seedpick), {})
for key, label in ARMS:
r = cell.get(key)
body = esc(r["continuation"].strip())[:1400] if r else "<em>—</em>"
w = wc(r["continuation"]) if r else 0
parts.append(f'<div class="card"><div class="arm">{esc(label)}</div><div class="txt">{body}</div><div class="wc">{w} words</div></div>')
parts.append('</div>')
# Panel B — beats
parts.append('<h2>B · Beat → paragraph (Instruct, chat template) — the Skaldsong question</h2>')
parts.append('<p class="sub">Can the Instruct arm still take direction after training on raw Yarros continuation text? Each beat expanded to one paragraph in Yarros\' voice.</p>')
if beats:
parts.append('<table><tr><th>beat</th><th>seed</th><th>on-beat (kw)</th><th>in-band 90140</th><th>ran-on</th><th>words</th></tr>')
for r in beats:
ob, kw, ib, ro, w = beat_flags(r)
yn = lambda v,good: (f'<span class="ok">{"yes" if v else "no"}</span>' if v==good else f'<span class="no">{"yes" if v else "no"}</span>') if v is not None else ''
parts.append(f'<tr><td class="beat">{esc(r.get("beat",r.get("id","")))[:64]}</td>'
f'<td>{r.get("seed","")}</td>'
f'<td>{("<span class=ok>yes</span>" if ob else "<span class=no>no</span>")} {kw}</td>'
f'<td>{yn(ib,True)}</td><td>{yn(ro,False)}</td><td>{w}</td></tr>')
parts.append('</table>')
# show the actual paragraphs
for r in beats[:6]:
para = esc(r.get("paragraph", r.get("text","")).strip())[:1400]
parts.append(f'<div class="prompt">{esc(r.get("beat",""))}</div><div class="card"><div class="txt">{para}</div></div>')
else:
parts.append('<p class="sub"><em>beats file not present</em></p>')
# Panel C — delta_cb
parts.append('<h2>C · delta_cb — did the adapter move the voice toward held-out Yarros?</h2>')
if DIST and DIST.exists():
parts.append(f'<pre class="dist">{esc(DIST.read_text())}</pre>')
else:
parts.append('<p class="sub"><em>distance summary not present</em></p>')
OUT.write_text("\n".join(parts), encoding="utf-8")
print(f"wrote {OUT} ({OUT.stat().st_size} bytes)")