#!/usr/bin/env python3
"""Build site2/index.html: the RNG-opening experiments."""
import json, sqlite3, shutil, html, os
from collections import Counter

BEATS = {"rock": "scissors", "paper": "rock", "scissors": "paper"}
ICON = {"rock": "✊", "paper": "✋", "scissors": "✌️"}
MAIN = "rps_big.sqlite"

PROBES = [
    ("rps_multiturn.sqlite",  "No opening at all",       0,  "Models free from round 1."),
    ("rps_noscissors.sqlite", "Round 1 may not be scissors", 0, "A rule in the system prompt, no RNG."),
    ("rps_norock.sqlite",     "Round 1 may not be rock", 0,  "Same, the other way round."),
    ("rps_seeded.sqlite",     "4 random rounds narrated", 4, "RNG played the opening; the models were told about it."),
    ("rps_prescribed.sqlite", "5 random rounds played by the models", 5, "Each model was given its own RNG script to play out. The two scripts happened to overlap in 4 of 5 rounds."),
    ("rps_balanced.sqlite",   "4 balanced rounds played by the models", 4, "Scripts constrained: no draws, wins split evenly."),
]

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_input,sonnet_input FROM rounds ORDER BY round")
    cols = [c[0] for c in cur.description]
    return meta, [dict(zip(cols, r)) for r in cur.fetchall()]

def score(rows):
    return (sum(1 for r in rows if r["winner"] == "haiku"),
            sum(1 for r in rows if r["winner"] == "sonnet"),
            sum(1 for r in rows if r["winner"] == "tie"))

meta, rows = load(MAIN)
P = int(meta.get("seed_rounds", "0") or 0) or int(json.loads(meta.get("prescribed_haiku", "[]")) and len(json.loads(meta["prescribed_haiku"])) or 0)
plan = {p: json.loads(meta.get(f"prescribed_{p}", "[]")) for p in ("haiku", "sonnet")}
P = len(plan["haiku"])
free = [r for r in rows if r["round"] > P]
pre = [r for r in rows if r["round"] <= P]
fh, fs, ft = score(free)
ph, ps, pt = score(pre)

# ---- move timeline ----
W, RH, PAD = 1180, 62, 46
n = len(rows)
cw = (W - 2*PAD) / n
def band(who, y, color):
    out = []
    for i, r in enumerate(rows):
        x = PAD + i*cw
        won = r["winner"] == who
        out.append(f'<rect x="{x:.1f}" y="{y}" width="{cw-2:.1f}" height="{RH-8}" rx="8" '
                   f'fill="{"#000" if won else "#fff"}" stroke="#000" stroke-width="2"/>')
        out.append(f'<text x="{x+cw/2:.1f}" y="{y+RH/2-2}" font-size="20" text-anchor="middle" '
                   f'dominant-baseline="middle" fill="{"#fff" if won else "#000"}">{r[who][0].upper()}</text>')
    return "".join(out)
sep = PAD + P*cw
timeline = f"""<svg viewBox="0 0 {W} {2*RH+80}" class="w-full">
<rect x="{PAD}" y="20" width="{P*cw:.1f}" height="{2*RH+8}" fill="#eee" rx="10"/>
<text x="{PAD}" y="14" font-size="17">rounds 1–{P}: scripted by the RNG</text>
<text x="{W-PAD}" y="14" font-size="17" text-anchor="end">rounds {P+1}–{n}: the models decide</text>
<line x1="{sep}" y1="18" x2="{sep}" y2="{2*RH+30}" stroke="#000" stroke-width="3" stroke-dasharray="7 5"/>
<text x="6" y="{20+RH/2}" font-size="18" font-weight="bold">H</text>
<text x="6" y="{20+RH+RH/2}" font-size="18" font-weight="bold">S</text>
{band("haiku", 22, "#000")}{band("sonnet", 22+RH, "#000")}
<text x="{PAD}" y="{2*RH+52}" font-size="17">round 1</text>
<text x="{W-PAD}" y="{2*RH+52}" font-size="17" text-anchor="end">round {n}</text>
<text x="{W/2}" y="{2*RH+52}" font-size="17" text-anchor="middle">a filled box is a round that player won</text>
</svg>"""

trs = []
for r in rows:
    scripted = r["round"] <= P
    bh = "font-bold" if r["winner"] == "haiku" else ""
    bs = "font-bold" if r["winner"] == "sonnet" else ""
    trs.append(f"""<tr class="border-t border-gray-300 {'bg-gray-100' if scripted else ''}">
<td class="p-2 text-center">{r['round']}{' <span class="text-sm text-gray-600">rng</span>' if scripted else ''}</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">{r['winner']}</td>
<td class="p-2 text-center text-gray-600">{r['haiku_ms']} / {r['sonnet_ms']} ms</td></tr>""")

probe_rows = []
for path, name, np_, note in PROBES:
    got = load(path)
    if not got: continue
    m2, r2 = got
    pl = json.loads(m2.get("prescribed_haiku", "[]"))
    k = len(pl) or int(m2.get("seed_rounds", "0") or 0)
    fr = [r for r in r2 if r["round"] > k]
    a, b, t = score(fr)
    probe_rows.append(f"""<tr class="border-t border-gray-300">
<td class="p-3">{name}<div class="text-base text-gray-600">{note}</div></td>
<td class="p-3 text-center">{len(fr)}</td><td class="p-3 text-center font-bold">{a}</td>
<td class="p-3 text-center font-bold">{b}</td><td class="p-3 text-center">{t}</td></tr>""")

hseq = "".join(r["haiku"][0] for r in free)
sseq = "".join(r["sonnet"][0] for r in free)

# ---- cross-provider section ----
def cross_block():
    runs = [("rps_deepseek.sqlite", "DeepSeek reasoning, Haiku not",
             "DeepSeek was left with its chain of thought on while Haiku had thinking switched off. "
             "Not a fair fight, and the section below explains why it was run that way by accident."),
            ("rps_deepseek_nothink.sqlite", "Neither model reasoning",
             "The same match with reasoning suppressed on both sides, which is the only setting "
             "where the two are actually comparable.")]
    out = []
    rows_html = []
    for path, title, note in runs:
        if not os.path.exists(path): continue
        db = sqlite3.connect(path)
        rr = [dict(zip([c[0] for c in cur.description], r)) for cur in
              [db.execute("SELECT round,a_move,b_move,winner,scripted,a_ms,b_ms FROM rounds ORDER BY round")]
              for r in cur.fetchall()]
        fr = [r for r in rr if not r["scripted"]]
        hw = sum(1 for r in fr if r["winner"] == "haiku")
        dw = sum(1 for r in fr if r["winner"] == "deepseek")
        dr = sum(1 for r in fr if r["winner"] == "draw")
        hms = round(sum(r["a_ms"] for r in fr)/len(fr))
        dms = round(sum(r["b_ms"] for r in fr)/len(fr))
        hseq2 = "".join(r["a_move"][0] for r in fr)
        dseq2 = "".join(r["b_move"][0] for r in fr)
        rows_html.append(f'''<tr class="border-t border-gray-300">
<td class="p-3">{title}<div class="text-base text-gray-600">{note}</div></td>
<td class="p-3 text-center">{len(fr)}</td><td class="p-3 text-center font-bold">{hw}</td>
<td class="p-3 text-center font-bold">{dw}</td><td class="p-3 text-center">{dr}</td>
<td class="p-3 text-center text-base">{hms} / {dms} ms</td></tr>''')
        out.append(f'''<div class="rounded-lg border-2 border-black p-6 mb-6 overflow-x-auto">
<div class="text-2xl font-bold mb-2">{title}</div>
<div class="text-lg mb-3">Free rounds only, first letter of each move:</div>
<div class="text-2xl font-mono whitespace-pre">Haiku    {hseq2}</div>
<div class="text-2xl font-mono whitespace-pre">DeepSeek {dseq2}</div></div>''')
    if not rows_html: return ""
    return f'''<div class="text-lg max-w-4xl space-y-4 mb-6">
<p>Everything above is Claude against Claude. To check whether the lock-in is a quirk of one company's
models, the same {30}-round format was run against <b>DeepSeek V4 Flash</b>, reached through the
OpenCode Go gateway rather than the Claude Code CLI: a different vendor, a different API, a different
transport for the conversation.</p>
<p>It behaves the same way. With reasoning switched off on both sides, {22} of the {24} free rounds
were draws and the two move sequences were identical from the third round onward. The rock, paper,
scissors cycle is not a Claude habit; it is what these models do when asked for a move with no
deliberation, whoever built them.</p>
<p>With its reasoning left on, DeepSeek won twice as many rounds as Haiku — and took an average of
17.6 seconds per move against Haiku's 1.9, with one move taking 63 seconds. That result says
something about thinking versus not thinking, not about which model is stronger.</p>
</div>
<div class="overflow-x-auto rounded-lg border-2 border-black mb-8">
<table class="w-full text-xl"><thead><tr class="bg-gray-100">
<th class="p-3 text-left">Match</th><th class="p-3">Free rounds</th><th class="p-3">Haiku</th>
<th class="p-3">DeepSeek</th><th class="p-3">Draws</th><th class="p-3">Avg latency</th></tr></thead>
<tbody>{"".join(rows_html)}</tbody></table></div>
{"".join(out)}
<p class="text-lg max-w-4xl mb-12">A note on how easily this was nearly got wrong: DeepSeek returns its
chain of thought in a separate <code>reasoning_content</code> field, and those tokens are folded into
<code>completion_tokens</code> with no separate count. Reading the usage numbers alone, the model looks
like it answered in one word without thinking. The only reliable tell was the latency.</p>'''

CROSS = cross_block()

page = f"""<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Scripted openings · Haiku vs Sonnet</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">What happens if you hand a model a random opening and then let go?</h1>
<p class="text-2xl mb-8 max-w-4xl">Claude Haiku 4.5 and Claude Sonnet 5 play {n} rounds of rock paper scissors as a real conversation. A random number generator writes each player's first {P} moves; the models play those out, and then decide the remaining {n-P} for themselves.</p>

<div class="border-2 border-black rounded-lg p-6 mb-10 max-w-4xl">
<p class="text-xl font-bold mb-2">The result in one line</p>
<p class="text-lg">Across the {len(free)} rounds the models actually chose, <b>Sonnet won {fs}, Haiku won {fh}</b>, with {ft} draws. Every earlier version of this experiment ended in a dead heat, most of them in an unbroken run of draws. The scripted opening is what made a difference visible.</p>
</div>

<h2 class="text-4xl font-bold mb-4">The whole match</h2>
{timeline}

<h2 class="text-4xl font-bold mt-12 mb-4">Why the opening is scripted</h2>
<div class="text-lg max-w-4xl space-y-4 mb-10">
<p>Left to themselves with reasoning switched off, both models do the same thing: they play rock, then paper, then scissors, then rock again, following the order the three options are listed in the prompt. Both sides do it at the same time, so the match becomes an unbroken chain of draws. An earlier {50}-round run ended 50 draws out of 50, with the two move sequences identical character for character.</p>
<p>Telling a model it may not open with rock does not help. It opens with paper instead, the next item on the list, and so does its opponent. The cycle just shifts by one.</p>
<p>What does help is starting the match somewhere that has no pattern to continue. The script here is built so the two players are pulled apart from the first move: <b>no draws, and the {P} scripted rounds split evenly, {ph} wins each</b>. By the time the models take over there is no clean cycle in the transcript to fall back into.</p>
</div>

<h2 class="text-4xl font-bold mb-4">The script the models were given</h2>
<p class="text-lg mb-4 max-w-4xl">Each player received its own move list in its system prompt and played it out itself, so those rounds sit in its transcript as its own turns rather than as something it was told about. Both models followed the script for all {P} rounds without a single deviation.</p>
<div class="flex flex-wrap gap-6 mb-4">
<div class="border-2 border-black rounded-lg p-6 flex-1 min-w-72">
<div class="text-2xl font-bold mb-2">Haiku's script</div>
<div class="text-xl">{" · ".join(plan["haiku"])}</div></div>
<div class="border-2 border-black rounded-lg p-6 flex-1 min-w-72">
<div class="text-2xl font-bold mb-2">Sonnet's script</div>
<div class="text-xl">{" · ".join(plan["sonnet"])}</div></div></div>
<pre class="bg-gray-100 rounded-lg p-4 text-base whitespace-pre-wrap mb-12">{html.escape(meta.get("system_prompt_haiku",""))}</pre>

<h2 class="text-4xl font-bold mb-4">What each model did once it was free</h2>
<div class="text-lg max-w-4xl space-y-4 mb-6">
<p>Sonnet's wins came from reading Haiku rather than from being unpredictable. Haiku settled into short repeating runs, and Sonnet took {fs} rounds off it, including two in a row at the end.</p>
</div>
<div class="rounded-lg border-2 border-black p-6 mb-12 overflow-x-auto">
<div class="text-lg mb-2">Free rounds only, first letter of each move:</div>
<div class="text-2xl font-mono whitespace-pre">Haiku  {hseq}</div>
<div class="text-2xl font-mono whitespace-pre">Sonnet {sseq}</div>
</div>

<h2 class="text-4xl font-bold mb-4">A model from a different company</h2>
{CROSS}

<h2 class="text-4xl font-bold mb-4">Every version tried</h2>
<p class="text-lg mb-4 max-w-4xl">Only rounds the models chose are counted; scripted rounds are excluded. The last row is the match above.</p>
<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">Opening</th><th class="p-3">Free rounds</th><th class="p-3">Haiku</th>
<th class="p-3">Sonnet</th><th class="p-3">Draws</th></tr></thead>
<tbody>{"".join(probe_rows)}
<tr class="border-t-2 border-black bg-gray-100"><td class="p-3 font-bold">{P} balanced rounds, {n} total
<div class="text-base font-normal text-gray-600">This page.</div></td>
<td class="p-3 text-center font-bold">{len(free)}</td><td class="p-3 text-center font-bold">{fh}</td>
<td class="p-3 text-center font-bold">{fs}</td><td class="p-3 text-center font-bold">{ft}</td></tr>
</tbody></table></div>

<h2 class="text-4xl font-bold mb-4">How the models were called</h2>
<p class="text-lg mb-4 max-w-4xl">Both players are the same Claude Code binary with every default stripped away: no tools, no built-in system prompt, no settings files. Reasoning is switched off on both sides, so what you see is the model's immediate response. Each player holds one session for the whole match, so the conversation really is {n} exchanges long rather than one long recap.</p>
<pre class="bg-gray-100 rounded-lg p-4 text-lg whitespace-pre-wrap mb-4">MAX_THINKING_TOKENS=0 claude -p --model MODEL --effort low --tools "" \\
  --system-prompt SYSTEM --setting-sources "" --strict-mcp-config \\
  --disable-slash-commands --resume SESSION --output-format json</pre>
<p class="text-lg mb-12 max-w-4xl">Each user turn reports only the round just played, in the form <code class="bg-gray-100 rounded-lg px-2">you: paper, opponent: scissors, result: you lost</code>, followed by a request for the next move.</p>

<h2 class="text-4xl font-bold mb-4">All {n} rounds</h2>
<div class="overflow-x-auto rounded-lg border-2 border-black mb-10">
<table class="w-full text-xl"><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</th></tr></thead>
<tbody>{"".join(trs)}</tbody></table></div>

<p class="text-lg">Caveat worth keeping in mind: this is one match of {len(free)} free rounds. It is enough to show that a scripted opening breaks the deadlock, and not nearly enough to rank the two models at rock paper scissors.</p>
<p class="text-lg mt-4">Raw data: <a class="underline" href="rps_big.sqlite">rps_big.sqlite</a> · code: <a class="underline" href="rps_multiturn.py">rps_multiturn.py</a>, <a class="underline" href="build_site2.py">build_site2.py</a></p>
</main>
<script>addEventListener('load',()=>setTimeout(()=>document.body.classList.add('ready'),200))</script>
</body></html>"""

os.makedirs("site2", exist_ok=True)
open("site2/index.html", "w").write(page)
for f in ("rps_big.sqlite", "rps_multiturn.py", "build_site2.py"): shutil.copy(f, "site2/")
print(f"built: {n} rounds, {P} scripted, free score haiku {fh} sonnet {fs} ties {ft}")
