openvibers/openvibe.report Commits

AI boards over Bench: /ai, per-kind Elo, Pareto and cost per solved, per-model pages (#5)

OP OpenVibers committed 16 days ago · d8e2fb8196 · 11 files, +452 −8

* AI boards over Bench: /ai, per-kind Elo, Pareto and cost per solved, per-model pages

* The Bench client (left out of the first push)

Changes

ddiff --git a/.env.example b/.env.example
iindex a6e4598..476cec2 100644
---- a/.env.example
++++ b/.env.example
@@@ -23,6 +23,9 @@ OV_NETWORK_URL=https://openvibe.network
# OpenVibe.Live's Rundown and after-show reports (GET /api/rundown/*, /api/recap/*).
OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000
++# Bench's public read API; set to an internal origin if Report and Bench share one.
++OV_BENCH_INTERNAL_URL=https://bench.openvibe.services
++
# OpenVibe.Billing's read API. Report quotes Billing and never computes money. The service needs the grants
# billing.stats.read (GET /api/v1/stats) and billing.balance.read (GET /api/v1/transactions?subject=…).
OV_BILLING_INTERNAL_URL=http://127.0.0.1:4600
ddiff --git a/README.md b/README.md
iindex 5ca7277..c7446d7 100644
---- a/README.md
++++ b/README.md
@@@ -33,6 +33,7 @@ OpenVibe.Live in both modes.
| **Report's tables** | `report_metric_days`, `report_metric_windows`, `report_streams` | the same routes while `REPORT_SOURCE=report` |
| **OpenVibe.Live** — The Rundown | `GET /api/rundown/`, `/api/rundown/topics`, `/api/rundown/t/:slug`, `/api/rundown/e/:streamId`, `/api/rundown/s/:username` | `/rundown`, `/rundown/t/:slug`, `/rundown/e/:streamId`, `/rundown/s/:username` |
| **OpenVibe.Live** — after-show reports | `GET /api/recap/:streamId`, `GET /api/recap/channel/:username` | `/after-show/:streamId`, `/c/:username` |
++| **OpenVibe.Bench** | `GET /api/v1/kinds`, `/api/v1/boards/:kind?type=elo\|pareto\|cost`, `/api/v1/models/:model` | `/ai`, `/ai/:kind`, `/ai/models/:model` |
| **OpenVibe.Billing** | `GET /api/v1/stats`, `GET /api/v1/transactions?subject=…` | the revenue read for a channel; network money counts |
A source that does not answer is null, after one short log line; the page (or API route) becomes a 404. The service
@@@ -72,9 +73,14 @@ All server-rendered in `server/views/index.js`; every dynamic value goes through
| `/rundown/s/:username` | one streamer's episodes and topics |
| `/after-show/:streamId` | one after-show report |
| `/c/:username` | one channel's latest after-show reports |
++| `/ai` | benchmark kinds grouped by room, with leaders and game counts |
++| `/ai/:kind` | Elo, Pareto quality against cost per solved, and cost per solved tables |
++| `/ai/models/:model` | a model's ratings and entries across kinds |
A bad path parameter (slug, stream id, username) or a source that answered nothing renders `views.notFound()` with
status 404. Pages are `Cache-Control: public, max-age=120`.
++AI pages use a 60-second Bench read cache and serve its last good copy with the read time during an outage. Their
++HTML is `no-store` so the outage notice stays current.
## API
@@@ -135,6 +141,7 @@ Every value comes from the environment (production: `/etc/openvibe/report.env`).
| `OV_NETWORK_INTERNAL_URL` | `http://127.0.0.1:4000` | Network's loopback origin (its manifest's `internalOrigin`) |
| `OV_NETWORK_URL` | `https://openvibe.network` | the OAuth token endpoint's origin |
| `OV_LIVE_INTERNAL_URL` | `http://127.0.0.1:3000` | Live's loopback origin |
++| `OV_BENCH_INTERNAL_URL` | `https://bench.openvibe.services` | Bench's public read origin, optionally an internal origin |
| `OV_BILLING_INTERNAL_URL` | `http://127.0.0.1:4600` | Billing's loopback origin |
| `OV_OAUTH_CLIENT_ID` / `OV_OAUTH_CLIENT_SECRET` | `report` / unset | Report's client-credentials token for `openvibe.billing` |
| `REPORT_BILLING_TOKEN` | unset | an explicit Billing bearer token for an operator run |
ddiff --git a/public/css/app.css b/public/css/app.css
iindex 9e12c74..46677c0 100644
---- a/public/css/app.css
++++ b/public/css/app.css
@@@ -32,6 +32,25 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
.rp-tabs { display: inline-flex; gap: 4px; margin-top: 16px; padding: 4px; border-radius: 999px; background: var(--bg-secondary, #101828); border: 1px solid var(--border, #1f2d47); }
.rp-tabs a { padding: 7px 14px; border-radius: 999px; color: var(--text-secondary, #96a7c2); text-decoration: none; font-size: .86rem; font-weight: 700; }
.rp-tabs a[aria-current] { background: var(--room-fill); color: var(--room-on); }
++.rp-ai-room { margin-top: 28px; }
++.rp-ai-room h2 { margin-bottom: 12px; }
++.rp-ai-room .rp-topic h3 { margin: 0; font-size: 1.05rem; }
++.rp-ai-room .rp-topic p { margin: 0; }
++.rp-ai-room .rp-topic .rp-fine { margin: 0; }
++.rp-ai-scroll { overflow-x: auto; margin: 20px 0; border: 1px solid var(--border, #1f2d47); border-radius: 16px; }
++.rp-ai-table { width: 100%; border-collapse: collapse; min-width: 650px; font-variant-numeric: tabular-nums; }
++.rp-ai-table th, .rp-ai-table td { padding: 11px 14px; text-align: left; vertical-align: top; border-bottom: 1px solid var(--border, #1f2d47); }
++.rp-ai-table thead th { font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted, #7386a3); }
++.rp-ai-table tbody tr:last-child > * { border-bottom: 0; }
++.rp-ai-table a { overflow-wrap: anywhere; }
++.rp-flag { display: inline-block; font-size: .72rem; color: var(--room-strong); margin-left: 5px; }
++.rp-ai-chart { margin: 20px 0; padding: 12px; border: 1px solid var(--border, #1f2d47); border-radius: 16px; background: var(--bg-card, #0f1729); }
++.rp-ai-chart svg { display: block; width: 100%; height: auto; }
++.rp-ai-chart line { stroke: var(--text-muted, #7386a3); stroke-width: 1; }
++.rp-ai-chart text { fill: var(--text-secondary, #96a7c2); font-size: 11px; }
++.rp-ai-chart .rp-ai-frontier { fill: var(--room-accent); stroke: var(--bg-card, #0f1729); stroke-width: 1; }
++.rp-ai-chart .rp-ai-point { fill: var(--text-muted, #7386a3); }
++.rp-ai-chart figcaption { margin-top: 6px; font-size: .8rem; color: var(--text-muted, #7386a3); }
.rp-counts { display: flex; flex-wrap: wrap; gap: 6px 20px; margin: 14px 0 0; color: var(--text-secondary, #96a7c2); }
.rp-counts b { color: var(--text-primary, #e6edf7); font-size: 1.15rem; font-variant-numeric: tabular-nums; }
@@@ -131,3 +150,15 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
.rp-eps dt code { font: 600 .92rem/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--room-strong); overflow-wrap: anywhere; }
.rp-eps dd { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; }
@media (prefers-reduced-motion: reduce) { .rp-topic { transition: none; } }
++
++/* AI boards: rooms side by side, tables that fit a phone, and a chart that scrolls rather than shrinking its labels */
++.rp-ai-rooms { display: grid; gap: 0 24px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 340px), 1fr)); align-items: start; }
++.rp-ai-rooms .rp-topics { grid-template-columns: 1fr; }
++.rp-ai-table a { text-decoration: none; font-weight: 700; }
++.rp-ai-table a:hover, .rp-ai-table a:focus-visible { text-decoration: underline; }
++.rp-ai-chart { overflow-x: auto; }
++@media (max-width: 640px) {
++ .rp-ai-table { min-width: 0; font-size: .86rem; }
++ .rp-ai-table th, .rp-ai-table td { padding: 9px 7px; }
++ .rp-ai-chart svg { min-width: 720px; }
++}
ddiff --git a/server/app.js b/server/app.js
iindex 1a366d9..a5bf188 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -6,6 +6,7 @@
*
* /, /stats, /rundown, /rundown/t/:slug, /rundown/e/:streamId, /rundown/s/:username,
* /after-show/:streamId, /c/:username the pages (http/pages.js)
++ * /ai, /ai/models/:model, /ai/:kind the AI boards, from OpenVibe.Bench (http/pages.js)
* /api/v1/* the JSON API (http/api.js)
* /robots.txt, /sitemap.xml, /llms.txt crawl artifacts (http/discovery.js)
* /api/health, /api/ready, /release.json, /release-metrics the plumbing (loopback scrape for /metrics)
@@@ -26,6 +27,7 @@ const { openStore } = require('./db');
const { createNetworkClient } = require('./data/network');
const { createLiveClient } = require('./data/live');
const { createBillingClient } = require('./data/billing');
++const { createBenchClient } = require('./data/bench');
const { createAnalytics } = require('./analytics');
const { createShadow } = require('./analytics/shadow');
const { createApi } = require('./http/api');
@@@ -51,11 +53,12 @@ async function createApp(opts = {}) {
const network = opts.network || createNetworkClient({ config, fetchImpl, log, now });
const live = opts.live || createLiveClient({ config, fetchImpl, log, now });
const billing = opts.billing || createBillingClient({ config, fetchImpl, log, now });
++ const bench = opts.bench || createBenchClient({ config, fetchImpl, log, now });
const analytics = opts.analytics || createAnalytics({ config, store, network, billing, now, log });
const shadow = opts.shadow || (store ? createShadow({ store, network, now, log }) : null);
views.configure({ baseUrl: config.baseUrl });
-- const ctx = { config, log, fetchImpl, now, store, network, live, billing, analytics, shadow };
++ const ctx = { config, log, fetchImpl, now, store, network, live, billing, bench, analytics, shadow };
const app = express();
app.disable('x-powered-by');
app.set('trust proxy', config.trustProxy);
ddiff --git a/server/config.js b/server/config.js
iindex 60e492e..29c755f 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -7,6 +7,7 @@
* Report reads Network's open stats and Live's Rundown and after-show reports over loopback. It also has its own
* PostgreSQL database (migrations/) for the analytics it ingests, and reads revenue from Billing. REPORT_SOURCE
* chooses which side the pages and GET /api/v1/stats answer from: `network` (today's behaviour) or `report`.
++ * The AI boards under /ai read OpenVibe.Bench's public boards the same way.
*/
require('dotenv').config();
@@@ -44,6 +45,10 @@ function load(env = process.env) {
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
liveUrl: trim(env.OV_LIVE_INTERNAL_URL || 'http://127.0.0.1:3000'),
++ // OpenVibe.Bench's public reads (kinds, boards, models, datasets) — the AI boards under /ai. Public HTML
++ // boards render here; Bench stays the benchmark authority that owns the numbers.
++ benchUrl: trim(env.OV_BENCH_INTERNAL_URL || 'https://bench.openvibe.services'),
++
// Report's own database (ADR-035). DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner
// role); development without DATABASE_URL uses an embedded PGlite database under REPORT_PGLITE_DIR.
db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.REPORT_PGLITE_DIR || '' },
ddiff --git a/server/data/bench.js b/server/data/bench.js
nnew file mode 100644
iindex 0000000..78908ee
---- /dev/null
++++ b/server/data/bench.js
@@@ -0,0 +1,92 @@
++'use strict';
++
++/**
++ * OpenVibe.Bench's public reads (plan 2026-10-28 §4.12):
++ * GET /api/v1/kinds every benchmark kind
++ * GET /api/v1/boards/:kind?type=elo|pareto|cost
++ * GET /api/v1/models/:model one model across every kind
++ *
++ * Reads are public and held for sixty seconds in this process. A read that has answered once keeps its copy
++ * for as long as Bench is down: the client answers the last good one with the moment it was read (`at`) and
++ * `stale: true`, so a board page stays a page — never an error page. A read that has never answered is null
++ * after one short log line. A kind id, board type or model name that cannot exist is refused before any request
++ * is made.
++ */
++const { trim, requestJson, TIMEOUT_MS } = require('./source');
++
++const TTL_MS = 60_000;
++const KIND_RE = /^[a-z0-9][a-z0-9._-]{0,79}$/;
++const MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._:+\/~\-]{0,119}$/;
++const BOARD_TYPES = Object.freeze(['elo', 'pareto', 'cost']);
++
++const isKindId = (value) => typeof value === 'string' && KIND_RE.test(value);
++
++/** A model as Bench pins it: one path segment (its own /models/:model route cannot serve more), 120 characters at most. */
++const isModelName = (value) => typeof value === 'string' && MODEL_RE.test(value);
++
++/** ?tab=elo|pareto|cost; anything else is the Elo board, exactly as the page tabs read it. */
++function normaliseTab(value) {
++ const raw = Array.isArray(value) ? value[0] : value;
++ return BOARD_TYPES.includes(raw) ? raw : 'elo';
++}
++
++/**
++ * opts: config ({ benchUrl }), fetchImpl, log, now, timeoutMs
++ */
++function createBenchClient({ config = {}, fetchImpl = globalThis.fetch, log = console, now = () => Date.now(), timeoutMs = TIMEOUT_MS } = {}) {
++ const baseUrl = trim(config.benchUrl || 'http://127.0.0.1:5230');
++ const clock = typeof now === 'function' ? now : () => Date.now();
++
++ // The last good copy of every read, kept beyond its minute so a Bench outage never empties a page. Kinds and
++ // boards are a fixed small set; models are capped, the oldest inserted out first.
++ const boards = new Map();
++ const models = new Map();
++
++ function keep(store, key, data, cap) {
++ store.set(key, data);
++ while (store.size > cap) store.delete(store.keys().next().value);
++ }
++
++ /** One read: the fresh copy, the last good one (stale) while Bench is down, or null when there is none yet. */
++ async function read(store, key, path, label, cap) {
++ const atMs = clock();
++ const hit = store.get(key);
++ if (hit && hit.atMs + TTL_MS > atMs) return { data: hit.data, at: hit.at, stale: false };
++ if (hit && hit.retryAtMs > atMs) return { data: hit.data, at: hit.at, stale: true };
++ const data = await requestJson(`${baseUrl}${path}`, { fetchImpl, log, timeoutMs, label });
++ if (data === null) {
++ if (hit) hit.retryAtMs = atMs + TTL_MS;
++ return hit ? { data: hit.data, at: hit.at, stale: true } : null;
++ }
++ const entry = { data, atMs, at: new Date(atMs).toISOString() };
++ keep(store, key, entry, cap);
++ return { data, at: entry.at, stale: false };
++ }
++
++ /** Every kind: { count, kinds: [{ id, room, entry, judge, name, metrics, created_at }] }. */
++ function kinds() {
++ return read(boards, 'kinds', '/api/v1/kinds', 'bench kinds', 500);
++ }
++
++ /** One kind's board: { board: { kind_id, category, period, type, rows } }. */
++ async function board(kindId, type = 'elo') {
++ if (!isKindId(kindId) || !BOARD_TYPES.includes(type)) return null;
++ return read(
++ boards,
++ `board:${kindId}:${type}`,
++ `/api/v1/boards/${encodeURIComponent(kindId)}?type=${type}`,
++ `bench board (${kindId}, ${type})`,
++ 500,
++ );
++ }
++
++ /** One model across every kind: { model, ratings, entries, total_cost_usd }. */
++ async function modelSummary(model) {
++ if (!isModelName(model)) return null;
++ return read(models, `model:${model}`, `/api/v1/models/${encodeURIComponent(model)}`, `bench model (${model})`, 200);
++ }
++
++ return { kinds, board, modelSummary, baseUrl, ttlMs: TTL_MS, isKindId, isModelName, normaliseTab };
++}
++
++module.exports = { createBenchClient, TTL_MS, BOARD_TYPES, isKindId, isModelName, normaliseTab };
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex f3ea951..3e28dc2 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -32,7 +32,7 @@ function siteUpdated() {
}
}
--function createDiscoveryRoutes({ config, live }) {
++function createDiscoveryRoutes({ config, live, bench }) {
const r = express.Router();
const site = String(config.baseUrl || 'https://openvibe.report').replace(/\/+$/, '');
const abs = (p) => `${site}${p}`;
@@@ -45,13 +45,18 @@ function createDiscoveryRoutes({ config, live }) {
});
r.get('/sitemap.xml', async (_req, res) => {
-- const data = await live.topics();
++ const [data, ai] = await Promise.all([live.topics(), bench.kinds()]);
const topics = (data && Array.isArray(data.topics)) ? data.topics : [];
++ const kinds = (ai && Array.isArray(ai.data.kinds)) ? ai.data.kinds : [];
const lastmod = siteUpdated();
const pages = [
{ loc: abs('/'), changefreq: 'daily', priority: 1.0 },
{ loc: abs('/stats'), changefreq: 'daily', priority: 0.9 },
{ loc: abs('/rundown'), changefreq: 'daily', priority: 0.9 },
++ { loc: abs('/ai'), changefreq: 'daily', priority: 0.9 },
++ ...kinds.filter((k) => k && bench.isKindId(k.id)).map((k) => ({
++ loc: abs(`/ai/${encodeURIComponent(k.id)}`), changefreq: 'daily', priority: 0.7,
++ })),
...topics.filter((t) => t && typeof t.slug === 'string').map((t) => ({
loc: abs(`/rundown/t/${encodeURIComponent(t.slug)}`),
changefreq: 'weekly',
@@@ -71,6 +76,7 @@ function createDiscoveryRoutes({ config, live }) {
{ title: 'OpenVibe.Report', url: abs('/'), note: 'the Report room' },
{ title: 'Open numbers', url: abs('/stats'), note: 'page views, accounts and traffic for every OpenVibe site' },
{ title: 'The Rundown', url: abs('/rundown'), note: 'what streamers talked about, topic by topic' },
++ { title: 'AI boards', url: abs('/ai'), note: 'Bench ratings, quality and cost across rooms' },
] },
{ title: 'Machine-readable', links: [
{ title: 'Open stats (JSON)', url: abs('/api/v1/stats?days=7'), note: '?days=7, 30 or 90' },
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex d42621e..20c4637 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -21,11 +21,11 @@ const { normaliseDays, isSlug, isStreamId, isUsername, streamIdOf } = require('.
const CACHE = 'public, max-age=120';
--function createPageRoutes({ network, live, analytics = null }) {
++function createPageRoutes({ network, live, bench, analytics = null }) {
const r = express.Router();
-- function send(res, html, status = 200) {
-- res.status(status).set('Cache-Control', CACHE).type('html').send(html);
++ function send(res, html, status = 200, cacheControl = CACHE) {
++ res.status(status).set('Cache-Control', cacheControl).type('html').send(html);
}
function notFound(res) {
return send(res, views.notFound(), 404);
@@@ -50,6 +50,30 @@ function createPageRoutes({ network, live, analytics = null }) {
return send(res, views.stats({ stats, days }));
});
++ r.get('/ai', async (_req, res) => {
++ const kinds = await bench.kinds();
++ const list = kinds && Array.isArray(kinds.data.kinds) ? kinds.data.kinds : [];
++ const boards = await Promise.all(list.map((kind) => bench.board(kind.id, 'elo')));
++ return send(res, views.aiIndex({ kinds, boards }), 200, 'no-store');
++ });
++
++ r.get('/ai/models/:model', async (req, res) => {
++ const model = req.params.model;
++ if (!bench.isModelName(model)) return notFound(res);
++ const summary = await bench.modelSummary(model);
++ return send(res, views.aiModel({ model, summary }), 200, 'no-store');
++ });
++
++ r.get('/ai/:kind', async (req, res) => {
++ const kindId = req.params.kind;
++ if (!bench.isKindId(kindId)) return notFound(res);
++ const tab = bench.normaliseTab(req.query.tab);
++ const [kinds, board, cost] = await Promise.all([bench.kinds(), bench.board(kindId, tab), tab === 'pareto' ? bench.board(kindId, 'cost') : Promise.resolve(null)]);
++ const kind = kinds && Array.isArray(kinds.data.kinds) ? kinds.data.kinds.find((k) => k.id === kindId) : null;
++ if (kinds && !kind) return notFound(res);
++ return send(res, views.aiBoard({ kindId, kind, tab, board, cost, kinds }), 200, 'no-store');
++ });
++
r.get('/rundown', async (_req, res) => {
const [rundown, topics] = await Promise.all([live.rundown(), live.topics()]);
if (!rundown || !topics) return notFound(res);
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 0750a28..1d7fee0 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -18,6 +18,7 @@ const { asset } = require('../assets');
const NAME = 'OpenVibe.Report';
const NETWORK = 'https://openvibe.network';
++const BENCH = 'https://bench.openvibe.services';
const ROOM = rooms.get('report');
const esc = kit.esc;
let RELEASE = 'dev';
@@@ -38,9 +39,40 @@ const pct = (v) => { const x = n(v); if (x == null) return ''; return `<span cla
const who = (u) => (u ? u.display_name || u.username || '' : '');
const AI = '<span class="rp-ai" title="Written by AI from the stream\'s own speech, chat and numbers">Written by AI</span>';
++// ── The AI boards (OpenVibe.Bench, plan 2026-10-28 §4.12): every number is Bench's; Report renders it ──
++/** Money as Bench quotes it (Report never derives a price): dollars, cents, or fractions of a cent. */
++const usd = (v) => {
++ const x = n(v);
++ if (x == null) return '—';
++ if (!x) return '$0';
++ const a = Math.abs(x);
++ if (a >= 1000) return `$${x.toLocaleString('en-US', { maximumFractionDigits: 0 })}`;
++ if (a >= 1) return `$${x.toFixed(2)}`;
++ return `$${Number(x.toPrecision(2))}`;
++};
++/** A share of games won or tasks passed: 0.8333 → 83%. */
++const share = (v) => { const x = n(v); return x == null ? '—' : `${Math.round(x * 100)}%`; };
++/** The rating with its 95% interval as text — "1043 ± 38" — the exact bounds in the title. */
++function rating(r) {
++ const elo = n(r && r.elo);
++ if (elo == null) return '—';
++ const lo = n(r && r.ci_lower), hi = n(r && r.ci_upper);
++ if (lo == null || hi == null) return `${Math.round(elo)}`;
++ const half = Math.round(Math.max(Math.abs(hi - elo), Math.abs(elo - lo)));
++ return `<span title="95% interval ${Math.round(lo)}–${Math.round(hi)} Elo">${Math.round(elo)} ± ${half}</span>`;
++}
++/** Provisional: Bench marks it on the boards; a summary's games say the same thing (fewer than ten). */
++const provisional = (r) => Boolean(r && (r.provisional != null ? r.provisional : n(r.games) != null && Number(r.games) < 10));
++const flag = (r) => (provisional(r) ? ' <span class="rp-flag" title="Fewer than ten games: this rating can still move a lot">provisional</span>' : '');
++const roomName = (id) => { const s = String(id || ''); return s ? s.charAt(0).toUpperCase() + s.slice(1) : ''; };
++const judgeWord = (j) => ({ human_pairwise: 'voted on blind, two at a time', rubric: 'scored on a rubric', automatic_grader: 'graded automatically', test_suite: 'judged by a test suite', game_outcome: 'judged by the game outcome' }[j] || j || '—');
++/** A kind's game count: every model's games added and halved, because two models play each game. */
++const gamesOf = (rows) => Math.round((rows || []).reduce((sum, r) => sum + (n(r && r.games) || 0), 0) / 2);
++
const NAV = [
{ label: 'Numbers', href: '/stats', icon: 'fa-chart-line' },
{ label: 'The Rundown', href: '/rundown', icon: 'fa-newspaper' },
++ { label: 'AI boards', href: '/ai', icon: 'fa-robot' },
{ label: 'API', href: '/developers', icon: 'fa-code', more: true },
];
@@@ -50,7 +82,7 @@ function layout({ title, description, canonical = null, path = null, robots = 'i
service: 'report',
apiBase: NETWORK,
links: NAV.map((l) => ({ ...l, active: l.href === active || (l.href !== '/' && active.startsWith(l.href)) })),
-- appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Numbers', href: '/stats', icon: 'fa-chart-line' }, { label: 'Rundown', href: '/rundown', icon: 'fa-newspaper' }],
++ appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Numbers', href: '/stats', icon: 'fa-chart-line' }, { label: 'Rundown', href: '/rundown', icon: 'fa-newspaper' }, { label: 'AI boards', href: '/ai', icon: 'fa-robot' }],
history: { type: 'page', title: title || NAME },
};
const footer = { service: 'report', variant: 'full', mount: '#ov-footer', brandName: NAME };
@@@ -176,6 +208,7 @@ function home({ stats, rundown, topics } = {}) {
explore: { title: 'More on Report', items: [
{ title: 'Every site\'s numbers', text: 'Page views, people, streams and how fast each service answers', href: '/stats', icon: 'ov:gauge' },
{ title: 'The Rundown', text: 'What streamers talked about, topic by topic', href: '/rundown', icon: 'ov:news' },
++ { title: 'AI boards', text: 'Every benchmark kind, its leader, quality against cost and cost per solved task', href: '/ai', icon: 'ov:ai' },
{ title: 'Status', text: 'Whether every OpenVibe service is up right now', href: 'https://openvibe.network/status', icon: 'ov:ping' },
] },
dev: { title: 'For developers', lede: 'Every number here is open JSON, readable from any site.', samples: [
@@@ -310,8 +343,92 @@ function developers() {
return layout({ title: 'The Report API', description: 'Open JSON for the network\'s numbers, The Rundown and after-show reports.', body, active: '/developers', path: '/developers' });
}
++function benchState(...reads) {
++ const stale = reads.filter((r) => r && r.stale);
++ if (!stale.length) return '';
++ const oldest = stale.map((r) => r.at).sort()[0];
++ return `<p class="rp-fine" role="status">Bench is unavailable. Showing the last good copy, read ${esc(dayTime(oldest))}.</p>`;
++}
++
++const aiPath = (kind) => `/ai/${encodeURIComponent(kind)}`;
++const modelPath = (model) => `/ai/models/${encodeURIComponent(model)}`;
++const modelLink = (subject) => `<a href="${modelPath(subject)}">${esc(subject)}</a>`;
++const datasetLink = (kind) => `<p class="rp-fine">Download the <a href="${BENCH}/api/v1/datasets/${encodeURIComponent(kind)}.jsonl">dataset JSONL</a> from Bench.</p>`;
++const emptyBoard = () => '<p class="rp-fine">No results yet for this board.</p>';
++
++function aiIndex({ kinds, boards = [] }) {
++ const list = kinds && Array.isArray(kinds.data.kinds) ? kinds.data.kinds : [];
++ const groups = new Map();
++ list.forEach((kind, i) => {
++ const room = String(kind.room || 'Other');
++ if (!groups.has(room)) groups.set(room, []);
++ const rows = boards[i] && boards[i].data.board && boards[i].data.board.rows || [];
++ groups.get(room).push({ kind, rows, available: Boolean(boards[i]) });
++ });
++ const sections = [...groups].sort(([a], [b]) => a.localeCompare(b)).map(([room, items]) => `<section class="rp-ai-room" aria-label="${esc(room)}"><h2>${esc(roomName(room))}</h2><ul class="rp-topics">${items.map(({ kind, rows, available }) => {
++ const leader = rows[0];
++ const href = aiPath(kind.id);
++ return `<li class="rp-topic"><h3><a href="${href}">${esc(kind.name || kind.id)}</a></h3><p class="rp-fine">${esc(kind.id)} · ${esc(judgeWord(kind.judge))}</p><p>${available ? `Leader: ${leader ? modelLink(leader.subject) : 'No leader yet'} · ${exact(gamesOf(rows))} games` : 'Board temporarily unavailable'}</p><p class="rp-meta"><a href="${href}">Elo</a> · <a href="${href}?tab=pareto">Pareto</a> · <a href="${href}?tab=cost">Cost per solved</a></p></li>`;
++ }).join('')}</ul></section>`).join('');
++ const body = `<header class="rp-page-h"><p class="rp-kicker">Benchmarks</p><h1>AI boards</h1><p class="rp-lede">Every benchmark kind across OpenVibe rooms, with ratings, quality and cost.</p></header>${benchState(kinds, ...boards)}${sections ? `<div class="rp-ai-rooms">${sections}</div>` : '' }${sections ? '' : '<p class="rp-fine">Bench results are temporarily unavailable.</p>'}`;
++ return layout({ title: 'AI boards', description: 'AI benchmark leaders and boards across OpenVibe rooms.', body, active: '/ai', path: '/ai' });
++}
++
++function aiTable(headers, rows) {
++ return `<div class="rp-ai-scroll"><table class="rp-ai-table"><thead><tr>${headers.map((h) => `<th scope="col">${esc(h)}</th>`).join('')}</tr></thead><tbody>${rows.join('')}</tbody></table></div>`;
++}
++
++function paretoRows(pareto, cost) {
++ const costs = new Map((cost && cost.data.board && cost.data.board.rows || []).map((r) => [r.subject, n(r.cost_per_solved)]));
++ const rows = (pareto && pareto.data.board && pareto.data.board.rows || []).map((r) => ({ ...r, quality: n(r.quality), costPerSolved: costs.get(r.subject) }));
++ return rows.map((r) => ({ ...r, frontier: r.quality != null && r.costPerSolved != null && !rows.some((other) => other !== r && other.quality != null && other.costPerSolved != null && other.quality >= r.quality && other.costPerSolved <= r.costPerSolved && (other.quality > r.quality || other.costPerSolved < r.costPerSolved)) }));
++}
++
++function paretoChart(rows) {
++ const points = rows.filter((r) => r.quality != null && r.costPerSolved != null && r.costPerSolved >= 0);
++ if (!points.length) return '<p class="rp-fine">No quality and cost per solved pairs yet.</p>';
++ const maxCost = Math.max(0.001, ...points.map((r) => r.costPerSolved));
++ const x = (v) => 70 + 650 * v / maxCost;
++ const y = (v) => 285 - 235 * Math.max(0, Math.min(1, v));
++ const marks = points.map((r) => {
++ const px = x(r.costPerSolved).toFixed(1), py = y(r.quality).toFixed(1);
++ return `<g><circle cx="${px}" cy="${py}" r="${r.frontier ? 5 : 3}" class="${r.frontier ? 'rp-ai-frontier' : 'rp-ai-point'}"><title>${esc(r.subject)}: ${share(r.quality)} quality, ${usd(r.costPerSolved)} per solved task${r.frontier ? ', frontier' : ''}</title></circle>${r.frontier ? `<text x="${px}" y="${(Number(py) - 9).toFixed(1)}" text-anchor="middle">${esc(r.subject)}</text>` : ''}</g>`;
++ }).join('');
++ return `<figure class="rp-ai-chart"><svg viewBox="0 0 800 340" role="img" aria-labelledby="rp-ai-chart-title rp-ai-chart-desc"><title id="rp-ai-chart-title">Quality against cost per solved task</title><desc id="rp-ai-chart-desc">Each point is a model. Labelled points form the best quality and cost frontier. The table below gives exact values.</desc><line x1="70" y1="285" x2="720" y2="285"/><line x1="70" y1="50" x2="70" y2="285"/><text x="70" y="307" text-anchor="middle">$0</text><text x="720" y="307" text-anchor="end">${esc(usd(maxCost))}</text><text x="45" y="285" text-anchor="end">0%</text><text x="45" y="55" text-anchor="end">100%</text><text x="395" y="332" text-anchor="middle">Cost per solved task (USD, lower is better)</text><text x="16" y="170" transform="rotate(-90 16 170)" text-anchor="middle">Quality (higher is better)</text>${marks}</svg><figcaption>Labelled points are on the quality and cost frontier.</figcaption></figure>`;
++}
++
++function aiBoard({ kindId, kind, tab, board, cost, kinds }) {
++ const name = kind && kind.name || kindId;
++ const rows = board && board.data.board && board.data.board.rows || [];
++ const tabs = [['elo', 'Elo'], ['pareto', 'Pareto'], ['cost', 'Cost per solved']].map(([id, label]) => `<a href="${aiPath(kindId)}${id === 'elo' ? '' : `?tab=${id}`}"${tab === id ? ' aria-current="page"' : ''}>${label}</a>`).join('');
++ let content;
++ if (tab === 'elo') content = rows.length ? aiTable(['Rank', 'Model', 'Rating (95% interval)', 'Games', 'Win rate'], rows.map((r, i) => `<tr><td>${i + 1}</td><th scope="row">${modelLink(r.subject)}${flag(r)}</th><td>${rating(r)}</td><td>${exact(r.games)}</td><td>${share(r.win_rate)}</td></tr>`)) : emptyBoard();
++ if (tab === 'pareto') {
++ const points = paretoRows(board, cost);
++ content = `${paretoChart(points)}${points.length ? aiTable(['Model', 'Quality', 'Cost per solved', 'Frontier'], points.map((r) => `<tr><th scope="row">${modelLink(r.subject)}</th><td>${share(r.quality)}</td><td>${usd(r.costPerSolved)}</td><td>${r.frontier ? 'Frontier' : '—'}</td></tr>`)) : ''}`;
++ }
++ if (tab === 'cost') content = rows.length ? aiTable(['Rank', 'Model', 'Solved', 'Entries', 'Total cost', 'Cost per solved'], rows.map((r, i) => `<tr><td>${i + 1}</td><th scope="row">${modelLink(r.subject)}</th><td>${exact(r.solved)}</td><td>${exact(r.entries)}</td><td>${usd(r.total_cost_usd)}</td><td>${usd(r.cost_per_solved)}</td></tr>`)) : emptyBoard();
++ if (!board) content = '<p class="rp-fine">Bench results are temporarily unavailable.</p>';
++ const body = `<header class="rp-page-h"><p class="rp-kicker"><a href="/ai">AI boards</a>${kind ? ` · ${esc(roomName(kind.room))}` : ''}</p><h1>${esc(name)}</h1><p class="rp-lede">${kind ? esc(judgeWord(kind.judge).replace(/^./, (c) => c.toUpperCase())) : 'Bench results'}.</p><nav class="rp-tabs" aria-label="Board view">${tabs}</nav></header>${benchState(kinds, board, cost)}<section aria-label="${esc(tab)} board">${content}</section>${datasetLink(kindId)}`;
++ return layout({ title: `${name}: AI board`, description: `${name} AI benchmark ratings, quality and cost.`, body, active: '/ai', path: aiPath(kindId) });
++}
++
++function aiModel({ model, summary }) {
++ const data = summary && summary.data || null;
++ const ratings = data && Array.isArray(data.ratings) ? data.ratings : [];
++ const entries = data && Array.isArray(data.entries) ? data.entries : [];
++ const kinds = [...new Set([...ratings.map((r) => r.kind_id), ...entries.map((e) => e.kind_id)])].filter(Boolean);
++ const rows = kinds.map((kind) => {
++ const r = ratings.find((item) => item.kind_id === kind);
++ const count = entries.filter((item) => item.kind_id === kind).length;
++ return `<tr><th scope="row"><a href="${aiPath(kind)}">${esc(kind)}</a></th><td>${r ? rating(r) : '—'}${r ? flag(r) : ''}</td><td>${r ? exact(r.games) : '—'}</td><td>${r ? share(r.win_rate) : '—'}</td><td>${count}</td></tr>`;
++ });
++ const body = `<header class="rp-page-h"><p class="rp-kicker"><a href="/ai">AI boards</a> · Model</p><h1>${esc(model)}</h1><p class="rp-lede">Performance across every kind this model has played.</p></header>${benchState(summary)}${data ? `<p class="rp-meta">Total cost: ${usd(data.total_cost_usd)} · ${kinds.length} kinds</p>${rows.length ? aiTable(['Kind', 'Rating (95% interval)', 'Games', 'Win rate', 'Recent entries'], rows) : emptyBoard()}` : '<p class="rp-fine">Bench results are temporarily unavailable.</p>'}`;
++ return layout({ title: `${model}: AI model`, description: `${model} across OpenVibe benchmark kinds.`, body, active: '/ai', path: modelPath(model) });
++}
++
function notFound() {
return layout({ title: 'Not found', robots: 'noindex, follow', body: `<header class="rp-page-h"><h1>Nothing here</h1><p class="rp-lede">That page does not exist, or its source has nothing for it. <a href="/">Back to Report</a>.</p></header>` });
}
--module.exports = { home, stats, rundown, topic, episode, speaker, recap, channel, developers, notFound, layout, setRelease, configure };
++module.exports = { home, stats, rundown, topic, episode, speaker, recap, channel, developers, aiIndex, aiBoard, aiModel, paretoRows, paretoChart, notFound, layout, setRelease, configure };
ddiff --git a/test/ai-pages.test.js b/test/ai-pages.test.js
nnew file mode 100644
iindex 0000000..3ba1ecc
---- /dev/null
++++ b/test/ai-pages.test.js
@@@ -0,0 +1,105 @@
++'use strict';
++
++const assert = require('assert');
++const { boot, check, done, stubFetch, jsonResponse } = require('./helpers/boot');
++
++(async () => {
++ let time = Date.parse('2026-10-28T12:00:00Z');
++ let down = false;
++ const source = stubFetch();
++ const calls = [];
++ const fetchImpl = async (url, opts) => {
++ const u = new URL(String(url));
++ if (u.port !== '5230') return source(url, opts);
++ calls.push(u.pathname + u.search);
++ if (down) throw new Error('Bench offline');
++ if (u.pathname === '/api/v1/kinds') return jsonResponse({ count: 1, kinds: [{ id: 'design.page', room: 'design', name: 'Page <design>', judge: 'human_pairwise' }] });
++ if (u.pathname === '/api/v1/boards/design.page') {
++ const type = u.searchParams.get('type');
++ const rows = {
++ elo: [
++ { subject: 'openai/gpt-4o', elo: 1043, ci_lower: 1005, ci_upper: 1081, games: 8, win_rate: 0.75, provisional: true },
++ { subject: 'model-b', elo: 980, ci_lower: 940, ci_upper: 1020, games: 8, win_rate: 0.25, provisional: true },
++ ],
++ pareto: [
++ { subject: 'openai/gpt-4o', quality: 0.8 },
++ { subject: 'model-b', quality: 0.6 },
++ ],
++ cost: [
++ { subject: 'model-b', entries: 8, solved: 6, total_cost_usd: 0.3, cost_per_solved: 0.05 },
++ { subject: 'openai/gpt-4o', entries: 8, solved: 8, total_cost_usd: 0.8, cost_per_solved: 0.1 },
++ ],
++ }[type];
++ return rows ? jsonResponse({ board: { kind_id: 'design.page', type, rows } }) : jsonResponse({}, 404);
++ }
++ if (u.pathname === '/api/v1/models/openai%2Fgpt-4o') return jsonResponse({ model: 'openai/gpt-4o', ratings: [{ kind_id: 'design.page', elo: 1043, ci_lower: 1005, ci_upper: 1081, games: 8, win_rate: 0.75 }], entries: [{ kind_id: 'design.page' }], total_cost_usd: 0.8 });
++ return jsonResponse({}, 404);
++ };
++ const t = await boot({ fetchImpl, now: () => time, env: { OV_BENCH_INTERNAL_URL: 'http://127.0.0.1:5230' } });
++ try {
++ await check('/ai groups kinds and links to all boards', async () => {
++ const r = await t.get('/ai');
++ assert.strictEqual(r.status, 200);
++ for (const s of ['Design', 'Page &lt;design&gt;', 'openai/gpt-4o', '8 games', '/ai/design.page?tab=pareto', '/ai/design.page?tab=cost']) assert.ok(r.text.includes(s), s);
++ assert.ok(!r.text.includes('Page <design>'));
++ });
++
++ await check('Elo shows ranks, intervals, win rates and provisional state', async () => {
++ const r = await t.get('/ai/design.page');
++ assert.strictEqual(r.status, 200);
++ for (const s of ['1043 ± 38', '75%', 'provisional', 'Rank', '95% interval', 'dataset JSONL']) assert.ok(r.text.includes(s), s);
++ });
++
++ await check('Pareto has an inline SVG, frontier labels and an exact-value table', async () => {
++ const r = await t.get('/ai/design.page?tab=pareto');
++ assert.strictEqual(r.status, 200);
++ for (const s of ['<svg', 'Quality against cost per solved task', 'Cost per solved task (USD', 'Frontier', 'model-b', 'dataset JSONL', '<table']) assert.ok(r.text.includes(s), s);
++ });
++
++ await check('cost and model views quote Bench numbers', async () => {
++ const cost = await t.get('/ai/design.page?tab=cost');
++ assert.strictEqual(cost.status, 200);
++ assert.ok(cost.text.includes('Cost per solved'));
++ assert.ok(cost.text.includes('$0.05'));
++ const model = await t.get('/ai/models/openai%2Fgpt-4o');
++ assert.strictEqual(model.status, 200);
++ assert.ok(model.text.includes('design.page'));
++ assert.ok(model.text.includes('1043 ± 38'));
++ });
++
++ await check('invalid kinds and models do not call Bench', async () => {
++ const before = calls.length;
++ assert.strictEqual((await t.get('/ai/bad!kind')).status, 404);
++ assert.strictEqual((await t.get('/ai/models/bad!model')).status, 404);
++ assert.strictEqual(calls.length, before);
++ });
++
++ await check('sitemap includes AI index and every kind', async () => {
++ const r = await t.get('/sitemap.xml');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('<loc>https://openvibe.report/ai</loc>'));
++ assert.ok(r.text.includes('<loc>https://openvibe.report/ai/design.page</loc>'));
++ });
++
++ await check('a Bench outage serves the last good pages with the read time', async () => {
++ down = true;
++ time += 60_001;
++ for (const path of ['/ai', '/ai/design.page', '/ai/design.page?tab=pareto', '/ai/models/openai%2Fgpt-4o']) {
++ const r = await t.get(path);
++ assert.strictEqual(r.status, 200, path);
++ assert.ok(r.text.includes('Showing the last good copy, read Oct 28, 12:00 UTC'), path);
++ }
++ });
++ } finally { await t.close(); }
++
++ await check('an initial Bench outage still renders an AI page', async () => {
++ const empty = await boot({ fetchImpl: async (url, opts) => String(url).includes(':5230') ? Promise.reject(new Error('offline')) : source(url, opts), env: { OV_BENCH_INTERNAL_URL: 'http://127.0.0.1:5230' } });
++ try {
++ const r = await empty.get('/ai');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('temporarily unavailable'));
++ } finally { await empty.close(); }
++ });
++
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/bench-client.test.js b/test/bench-client.test.js
nnew file mode 100644
iindex 0000000..63a9b2d
---- /dev/null
++++ b/test/bench-client.test.js
@@@ -0,0 +1,51 @@
++'use strict';
++
++const assert = require('assert');
++const { createBenchClient } = require('../server/data/bench');
++const { check, done, jsonResponse } = require('./helpers/boot');
++
++(async () => {
++ let time = Date.parse('2026-10-28T12:00:00Z');
++ let down = false;
++ let calls = 0;
++ const fetchImpl = async () => {
++ calls++;
++ if (down) throw new Error('Bench offline');
++ return jsonResponse({ count: 1, kinds: [{ id: 'design.page', room: 'design' }] });
++ };
++ const logs = [];
++ const bench = createBenchClient({ config: { benchUrl: 'http://bench.test' }, fetchImpl, now: () => time, log: { warn: (line) => logs.push(line) } });
++
++ await check('Bench reads stay fresh for 60 seconds and keep their source time', async () => {
++ const first = await bench.kinds();
++ time += 59_999;
++ const second = await bench.kinds();
++ assert.deepStrictEqual(second, first);
++ assert.strictEqual(calls, 1);
++ assert.strictEqual(first.at, '2026-10-28T12:00:00.000Z');
++ });
++
++ await check('a failed refresh serves the last good copy with its time', async () => {
++ down = true;
++ time++;
++ const stale = await bench.kinds();
++ assert.strictEqual(stale.stale, true);
++ assert.strictEqual(stale.at, '2026-10-28T12:00:00.000Z');
++ assert.strictEqual(stale.data.kinds[0].id, 'design.page');
++ assert.strictEqual(calls, 2);
++ assert.strictEqual(logs.length, 1);
++ time += 30_000;
++ assert.strictEqual((await bench.kinds()).stale, true);
++ assert.strictEqual(calls, 2, 'the failed refresh also has a 60-second retry pause');
++ });
++
++ await check('invalid parameters never reach Bench', async () => {
++ assert.strictEqual(await bench.board('../bad'), null);
++ assert.strictEqual(await bench.board('design.page', 'wrong'), null);
++ assert.strictEqual(await bench.modelSummary('bad?name'), null);
++ assert.strictEqual(calls, 2);
++ assert.strictEqual(bench.isModelName('openai/gpt-4o'), true);
++ });
++
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });