[object Object]

← back to Model Arena

model-arena: continuous themed arena loop (art/culture/news/political cartoons)

0fc42041b980fa409b5419014e9f27f2ffa74d50 · 2026-09-18 14:32:32 -0700 · Steve Abrams

New scripts/arena-loop.js (pm2 model-arena-loop): every cycle creates ONE $0
battle from a 16-prompt themed bank tagged Art/Culture/News/Political Cartoon,
fanning to a host-interleaved rotating window (CAP=6) of discovered local models
so all locals compete over time and each battle spans an ollama + an openai-local
model. Never auto-crowns (human vote stays pure); logs each cycle to
yolo/daily-log.jsonl; polls-to-settle with a cap so a wedged/OOM model times out
and the loop continues (no deadlock). ONESHOT/MODELS_OVERRIDE for verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HTnyDUiR2At9Aj5f6RGRav

Files touched

Diff

commit 0fc42041b980fa409b5419014e9f27f2ffa74d50
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Sep 18 14:32:32 2026 -0700

    model-arena: continuous themed arena loop (art/culture/news/political cartoons)
    
    New scripts/arena-loop.js (pm2 model-arena-loop): every cycle creates ONE $0
    battle from a 16-prompt themed bank tagged Art/Culture/News/Political Cartoon,
    fanning to a host-interleaved rotating window (CAP=6) of discovered local models
    so all locals compete over time and each battle spans an ollama + an openai-local
    model. Never auto-crowns (human vote stays pure); logs each cycle to
    yolo/daily-log.jsonl; polls-to-settle with a cap so a wedged/OOM model times out
    and the loop continues (no deadlock). ONESHOT/MODELS_OVERRIDE for verification.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01HTnyDUiR2At9Aj5f6RGRav
---
 scripts/arena-loop.js | 129 ++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 129 insertions(+)

diff --git a/scripts/arena-loop.js b/scripts/arena-loop.js
new file mode 100644
index 0000000..ad532f0
--- /dev/null
+++ b/scripts/arena-loop.js
@@ -0,0 +1,129 @@
+#!/usr/bin/env node
+// Continuous themed Model Arena loop — always-on generator (pm2: model-arena-loop).
+// Every cycle it creates ONE new $0 battle on a rotating prompt drawn from a themed
+// bank focused on ART, CULTURE, NEWS, and POLITICAL CARTOONS, fanning it out to a
+// rotating window of the DISCOVERED local models (all 3 hosts) so every local model
+// competes over time and each battle spans an ollama + an openai-local model. It never
+// crowns (the human 👑 vote stays pure) — it just accrues AI-refereed battles and logs
+// each cycle to yolo/daily-log.jsonl. A wedged/OOM model times out via the server
+// watchdog and the battle still settles, so the loop never deadlocks.
+//
+// Env: BASE (http://127.0.0.1:9758), AUTH (admin:DW2024!), ARENA_LOOP_CAP (models per
+//      battle, default 6), ARENA_LOOP_GAP_MS (gap after a battle settles, default 300000),
+//      ARENA_LOOP_SETTLE_CAP_MS (max wait for a battle to settle, default 2700000),
+//      ONESHOT=1 (run one cycle then exit — used by the verify step).
+const http = require('http'), fs = require('fs'), path = require('path');
+const BASE = process.env.BASE || 'http://127.0.0.1:9758';
+const AUTH = process.env.AUTH || 'admin:DW2024!';
+const LOG = process.env.DAILY_LOG || path.join(__dirname, '..', 'yolo', 'daily-log.jsonl');
+const STATE = path.join(__dirname, '..', 'yolo', 'arena-loop-state.json');
+const CAP = parseInt(process.env.ARENA_LOOP_CAP || '6', 10);
+const GAP_MS = parseInt(process.env.ARENA_LOOP_GAP_MS || '300000', 10);
+const SETTLE_CAP_MS = parseInt(process.env.ARENA_LOOP_SETTLE_CAP_MS || '2700000', 10);
+
+// themed prompt bank — art / culture / news / political cartoons, tagged so
+// inferCategory + the per-category leaderboards bucket them correctly.
+const POOL = [
+  // ---- ART ----
+  ['gen-art', 'Art', 'Generative Art Canvas', 'An interactive generative-art piece (flow field or particle system) on a canvas, with live controls to reshape palette, density, and motion, plus a snapshot/export. Make it feel like a real artwork, not a demo.'],
+  ['mondrian', 'Art', 'Neoplastic Composition Studio', 'An interactive Mondrian-style neoplastic composition generator: adjustable grid, primary-color palette, line weight, randomize, and a save-as-image action.'],
+  ['kinetic-type', 'Art', 'Kinetic Typography Poster', 'An animated typographic art poster on a strong editorial grid that reacts to the cursor with restrained, tasteful motion and respects prefers-reduced-motion.'],
+  ['fractal', 'Art', 'Fractal Explorer', 'An interactive Mandelbrot/Julia fractal explorer with smooth zoom, palette cycling, a coordinate/iteration readout, and a reset.'],
+  // ---- CULTURE ----
+  ['museum', 'Culture', 'Interactive Museum Explainer', 'An interactive museum-style explainer of an INVENTED art movement: a timeline, artifact cards with detail views, a guided reading order, and accessible keyboard navigation.'],
+  ['culture-map', 'Culture', 'World Culture Map', 'An interactive cultural map of INVENTED festivals across world regions, with region filters, a detail panel per region, and accessible navigation.'],
+  ['languages', 'Culture', 'Endangered Languages Explainer', 'An interactive explainer visualizing an INVENTED endangered-languages dataset with a simple map, a filter, and story cards that expand.'],
+  ['heritage', 'Culture', 'Heritage Site Storyscroll', 'A cinematic, accessible scrollytelling page about an INVENTED heritage site, with chapter navigation, restrained motion, and reduced-motion support.'],
+  // ---- NEWS ----
+  ['election-viz', 'News', 'Election Results Visualization', 'A data-driven news visualization of INVENTED election results: a region map or bar breakdown, a filter, a clear headline takeaway, and a source/methodology note.'],
+  ['econ-brief', 'News', 'Economic Indicators Briefing', 'A newsroom data dashboard of INVENTED economic indicators with charts, KPI cards, a time-range filter, empty/error states, and a source note.'],
+  ['news-ticker', 'News', 'Live News Landing', 'A responsive breaking-news landing with a live-updating ticker, clear story hierarchy, a category filter, and an accessible reading order.'],
+  ['climate-story', 'News', 'Climate Data Story', 'A data-journalism scrollytelling piece charting an INVENTED climate dataset, with annotated charts, a clear narrative arc, and reduced-motion support.'],
+  // ---- POLITICAL CARTOONS ----
+  ['editorial-cartoon', 'Political Cartoon', 'Editorial Cartoon', 'Render an ORIGINAL editorial/political cartoon entirely in canvas or SVG about an INVENTED civic issue, with a caption and a caption show/hide toggle. Keep it satirical but non-partisan and non-defamatory — use invented figures and symbols only.'],
+  ['satire-strip', 'Political Cartoon', 'Three-Panel Satire Strip', 'A three-panel political-satire comic strip drawn in SVG about an INVENTED policy debate, with panel navigation and per-panel captions. Invented characters only; keep it good-natured.'],
+  ['metaphor', 'Political Cartoon', 'Political Metaphor Illustration', 'An SVG editorial illustration built around a single visual metaphor for an INVENTED political theme, with an explanatory caption and a light/dark toggle.'],
+  ['civic-explainer', 'Political Cartoon', 'Civic Explainer Cartoon', 'An illustrated (SVG) civic explainer cartoon that walks panel-by-panel through how an INVENTED civic process works, with navigation and captions.'],
+].map(([id, cat, t, p]) => ({ id, cat, t, p }));
+
+const brief = (pick) => `Build this as one complete, self-contained HTML file: ${pick.p} Use realistic invented content and a strong information hierarchy. Every visible control must work and give clear feedback; include relevant empty, error, and completion states where applicable. Make it cohesive from 390px to 1440px, keyboard accessible with visible focus, and respectful of reduced motion.`;
+
+function req(method, p, body) {
+  return new Promise((resolve, reject) => {
+    const u = new URL(BASE + p);
+    const r = http.request(u, { method, headers: { 'Content-Type': 'application/json', Authorization: 'Basic ' + Buffer.from(AUTH).toString('base64') } }, resp => {
+      let b = ''; resp.on('data', d => b += d); resp.on('end', () => { try { resolve(JSON.parse(b)); } catch { resolve({}); } });
+    });
+    r.on('error', reject); if (body) r.write(JSON.stringify(body)); r.end();
+  });
+}
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+function loadState() { try { return JSON.parse(fs.readFileSync(STATE, 'utf8')); } catch { return { promptIdx: 0, modelOffset: 0 }; } }
+function saveState(s) { try { fs.mkdirSync(path.dirname(STATE), { recursive: true }); fs.writeFileSync(STATE, JSON.stringify(s)); } catch {} }
+
+// all discovered/curated LOCAL models (ollama + openai-local), interleaved by host so a
+// windowed slice always spans multiple hosts. host is used only for grouping.
+async function localModelsInterleaved() {
+  const all = await req('GET', '/api/models');
+  if (!Array.isArray(all)) return [];
+  const avail = all.filter(m => m.available && (m.kind === 'local' || m.kind === 'openai-local'));
+  const byHost = {};
+  for (const m of avail) { const k = m.host || m.id; (byHost[k] = byHost[k] || []).push(m); }
+  const hosts = Object.keys(byHost);
+  const inter = [];
+  for (let i = 0, more = true; more; i++) {
+    more = false;
+    for (const h of hosts) { if (byHost[h][i]) { inter.push(byHost[h][i]); more = true; } }
+  }
+  return inter;
+}
+
+// pick CAP models as a wrapping window over the interleaved roster, advancing the offset
+function pickModels(inter, offset) {
+  if (!inter.length) return [];
+  const n = Math.min(CAP, inter.length);
+  const out = [];
+  for (let i = 0; i < n; i++) out.push(inter[(offset + i) % inter.length].id);
+  return out;
+}
+
+async function oneCycle(state) {
+  const inter = await localModelsInterleaved();
+  const hostCount = new Set(inter.map(m => m.host || m.id)).size;
+  if (inter.length < 2) { console.error('[loop] only ' + inter.length + ' local models available — waiting'); return false; }
+  const pick = POOL[state.promptIdx % POOL.length];
+  const models = process.env.MODELS_OVERRIDE ? process.env.MODELS_OVERRIDE.split(',').map(s => s.trim()).filter(Boolean) : pickModels(inter, state.modelOffset);
+  const title = `Arena · ${pick.cat}: ${pick.t}`;
+  const c = await req('POST', '/api/challenges', { title, prompt: brief(pick), models, category: pick.cat });
+  if (!c || !c.id) { console.error('[loop] create failed', JSON.stringify(c).slice(0, 200)); return false; }
+  console.log('[loop] created', c.id, '—', pick.cat + ':', pick.t, '| models:', models.length, '| hosts:', hostCount);
+  // advance rotation only on a successful create
+  state.promptIdx = (state.promptIdx + 1) % POOL.length;
+  state.modelOffset = (state.modelOffset + CAP) % Math.max(1, inter.length);
+  saveState(state);
+  // poll until settled (or cap) — never deadlock; server watchdog fails wedged runs
+  const t0 = Date.now();
+  let settled = false, cur = null;
+  while (Date.now() - t0 < SETTLE_CAP_MS) {
+    await sleep(8000);
+    cur = await req('GET', '/api/challenges/' + c.id);
+    if (Array.isArray(cur.runs) && cur.runs.length && cur.runs.every(r => r.status === 'done' || r.status === 'error')) { settled = true; break; }
+  }
+  const done = (cur && cur.runs || []).filter(r => r.status === 'done').length;
+  const errored = (cur && cur.runs || []).filter(r => r.status === 'error').length;
+  const entry = { ts: new Date().toISOString(), id: c.id, promptId: pick.id, category: pick.cat, title, models: models.length, done, errored, settled, aiPick: (cur && cur.aiPick) || null, loop: true };
+  try { fs.mkdirSync(path.dirname(LOG), { recursive: true }); fs.appendFileSync(LOG, JSON.stringify(entry) + '\n'); } catch (e) { console.error('[loop] LOG append failed', e); }
+  console.log('[loop] settled', c.id, '— done', done, 'errored', errored, settled ? '' : '(settle cap hit)');
+  return true;
+}
+
+(async () => {
+  const state = loadState();
+  if (process.env.ONESHOT === '1') { const ok = await oneCycle(state); process.exit(ok ? 0 : 1); }
+  console.log('[loop] continuous themed arena loop starting — cap', CAP, 'gap', GAP_MS + 'ms');
+  // eslint-disable-next-line no-constant-condition
+  while (true) {
+    try { const ok = await oneCycle(state); await sleep(ok ? GAP_MS : 60000); }
+    catch (e) { console.error('[loop] cycle error', e && e.message || e); await sleep(60000); }
+  }
+})();

← fa7e697 model-arena: openai-local adapter + dynamic multi-host model  ·  back to Model Arena  ·  model-arena: HOT arena sampling temperature (ARENA_TEMP, def 9f431e6 →