#!/usr/bin/env python3
"""Build site/index.html from one or more rps sqlite files. UnoCSS, white bg, black text, big type, rounded-lg."""
import json, sqlite3, shutil, html, os
from collections import Counter

RUNS = [
    ("A", "rps.sqlite", "Run A · default thinking budget",
     "Only <code>--effort low</code> was set. Sonnet 5 did no thinking at all; Haiku 4.5 ignored the low setting and kept thinking for thousands of tokens per round, taking 20 to 45 seconds each time."),
    ("B", "rps_nothink.sqlite", "Run B · thinking disabled",
     "Same prompts, but <code>MAX_THINKING_TOKENS=0</code> forced both models to answer immediately with no reasoning at all."),
    ("C", "rps_multiturn.sqlite", "Run C · real conversation, thinking disabled",
     "Thinking still disabled, but the match is now a genuine multi-turn conversation. Each player keeps one persistent session, so its own past moves sit in the transcript as its own assistant turns, and each user turn reports only the previous round: <code>you: paper, opponent: scissors, result: you lost</code>. Runs A and B instead re-narrated the whole history back to the model inside a single user message every round."),
]
BEATS = {"rock": "scissors", "paper": "rock", "scissors": "paper"}
COUNTER = {v: k for k, v in BEATS.items()}
ICON = {"rock": "✊", "paper": "✋", "scissors": "✌️", "invalid": "❌"}

def load(path):
    if not os.path.exists(path): return None
    db = sqlite3.connect(path)
    meta = dict(db.execute("SELECT key,value FROM meta"))
    cur = db.execute("SELECT round,haiku,sonnet,winner,haiku_raw,sonnet_raw,haiku_ms,sonnet_ms,"
                     "haiku_cost,sonnet_cost,haiku_think,sonnet_think,haiku_attempts,sonnet_attempts,"
                     "sonnet_prompt FROM rounds ORDER BY round")
    cols = [c[0] for c in cur.description]
    rows = [dict(zip(cols, r)) for r in cur.fetchall()]
    return meta, rows

def stats(rows, p, o):
    mv = [r[p] for r in rows]; ov = [r[o] for r in rows]; n = len(rows)
    best = cur = 0
    for r in rows:
        cur = cur + 1 if r["winner"] == p else 0; best = max(best, cur)
    return dict(n=n, dist=Counter(mv),
        wins=sum(1 for r in rows if r["winner"] == p),
        losses=sum(1 for r in rows if r["winner"] == o),
        ties=sum(1 for r in rows if r["winner"] == "tie"),
        repeat=sum(1 for i in range(1, n) if mv[i] == mv[i-1]),
        beat_opp_prev=sum(1 for i in range(1, n) if mv[i] == COUNTER.get(ov[i-1])),
        cycle=sum(1 for i in range(1, n) if mv[i] == COUNTER.get(mv[i-1])),
        streak=best,
        avg_ms=round(sum(r[p+"_ms"] for r in rows)/max(n,1)),
        cost=round(sum(r[p+"_cost"] for r in rows), 4),
        think=sum(r[p+"_think"] for r in rows),
        retries=sum(r[p+"_attempts"]-1 for r in rows),
        invalid=sum(1 for r in rows if r[p] == "invalid"))

def pct(a, b): return f"{100*a/b:.0f}%" if b else "-"

def card(name, st, model):
    d = st["dist"]
    return f"""<div class="border-2 border-black rounded-lg p-6 flex-1 min-w-72">
<div class="text-3xl font-bold">{name}</div>
<div class="text-base text-gray-600 mb-3">{html.escape(model)}</div>
<div class="text-6xl font-bold mb-4">{st['wins']}<span class="text-2xl font-normal"> wins</span></div>
<table class="w-full text-lg"><tbody>
<tr><td>Win / lose / tie</td><td class="text-right">{st['wins']} / {st['losses']} / {st['ties']}</td></tr>
<tr><td>Rock / paper / scissors</td><td class="text-right">{d.get('rock',0)} / {d.get('paper',0)} / {d.get('scissors',0)}</td></tr>
<tr><td>Repeated own last move</td><td class="text-right">{st['repeat']} ({pct(st['repeat'], st['n']-1)})</td></tr>
<tr><td>Countered opponent's last move</td><td class="text-right">{st['beat_opp_prev']} ({pct(st['beat_opp_prev'], st['n']-1)})</td></tr>
<tr><td>Cycled R→P→S</td><td class="text-right">{st['cycle']} ({pct(st['cycle'], st['n']-1)})</td></tr>
<tr><td>Longest win streak</td><td class="text-right">{st['streak']}</td></tr>
<tr><td>Average latency</td><td class="text-right">{st['avg_ms']} ms</td></tr>
<tr><td>Thinking tokens</td><td class="text-right">{st['think']}</td></tr>
<tr><td>Invalid / retries</td><td class="text-right">{st['invalid']} / {st['retries']}</td></tr>
<tr><td>Cost</td><td class="text-right">${st['cost']}</td></tr>
</tbody></table></div>"""

def chart(rows):
    n = len(rows); pts = []; d = 0
    for r in rows:
        d += 1 if r["winner"] == "haiku" else -1 if r["winner"] == "sonnet" else 0
        pts.append(d)
    W, H = 1000, 260
    mx = max([abs(v) for v in pts] + [5])
    X = lambda i: 50 + (W-70) * i / max(n-1, 1)
    Y = lambda v: H/2 - v * (H/2 - 20) / mx
    path = " ".join(f"{'M' if i==0 else 'L'}{X(i):.1f},{Y(v):.1f}" for i, v in enumerate(pts))
    return f"""<svg viewBox="0 0 {W} {H}" class="w-full rounded-lg border-2 border-black bg-white">
<line x1="50" y1="{H/2}" x2="{W-20}" y2="{H/2}" stroke="#999" stroke-dasharray="6 6"/>
<path d="{path}" fill="none" stroke="black" stroke-width="3"/>
<text x="6" y="{Y(mx)+6:.0f}" font-size="18">+{mx}</text>
<text x="6" y="{H/2+6}" font-size="18">0</text>
<text x="6" y="{Y(-mx)+6:.0f}" font-size="18">−{mx}</text>
<text x="{W-20}" y="{H-6}" font-size="18" text-anchor="end">round {n}</text></svg>"""

def table(rows):
    trs = []
    for r in rows:
        w = r["winner"]
        bh = "font-bold" if w == "haiku" else "text-gray-500"
        bs = "font-bold" if w == "sonnet" else "text-gray-500"
        if w == "tie": bh = bs = ""
        trs.append(f"""<tr class="border-t border-gray-300">
<td class="p-2 text-center">{r['round']}</td>
<td class="p-2 text-center {bh}">{ICON[r['haiku']]} {r['haiku']}</td>
<td class="p-2 text-center {bs}">{ICON[r['sonnet']]} {r['sonnet']}</td>
<td class="p-2 text-center">{w}</td>
<td class="p-2 text-center text-gray-600">{r['haiku_ms']} / {r['sonnet_ms']} ms</td>
<td class="p-2 text-center text-gray-600">{r['haiku_think']} / {r['sonnet_think']}</td></tr>""")
    return f"""<div class="overflow-x-auto rounded-lg border-2 border-black">
<table class="w-full text-lg"><thead><tr class="bg-gray-100">
<th class="p-2">#</th><th class="p-2">Haiku</th><th class="p-2">Sonnet</th><th class="p-2">Winner</th>
<th class="p-2">Latency H/S</th><th class="p-2">Thinking H/S</th></tr></thead>
<tbody>{''.join(trs)}</tbody></table></div>"""

sections, summary_rows, sys_prompt, sample_prompt = [], [], "", ""
for key, path, title, note in RUNS:
    got = load(path)
    if not got: continue
    meta, rows = got
    if not rows: continue
    models = json.loads(meta.get("models", "{}"))
    sys_prompt = meta.get("system_prompt", sys_prompt)
    sample_prompt = rows[-1]["sonnet_prompt"]
    Hs, Ss = stats(rows, "haiku", "sonnet"), stats(rows, "sonnet", "haiku")
    lead = "Haiku" if Hs["wins"] > Ss["wins"] else "Sonnet" if Ss["wins"] > Hs["wins"] else "Nobody"
    summary_rows.append((title, len(rows), Hs["wins"], Ss["wins"], Hs["ties"], lead))
    sections.append(f"""<section class="mb-16">
<h2 class="text-4xl font-bold mb-2">{title} · {len(rows)} rounds</h2>
<p class="text-lg text-gray-700 mb-6 max-w-4xl">{note}</p>
<div class="flex flex-wrap gap-6 mb-8">{card("Haiku 4.5", Hs, models.get("haiku",""))}{card("Sonnet 5", Ss, models.get("sonnet",""))}</div>
<p class="text-lg mb-2">Cumulative score. Above the dashed line means Haiku is ahead.</p>
{chart(rows)}
<details class="mt-6"><summary class="cursor-pointer text-2xl font-bold rounded-lg bg-gray-100 p-4">All {len(rows)} rounds</summary>
<div class="mt-4">{table(rows)}</div></details>
</section>""")

srows = "".join(f"""<tr class="border-t border-gray-300"><td class="p-3">{t}</td>
<td class="p-3 text-center">{n}</td><td class="p-3 text-center font-bold">{h}</td>
<td class="p-3 text-center font-bold">{s}</td><td class="p-3 text-center">{ti}</td>
<td class="p-3 text-center">{ld}</td></tr>""" for t, n, h, s, ti, ld in summary_rows)

counts = {k: n for k, n, *_ in [(t.split(" ")[1], n) for t, n, *_ in summary_rows]} if summary_rows else {}
a_n = summary_rows[0][1] if len(summary_rows) > 0 else 0
b_n = summary_rows[1][1] if len(summary_rows) > 1 else 0

page = f"""<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Haiku vs Sonnet · rock paper scissors</title>
<script src="https://cdn.jsdelivr.net/npm/@unocss/runtime"></script>
<style>html{{font-size:20px}}body{{visibility:hidden}}body.ready{{visibility:visible}}</style>
</head><body class="bg-white text-black font-sans">
<main class="max-w-6xl mx-auto p-6 py-12">
<h1 class="text-6xl font-bold leading-tight mb-4">Does the bigger model play rock paper scissors better?</h1>
<p class="text-2xl mb-6 max-w-4xl">Claude Haiku 4.5 against Claude Sonnet 5. Before every round each player is shown the complete move history of the match, so any habit one model falls into is visible to the other.</p>
<div class="border-2 border-black rounded-lg p-6 mb-8 max-w-4xl">
<p class="text-xl font-bold mb-2">Read the sample sizes first</p>
<p class="text-lg mb-3">Runs A and B were set up for 100 rounds each but the account hit its session limit partway through, and the API stopped returning moves. Run A kept {a_n} valid rounds, run B kept {b_n}. Every round after the cutoff was an infrastructure error rather than a played move, and those rows were deleted from the databases instead of being scored. Run C was a shorter match by design and ran to completion.</p>
<p class="text-lg">These are small samples from single matches. Nothing here settles whether one model is better at the game; the differences worth looking at are between the three <em>formats</em>, which turn out to be much larger than the differences between the two models.</p>
</div>

<div class="overflow-x-auto rounded-lg border-2 border-black mb-12">
<table class="w-full text-xl"><thead><tr class="bg-gray-100">
<th class="p-3 text-left">Match</th><th class="p-3">Rounds</th><th class="p-3">Haiku</th>
<th class="p-3">Sonnet</th><th class="p-3">Ties</th><th class="p-3">Leader</th></tr></thead>
<tbody>{srows}</tbody></table></div>

<h2 class="text-4xl font-bold mb-4">Setup</h2>
<p class="text-lg mb-4 max-w-4xl">Both players are the same Claude Code binary invoked with every default stripped away: no tools, no built-in system prompt, no settings files, no session persistence. What reaches the model is only the system prompt below plus the round history, roughly 300 tokens at the start.</p>
<pre class="bg-gray-100 rounded-lg p-4 text-lg whitespace-pre-wrap mb-4">claude -p --model MODEL --effort low --tools "" \\
  --system-prompt SYSTEM --setting-sources "" --strict-mcp-config \\
  --disable-slash-commands --no-session-persistence --output-format json</pre>
<p class="text-lg mb-2">System prompt, identical for both players:</p>
<pre class="bg-gray-100 rounded-lg p-4 text-lg whitespace-pre-wrap mb-4">{html.escape(sys_prompt)}</pre>
<p class="text-lg mb-2">User message for the last round, as one player sees it:</p>
<pre class="bg-gray-100 rounded-lg p-4 text-base whitespace-pre-wrap max-h-80 overflow-auto mb-12">{html.escape(sample_prompt)}</pre>

{''.join(sections)}

<h2 class="text-4xl font-bold mb-4">What actually happened</h2>
<div class="text-lg max-w-4xl mb-12 space-y-4">
<p>The question was whether the bigger model plays better. The honest answer from these three matches is that the <b>prompt format mattered far more than the model</b>.</p>
<p>In run A, where Haiku was still reasoning for thousands of tokens per round and Sonnet was answering instantly, Haiku came out ahead. In run B, with reasoning switched off on both sides, Sonnet came out ahead. Neither margin is large enough over 40 and 57 rounds to mean much on its own.</p>
<p>Run C is the result that is not close. Moving from a single narrated history to a real conversation, changing nothing else, produced <b>fifty ties out of fifty</b>. Both models locked onto rock, paper, scissors, rock, paper, scissors and neither deviated once for the entire match. The two move sequences are character-for-character identical.</p>
<p>The likely reason is where the history sits. In runs A and B a player's own past moves were narrated back to it as text written by someone else. In run C they are its own assistant turns, and continuing a pattern you can see yourself having authored is a much stronger pull than continuing one you were merely told about. With reasoning disabled there is nothing to interrupt that pull, so both players fell into the same cycle and stayed there.</p>
<p>Which means the experiment as designed cannot punish habit-forming models the way it was supposed to. Showing a model the full history is meant to let its opponent exploit any pattern. Instead, in the most conversational format, the history is what <em>created</em> the pattern, and both sides adopted the same one at the same time.</p>
</div>

<h2 class="text-4xl font-bold mb-4">How to read the numbers</h2>
<ul class="text-lg list-disc pl-8 mb-12 max-w-4xl space-y-2">
<li><b>Countered opponent's last move</b> is the classic reflex: playing whatever beats what the other side just played. A high number means the model is easy to predict one step ahead.</li>
<li><b>Cycled R→P→S</b> counts rounds where a player advanced its own move one step around the wheel, the most common human habit too.</li>
<li><b>Ties</b> are high whenever both players fall into the same rotation at the same time.</li>
</ul>

<p class="text-lg">Raw data and code: <a class="underline" href="rps.sqlite">rps.sqlite</a>,
<a class="underline" href="rps_nothink.sqlite">rps_nothink.sqlite</a>,
<a class="underline" href="rps.py">rps.py</a>, <a class="underline" href="build_site.py">build_site.py</a></p>
</main>
<script>addEventListener('load',()=>setTimeout(()=>document.body.classList.add('ready'),200))</script>
</body></html>"""

os.makedirs("site", exist_ok=True)
open("site/index.html", "w").write(page)
for f in ("rps.sqlite", "rps_nothink.sqlite", "rps.py", "build_site.py"):
    if os.path.exists(f): shutil.copy(f, "site/")
for t, n, h, s, ti, ld in summary_rows:
    print(f"{t}: {n} rounds, haiku {h} sonnet {s} ties {ti}")
