"""Build the self-contained report from committed evaluation evidence."""

import html
import json
from collections import Counter
from pathlib import Path

ROOT = Path(__file__).resolve().parent
SITE = ROOT.parents[1]
DATA = {name: json.loads((ROOT / f"{name}.json").read_text()) for name in ("haiku", "gemini")}
JUDGMENTS = json.loads((ROOT / "judgments.json").read_text())
RUBRIC = json.loads((ROOT / "rubric.json").read_text())
LABELS = {"haiku": "Claude Haiku 4.5", "gemini": "Gemini 3 Flash Preview"}
ARMS = {"default": "Default", "override": "Readable override"}
COLORS = {"pass": "#337d67", "partial": "#b07b29", "fail": "#b34c40"}
ESC = lambda value: html.escape(str(value), quote=True)


def distribution(rows, key, arm):
    return Counter(row[key] for row in rows if row["arm"] == arm and row[key] != "not_applicable")


def bar(counts):
    total = sum(counts.values())
    segments = "".join(f'<i class="{status}" style="width:{100 * counts[status] / total:.3f}%"></i>' for status in COLORS if counts[status])
    caption = ", ".join(f"{counts[s]} {s}" for s in COLORS if counts[s])
    return f'<div class="bar" role="img" aria-label="{ESC(caption)}">{segments}</div><span class="counts">{ESC(caption)} · n={total}</span>'


def matrix(model=None):
    titles = [r for r in JUDGMENTS["titles"] if model is None or r["model_file"] == model]
    groups = [r for r in JUDGMENTS["groups"] if model is None or r["model_file"] == model]
    rows = []
    for rule in RUBRIC["rules"]:
        key = rule["id"]
        source = groups if key in ("consistent", "variation") else titles
        cells = "".join(f'<td>{bar(distribution(source, key, arm))}</td>' for arm in ARMS)
        unit = "family groups" if source is groups else "titles"
        rows.append(f'<tr><th scope="row">{ESC(rule["name"])}<small>{unit}</small></th>{cells}</tr>')
    rows.append('<tr class="fidelity"><th scope="row">Fidelity gate<small>Accurate requested work</small></th>' + "".join(f'<td>{bar(distribution(titles, "fidelity", arm))}</td>' for arm in ARMS) + '</tr>')
    return '<div class="scroll"><table class="matrix"><thead><tr><th>Criterion</th><th>Default</th><th>Readable override</th></tr></thead><tbody>' + "".join(rows) + '</tbody></table></div>'


def pair(case_id, model, repeat=1, featured=False):
    case = next(c for c in DATA[model]["cases"] if c["id"] == case_id)
    records = {r["arm"]: r for r in DATA[model]["records"] if r["case_id"] == case_id and r["repeat"] == repeat}
    cards = []
    for arm, label in ARMS.items():
        record = records[arm]
        judgment = next(r for r in JUDGMENTS["titles"] if r["model_file"] == model and r["case_id"] == case_id and r["repeat"] == repeat and r["arm"] == arm)
        badges = " ".join(f'<span class="badge {judgment[key]}">{key}: {judgment[key]}</span>' for key in ("parse", "frontload", "fidelity"))
        cards.append(f'<div class="title-card {arm}"><div class="arm">{label}</div><p class="generated">{ESC(record["title"])}</p><div>{badges}</div><p class="reason">{ESC(judgment["reason"])}</p><small>{record["characters"]} characters · {record["latency_seconds"]:.2f}s</small></div>')
    request = f'<details><summary>Full synthetic request · {len(case["input"]):,} characters</summary><pre>{ESC(case["input"])}</pre></details>'
    return f'<article class="pair" data-model="{model}" data-family="{ESC(case["family"])}"><div class="pair-meta">{ESC(LABELS[model])} · run {repeat} · {ESC(case_id)}</div><div class="paired">{"".join(cards)}</div>{request}</article>'


all_pairs = "".join(pair(c["id"], model, repeat) for model in DATA for repeat in (1, 2) for c in DATA[model]["cases"])
# Deliberately show improvements and regressions together, not just winning examples.
featured = "".join(pair(c, model) for c, model in [("feedback-mixed", "gemini"), ("feedback-praise", "gemini"), ("auth-login", "haiku"), ("long-tail", "gemini")])
groups = []
for group in JUDGMENTS["groups"]:
    titles = [r["title"] for r in DATA[group["model_file"]]["records"] if r["repeat"] == group["repeat"] and r["arm"] == group["arm"] and next(c for c in DATA[group["model_file"]]["cases"] if c["id"] == r["case_id"])["family"] == group["family"]]
    groups.append(f'<details><summary>{ESC(LABELS[group["model_file"]])} · {ARMS[group["arm"]]} · run {group["repeat"]} · {group["family"]} · consistency {group["consistent"]}, variation {group["variation"]}</summary><ul class="sidebar">' + "".join(f'<li>{ESC(t)}</li>' for t in titles) + f'</ul><p>{ESC(group["reason"])}</p></details>')
protocol = "".join(f'<details><summary>{ESC(r["name"])} · {ESC(r["scope"])}</summary>' + "".join(f'<p><strong>{status.title()}:</strong> {ESC(r[status])}</p>' for status in ("pass", "partial", "fail") if status in r) + '</details>' for r in RUBRIC["rules"]) + '<p>The initial parse rule did not define partial explicitly. Its post-scoring clarification appears in the adjudication record above; the original rubric remains unchanged.</p>'

embedded = json.dumps({"models": DATA, "judgments": JUDGMENTS, "rubric": RUBRIC, "original_judgments": json.loads((ROOT / "judgments-original.json").read_text()), "adjudication": json.loads((ROOT / "adjudication.json").read_text())}, ensure_ascii=False).replace("</", "<\\/")

page = '''<!doctype html>
<!-- notebook-attribution: {"version":1,"contributions":[{"agent":"SmolPaws","llm":null,"date":"2026-10-30","role":"author"}]} -->
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Can a writing skill improve conversation titles? — Engel’s Notebook</title>
<meta name="description" content="128 real SDK title generations, two models, five writing principles. A blinded rubric finds useful corrections and measurable tradeoffs.">
<style>
:root{--bg:#f6f3eb;--paper:#fffdf8;--ink:#272b28;--muted:#64685f;--line:#d8d7ca;--teal:#286854;--gold:#9b7130;--pass:#337d67;--partial:#8b5f1e;--fail:#b34c40;--mono:ui-monospace,SFMono-Regular,Menlo,monospace;--sans:"Avenir Next","Segoe UI",sans-serif}
*{box-sizing:border-box}html{scroll-behavior:smooth;scroll-padding-top:24px}body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.65 var(--sans)}a{color:var(--teal);text-underline-offset:3px}a:hover{color:var(--gold)}.wrap{max-width:1320px;margin:auto;padding:48px 30px 90px;display:grid;grid-template-columns:180px minmax(0,1fr);gap:54px}nav{position:sticky;top:32px;align-self:start;font:12px/1.9 var(--mono)}nav ol{padding-left:20px}nav a{text-decoration:none;color:var(--muted)}nav .home{display:block;margin-bottom:30px;color:var(--teal)}main{min-width:0}header{padding:22px 0 32px;border-bottom:1px solid var(--line)}.eyebrow,.arm,.pair-meta,.kicker{font:11px/1.7 var(--mono);text-transform:uppercase;letter-spacing:.1em;color:var(--gold)}h1{font:normal clamp(42px,5.5vw,76px)/1.03 Georgia,serif;letter-spacing:-.045em;max-width:800px;margin:18px 0 26px}h2{font:normal 34px/1.15 Georgia,serif;margin:52px 0 20px;letter-spacing:-.02em}h3{font-size:18px}p{max-width:76ch}.lead{font-size:20px;max-width:68ch}.muted,small{color:var(--muted)}.stats{display:flex;gap:34px;flex-wrap:wrap;margin:28px 0 0}.stats strong{font:normal 34px/1.2 Georgia,serif;display:block}.stats span{font:11px var(--mono);color:var(--muted)}.takeaway{border-left:3px solid var(--teal);padding:4px 22px;margin:25px 0;font-size:18px;background:#e9eee5}.takeaway p{padding:12px 0}.legend{display:flex;gap:20px;flex-wrap:wrap;font:12px var(--mono);margin:16px 0}.dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:6px}.pass{background:var(--pass)}.partial{background:var(--partial)}.fail{background:var(--fail)}.scroll{overflow-x:auto}table{width:100%;border-collapse:collapse}th,td{padding:16px 15px;border-bottom:1px solid var(--line);text-align:left;vertical-align:middle}thead th{font:11px var(--mono);text-transform:uppercase;color:var(--muted)}.matrix th:first-child{width:35%}.matrix small{display:block;font-size:12px;font-weight:normal}.matrix td{width:32.5%;min-width:180px}.bar{display:flex;height:12px;border-radius:2px;overflow:hidden;margin-bottom:7px;background:var(--line)}.bar i{height:100%;display:block}.counts{font:11px/1.5 var(--mono);color:var(--muted)}.fidelity{background:#eceee5}.paired{display:grid;grid-template-columns:1fr 1fr;gap:15px}.pair{padding:22px 0;border-top:1px solid var(--line);break-inside:avoid}.pair-meta{margin-bottom:12px;letter-spacing:.04em}.title-card{padding:20px;background:var(--paper);border:1px solid var(--line);border-top:3px solid #acae9f;min-width:0}.title-card.override{border-top-color:var(--teal)}.title-card .arm{color:var(--muted)}.generated{font-size:19px;font-weight:600;line-height:1.5;margin:15px 0 20px;overflow-wrap:anywhere}.reason{font-size:13px;line-height:1.6;color:var(--muted);margin-bottom:10px}.badge{font:10px/2 var(--mono);display:inline-block;padding:0 6px;border-radius:3px;color:white;margin:0 1px 3px 0}.title-card small{font:10px var(--mono)}details{border:1px solid var(--line);margin:12px 0;background:#ffffff35}summary{cursor:pointer;font-size:13px;padding:12px 15px}details>p,details>ul{margin:14px 18px}pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#eae8df;padding:18px;font:12px/1.75 var(--mono);max-height:500px;overflow:auto;margin:0}code{font:12px var(--mono);background:#eae8df;padding:2px 5px}figure{margin:28px 0}svg{display:block;width:100%;height:auto;background:var(--paper);border:1px solid var(--line)}figcaption{font-size:12px;color:var(--muted);margin-top:9px}.filters{display:flex;gap:15px;flex-wrap:wrap;margin:20px 0}.filters label{font:12px var(--mono)}select{font:14px var(--sans);padding:8px 12px;margin-left:5px;background:var(--paper);border:1px solid var(--line);color:var(--ink)}.sidebar{list-style:none;padding:0;font-size:16px}.sidebar li{padding:9px 14px;background:var(--paper);border-bottom:1px solid var(--line)}.notes{padding:18px 24px;background:#ede7d7;border-left:3px solid var(--gold)}footer{margin-top:60px;padding-top:24px;border-top:1px solid var(--line);font-size:12px;color:var(--muted)}.sources li{margin:8px 0}.hide{display:none!important}:focus-visible{outline:3px solid var(--gold);outline-offset:4px}[hidden]{display:none!important}
@media(max-width:850px){.wrap{grid-template-columns:1fr;padding:24px 18px 60px;gap:10px}nav{position:static}nav ol{display:flex;flex-wrap:wrap;gap:6px 24px;margin:0;padding-left:18px}nav .home{margin:0 0 12px}.paired{grid-template-columns:1fr}.stats{gap:22px}h1{font-size:47px}h2{font-size:29px}.matrix th:first-child{min-width:170px}.lead{font-size:18px}}
@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}}@media print{nav,.filters{display:none}.wrap{display:block;padding:0}.pair,details{break-inside:avoid}body{background:white}a{color:inherit}}
</style></head><body><div class="wrap"><nav aria-label="Contents"><a class="home" href="../">← Engel’s Notebook</a><div class="kicker">Field notes / 30 Oct 2026</div><ol><li><a href="#verdict">Read the verdict</a></li><li><a href="#rules">Five-rule scorecard</a></li><li><a href="#examples">See the tradeoffs</a></li><li><a href="#caps">Input caps, separately</a></li><li><a href="#all">Every title</a></li><li><a href="#method">Method & limitations</a></li></ol></nav><main>
<header id="verdict"><div class="eyebrow">An experiment in readable AI text</div><h1>Better titles.<br>Some new friction.</h1><p class="lead">A writing-skill override corrected wrong-task titles in this small sample. It did not improve every writing rule. Action-first phrasing sometimes pushed the topic back or ran into the existing 50-character output limit.</p><div class="stats"><div><strong>128</strong><span>real SDK generations</span></div><div><strong>2 × 16 × 2</strong><span>models · cases · repeats / arm</span></div><div><strong>5 rules</strong><span>blinded AI rubric</span></div></div></header>
<div class="takeaway"><p><strong>Keep testing before changing the default.</strong> The override reduced fidelity failures from <strong>4/64 to 2/64</strong>. Strict front-loading passes fell from <strong>57/64 to 55/64</strong>. Both are observations from these prompts and models, not general performance guarantees.</p></div>
<p class="muted">Default vs a title-specific wrapper around the full <a href="https://github.com/OpenHands/extensions/pull/871">readable-text skill</a>. Both arms used the same <a href="https://github.com/OpenHands/software-agent-sdk/pull/6337">uncapped SDK helper</a>. Results are the displayed titles after SDK output processing. <a href="../experiments/readable-titles/judgments.json">Audit every judgment ↗</a></p>
<section id="rules"><h2>Five rules do not add up to one score.</h2><p>Green means a full rubric pass. Amber means partial. Red means a failure. Consistency and meaningful variation are judged across sibling title groups, so their denominators differ. Inapplicable cases are excluded, never counted as passes.</p><div class="legend"><span><i class="dot pass"></i>Pass</span><span><i class="dot partial"></i>Partial</span><span><i class="dot fail"></i>Fail</span></div>@@MATRIX@@<p><strong>After the blind review:</strong> a cross-session audit found inconsistent grading of identical outputs and compatible abbreviations. I adjudicated those cases and two praise-attribution errors before publication. The chart uses the corrected grades. <a href="../experiments/readable-titles/judgments-original.json">Original blind grades</a> · <a href="../experiments/readable-titles/adjudication.json">Every correction and post-scoring clarification</a>.</p><p class="muted">Combined descriptive counts; the model-specific results below show why pooling alone is insufficient. Fidelity is a separate accuracy check, not a sixth writing principle.</p><details><summary>Claude Haiku 4.5 · 64 titles</summary>@@HAIKU_MATRIX@@</details><details><summary>Gemini 3 Flash Preview · 64 titles</summary>@@GEMINI_MATRIX@@</details><details><summary>Read the scoring rules</summary>@@PROTOCOL@@</details><p><strong>Model difference:</strong> four default-arm fidelity failures came from Gemini. Haiku’s override added two fidelity failures by changing “summarize customer praise” into “praise the lessons.” The override’s front-loading count stayed level for Haiku and fell by two for Gemini.</p></section>
<section id="examples"><h2>The useful corrections have a cost.</h2><p>Four contrasts, deliberately including regressions. These are exact first-repeat outputs, not rewritten examples. The complete two-repeat dataset follows below.</p>@@FEATURED@@<div class="notes"><p><strong>The output cap remains.</strong> Titles ended in an ellipsis in <strong>2/64 default</strong> outputs and <strong>5/64 override</strong> outputs. These are observed ellipsis endings, not instrumented raw-response truncation counts. The blind scorer judged the text a user would see.</p><p>The praise example moves “offline” later. The long-input example retains the correct topic in both arms, but the default changes “investigate” to “fix.” Removing the input cap and improving instruction fidelity solve different problems.</p></div><details><summary>Inspect sibling collections · all 24 family-level judgments</summary>@@GROUPS@@</details></section>
<section id="caps"><h2>Full inputs are a separate, verified change.</h2><p>The model comparison isolates prompt wording: <strong>both arms receive full input</strong>. It cannot measure the effect of removing the old input caps. Five separate deterministic regressions cover that SDK change.</p><figure><svg viewBox="0 0 860 285" role="img" aria-labelledby="caps-title caps-desc"><title id="caps-title">Input caps before and after; output cap unchanged</title><desc id="caps-desc">Before: instruction overrides stop at 2000 characters and first-message input at 1000. After: both inputs remain complete. Both paths retain a 50-character title output cap.</desc><g font-family="Georgia,serif" fill="#272b28"><text x="28" y="35" font-size="23">Before</text><text x="450" y="35" font-size="23">After</text></g><g font-family="sans-serif" font-size="14"><rect x="25" y="58" width="385" height="65" rx="3" fill="#eee7d8" stroke="#b07b29" stroke-dasharray="5 3"/><text x="42" y="84" fill="#272b28">Override: reject above 2,000 characters</text><text x="42" y="107" fill="#64685f">User message: keep only first 1,000</text><rect x="447" y="58" width="385" height="65" rx="3" fill="#e7eee4" stroke="#337d67"/><text x="464" y="84" fill="#272b28">Override: complete instructions</text><text x="464" y="107" fill="#272b28">User message: complete request</text><path d="M218 128v27m422-27v27" stroke="#64685f" stroke-width="2"/><rect x="25" y="165" width="385" height="75" rx="3" fill="#fffdf8" stroke="#d8d7ca"/><rect x="447" y="165" width="385" height="75" rx="3" fill="#fffdf8" stroke="#d8d7ca"/><text x="42" y="195" fill="#272b28">Title LLM → output cap: 50 characters</text><text x="42" y="219" fill="#64685f">Fallback behavior unchanged</text><text x="464" y="195" fill="#272b28">Title LLM → output cap: 50 characters</text><text x="464" y="219" fill="#64685f">Provider context limits still apply</text></g></svg><figcaption>Input preservation changes. Output constraints do not. <a href="https://github.com/OpenHands/software-agent-sdk/blob/01bb167b04c00afc4d781f198fd8941a543eb266/openhands-sdk/openhands/sdk/conversation/title_utils.py#L83-L185">generate_title_with_llm:83–185</a> · <a href="https://github.com/OpenHands/software-agent-sdk/blob/01bb167b04c00afc4d781f198fd8941a543eb266/openhands-sdk/openhands/sdk/conversation/request.py#L425">ConversationConfig.prompt</a></figcaption></figure><ul><li>Four real SDK HTTP-transport cases: default prompt, placeholder substitution, appended message, and a long override.</li><li>One real configuration/schema round-trip case with an override longer than 2,000 characters.</li><li>All five fail against the old source and pass with the patch. The title and server-span suites total <strong>27 passing tests</strong>.</li></ul><p><a href="https://github.com/OpenHands/software-agent-sdk/blob/01bb167b04c00afc4d781f198fd8941a543eb266/tests/sdk/conversation/test_generate_title.py#L509-L553">Regression tests:509–553</a> · <a href="https://github.com/OpenHands/software-agent-sdk/pull/6337">SDK PR #6337</a> · <a href="https://github.com/OpenHands/docs/pull/1053">Paired docs #1053</a></p></section>
<section id="all"><h2>Every title. No winning-example filter.</h2><p>All 64 matched pairs are below: 16 synthetic requests, two models, two repeats. Each pair shows the same request under both prompts. Expand any request to inspect what the title should preserve.</p><div class="filters"><label>Model <select id="model"><option value="all">Both models</option><option value="haiku">Claude Haiku 4.5</option><option value="gemini">Gemini 3 Flash Preview</option></select></label><label>Family <select id="family"><option value="all">All families</option><option>auth</option><option>upgrade</option><option>frontloading</option><option>feedback</option><option>fidelity</option><option>status</option><option>input-cap</option></select></label><span id="visible" role="status" aria-live="polite">64 pairs</span></div><div id="pairs">@@ALL_PAIRS@@</div></section>
<section id="method"><h2>A small, inspectable experiment.</h2><p>Generation ran on 30 October 2026 through the actual Python SDK <code>generate_title_with_llm</code> helper, not a reconstructed prompt-only mock. The 2,855-character override contains the skill body and a title-specific wrapper. Arms alternate order by case and repeat. No fallback title is counted as a successful generation.</p><ul><li><strong>Models:</strong> <code>litellm_proxy/anthropic/claude-haiku-4-5</code> and <code>litellm_proxy/gemini-3-flash-preview</code>. These are requested aliases, not independently verified provider model versions.</li><li><strong>Settings:</strong> temperature configured as 0, reasoning effort none, 256 output tokens, 50 title characters, 45-second timeout, one configured retry. Provider/model handling may omit unsupported sampling parameters. Anthropic legacy thinking was disabled for Haiku; Gemini’s unused Anthropic-specific budget retained its default.</li><li><strong>Judge:</strong> Codex / gpt-6-astra, two separate sessions. Each received shuffled titles, original requests, and the prewritten rubric. Arm labels, model identities, and generation prompts were hidden. The sessions used the same scorer model, not independent human raters.</li><li><strong>Scope:</strong> 16 hand-chosen synthetic requests. Three auth siblings, three upgrade siblings, two filler-heavy requests, three feedback sentiments, comparison, negation, uncertainty, completed-work status, and one long-tail request. No private conversations.</li></ul><div class="notes"><p><strong>Limits:</strong> two low-temperature repeats are not independent user studies. This convenience sample does not measure reading speed, preference, or statistical significance. The wrapper adds title-specific constraints as well as the skill, so the comparison evaluates that combined override. Emoji constraints differ too: the baseline offers a fixed category list; the override asks for a relevant emoji. The fixed corpus was not used to tune and rerun the successful prompt.</p><p><strong>Setup failures are retained:</strong> nine failed calls preceded the successful comparison: six Haiku requests hit a too-small manual-thinking budget; three requests used an unavailable Gemini 2.5 alias. Those calls produced no titles and are excluded from the 128-title quality denominators. One earlier configuration-only run failed before an API call. <a href="../experiments/readable-titles/setup-failures.json">Setup-failure records ↗</a></p></div><details><summary>Exact override · 2,855 characters</summary><pre>@@OVERRIDE@@</pre></details><p>The original metadata names the base plus uncommitted patch at generation time. That source was later committed as <code>01bb167</code>; the datasets retain both the original label and the verified commit. Both arms share the SDK system message; the override replaces only its user-message template. <a href="https://github.com/OpenHands/software-agent-sdk/blob/01bb167b04c00afc4d781f198fd8941a543eb266/openhands-sdk/openhands/sdk/conversation/title_utils.py#L113-L160">Exact default and system prompts ↗</a></p><h3>Evidence and reproduction</h3><ul class="sources"><li><a href="../experiments/readable-titles/evaluate.py">Evaluation harness</a> · <a href="../experiments/readable-titles/build_report.py">Page generator</a> · <a href="../experiments/readable-titles/rubric.json">Prewritten rubric</a> · <a href="../experiments/readable-titles/scoring-prompt.txt">Exact scoring instruction</a></li><li><a href="../experiments/readable-titles/haiku.json">Haiku: all inputs, override, and 64 outputs</a> · <a href="../experiments/readable-titles/gemini.json">Gemini: all inputs, override, and 64 outputs</a></li><li><a href="../experiments/readable-titles/judgments.json">All blinded judgments with revealed mappings</a> · <a href="../experiments/readable-titles/reproduce.txt">Reproduction commands and environment notes</a></li><li><a href="https://fsdatalab.github.io/blog/agent-text-at-scale/">Shreya Shankar: Five Principles for Generating Readable AI Text in Bulk</a> · <a href="https://github.com/OpenHands/extensions/blob/cad9dfcc2a68473a64aa4537e76f49c13dcbbf66/skills/readable-text/SKILL.md">Pinned readable-text skill</a></li></ul><p>Recommendation: preserve the full input, then refine the override’s topic-first wording and brevity in a <em>new, separately labeled experiment</em>. These results do not justify silently replacing the SDK default.</p></section>
<footer>This page and its evaluation were produced by AI agent SmolPaws on behalf of Engel Nyst. Rubric judgments came from Codex / gpt-6-astra. All test requests are synthetic. No estimated proxy cost is presented.</footer>
</main></div><script type="application/json" id="evidence">@@EVIDENCE@@</script><script>
const model=document.getElementById('model'),family=document.getElementById('family');function filter(){let count=0;document.querySelectorAll('#pairs .pair').forEach(p=>{p.hidden=(model.value!=='all'&&p.dataset.model!==model.value)||(family.value!=='all'&&p.dataset.family!==family.value);if(!p.hidden)count++});document.getElementById('visible').textContent=count+' pairs'}model.addEventListener('change',filter);family.addEventListener('change',filter);
</script></body></html>'''
replacements = {"MATRIX": matrix(), "HAIKU_MATRIX": matrix("haiku"), "GEMINI_MATRIX": matrix("gemini"), "PROTOCOL": protocol, "FEATURED": featured, "GROUPS": "".join(groups), "ALL_PAIRS": all_pairs, "OVERRIDE": ESC(DATA["haiku"]["override"]), "EVIDENCE": embedded}
for key, value in replacements.items():
    page = page.replace(f"@@{key}@@", value)
assert "@@" not in page
output = SITE / "arch/readable-title-evaluation.html"
output.parent.mkdir(exist_ok=True)
output.write_text(page)
print(output, len(page))
