yolo idea D: head-to-head record matrix — pairwise W-L (row vs col) from crowned battles, color-coded, under the leaderboard
Files touched
M public/index.htmlM server.jsM yolo/IDEAS.md
Diff
commit 6fea94d0611bc494784572f14f31cb6030f1ba1c
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Jul 23 00:27:55 2026 -0700
yolo idea D: head-to-head record matrix — pairwise W-L (row vs col) from crowned battles, color-coded, under the leaderboard
---
public/index.html | 24 ++++++++++++++++++++++++
server.js | 5 ++++-
yolo/IDEAS.md | 1 +
3 files changed, 29 insertions(+), 1 deletion(-)
diff --git a/public/index.html b/public/index.html
index 36e280a..d831ae1 100644
--- a/public/index.html
+++ b/public/index.html
@@ -190,6 +190,9 @@ tr.clk{cursor:pointer}tr.clk:hover td{background:rgba(0,229,255,.06)}
<thead><tr><th>#</th><th>Model</th><th>Kind</th><th>ELO</th><th>Battles</th><th>Wins</th><th>Win %</th><th>🤖 avg</th><th>$/win</th><th>Errors</th><th>Avg time</th><th>Spend</th></tr></thead>
<tbody id="b-rows"></tbody>
</table>
+ <h2 style="margin-top:26px">Head-to-Head</h2>
+ <div class="note">Each cell = row model's win–loss record vs the column model, from crowned battles they both rendered in.</div>
+ <div style="overflow:auto"><table id="h2h-tbl"></table></div>
</section>
</main>
<script>
@@ -427,12 +430,14 @@ async function loadBoard(){
const tr = document.createElement('tr');
tr.className='clk'+(i===0 && s.wins>0?' first':'');
tr.onclick=()=>openProfile(s.id);
+ // (h2h rendered after the loop)
tr.innerHTML = `<td>${i+1}</td><td>${s.label}</td><td>${s.kind}</td><td><b>${s.elo}</b></td><td>${s.battles}</td><td>${s.wins}</td>
<td>${s.winRate==null?'—':s.winRate+'%'}</td><td>${s.avgAiScore==null?'—':s.avgAiScore}</td>
<td>${s.costPerWin==null?'—':(s.costPerWin?'$'+s.costPerWin.toFixed(3):'$0')}</td>
<td>${s.errors}</td><td>${s.avgSeconds==null?'—':s.avgSeconds+'s'}</td><td>${s.spend?'$'+s.spend.toFixed(2):'$0'}</td>`;
tb.appendChild(tr);
});
+ renderH2H(d);
}
// ---- model profile ----
@@ -456,6 +461,25 @@ async function openProfile(mid){
g.appendChild(card);
}
}
+function renderH2H(d){
+ const tbl=$('#h2h-tbl'); if(!tbl) return;
+ // only models that fought at least one crowned battle
+ const active=d.models.filter(s=>s.battles>0);
+ if (active.length<2){ tbl.innerHTML='<tr><td class="note" style="border:0">Not enough crowned battles yet.</td></tr>'; return; }
+ const h=d.h2h||{};
+ let html='<thead><tr><th>vs →</th>'+active.map(s=>`<th title="${s.label}">${s.label.split(' ')[0]}</th>`).join('')+'</tr></thead><tbody>';
+ for (const r of active){
+ html+=`<tr><th style="text-align:left">${r.label}</th>`;
+ for (const c of active){
+ if (r.id===c.id){ html+='<td style="color:#3a3f5c">—</td>'; continue; }
+ const rec=(h[r.id]&&h[r.id][c.id])||{w:0,l:0};
+ const cls = rec.w>rec.l?'color:#2eff8f':(rec.l>rec.w?'color:#ff5d5d':'color:#7d84ad');
+ html+=`<td style="${cls}">${rec.w}–${rec.l}</td>`;
+ }
+ html+='</tr>';
+ }
+ tbl.innerHTML=html+'</tbody>';
+}
loadModels().then(loadList);
</script>
diff --git a/server.js b/server.js
index e6a76e0..5fca6cf 100644
--- a/server.js
+++ b/server.js
@@ -334,6 +334,8 @@ function buildLedger(categoryFilter) {
const stats = {};
for (const m of MODELS) stats[m.id] = { id: m.id, label: m.label, kind: m.kind, battles: 0, wins: 0, errors: 0, totalSeconds: 0, doneRuns: 0, spend: 0, elo: 1000, aiScoreSum: 0, aiScoreN: 0 };
let aiAgree = 0, aiJudged = 0;
+ const h2h = {}; // h2h[winner][loser] = {w,l} from the winner's perspective (pairwise)
+ const bump = (a, b, win) => { (h2h[a] = h2h[a] || {}); (h2h[a][b] = h2h[a][b] || { w: 0, l: 0 }); h2h[a][b][win ? 'w' : 'l']++; };
const pool = categoryFilter ? challenges.filter(c => (c.category || 'Custom') === categoryFilter) : challenges;
// chronological pass (array is creation order) so ELO updates in battle order
for (const c of pool) {
@@ -350,6 +352,7 @@ function buildLedger(categoryFilter) {
const w = stats[c.winner], lo = stats[l.model];
const ew = 1 / (1 + Math.pow(10, (lo.elo - w.elo) / 400));
w.elo += 24 * (1 - ew); lo.elo += 24 * (0 - (1 - ew));
+ bump(c.winner, l.model, true); bump(l.model, c.winner, false);
}
}
// AI-vs-human agreement on judged+crowned battles
@@ -364,7 +367,7 @@ function buildLedger(categoryFilter) {
costPerWin: s.wins ? +(s.spend / s.wins).toFixed(4) : null,
spend: +s.spend.toFixed(4),
})).sort((a, b) => b.elo - a.elo || (b.winRate || 0) - (a.winRate || 0) || b.wins - a.wins);
- return { models, aiAgreePct: aiJudged ? Math.round(100 * aiAgree / aiJudged) : null, aiJudged };
+ return { models, aiAgreePct: aiJudged ? Math.round(100 * aiAgree / aiJudged) : null, aiJudged, h2h };
}
// ---------- http ----------
diff --git a/yolo/IDEAS.md b/yolo/IDEAS.md
index d95247f..229d829 100644
--- a/yolo/IDEAS.md
+++ b/yolo/IDEAS.md
@@ -35,3 +35,4 @@ Answering the /contrarian critique ("one builder vote = theater", "toy demos not
- [DONE] #A Per-category leaderboards — challenges auto-tagged Games/Real Work/Custom (inferCategory + backfill of existing); ledger accepts ?category, leaderboard has a category filter. Answers 'which model is best at REAL WORK vs games'. Games: Grok #1, 100% AI/human agreement.
- [DONE] #B Export/share — GET /export/:id returns a self-contained standalone HTML of a battle (all artifacts inlined via sandboxed iframe srcdoc, scoreboard, winner/AI-pick), downloadable; verified it renders offline from file://. '⬇ export' button on each battle.
- [DONE] #C Model profile page — click any leaderboard row → that model's full body of work (every artifact thumbnail across battles, won/AI-pick badges, per-battle stats) + aggregate stats. /api/models/:id/profile.
+- [DONE] #D Head-to-head matrix — pairwise win-loss record (row vs column) from crowned battles, green/red-coded, below the leaderboard. Grok dominant (3-0 vs Kimi/Hermes/Gemma).