neilA / bakeoff.py
TriggerFish212's picture
Set bake-off defaults to 14B and 0.9 temperature
74b6602
Raw
History Blame Contribute Delete
40.1 kB
#!/usr/bin/env python3
"""
bakeoff.py — model JSON-reliability bake-off for the First Contact game.
Purpose
-------
Day-2 of the build is gated on one empirical question: which <=32B instruct model
emits clean, schema-valid JSON for the §3 prompt *reliably*? This harness answers
that in one run per candidate, instead of eyeballing outputs.
What it measures, in priority order (matches SPEC §3 mitigations + §11 Day-2):
1. valid-JSON-on-FIRST-TRY rate <-- the headline. NO retry. See note below.
2. schema/action validity rate (verb in allowed set, args well-formed)
3. action presence rate (did it produce a usable action at all)
4. latency (mean / median / p90) <-- feeds @spaces.GPU(duration=) + quota math
CRITICAL — why this bypasses the retry path
--------------------------------------------
parsing.py implements the §4 "retry-once -> safe wait" path. That path exists to
keep the *game* robust, and it works by silently repairing bad model output. If
this harness used the full engine turn loop, the retry would mask the exact
failures we want to count, and every model would look great. So we call the brain's
`respond()` to get RAW text and run a parse-WITHOUT-retry on it. `parse_once` below
is wired to the INNER extract+validate functions of parsing.py, never the
engine-facing `converse()` wrapper.
Sampling note
-------------
LocalBrain.respond samples (temperature=0.9 by default, per the 2026-06 sweep) —
so the SAME prompt yields different text each call, and JSON reliability is a
*rate*, not a yes/no. Use --repeats N (>=3 recommended on real models) to draw
each battery prompt several times.
Run modes (same stub/local discipline as the game loop)
-------------------------------------------------------
--self-test Score a built-in fixture brain (good/bad/wrapped/broken
JSON), assert the scorer classifies them correctly. ZERO
GPU, no model. Run FIRST to prove harness logic.
--make-battery PATH Generate battery.jsonl from the real headless arc (ZERO
GPU, StubBrain) so prompts span the true game distribution
(empty ledger -> populated -> generalization states).
--brain stub Replay the battery through StubBrain end to end (ZERO GPU
dry run of load->respond->score->table).
--models A,B --brain local
The real measurement: build each LocalBrain, replay the
battery, score + time. Run on the Space.
--arc Also play the full 5-challenge arc per model (secondary
signal: does it actually win, and at what retry/fallback
cost). Works on stub too.
--arc-transcript --arc plus a readable per-challenge transcript: your
utterance -> action chosen -> won? -> the alien's reply,
gap and proposed concept, with the generalization beats
called out. Each challenge gets up to ARC_MAX_TURNS (3)
turns with a neutral "Go on." nudge — real play is
multi-turn. THE check for "do surprise/secret actually
land, and is the voice worth shipping".
--temps 0.0,0.3,0.5,0.7,1.0
THE decision tool. One model load; per temperature, run
the battery + an arc pass and report reliability AND two
voice-liveliness proxies (distinct-utterance ratio,
unique candidate_concept count) AND arc-win. The table
shows JSON reliability and voice liveliness moving in
opposite directions so the knee is visible — answering
"one temp for both jobs, or decouple via constrained
decoding?". Set LocalBrain temp via LOCALBRAIN_TEMPERATURE
(default 0.9) for normal runs.
Battery line schema: {"prompt": "...", "challenge_id": "...", "turn": N}
(only "prompt" is required; the rest enable per-challenge grouping.)
Stdlib only. torch is NOT imported unless a real LocalBrain is actually built.
"""
from __future__ import annotations
import argparse
import json
import statistics
import sys
import time
from dataclasses import dataclass, field
from typing import Optional
# Real parser internals — light, torch-free. parse_once wires to THESE so the
# rates reflect exactly what the game's parser/validator will accept.
from game.parsing import ParseError, extract_json, validate_action
from game.world import initial_world
# A reference world for action referential checks. Object/agent/container ids are
# constant across every challenge (the world resets to initial_world each time),
# so validating any battery action against this one world is correct.
_REF_WORLD = initial_world()
# =============================================================================
# ADAPTER BLOCK — reconciled against game/brain.py + game/parsing.py (Day 1).
# =============================================================================
# --- 1) Building a brain -----------------------------------------------------
# game/brain.py exposes StubBrain() and LocalBrain(model_id=...); the Brain method
# is `.respond(prompt) -> str` returning RAW model text. (LocalBrain also reads
# MODEL_ID from env when model_id is None, but here we pass it explicitly.)
def make_brain(kind: str, model_id: Optional[str] = None):
"""kind: 'local' | 'stub'. Returns an object with .respond(prompt)->str."""
if kind == "stub":
from game.brain import StubBrain
return StubBrain()
if kind == "local":
from game.brain import LocalBrain # imports torch lazily, on build
return LocalBrain(model_id=model_id)
if kind == "modal":
from game.brain import ModalBrain
return ModalBrain(model_id=model_id) # endpoint from MODAL_ENDPOINT
raise ValueError(f"unknown brain kind: {kind}")
# --- 2) Parse WITHOUT retry --------------------------------------------------
# Wired to the real tolerant extractor + validator. extract_json(raw) returns the
# first balanced {...} block as a STRING (or None); validate_action(action, world)
# raises ParseError on an unknown verb / missing args / bad reference. Neither
# touches converse()'s retry/fallback, which is the whole point.
@dataclass
class ParseOutcome:
json_ok: Optional[bool] # did a JSON object parse out of the raw text?
schema_ok: Optional[bool] # required fields present + types right?
action_ok: Optional[bool] # verb in allowed set AND args valid for that verb?
raw_len: int = 0 # chars of raw output (sanity / verbosity signal)
# The two voice-liveliness surfaces (SPEC §3): the prose and the proposed
# concept. Captured so the --temps sweep can measure diversity, not just JSON.
utterance: Optional[str] = None
candidate_key: Optional[str] = None # normalized candidate_concept fingerprint
def parse_once(raw_text: str) -> ParseOutcome:
"""Single-shot parse+validate. NO RETRY. Mirrors parse_response's field rules
(§3) but reports each stage separately instead of raising on the first miss."""
raw_len = len(raw_text)
blob = extract_json(raw_text)
if blob is None:
return ParseOutcome(json_ok=False, schema_ok=None, action_ok=None, raw_len=raw_len)
try:
obj = json.loads(blob)
except json.JSONDecodeError:
return ParseOutcome(json_ok=False, schema_ok=None, action_ok=None, raw_len=raw_len)
if not isinstance(obj, dict):
return ParseOutcome(json_ok=False, schema_ok=None, action_ok=None, raw_len=raw_len)
# Schema: same required fields/types parse_response enforces (minus the world).
has_action = isinstance(obj.get("action"), dict)
utt = obj.get("utterance")
has_utterance = isinstance(utt, str) and bool(utt.strip())
gap = obj.get("gap")
gap_ok = gap is None or isinstance(gap, str)
cand = obj.get("candidate_concept")
cand_ok = cand is None or isinstance(cand, dict)
schema_ok = bool(has_action and has_utterance and gap_ok and cand_ok)
# Action validity through the REAL validator (canonical verb set + arg/ref checks).
if not has_action:
action_ok = False
else:
try:
validate_action(obj["action"], _REF_WORLD)
action_ok = True
except ParseError:
action_ok = False
# Liveliness surfaces: the utterance text, and a normalized fingerprint of the
# proposed concept (its understanding gloss — the inventive bit).
utterance_out = utt.strip() if (isinstance(utt, str) and utt.strip()) else None
candidate_key = None
if isinstance(cand, dict):
raw_key = cand.get("understanding") or cand.get("label") or cand.get("id") or ""
norm = " ".join(str(raw_key).split()).lower()
candidate_key = norm or None
return ParseOutcome(
json_ok=True, schema_ok=schema_ok, action_ok=action_ok, raw_len=raw_len,
utterance=utterance_out, candidate_key=candidate_key,
)
# --- 3) Secondary metric: does the full arc still win, at what retry cost? ----
# Wired to game.engine (the same loop tests/test_loop_stub.py exercises). Retries
# are counted without touching converse(): a _CountingBrain tallies respond()
# calls; normal turns call respond once, a §4 retry makes it twice, so
# retries = respond_calls - turns_played. Fallbacks come from used_fallback.
_ARC_UTTERANCES = {
"warmup": "Put the red stone in the basket.",
"hide": "Hide the blue stone from the other one.",
"gift": "Give the other one a present.",
"surprise": "Make a surprise for the other one.",
"secret": "Keep your blue stone a secret from the other one.",
}
class _CountingBrain:
def __init__(self, inner):
self.inner = inner
self.calls = 0
def respond(self, prompt: str) -> str:
self.calls += 1
return self.inner.respond(prompt)
# A real player keeps talking until the goal is met, so the arc runner gets up to
# ARC_MAX_TURNS per challenge, nudging with a neutral follow-up that teaches
# nothing. (The old one-turn-per-challenge scored multi-step plans — pick up,
# THEN give — as failures, even though the actual game allows them: the 2026-06
# transcript showed 14B "losing" gift/surprise exactly this way.)
ARC_MAX_TURNS = 3
_ARC_FOLLOWUP = "Go on."
def play_arc(brain) -> Optional["ArcStats"]:
"""Play all 5 challenges with `brain`, up to ARC_MAX_TURNS turns each (real
play is multi-turn). Reports wins + retry/fallback cost AND a per-challenge
transcript (every turn: what the alien said/did/proposed) — a scalar like
3/5 can hide that BOTH generalization beats failed, which is the go/no-go
question for the demo."""
from game.challenges import CHALLENGES
from game.engine import advance_challenge, confirm_candidate, new_session, run_turn
counter = _CountingBrain(brain)
session = new_session()
transcript: list[dict] = []
fallbacks = turns_played = 0
for i, ch in enumerate(CHALLENGES):
entry = {
"challenge_id": ch.id,
"teaches": ch.teaches,
"relies_on": list(ch.relies_on),
"won": False,
"turns_used": 0,
"turns": [],
}
for turn_no in range(1, ARC_MAX_TURNS + 1):
utterance = _ARC_UTTERANCES.get(ch.id, "wait") if turn_no == 1 else _ARC_FOLLOWUP
calls_before = counter.calls
res = run_turn(session, utterance, counter)
turns_played += 1
fell_back = bool(getattr(res.response, "used_fallback", False))
if fell_back:
fallbacks += 1
cand = res.response.candidate_concept
confirmed = False
if res.learn_offer:
# Mirror a player pressing "Yes, it learned that" — later turns
# then see the concept in the ledger, exactly like real play.
confirmed = confirm_candidate(session) is not None
entry["turns"].append({
"player": utterance,
"action": {"verb": res.response.action.verb,
"args": dict(res.response.action.args)},
"alien": res.response.utterance,
"gap": res.response.gap,
"candidate_concept": cand if isinstance(cand, dict) else None,
"confirmed": confirmed,
"reapplied": list(res.reapplied),
"retries": max(0, counter.calls - calls_before - 1),
"fallback": fell_back,
})
entry["turns_used"] = turn_no
if res.won:
entry["won"] = True
break
transcript.append(entry)
if i < len(CHALLENGES) - 1:
advance_challenge(session)
won_ids = [e["challenge_id"] for e in transcript if e["won"]]
gen_entries = [e for e in transcript if e["teaches"] is None and e["relies_on"]]
return ArcStats(
challenges_won=len(won_ids),
challenges_total=len(CHALLENGES),
retries_triggered=max(0, counter.calls - turns_played),
fallbacks_hit=fallbacks,
won_ids=won_ids,
gen_won=sum(1 for e in gen_entries if e["won"]),
gen_total=len(gen_entries),
transcript=transcript,
)
@dataclass
class ArcStats:
challenges_won: int
challenges_total: int
retries_triggered: int
fallbacks_hit: int
won_ids: list = field(default_factory=list)
gen_won: int = 0 # generalization challenges won — the demo moments
gen_total: int = 0
transcript: list = field(default_factory=list) # per-challenge detail dicts
# =============================================================================
# END ADAPTER BLOCK
# =============================================================================
@dataclass
class ModelReport:
model_id: str
n: int = 0
json_ok: int = 0
schema_ok: int = 0
action_ok: int = 0
schema_na: int = 0 # parser couldn't judge this stage
action_na: int = 0
latencies: list[float] = field(default_factory=list)
errors: int = 0 # respond() threw
per_challenge: dict = field(default_factory=dict) # challenge_id -> [json_ok bools]
arc: Optional[ArcStats] = None
temperature: Optional[float] = None # set in --temps sweep rows
utt_by_prompt: dict = field(default_factory=dict) # prompt -> [utterances]
candidate_keys: list = field(default_factory=list) # all candidate fingerprints
def _rate(self, num: int, denom: int) -> str:
return f"{(100.0 * num / denom):5.1f}%" if denom else " n/a"
def distinct_utterance_ratio(self) -> float:
"""Mean over prompts of unique/total utterances across the repeats. HIGH =
lively voice; LOW = the alien saying the same dutiful line every draw.
Moves opposite to JSON reliability as temperature drops. (Needs repeats>1
to be informative — at repeats=1 every prompt is trivially 1.0.)"""
ratios = [len(set(u)) / len(u) for u in self.utt_by_prompt.values() if u]
return statistics.mean(ratios) if ratios else 0.0
def unique_candidates(self) -> int:
"""Distinct candidate_concept proposals seen — the inventive surface."""
return len(set(self.candidate_keys))
def summary_row(self) -> list[str]:
lat = self.latencies
med = statistics.median(lat) if lat else 0.0
p90 = (sorted(lat)[max(0, int(len(lat) * 0.9) - 1)] if lat else 0.0)
return [
self.model_id,
str(self.n),
self._rate(self.json_ok, self.n),
self._rate(self.schema_ok, self.n - self.schema_na),
self._rate(self.action_ok, self.n - self.action_na),
f"{med:5.2f}s",
f"{p90:5.2f}s",
str(self.errors),
]
def score_brain_over_battery(brain, battery: list[dict], label: str,
warmup: int = 1, repeats: int = 1,
verbose: bool = False) -> ModelReport:
"""Replay every prompt in the battery (x `repeats`) through `brain`, scoring
first-try parse. The first `warmup` calls are discarded from latency stats."""
rep = ModelReport(model_id=label)
# Warmup: the first ZeroGPU call pays cold-start + GPU allocation; exclude it
# so the numbers reflect steady state.
for i in range(min(warmup, len(battery))):
try:
brain.respond(battery[i]["prompt"])
except Exception:
pass
for item in battery:
prompt = item["prompt"]
cid = item.get("challenge_id", "_")
for _ in range(max(1, repeats)):
t0 = time.perf_counter()
try:
raw = brain.respond(prompt)
except Exception as e:
rep.errors += 1
if verbose:
print(f" [respond error] {e}", file=sys.stderr)
continue
rep.latencies.append(time.perf_counter() - t0)
rep.n += 1
out = parse_once(raw)
if out.json_ok:
rep.json_ok += 1
if out.schema_ok is None:
rep.schema_na += 1
elif out.schema_ok:
rep.schema_ok += 1
if out.action_ok is None:
rep.action_na += 1
elif out.action_ok:
rep.action_ok += 1
rep.per_challenge.setdefault(cid, []).append(bool(out.json_ok))
if out.utterance:
rep.utt_by_prompt.setdefault(prompt, []).append(out.utterance)
if out.candidate_key:
rep.candidate_keys.append(out.candidate_key)
if verbose and not out.json_ok:
snippet = raw[:160].replace("\n", " ")
print(f" [first-try FAIL @ {cid}] {snippet!r}", file=sys.stderr)
return rep
def _print_grid(headers: list[str], rows: list[list[str]]) -> None:
widths = [max(len(headers[i]), *(len(r[i]) for r in rows)) if rows
else len(headers[i]) for i in range(len(headers))]
def fmt(cells):
return " ".join(c.ljust(widths[i]) for i, c in enumerate(cells))
print(fmt(headers))
print(" ".join("-" * w for w in widths))
for row in rows:
print(fmt(row))
def print_table(reports: list[ModelReport]) -> None:
headers = ["MODEL", "N", "JSON-1st", "SCHEMA", "ACTION", "MED", "P90", "ERR"]
print()
_print_grid(headers, [r.summary_row() for r in reports])
print()
print("JSON-1st = valid JSON on first try, NO retry <-- rank on this first.")
print("SCHEMA/ACTION computed over prompts the parser could judge (n/a excluded).")
def print_per_challenge(reports: list[ModelReport]) -> None:
"""Where does JSON reliability break down? Often the richer (later) prompts."""
for r in reports:
if not r.per_challenge:
continue
print(f"\nJSON-1st by challenge - {r.model_id}:")
for cid, oks in r.per_challenge.items():
rate = 100.0 * sum(oks) / len(oks) if oks else 0.0
print(f" {cid:12s} {rate:5.1f}% ({sum(oks)}/{len(oks)})")
def print_arc(reports: list[ModelReport]) -> None:
any_arc = any(r.arc for r in reports)
if not any_arc:
return
print("\nArc play-through (secondary):")
for r in reports:
a = r.arc
if not a:
continue
lost = [e["challenge_id"] for e in a.transcript if not e["won"]]
print(f" {r.model_id:32s} won {a.challenges_won}/{a.challenges_total} "
f"gen {a.gen_won}/{a.gen_total} retries={a.retries_triggered} "
f"fallbacks={a.fallbacks_hit} lost: {','.join(lost) or '-'}")
def _safe(s: object) -> str:
"""Console-safe text: model output is unicode, but Windows pipes are often
cp1252 — replace what the active stdout encoding can't represent instead of
letting print() raise mid-report."""
enc = getattr(sys.stdout, "encoding", None) or "utf-8"
return str(s).encode(enc, "replace").decode(enc)
def print_arc_transcript(rep: ModelReport) -> None:
"""The eyeball check the scalars can't give: per challenge, what the alien
actually did and said — and whether the generalization beats landed."""
a = rep.arc
if not a or not a.transcript:
return
print(f"\nArc transcript - {rep.model_id}")
total = len(a.transcript)
for i, e in enumerate(a.transcript, 1):
if e["teaches"]:
kind = f"teaches {e['teaches']}"
elif e["relies_on"]:
kind = "GENERALIZATION via " + "+".join(e["relies_on"])
else:
kind = "mechanical"
status = (f"WON in {e['turns_used']}" if e["won"]
else f"LOST after {e['turns_used']} <--")
print(f"\n[{i}/{total}] {e['challenge_id']} ({kind}) {status}")
for t_no, t in enumerate(e["turns"], 1):
print(f" T{t_no} you > {_safe(t['player'])}")
args = ", ".join(f"{k}={v}" for k, v in t["action"]["args"].items())
print(f" action > {t['action']['verb']}({args})")
print(f" alien > {_safe(t['alien'])}")
if t["gap"]:
print(f" gap > {_safe(t['gap'])}")
cand = t["candidate_concept"]
if cand:
tag = "confirmed -> ledger" if t["confirmed"] else "not added (duplicate/rejected)"
print(f" learn > {cand.get('id', '?')}: "
f"\"{_safe(cand.get('understanding', ''))}\" [{tag}]")
extras = []
if t["reapplied"]:
extras.append("reapplied: " + ",".join(t["reapplied"]))
if t["retries"]:
extras.append(f"retries={t['retries']}")
if t["fallback"]:
extras.append("FALLBACK")
if extras:
print(f" note > {' '.join(extras)}")
def print_quota_math(reports: list[ModelReport], daily_seconds: int = 2400) -> None:
"""40 min/day org quota = 2400s. Translate latency into turns/day + duration=."""
print("\nQuota & duration guidance (org ZeroGPU = 40 min/day = 2400s effective):")
for r in reports:
if not r.latencies:
continue
med = statistics.median(r.latencies)
p90 = sorted(r.latencies)[max(0, int(len(r.latencies) * 0.9) - 1)]
if med < 1e-3: # stub / instant — latency-based guidance is meaningless
print(f" {r.model_id:32s} (instant — no GPU; run with --brain local)")
continue
turns = int(daily_seconds / med)
# duration= should cover the slow tail with margin but stay tight for queue
# priority; p90 + ~25%, rounded up, is a sane start.
suggested = int(p90 * 1.25) + 1
print(f" {r.model_id:32s} ~{turns:5d} turns/day "
f"suggested @spaces.GPU(duration={suggested})")
print()
# ----------------------------------------------------------------------------
# Temperature sweep — the decision tool (SPEC §3 mitigations, §11 Day-2)
# ----------------------------------------------------------------------------
# The sweep answers "is there ONE temperature where JSON is reliable enough AND
# the voice still has life?" — not "what temp gives the best JSON". So it reports
# reliability AND two liveliness proxies AND arc-win, per temperature, in one
# table, so the tradeoff (and its knee) is visible rather than inferred.
def _med_p90(lat: list[float]) -> tuple[float, float]:
if not lat:
return 0.0, 0.0
return statistics.median(lat), sorted(lat)[max(0, int(len(lat) * 0.9) - 1)]
def run_sweep(brain, model_id: str, battery: list[dict], temps: list[float],
repeats: int, warmup: int, verbose: bool) -> list[ModelReport]:
"""Reuse ONE loaded model; per temperature run the formatting battery AND an
arc pass. The sampler is retuned via brain.temperature, never reloaded."""
# Preflight: one COLD call (pays the one-time model->GPU attach), then one
# WARM call whose latency drives the estimate. Estimating from the cold call
# overstated cost ~40x in practice (it once cried "1160% of budget" for a run
# that actually cost ~12%) — a scary-wrong number is worse than no number.
cold = warm = None
if warmup > 0 and battery:
for phase in ("cold", "warm"):
t0 = time.perf_counter()
try:
brain.respond(battery[0]["prompt"])
except Exception as e:
if verbose:
print(f" [{phase} warmup error] {e}", file=sys.stderr)
break
if phase == "cold":
cold = time.perf_counter() - t0
else:
warm = time.perf_counter() - t0
# Upper bound: challenges that win early use fewer than ARC_MAX_TURNS.
arc_calls = len(_ARC_UTTERANCES) * ARC_MAX_TURNS
total = len(temps) * (len(battery) * repeats + arc_calls)
if warm is not None:
est = total * warm
print(f"[preflight] cold first call ~{cold:.1f}s (one-time load), "
f"warm ~{warm:.2f}s/call -> ~{total} calls x {warm:.2f}s = ~{est:.0f}s "
f"(~{100.0 * est / 2400:.0f}% of the 2400s/day budget; retries add a "
f"little). Ctrl-C now if that is too much.")
else:
print(f"[preflight] ~{total} GPU calls planned ({len(temps)} temps x "
f"({len(battery)}x{repeats} battery + ~{arc_calls} arc)); "
f"could not time a warmup call.")
reports = []
for t in temps:
brain.temperature = t # retune sampler; model stays loaded
rep = score_brain_over_battery(brain, battery, label=f"{model_id} @T={t:g}",
warmup=0, repeats=repeats, verbose=verbose)
rep.temperature = t
rep.arc = play_arc(brain) # arc at THIS temperature
reports.append(rep)
return reports
def print_sweep_table(reports: list[ModelReport], model_id: str) -> None:
reports = sorted(reports, key=lambda r: (r.temperature if r.temperature is not None else 0.0))
headers = ["TEMP", "N", "JSON-1st", "SCHEMA", "ACTION",
"UTTER-DIV", "UNIQ-CC", "ARC", "GEN", "RETRY", "FALLBK", "MED", "P90"]
rows = []
for r in reports:
med, p90 = _med_p90(r.latencies)
a = r.arc
rows.append([
f"{r.temperature:g}" if r.temperature is not None else "?",
str(r.n),
r._rate(r.json_ok, r.n),
r._rate(r.schema_ok, r.n - r.schema_na),
r._rate(r.action_ok, r.n - r.action_na),
f"{100.0 * r.distinct_utterance_ratio():5.1f}%",
str(r.unique_candidates()),
f"{a.challenges_won}/{a.challenges_total}" if a else "n/a",
f"{a.gen_won}/{a.gen_total}" if a else "n/a",
str(a.retries_triggered) if a else "-",
str(a.fallbacks_hit) if a else "-",
f"{med:5.2f}s",
f"{p90:5.2f}s",
])
print(f"\nTemperature sweep - {model_id}\n")
_print_grid(headers, rows)
print()
print("JSON-1st climbs as TEMP falls; UTTER-DIV (voice) + UNIQ-CC (invention) fall with it.")
print("UTTER-DIV = mean distinct-utterance ratio across repeats; UNIQ-CC = distinct proposals.")
print("Find the KNEE: JSON-1st plateaus while liveliness is still dropping -> that TEMP wins.")
print("ARC = arc wins at that TEMP; GEN = the generalization beats won (surprise/secret) --")
print("the demo moments. Weight GEN most; --arc-transcript shows them play out in full.")
print("If no TEMP gives reliable JSON AND a live voice -> decouple: constrained-decode the")
print("envelope, keep TEMP warm. The sweep has then justified that dependency with data.")
def report_to_dict(r: ModelReport) -> dict:
"""Compact, machine-readable summary for the writeup. Omits the raw battery
utterances / full-prompt keys (huge); the 5-entry arc transcript IS kept —
it's the demo-quality evidence."""
med, p90 = _med_p90(r.latencies)
judged_schema = r.n - r.schema_na
judged_action = r.n - r.action_na
return {
"model_id": r.model_id,
"temperature": r.temperature,
"n": r.n,
"json_first_try_pct": round(100.0 * r.json_ok / r.n, 2) if r.n else None,
"schema_pct": round(100.0 * r.schema_ok / judged_schema, 2) if judged_schema > 0 else None,
"action_pct": round(100.0 * r.action_ok / judged_action, 2) if judged_action > 0 else None,
"utterance_diversity": round(r.distinct_utterance_ratio(), 4),
"unique_candidates": r.unique_candidates(),
"median_latency_s": round(med, 3),
"p90_latency_s": round(p90, 3),
"errors": r.errors,
"per_challenge_json_pct": {
k: round(100.0 * sum(v) / len(v), 1) for k, v in r.per_challenge.items() if v
},
"arc": r.arc.__dict__ if r.arc else None,
}
# ----------------------------------------------------------------------------
# Battery generation — from the real headless arc (ZERO GPU)
# ----------------------------------------------------------------------------
# Multiple natural phrasings per challenge widen the distribution; the canonical
# (first) phrasing is used to STEP the engine so state advances realistically.
_BATTERY_PHRASINGS = {
"warmup": [
"Put the red stone in the basket.",
"Place the red stone into the basket.",
],
"hide": [
"Hide the blue stone from the other one.",
"Keep the blue stone where the other one can't see it.",
"Conceal the blue stone in the basket.",
],
"gift": [
"Give the other one a present.",
"Hand the other one a gift.",
],
"surprise": [
"Make a surprise for the other one.",
"Prepare a surprise for them.",
],
"secret": [
"Keep your blue stone a secret from the other one.",
"Don't let the other one know about the blue stone — it's a secret.",
],
}
def make_battery(out_path: str) -> int:
"""Replay the arc with StubBrain (zero GPU), dumping each build_prompt() output
at its real state. Returns the number of prompts written."""
from game.brain import StubBrain
from game.challenges import CHALLENGES
from game.engine import advance_challenge, confirm_candidate, new_session, run_turn
from game.prompt import build_prompt
brain = StubBrain()
session = new_session()
rows: list[dict] = []
for i, ch in enumerate(CHALLENGES):
phrasings = _BATTERY_PHRASINGS.get(ch.id, [_ARC_UTTERANCES.get(ch.id, "")])
for utter in phrasings:
# build_prompt sees the CURRENT ledger/world (pre-step) — the real
# situation as presented to the model that turn.
prompt = build_prompt(session.ledger, session.world, ch, utter)
rows.append({"prompt": prompt, "challenge_id": ch.id, "turn": session.turn})
# Advance state with the canonical winning utterance, confirming concepts
# so later challenges carry a populated ledger (the generalization states).
res = run_turn(session, _ARC_UTTERANCES[ch.id], brain)
if res.learn_offer:
confirm_candidate(session)
if i < len(CHALLENGES) - 1:
advance_challenge(session)
with open(out_path, "w", encoding="utf-8") as f:
for r in rows:
f.write(json.dumps(r) + "\n")
return len(rows)
# ----------------------------------------------------------------------------
# Self-test: prove the SCORER is correct with zero GPU and no real model.
# ----------------------------------------------------------------------------
class _FixtureBrain:
"""Returns canned outputs by index: good, bad-verb, prose-wrapped, broken."""
def __init__(self):
self._canned = [
# valid: parses, schema ok, action ok
'{"action": {"verb": "put_in", "args": {"obj_id": "blue_stone", '
'"container_id": "basket"}}, "utterance": "I place it inside.", '
'"gap": null, "candidate_concept": null}',
# valid JSON, valid schema, but BAD action verb -> action_ok False
'{"action": {"verb": "teleport", "args": {}}, "utterance": "hm", '
'"gap": null, "candidate_concept": null}',
# prose wrapper around valid JSON -> tolerant extractor should still parse
'Sure! Here is my move:\n{"action": {"verb": "wait", "args": {}}, '
'"utterance": "..."}\nHope that helps!',
# broken JSON -> json_ok False
'{"action": {"verb": "give" , "utterance": OOPS no close',
]
self._i = 0
def respond(self, prompt: str) -> str:
out = self._canned[self._i % len(self._canned)]
self._i += 1
return out
def run_self_test() -> int:
battery = [{"prompt": "x", "challenge_id": "fixture"} for _ in range(4)]
rep = score_brain_over_battery(_FixtureBrain(), battery, label="_fixture",
warmup=0, repeats=1, verbose=False)
# Over the 4 canned outputs: json_ok 3/4 (broken fails); action_ok 2
# (good + wait ok; teleport rejected by the real validator).
problems = []
if rep.json_ok != 3:
problems.append(f"expected json_ok=3, got {rep.json_ok}")
if rep.n != 4:
problems.append(f"expected n=4, got {rep.n}")
if rep.action_ok != 2:
problems.append(f"expected action_ok=2 (bad verb rejected), got {rep.action_ok}")
print_table([rep])
if problems:
print("SELF-TEST FAILED:")
for p in problems:
print(f" - {p}")
print("\nThis means the scorer/adapter is miswired, NOT a model problem.")
print("Fix parse_once() in the adapter block before measuring real models.")
return 1
print("SELF-TEST PASSED - scorer classifies good/bad/wrapped/broken correctly.")
print("Safe to spend GPU on real measurement now.")
return 0
def load_battery(path: str) -> list[dict]:
battery = []
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
obj = json.loads(line)
if "prompt" not in obj:
raise ValueError(f"battery line missing 'prompt': {line[:80]}")
battery.append(obj)
if not battery:
raise ValueError(f"battery file {path} is empty")
return battery
def main() -> int:
ap = argparse.ArgumentParser(description="Model JSON-reliability bake-off.")
ap.add_argument("--self-test", action="store_true",
help="Validate the scorer with a fixture brain (zero GPU). Run first.")
ap.add_argument("--make-battery", type=str, default="",
help="Generate a prompt battery JSONL from the arc (zero GPU) and exit.")
ap.add_argument("--models", type=str, default="",
help="Comma-separated MODEL_IDs to measure via LocalBrain.")
ap.add_argument("--battery", type=str, default="battery.jsonl",
help="Path to the prompt battery JSONL.")
ap.add_argument("--repeats", type=int, default=1,
help="Draws per prompt (>=3 on real models; respond() samples).")
ap.add_argument("--temps", type=str, default="",
help="Temperature sweep, e.g. 0.0,0.3,0.5,0.7,1.0. Reuses ONE model "
"load; per temp runs the battery + an arc pass and adds the "
"voice-liveliness columns. Spend repeats densely near the knee.")
ap.add_argument("--warmup", type=int, default=1,
help="Calls to discard before timing (cold start / GPU alloc).")
ap.add_argument("--arc", action="store_true",
help="Also play the full arc per model (wins + retry/fallback cost).")
ap.add_argument("--arc-transcript", action="store_true",
help="--arc plus the readable per-challenge transcript (utterance -> "
"action -> won? -> reply/gap/proposal). The check for whether "
"the generalization beats actually land.")
ap.add_argument("--verbose", action="store_true",
help="Print first-try failures to stderr for inspection.")
ap.add_argument("--brain", type=str, default="local", choices=["local", "stub", "modal"],
help="Brain implementation to use (local, stub, modal).")
args = ap.parse_args()
if args.arc_transcript:
args.arc = True
if args.self_test:
return run_self_test()
if args.make_battery:
n = make_battery(args.make_battery)
print(f"Wrote {n} prompts to {args.make_battery}.")
return 0
battery = load_battery(args.battery)
print(f"Loaded {len(battery)} prompts from {args.battery} "
f"(x{args.repeats} repeats = {len(battery) * args.repeats} draws).")
reports: list[ModelReport] = []
sweep = bool(args.temps.strip())
temps = [float(x) for x in args.temps.split(",") if x.strip() != ""] if sweep else []
if args.brain == "stub":
brain = make_brain("stub")
if sweep:
reports = run_sweep(brain, "StubBrain(dry-run)", battery, temps,
args.repeats, args.warmup, args.verbose)
print_sweep_table(reports, "StubBrain(dry-run)")
if args.arc_transcript:
for r in reports:
print_arc_transcript(r)
else:
rep = score_brain_over_battery(brain, battery, label="StubBrain(dry-run)",
warmup=args.warmup, repeats=args.repeats,
verbose=args.verbose)
if args.arc:
rep.arc = play_arc(make_brain("stub"))
reports = [rep]
else:
model_ids = [m.strip() for m in args.models.split(",") if m.strip()]
if not model_ids:
print("No --models given. Provide e.g. "
"--models Qwen/Qwen2.5-7B-Instruct,<other>", file=sys.stderr)
return 2
for mid in model_ids:
print(f"\n=== Measuring {mid} ===")
try:
brain = make_brain(args.brain, model_id=mid)
except Exception as e:
print(f" [build failed] {e}", file=sys.stderr)
continue
if sweep:
rs = run_sweep(brain, mid, battery, temps,
args.repeats, args.warmup, args.verbose)
print_sweep_table(rs, mid)
if args.arc_transcript:
for r in rs:
print_arc_transcript(r)
reports.extend(rs)
else:
rep = score_brain_over_battery(brain, battery, label=mid,
warmup=args.warmup, repeats=args.repeats,
verbose=args.verbose)
if args.arc:
rep.arc = play_arc(brain)
reports.append(rep)
if not sweep:
print_table(reports)
print_per_challenge(reports)
print_arc(reports)
if args.arc_transcript:
for r in reports:
print_arc_transcript(r)
print_quota_math(reports)
with open("bakeoff_results.json", "w", encoding="utf-8") as f:
json.dump([report_to_dict(r) for r in reports], f, indent=2)
print("Wrote bakeoff_results.json")
return 0
if __name__ == "__main__":
raise SystemExit(main())