Files
esh-pfi-infrastructure/scripts/r49-corpus/gen_beats_chat.py
T
vh e15c5ee5ea Instruct probe: voice and instruction-following coexist
Trained the same corpus onto Qwen3-4B instruct rather than -Base, with seed, steps
and token count held so the carrier is the only variable. The chain picked
checkpoint-150 by loss automatically, applying the lesson that cost a re-cut on
4B-Base.

The central risk did not materialise. The assistant prior did not block the voice:
curly quotes land at 16 of 18, identical to the 4B-Base tuned arm, against 1 of 18
on the unadapted control, and task-leak is 0 of 18 where the base carrier leaked 4.
Instruction-following also survived raw-text training -- 10 of 10 on-beat through
the chat template, the same as the untuned control.

The cost is length discipline rather than comprehension. In-band dropped from 10 of
10 to 6 of 10 and the median went from 124 to 140 words. Training on Victorian
prose made it wordier, which is a soft degradation and not a break.

Held-out sits at 2.908 against 4B-Base's 2.814, and it plateaus without turning
where the base carrier overfit at step 75. The assistant prior competes for
capacity, so the instruct carrier absorbs less rather than overfitting more.

What raw-continuation training does not fix is the plot furniture. The tuned
instruct arm renders the beat and then drags the referent -- "He licked her clean...
my master thus, my husband thus", turning the dog into a man, because the corpus is
about masters and husbands. Another beat ran to 247 words and gave the narrator a
list of duties. That is precisely what instruction-pair training addresses, since
pairs teach render-this-and-stop where continuation teaches keep-writing. The probe
de-risks the instruction-pair path without substituting for it.

One metric note against future misreading: ran_on reports 10 of 10 on both arms and
is uninformative on this job, because a single paragraph contains no blank line for
it to find.
2026-09-11 08:32:30 -07:00

98 lines
5.2 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.
"""Beat → paragraph through the CHAT TEMPLATE, which is the product's real shape.
The point of this script is to ask the one question the whole instruct experiment
rests on: after training raw Brontë continuation text into an instruct model, does
it still take direction? Raw text trained into a model whose weights expect
<|im_start|> framing can degrade the template behaviour, and if it has, the adapter
bought voice at the cost of the only capability Skaldsong needs.
So this drives the model exactly as Skaldsong would -- system prompt stating the job
and the length, user message carrying the beat -- and reports the three things that
decide whether the answer is usable:
on-beat did it render THIS beat, or wander into Brontë's own plot furniture
in-band did it honour the length, which is the cheapest proxy for "took direction"
ran-on did it close a paragraph, or keep going into the next beat's territory
Operator's constraint, and it is the one that rules out fixing this downstream: if a
frontier model has to judge every paragraph, the tiny model has no purpose. So these
have to be checkable without one.
"""
from __future__ import annotations
import argparse, json, re, time
from pathlib import Path
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
SYS = ("You expand a single story beat into ONE paragraph of prose in the manner of Charlotte "
"Brontë — her diction, sentence rhythm and first-person retrospective narration, mid-19th "
"century. Render the beat itself; do not move past it, do not add a new scene, do not "
"comment. Output the paragraph only, 90140 words.")
ap = argparse.ArgumentParser()
ap.add_argument("--base", required=True)
ap.add_argument("--adapter", default=None)
ap.add_argument("--beats", required=True)
ap.add_argument("--out", required=True)
ap.add_argument("--arm", required=True)
ap.add_argument("--seeds", type=int, nargs="+", default=[1234, 5678])
ap.add_argument("--max-new-tokens", type=int, default=320)
ap.add_argument("--temperature", type=float, default=0.9)
ap.add_argument("--top-p", type=float, default=0.95)
a = ap.parse_args()
beats = json.loads(Path(a.beats).read_text())
tok = AutoTokenizer.from_pretrained(a.base)
if tok.chat_template is None:
raise SystemExit("REFUSING: this carrier has no chat template -- it is not an instruct build")
model = AutoModelForCausalLM.from_pretrained(a.base, dtype=torch.bfloat16,
attn_implementation="sdpa").to("cuda")
if a.adapter:
from peft import PeftModel
model = PeftModel.from_pretrained(model, a.adapter)
nz = sum(1 for m in model.modules() if hasattr(m, "lora_B")
and float(m.lora_B["default"].weight.abs().sum()) > 0)
tot = sum(1 for m in model.modules() if hasattr(m, "lora_B"))
print(f"[gen] adapter bound: {nz}/{tot} lora_B tensors non-zero", flush=True)
if nz == 0:
raise SystemExit("REFUSING: adapter applied but every lora_B is zero")
model.eval()
STOP = re.compile(r"\n\s*\n")
DROP = {"the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "of", "his", "her",
"he", "she", "it", "was", "were", "had", "would", "not", "be", "by", "as",
"with", "for", "from", "that", "this", "up", "down"}
out = Path(a.out); out.parent.mkdir(parents=True, exist_ok=True)
t0 = time.time()
with out.open("w", encoding="utf-8") as fh:
for b in beats:
for seed in a.seeds:
torch.manual_seed(seed)
text = tok.apply_chat_template(
[{"role": "system", "content": SYS},
{"role": "user", "content": "BEAT: " + b["beat"]}],
tokenize=False, add_generation_prompt=True,
**({"enable_thinking": False} if "enable_thinking" in (tok.chat_template or "") else {}))
ids = tok(text, return_tensors="pt", add_special_tokens=False).to("cuda")
with torch.no_grad():
g = model.generate(**ids, do_sample=True, temperature=a.temperature,
top_p=a.top_p, max_new_tokens=a.max_new_tokens,
pad_token_id=tok.eos_token_id)
raw = tok.decode(g[0][ids["input_ids"].shape[1]:], skip_special_tokens=True).strip()
m = STOP.search(raw)
para = (raw[:m.start()] if m else raw).strip()
kws = [w for w in re.findall(r"[a-z']+", b["beat"].lower())
if w not in DROP and len(w) > 3]
hit = sum(1 for k in kws if k[:5] in para.lower())
w = len(para.split())
fh.write(json.dumps({"format": a.arm, "id": b["id"], "beat": b["beat"], "seed": seed,
"prompt": "BEAT: " + b["beat"], "paragraph": para,
"ran_on": m is None, "words": w,
"in_band": 90 <= w <= 140,
"beat_keywords": kws, "keyword_hits": hit}) + "\n")
print(f" {b['id']:>4} seed={seed} {w:>4}w kw {hit}/{len(kws)} "
f"{'in-band' if 90 <= w <= 140 else 'OUT-OF-BAND'}"
f"{' RAN-ON' if m is None else ''}", flush=True)
print(f"[gen] {a.arm} -> {out} in {time.time()-t0:.0f}s", flush=True)