openvibers/openvibe.report Commits

The AI model comparison moves to OpenVibe.Compare: /ai pages answer 301 (#215)

OP OpenVibers committed 3 h ago · b70fb65d91 · 13 files, +92 −1424

The owner, looking at openvibe.report/ai on 2026-11-13: "This kinda shit is supposed to be a part of openvibe.compare".
Compare #23 carries the pages (hub, leaderboard, providers, models, pairs, method, feed, boards). Report keeps the
ledgers and the JSON API Compare reads, the Arena's rounds and votes (http/arena.js) until Compare signs people in,
and the staff page /ai/exchange.

- One route: /ai and every page under it 301 to https://openvibe.compare with the same path and query.
- Gone: the page functions, views/ai-comparison.js, its CSS, the six AI views, the /ai Markdown twins, the AI tab in
  the bar and the app bar, the /ai sitemap entries and the RSS alternate. llms.txt and the home card name Compare.
- Checked by ai-moved (the 301s; the Arena and the staff page stay; no tab; no sitemap entry; the API stays). The
  catalogue and market joins are checked through the JSON API; the page-only checks went with the pages.

Changes

ddiff --git a/public/css/ai-comparison.css b/public/css/ai-comparison.css
ddeleted file mode 100644
iindex 475279b..0000000
---- a/public/css/ai-comparison.css
++++ /dev/null
@@@ -1,111 +0,0 @@
--/* Report's AI comparison pages. Report's own tokens only (--bg-card, --border, --text-*, --accent, --room-*); the kit
-- draws tables (stacked into cards under 600 px), stats, sections, folds and the search pill. */
--.ai-page{display:grid;gap:clamp(20px,3vw,32px);padding-block:clamp(12px,2vw,24px) 48px}
--.ai-page>*{min-width:0}
--
--/* Headers */
--.ai-head{display:grid;gap:14px;padding:clamp(18px,3.4vw,36px) 0 4px;position:relative}
--.ai-head::before{content:"";position:absolute;inset:-24px -16px auto;height:220px;z-index:-1;pointer-events:none;
-- background:radial-gradient(60% 100% at 18% 0,color-mix(in srgb,var(--room-accent,var(--accent)) 16%,transparent),transparent 70%)}
--.ai-head h1{margin:0;font-size:clamp(1.85rem,4.6vw,3.1rem);line-height:1.06;letter-spacing:-.02em;max-width:20ch;text-wrap:balance}
--.ai-head--page h1{font-size:clamp(1.6rem,3.6vw,2.4rem)}
--.ai-kicker{margin:0;font-size:.78rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--room-accent,var(--accent))}
--.ai-kicker a{color:inherit;text-decoration:none}
--.ai-kicker a:hover{text-decoration:underline}
--.ai-lede{margin:0;max-width:62ch;color:var(--text-secondary);font-size:clamp(.98rem,1.6vw,1.08rem);line-height:1.5}
--.ai-lede code{font-size:.85em;color:var(--text-muted);overflow-wrap:anywhere}
--.ai-head .ovk-find{max-width:520px}
--.ai-fine,.ai-data{margin:10px 0 0;color:var(--text-muted);font-size:.86rem}
--.ai-data a{color:var(--text-secondary)}
--.ai-quiet{color:var(--text-muted);font-size:.9em}
--.ai-bad{color:var(--danger)}
--
--/* A model: mark, name, maker and tags */
--.ai-model{display:grid;grid-template-columns:auto minmax(0,1fr);align-items:center;gap:10px;color:inherit;text-decoration:none;min-width:0}
--.ai-model-text{display:grid;gap:2px;min-width:0}
--.ai-model-text b{font-weight:650;overflow-wrap:anywhere}
--.ai-model:hover b{color:var(--accent);text-decoration:underline;text-underline-offset:3px}
--.ai-model-text small{display:flex;flex-wrap:wrap;align-items:center;gap:6px;color:var(--text-muted);font-size:.8rem}
--.ai-model-text .ovk-badge{font-size:.7rem}
--
--/* Numbers in cells: the value, then what it rests on */
--.ai-board td b,.ai-board td .ai-rating b,.ai-price b{font-variant-numeric:tabular-nums;font-weight:650}
--.ai-board td small,.ai-rating small,.ai-price small{display:block;margin-top:2px;color:var(--text-muted);font-size:.78rem;font-weight:400}
--.ai-rank{display:inline-grid;place-items:center;min-width:2.1em;height:2.1em;padding:0 .4em;border-radius:999px;font-weight:700;font-variant-numeric:tabular-nums;
-- background:var(--bg-secondary,color-mix(in srgb,var(--text-primary) 6%,transparent));color:var(--text-secondary);font-size:.85rem}
--.ai-rank.is-top{background:color-mix(in srgb,var(--room-accent,var(--accent)) 22%,transparent);color:var(--text-primary)}
--.ai-board th.is-primary,.ai-board td:nth-child(2){min-width:13rem}
--
--/* Inside a kit section, the page's own blocks sit on its padding */
--.ovk-section>.ai-fine,.ovk-section>.ai-data,.ovk-section>.ai-note,.ovk-section>.ai-picks,.ovk-section>.ai-chips,.ovk-section>.ai-answers,.ovk-section>.ai-prompt,.ovk-section>ul{margin:0 14px 14px}
--.ovk-section>.ovk-table-wrap+.ai-fine,.ovk-section>.ovk-table-wrap+.ai-data{margin-top:12px}
--.ai-note{margin:0 0 12px;padding:12px 14px;border:1px solid color-mix(in srgb,var(--accent) 35%,var(--border));border-radius:12px;background:color-mix(in srgb,var(--accent) 8%,transparent);color:var(--text-secondary);font-size:.92rem}
--.ovk-section>.ai-note{margin:0 14px 12px}
--.ai-page .ovk-table td small{display:block;margin-top:2px;color:var(--text-muted);font-size:.78rem;font-weight:400}
--.ai-page .ovk-table td b{font-variant-numeric:tabular-nums}
--
--/* Top picks */
--.ai-picks{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,230px),1fr));gap:12px}
--.ai-pick{height:100%;box-sizing:border-box;display:grid;align-content:start;gap:8px;padding:16px;border:1px solid var(--border);border-radius:16px;background:var(--bg-card);color:inherit;text-decoration:none;
-- transition:border-color .15s,transform .15s}
--.ai-pick:hover{border-color:color-mix(in srgb,var(--room-accent,var(--accent)) 60%,var(--border));transform:translateY(-1px)}
--.ai-pick-k{font-size:.74rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--room-accent,var(--accent))}
--.ai-pick-m{display:flex;align-items:center;gap:10px;min-width:0}
--.ai-pick-m b{font-size:1.08rem;overflow-wrap:anywhere}
--.ai-pick-n{font-size:1.25rem;font-weight:700;font-variant-numeric:tabular-nums}
--.ai-pick small{color:var(--text-muted);font-size:.82rem;line-height:1.4}
--
--/* Chip links: tasks, comparisons */
--.ai-chips{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:8px}
--.ai-chips a{display:inline-flex;align-items:center;min-height:40px;padding:0 14px;border:1px solid var(--border);border-radius:999px;background:var(--bg-card);color:var(--text-primary);text-decoration:none;font-size:.9rem}
--.ai-chips a:hover{border-color:var(--accent);color:var(--accent)}
--
--/* Providers */
--.ai-prov{display:inline-flex;align-items:center;gap:8px}
--
--/* Filters */
--.ai-filters{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,150px),1fr));gap:10px 12px;align-items:end;padding:14px;border:1px solid var(--border);border-radius:16px;background:var(--bg-card)}
--.ai-filter{display:grid;gap:5px;min-width:0;font-size:.8rem;color:var(--text-secondary)}
--.ai-filter--q{grid-column:1/-1}
--.ai-filter input,.ai-filter select{box-sizing:border-box;width:100%;min-width:0;min-height:42px;padding:0 12px;border:1px solid var(--border);border-radius:10px;background:var(--bg-primary,transparent);color:var(--text-primary);font:inherit;font-size:.95rem}
--.ai-filter input:focus,.ai-filter select:focus{outline:2px solid var(--accent);outline-offset:1px}
--.ai-filters .ovk-btn{min-height:42px}
--
--/* A model page */
--.ai-model-head{display:flex;align-items:center;gap:16px;min-width:0}
--.ai-model-head>div{display:grid;gap:6px;min-width:0}
--.ai-model-head h1{overflow-wrap:anywhere}
--.ai-head--model .ai-lede{display:flex;flex-wrap:wrap;align-items:center;gap:6px}
--.ai-try{position:sticky;bottom:calc(12px + env(safe-area-inset-bottom));z-index:3;display:flex;flex-wrap:wrap;gap:10px;justify-content:center;padding:10px;border:1px solid var(--border);border-radius:999px;
-- background:color-mix(in srgb,var(--bg-card) 92%,transparent);backdrop-filter:blur(10px);width:max-content;max-width:100%;margin-inline:auto;box-shadow:0 10px 30px rgba(0,0,0,.25)}
--
--/* Compare */
--.ai-vs{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,280px),1fr));gap:12px}
--.ai-vs-card{display:grid;gap:14px;padding:18px;border:1px solid var(--border);border-radius:16px;background:var(--bg-card);min-width:0}
--.ai-vs-card dl{margin:0;display:grid;gap:10px}
--.ai-vs-card dl div{display:grid;grid-template-columns:7.5rem minmax(0,1fr);gap:10px;align-items:baseline}
--.ai-vs-card dt{color:var(--text-muted);font-size:.82rem}
--.ai-vs-card dd{margin:0}
--.ai-vs-form .ovk-btn{align-self:end}
--
--/* One prompt, many answers */
--.ai-prompt{margin:0 0 12px;font-size:1.05rem;font-weight:600}
--.ai-answers{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,260px),1fr));gap:12px}
--.ai-answers article{display:grid;gap:10px;padding:16px;border:1px solid var(--border);border-radius:16px;background:var(--bg-card);min-width:0}
--.ai-answers p{margin:0;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--text-secondary)}
--
--@media (max-width:600px){
-- .ai-board th.is-primary,.ai-board td:nth-child(2){min-width:0}
-- .ai-filters{grid-template-columns:1fr 1fr}
-- .ai-model-head{gap:12px}
--}
--
--/* Every section reads as a section: the kit's small label for an action-less section becomes the same heading the
-- sections with actions have, so the page's hierarchy is one level, not two. */
--.ai-page .ovk-section>h2{padding:14px 14px 10px;color:var(--text-primary);font-size:1.02rem;font-weight:700;letter-spacing:0;text-transform:none}
--.ai-cell{display:block;min-width:0}
--@media (max-width:600px){.ai-page .ovk-table--stack td .ai-cell{text-align:right}}
--/* Numbers in a header wrap into a grid instead of scrolling off a phone. */
--.ai-head .ovk-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,140px),1fr));overflow:visible}
--@media (max-width:700px){.ai-try{position:static;width:auto;border-radius:16px;box-shadow:none}}
--.ai-warn{color:var(--warning,#f59e0b)}
ddiff --git a/server/agent-pages.js b/server/agent-pages.js
iindex da88d36..bd4505e 100644
---- a/server/agent-pages.js
++++ b/server/agent-pages.js
@@@ -6,26 +6,16 @@
const { normaliseDays } = require('./data/source');
// /design and /rundown since 11-03: llms.txt listed them as HTML pages with no twin.
--const twinPath = (path) => /^\/(?:|stats|developers|design|rundown|ai|ai\/[^/]+|ai\/models\/[^/]+)$/.test(String(path || '/'));
++// The /ai pages moved to OpenVibe.Compare on 2026-11-13 (their twins with them).
++const twinPath = (path) => /^\/(?:|stats|developers|design|rundown)$/.test(String(path || '/'));
--function createAgentPages({ publicPages, bench }) {
++function createAgentPages({ publicPages }) {
async function renderPublicPage(path, { query } = {}) {
let page = null;
-- const model = /^\/ai\/models\/([^/]+)$/.exec(path);
-- const kind = /^\/ai\/([^/]+)$/.exec(path);
if (path === '/') page = await publicPages.home();
else if (path === '/developers') page = publicPages.developers();
else if (path === '/design') page = publicPages.design();
else if (path === '/stats') page = await publicPages.stats(normaliseDays(query && query.get('days')));
-- else if (path === '/ai') page = await publicPages.aiIndex();
-- else if (path === '/ai/method') page = await publicPages.aiMethod();
-- else if (path === '/ai/compare') {
-- const models = String((query && query.get('models')) || '').split(',').map((x) => x.trim()).filter(Boolean);
-- page = await publicPages.aiCompare(models);
-- }
-- else if (model) page = await publicPages.aiModel(decodeURIComponent(model[1]));
-- else if (kind && bench.isBenchmarkId(kind[1])) page = await publicPages.aiBenchmark(kind[1]);
-- else if (kind) page = await publicPages.aiBoard(kind[1], bench.normaliseTab(query && query.get('tab')));
return page ? { html: page.html } : null;
}
ddiff --git a/server/app.js b/server/app.js
iindex 5268894..f206f6c 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -10,7 +10,7 @@
* /me, /u/:username a person's stats: private by default, the published
* sections at /u (the owner, 2026-11-09)
* /auth/* sign-in with OpenVibe.Network (openvibe-sdk/sso)
-- * /ai, /ai/models/:model, /ai/:kind the AI boards, from OpenVibe.Bench (http/pages.js)
++ * /ai… 301 to OpenVibe.Compare, except /ai/arena (http/arena.js) and /ai/exchange
* /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)
@@@ -342,7 +342,7 @@ async function createApp(opts = {}) {
app.use(createPageRoutes({ ...ctx, publicPages }));
require('openvibe-shared/agent-layer').mount(app, {
descriptor: require('./agent-descriptor'),
-- render: require('./agent-pages').createAgentPages({ publicPages, bench }).renderPublicPage,
++ render: require('./agent-pages').createAgentPages({ publicPages }).renderPublicPage,
});
app.use((_req, res) => {
res.status(404).set('Cache-Control', PAGE_CACHE).type('html').send(views.notFound());
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 871a26a..1b9ffba 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -46,14 +46,11 @@ function createDiscoveryRoutes({ config, live, bench, sessionReports = null, meS
});
r.get('/sitemap.xml', async (_req, res) => {
-- const [ai, overview, reports, people] = await Promise.all([
-- bench.kinds(), bench.overview(), sessionReports ? sessionReports.listPublic({ limit: 500 }) : [],
++ // The /ai pages moved to OpenVibe.Compare on 2026-11-13; its sitemap lists them.
++ const [reports, people] = await Promise.all([
++ sessionReports ? sessionReports.listPublic({ limit: 500 }) : [],
meStats ? meStats.listPublished({ limit: 500 }).catch(() => []) : [],
]);
-- const kinds = (ai && Array.isArray(ai.data.kinds)) ? ai.data.kinds : [];
-- const o = overview && overview.data ? overview.data : {};
-- const benches = Array.isArray(o.list) ? o.list : [];
-- const models = Array.isArray(o.model_list) ? o.model_list.map((m) => m.model).filter(Boolean) : [...new Set((Array.isArray(o.recent) ? o.recent : []).map((r) => r && r.model).filter(Boolean))];
const lastmod = siteUpdated();
// Only the pages this site owns. /rundown and its topic, episode and speaker pages carry rel=canonical to
// OpenVibe.Live's Rundown, so listing them here would advertise a duplicate.
@@@ -61,12 +58,6 @@ function createDiscoveryRoutes({ config, live, bench, sessionReports = null, meS
{ loc: abs('/'), changefreq: 'daily', priority: 1.0 },
{ loc: abs('/stats'), changefreq: 'daily', priority: 0.9 },
{ loc: abs('/design'), changefreq: 'weekly', priority: 0.6 },
-- { loc: abs('/ai'), changefreq: 'daily', priority: 0.9 },
-- { loc: abs('/ai/leaderboard'), changefreq: 'daily', priority: 0.9 },
-- { loc: abs('/ai/providers'), changefreq: 'daily', priority: 0.7 },
-- { loc: abs('/ai/arena'), changefreq: 'daily', priority: 0.6 },
-- { loc: abs('/ai/method'), changefreq: 'weekly', priority: 0.6 },
-- { loc: abs('/ai/compare'), changefreq: 'weekly', priority: 0.6 },
...reports.map((rep) => ({ loc: abs('/s/' + encodeURIComponent(rep.id)), changefreq: 'daily', priority: 0.6 })),
// A person's public stats page is crawlable only when they published a section; the private default is
// never in the sitemap. The last modification is the snapshot they last computed.
@@@ -74,19 +65,6 @@ function createDiscoveryRoutes({ config, live, bench, sessionReports = null, meS
loc: abs(`/u/${encodeURIComponent(p.username)}`), changefreq: 'weekly', priority: 0.5,
...(dayOf(p.generated_at) ? { lastmod: dayOf(p.generated_at) } : {}),
})),
-- ...kinds.filter((k) => k && bench.isKindId(k.id)).map((k) => ({
-- loc: abs(`/ai/${encodeURIComponent(k.id)}`), changefreq: 'daily', priority: 0.7,
-- })),
-- ...benches.filter((b) => b && bench.isBenchmarkId(b.id)).map((b) => ({
-- loc: abs(`/ai/${encodeURIComponent(b.id)}`), changefreq: 'daily', priority: 0.8,
-- ...(dayOf(b.last_run) ? { lastmod: dayOf(b.last_run) } : {}),
-- })),
-- ...models.filter((m) => bench.isModelName(m)).map((m) => ({
-- loc: abs(`/ai/models/${encodeURIComponent(m)}`), changefreq: 'daily', priority: 0.6,
-- })),
-- ...models.filter((m) => bench.isModelName(m)).slice(0, 200).flatMap((a, i, list) => list.slice(i + 1).map((b) => ({
-- loc: abs(`/ai/compare/${encodeURIComponent(a)}-vs-${encodeURIComponent(b)}`), changefreq: 'weekly', priority: 0.5,
-- }))).slice(0, 200),
].map((p) => ({ ...p, ...(lastmod && !p.lastmod ? { lastmod } : {}) }));
res.type('application/xml').set('Cache-Control', CACHE).send(seo.sitemapXml(pages));
});
@@@ -104,9 +82,8 @@ function createDiscoveryRoutes({ config, live, bench, sessionReports = null, meS
{ title: 'Open numbers', url: abs('/stats'), note: 'page views, accounts and traffic for every OpenVibe site' },
{ title: 'Design health', url: abs('/design'), note: 'how closely each site follows the shared design kit, read from its code; per site at /design/<site>' },
{ title: 'The Rundown', url: 'https://openvibe.live/rundown', note: 'what streamers talked about, topic by topic (OpenVibe.Live is its canonical home)' },
-- { title: 'AI', url: abs('/ai'), note: 'OpenVibe.Bench results: quality, price and speed for every task kind and benchmark' },
-- { title: 'How AI results are judged', url: abs('/ai/method'), note: 'scores, verified runs, cost per solved, speed and the frontier' },
-- { title: 'Compare AI models', url: abs('/ai/compare'), note: 'two or three models on the same benchmarks' },
++ { title: 'AI model comparison', url: 'https://openvibe.compare/ai', note: 'the leaderboard, ratings, prices and measured speed of every AI model now live on OpenVibe.Compare' },
++ { title: 'The AI Arena', url: abs('/ai/arena'), note: 'two anonymous answers to one prompt; your vote moves the ratings' },
] },
// A JSON API is what an agent came for, not something to skip (AGT-5 review).
{ title: 'API', links: [
@@@ -114,7 +91,7 @@ function createDiscoveryRoutes({ config, live, bench, sessionReports = null, meS
{ title: 'Design health (JSON)', url: abs('/api/v1/design-health'), note: 'every site; /api/v1/design-health/<site> for one' },
{ title: 'The Rundown (JSON)', url: abs('/api/v1/rundown') },
{ title: 'Topics (JSON)', url: abs('/api/v1/rundown/topics') },
-- { title: 'New AI results (RSS)', url: abs('/ai/feed.xml') },
++ { title: 'New AI results (RSS)', url: 'https://openvibe.compare/ai/feed.xml' },
{ title: 'New AI results (JSON)', url: abs('/api/v1/ai/results') },
] },
],
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex c7658db..1c1ddd8 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -11,12 +11,8 @@
* GET /rundown/s/:username one streamer
* GET /after-show/:streamId one after-show report
* GET /c/:username one channel's after-show reports
-- * GET /ai OpenVibe.Bench's public results: task kinds, benchmarks, runs and last runs
-- * GET /ai/method how a score, a cost and a speed are made
-- * GET /ai/compare two or three models side by side
-- * GET /ai/feed.xml new runs as RSS
-- * GET /ai/:ref one task kind (Elo/price/speed) or one public benchmark (bmk_…)
-- * GET /ai/models/:model one model's runs over time, tasks, cost and recent runs
++ * GET /ai… 301 to OpenVibe.Compare (the AI model comparison moved there on 2026-11-13), except
++ * the Arena's rounds and votes (http/arena.js) and the staff page /ai/exchange
*
* A bad path parameter is a 404 without a source request; so is a source that answers nothing. Pages are public
* for two minutes.
@@@ -25,7 +21,6 @@
* Markdown twin render the very same page; `agent: true` in those views adds the discovery links.
*/
const express = require('express');
--const { findCatalogModel, makerOf } = require('../data/ai-join');
const contracts = require('openvibe-contracts');
const views = require('../views');
const { normaliseDays, isSlug, isStreamId, isUsername, streamIdOf } = require('../data/source');
@@@ -33,9 +28,10 @@ const { twinPath } = require('../agent-pages');
const designViews = require('../views/design');
const designHealth = require('../data/design-health');
const sharing = require('../sharing');
--const aiComparisonViews = require('../views/ai-comparison');
const CACHE = 'public, max-age=120';
++/** Where the AI model comparison lives since 2026-11-13. */
++const COMPARE = 'https://openvibe.compare';
/** The pages the agent layer twins: their data, then the view. Each returns { html } or null (nothing to show). */
function createPublicPages({ sessionReports, network, live, bench, aiCatalogue = null, analytics = null, viewsData = null }) {
@@@ -56,190 +52,6 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =
return { html: views.developers() };
}
-- async function aiIndex() {
-- return aiComparisonHub();
-- }
--
-- async function comparisonData() {
-- const [kindsResult, catalogue] = await Promise.all([bench.kinds(), require('../data/ai-market').comparisonCatalogue(aiCatalogue)]);
-- const kinds = kindsResult && kindsResult.data && kindsResult.data.kinds || [];
-- const boards = await Promise.all(kinds.map((kind) => bench.board(kind.id, 'elo')));
-- const catModels = (catalogue && catalogue.models || []).map((model) => ({ ...model, offers: (model.offers || []).map((offer) => ({ ...offer, source: offer.source || { name: 'OpenVibe.AI', date: catalogue.generated_at || null, sample_size: offer.requests_7d ?? null } })) }));
-- const findCat = (id) => findCatalogModel(catModels, id);
-- const rows = [];
-- for (let i = 0; i < boards.length; i += 1) {
-- const list = boards[i] && boards[i].data && boards[i].data.board && boards[i].data.board.rows || [];
-- for (const entry of list) {
-- const id = entry.subject || entry.model || entry.name;
-- const cat = findCat(id);
-- rows.push({ id, routed: cat ? cat.routed !== false : false, kind: kinds[i].id, model_kind: cat && cat.kind || 'chat', maker: makerOf(cat, id), weights: cat && (cat.weights || (cat.open_weights == null ? null : cat.open_weights ? 'open' : 'closed')), status: cat && cat.status || 'current', uncensored: Boolean(cat && cat.uncensored), community: Boolean(cat && cat.community), rating: { value: entry.elo == null || Number(entry.games) === 0 ? null : Number(entry.elo), interval: Number(entry.games) === 0 ? [null, null] : [entry.ci_lower ?? null, entry.ci_upper ?? null], source: 'OpenVibe.Bench', date: boards[i].at || null, sample_size: entry.games ?? null }, benchmark: { value: entry.score == null ? null : Number(entry.score), reason: entry.score == null ? 'No benchmark score.' : undefined }, provider_offers: cat && Array.isArray(cat.offers) ? cat.offers : [], source: { name: 'OpenVibe.Bench', date: boards[i].at || null, sample_size: entry.games ?? null } });
-- }
-- }
-- const grouped = new Map();
-- for (const row of rows) {
-- const existing = grouped.get(row.id);
-- if (existing) existing.task_ratings.push({ kind: row.kind, rating: row.rating, benchmark: row.benchmark });
-- else grouped.set(row.id, { ...row, task_ratings: [{ kind: row.kind, rating: row.rating, benchmark: row.benchmark }] });
-- }
-- rows.length = 0;
-- rows.push(...grouped.values());
-- // Every model the Exchange sells is listed with its prices and speed, rated or not: a model without
-- // Bench games yet shows as not rated, never as missing.
-- const ratedCats = new Set(rows.map((row) => findCat(row.id)).filter(Boolean));
-- for (const cat of catModels) {
-- if (ratedCats.has(cat)) continue;
-- const id = cat.slug || cat.canonical || cat.id;
-- if (!id) continue;
-- const source = { name: 'OpenVibe.AI', date: catalogue.generated_at || null, sample_size: null };
-- rows.push({ id, routed: cat.routed !== false, kind: cat.kind || null, model_kind: cat.kind || 'chat', maker: makerOf(cat, id), weights: cat.weights || (cat.open_weights == null ? null : cat.open_weights ? 'open' : 'closed'), status: cat.status || 'current', uncensored: Boolean(cat.uncensored), community: Boolean(cat.community), rating: { value: null, interval: [null, null], source: 'OpenVibe.Bench', date: null, sample_size: 0 }, benchmark: { value: null, reason: 'Not rated yet.' }, provider_offers: Array.isArray(cat.offers) ? cat.offers : [], source, task_ratings: [], unrated: true });
-- }
-- for (const kind of kinds) {
-- const ranked = rows.map((row) => ({ row, task: row.task_ratings.find((rating) => rating.kind === kind.id) })).filter((x) => x.task).sort((a, b) => (b.task.rating.value ?? -Infinity) - (a.task.rating.value ?? -Infinity));
-- ranked.forEach(({ task }, i) => {
-- const [lo, hi] = task.rating.interval || [];
-- if (lo == null || hi == null) return;
-- const overlap = ranked.map((other, j) => ({ other, rank: j + 1 })).filter(({ other }) => other.task.rating.interval && other.task.rating.interval[0] != null && other.task.rating.interval[1] != null && other.task.rating.interval[0] <= hi && other.task.rating.interval[1] >= lo).map(({ rank }) => rank);
-- task.rank = overlap.length ? { min: Math.min(...overlap), max: Math.max(...overlap), position: i + 1 } : null;
-- });
-- }
-- const outPrice = (r) => Math.min(...(r.provider_offers || []).map((o) => (o.price && o.price.out != null ? Number(o.price.out) : NaN)).filter(Number.isFinite), Infinity);
-- rows.sort((a, b) => (b.rating.value ?? -Infinity) - (a.rating.value ?? -Infinity)
-- || (b.routed === true) - (a.routed === true)
-- || outPrice(a) - outPrice(b)
-- || String(a.id).localeCompare(String(b.id)));
-- rows.forEach((row, i) => {
-- const [lo, hi] = row.rating.interval || [];
-- if (lo == null || hi == null) row.rank = null;
-- else {
-- const overlap = rows.map((other, j) => ({ other, rank: j + 1 })).filter(({ other }) => other.rating.interval && other.rating.interval[0] != null && other.rating.interval[1] != null && other.rating.interval[0] <= hi && other.rating.interval[1] >= lo).map(({ rank }) => rank);
-- row.rank = overlap.length ? { min: Math.min(...overlap), max: Math.max(...overlap), position: i + 1 } : null;
-- }
-- });
-- const tasks = kinds.map((kind) => ({ id: kind.id, name: kind.name }));
-- const providers = catModels.flatMap((m) => (m.offers || []).map((o) => ({ ...o, routed: o.routed !== false, model: m.slug })));
-- return { rows, providers, tasks, picks: [], catalogue };
-- }
--
-- async function aiComparisonHub() {
-- const data = await comparisonData();
-- const num = (x) => (x == null || x === '' ? NaN : Number(x));
-- const chat = data.rows.filter((r) => (r.model_kind || 'chat') === 'chat');
-- const rated = chat.filter((r) => r.rating.value != null);
-- const best = (list) => [...list].sort((a, b) => b.rating.value - a.rating.value)[0];
-- const cheapest = (r) => Math.min(...(r.provider_offers || []).map((o) => num(o.price && o.price.out)).filter(Number.isFinite), Infinity);
-- const fastest = (r) => Math.min(...(r.provider_offers || []).filter((o) => Number(o.requests_7d) > 0).map((o) => num(o.speed && o.speed.p50_ms)).filter(Number.isFinite), Infinity);
-- const free = (r) => (r.provider_offers || []).some((o) => o.free === true || o.free_now === true || num(o.price && o.price.out) === 0);
-- const taskRows = (kind) => rated.map((row) => { const task = row.task_ratings.find((x) => x.kind === kind && x.rating && x.rating.value != null); return task ? { ...row, kind, rating: task.rating, benchmark: task.benchmark } : null; }).filter(Boolean);
-- const choose = (id, model, why) => ({ id, model: model || null, why: model ? why : 'Not enough source data to establish this pick.' });
-- data.picks = [
-- choose('overall', best(rated), 'Highest rating from blind votes and open runs.'),
-- choose('value', rated.filter((r) => Number.isFinite(cheapest(r))).sort((a, b) => cheapest(a) - cheapest(b))[0], 'Lowest output price among rated models.'),
-- choose('fastest', chat.filter((r) => Number.isFinite(fastest(r))).sort((a, b) => fastest(a) - fastest(b))[0], 'Fastest typical reply on real traffic this week.'),
-- choose('open', best(rated.filter((r) => ['open', 'open_weights'].includes(r.weights))), 'Highest-rated model with open weights.'),
-- choose('code', best(taskRows('code')), 'Highest rating on code tasks.'),
-- choose('writing', best(taskRows('writing')), 'Highest rating on writing tasks.'),
-- choose('uncensored', best(rated.filter((r) => r.uncensored)), 'Highest-rated uncensored model; 18+.'),
-- choose('free', best(rated.filter(free)) || chat.find(free), 'Free to use now through the Exchange.'),
-- ];
-- data.others = data.rows.length - chat.length;
-- data.rows = chat;
-- return { html: aiComparisonViews.hub(data) };
-- }
-- async function aiComparisonLeaderboard(query = {}) {
-- const nz = (x) => (x == null || x === '' ? NaN : Number(x));
-- const data = await comparisonData();
-- let rows = data.rows;
-- if (query.kind) rows = rows.map((x) => {
-- const task = x.task_ratings.find((rating) => rating.kind === query.kind);
-- return task ? { ...x, kind: query.kind, rating: task.rating, benchmark: task.benchmark } : x.kind === query.kind ? x : null;
-- }).filter(Boolean);
-- const type = ['chat', 'embedding', 'stt', 'all'].includes(query.type) ? query.type : 'chat';
-- if (type !== 'all') rows = rows.filter((x) => (x.model_kind || 'chat') === type);
-- if (query.maker) rows = rows.filter((x) => String(x.maker || '').toLowerCase() === String(query.maker).toLowerCase());
-- if (query.weights) rows = rows.filter((x) => x.weights === query.weights || (query.weights === 'open' && x.weights === 'open_weights'));
-- if (query.status !== 'all') rows = rows.filter((x) => x.status === (query.status || 'current'));
-- if (query.uncensored) rows = rows.filter((x) => x.uncensored);
-- if (query.community) rows = rows.filter((x) => x.community);
-- if (query.price) rows = rows.filter((x) => x.provider_offers.some((o) => {
-- const p = nz(o.price && o.price.out);
-- return query.price === 'free' ? (o.free === true || o.free_now === true || p === 0) : !Number.isFinite(p) ? false : query.price === 'low' ? p <= 1 : query.price === 'mid' ? p > 1 && p <= 10 : query.price === 'high' ? p > 10 : true;
-- }));
-- if (query.q) rows = rows.filter((x) => `${x.id} ${x.maker || ''}`.toLowerCase().includes(String(query.q).toLowerCase()));
-- if (query.sort === 'name') rows.sort((a, b) => String(a.id).localeCompare(String(b.id)));
-- if (query.sort === 'price') rows.sort((a, b) => Math.min(...a.provider_offers.map((o) => nz(o.price && o.price.out)).filter(Number.isFinite), Infinity) - Math.min(...b.provider_offers.map((o) => nz(o.price && o.price.out)).filter(Number.isFinite), Infinity));
-- if (query.sort === 'speed') rows.sort((a, b) => Math.min(...a.provider_offers.filter((o) => Number(o.requests_7d) > 0).map((o) => nz(o.speed && o.speed.p50_ms)).filter(Number.isFinite), Infinity) - Math.min(...b.provider_offers.filter((o) => Number(o.requests_7d) > 0).map((o) => nz(o.speed && o.speed.p50_ms)).filter(Number.isFinite), Infinity));
-- if (query.sort === 'rating') rows.sort((a, b) => (b.rating.value ?? -Infinity) - (a.rating.value ?? -Infinity));
-- return { html: aiComparisonViews.leaderboard({ rows, filters: query }) };
-- }
-- async function aiComparisonProviders() { const data = await comparisonData(); return { html: aiComparisonViews.providers(data) }; }
-- async function aiComparisonModel(id) {
-- if (!bench.isModelName(id)) return null;
-- const data = await comparisonData();
-- const row = data.rows.find((x) => x.id === id);
-- const detail = await bench.modelDetail(id);
-- return { html: aiComparisonViews.model({ id, detail: detail && detail.data, sourceDate: detail && detail.at, row, providers: data.providers }) };
-- }
-- async function aiComparison({ models = [] } = {}) {
-- const data = await comparisonData();
-- const selected = models.map((id) => data.rows.find((x) => x.id === id) || { id, reason: 'No public model data.' });
-- return { html: aiComparisonViews.compare({ models: selected, options: data.rows.map((x) => x.id) }) };
-- }
-- function aiArena() { return { html: aiComparisonViews.arena() }; }
--
-- async function aiBoard(kindId, tab) {
-- if (!bench.isKindId(kindId)) return null;
-- const [kinds, board, cost, benchmarks, runs, overview] = await Promise.all([
-- bench.kinds(),
-- bench.board(kindId, tab),
-- tab === 'pareto' ? bench.board(kindId, 'cost') : Promise.resolve(null),
-- bench.benchmarks(),
-- bench.runs({ kind: kindId, limit: 8 }),
-- bench.overview(),
-- ]);
-- const kind = kinds && Array.isArray(kinds.data.kinds) ? kinds.data.kinds.find((k) => k.id === kindId) : null;
-- if (kinds && !kind) return null;
-- const kindBenchmarks = benchmarks && benchmarks.data && Array.isArray(benchmarks.data.benchmarks)
-- ? benchmarks.data.benchmarks.filter((b) => b.kind === kindId) : [];
-- const rankings = await Promise.all(kindBenchmarks.map((b) => bench.rankings(b.id, 'score')));
-- return { html: views.aiBoard({ kindId, kind, tab, board, cost, kinds, benchmarks, rankings, runs, overview }) };
-- }
--
-- async function aiBenchmark(benchmarkId) {
-- if (!bench.isBenchmarkId(benchmarkId)) return null;
-- const [benchmark, rankings, runs, overview] = await Promise.all([
-- bench.benchmark(benchmarkId), bench.rankings(benchmarkId, 'score'), bench.runs({ benchmark: benchmarkId, limit: 20 }), bench.overview(),
-- ]);
-- // A public id the overview does not list is a 404; a Bench that did not answer at all is the honest unavailable page.
-- const list = overview && overview.data && Array.isArray(overview.data.list) ? overview.data.list : null;
-- if (list && !list.some((b) => b.id === benchmarkId)) return null;
-- return { html: views.aiBenchmark({ benchmark, rankings, runs }) };
-- }
--
-- async function aiModel(model) {
-- if (!bench.isModelName(model)) return null;
-- const [detail, legacy] = await Promise.all([bench.modelDetail(model), bench.modelSummary(model)]);
-- return { html: views.aiModel({ model, detail, legacy }) };
-- }
--
-- async function aiMethod() {
-- const [overview, kinds] = await Promise.all([bench.overview(), bench.kinds()]);
-- return { html: views.aiMethod({ overview, kinds }) };
-- }
--
-- async function aiCompare(models = [], errors = []) {
-- const clean = [...new Set((models || []).filter((m) => bench.isModelName(m)))].slice(0, 3);
-- const [details, legacy] = await Promise.all([
-- Promise.all(clean.map((m) => bench.modelDetail(m))),
-- Promise.all(clean.map((m) => bench.modelSummary(m))),
-- ]);
-- return { models: clean, html: views.aiCompare({ models: clean, details, legacy, errors }) };
-- }
--
-- async function aiFeed() {
-- const [runs, overview] = await Promise.all([bench.runs({ limit: 20 }), bench.overview()]);
-- return { runs, overview };
-- }
--
function design() { return { html: designViews.designHealth(designHealth.all()) }; }
async function rundown() {
@@@ -250,7 +62,7 @@ function createPublicPages({ sessionReports, network, live, bench, aiCatalogue =
return { html: views.rundown({ rundown: r, topics, reports }) };
}
-- return { home, stats, developers, design, rundown, aiIndex, aiBoard, aiBenchmark, aiModel, aiMethod, aiCompare, aiFeed, aiComparisonHub, aiComparisonLeaderboard, aiComparisonProviders, aiComparisonModel, aiComparison, aiArena };
++ return { home, stats, developers, design, rundown };
}
/** The new-results feed: one item per public run, with its own Bench date and no invented number. */
@@@ -319,49 +131,13 @@ function createPageRoutes(ctx) {
return send(req, res, page.html);
});
-- r.get('/ai', async (_req, res) => send(_req, res, (await publicPages.aiComparisonHub()).html));
--
-- r.get('/ai/leaderboard', async (req, res) => send(req, res, (await publicPages.aiComparisonLeaderboard(req.query)).html));
-- r.get('/ai/providers', async (req, res) => send(req, res, (await publicPages.aiComparisonProviders()).html));
-- r.get('/ai/arena', (_req, res) => send(_req, res, publicPages.aiArena().html));
-- r.get('/ai/compare', async (req, res) => {
-- // The picker sends a and b; older links send models=a,b.
-- const picked = [req.query.a, req.query.b].map((x) => String(x || '').trim()).filter(Boolean);
-- const models = picked.length ? picked : String(req.query.models || '').split(',').map((x) => x.trim()).filter(Boolean);
-- if (models.length === 2) return res.redirect(301, `/ai/compare/${encodeURIComponent(models[0])}-vs-${encodeURIComponent(models[1])}`);
-- return send(req, res, (await publicPages.aiComparison({ models })).html);
-- });
-- r.get('/ai/compare/:pair', async (req, res) => {
-- const match = String(req.params.pair).match(/^(.+)-vs-(.+)$/);
-- if (!match) return notFound(req, res);
-- const ordered = [decodeURIComponent(match[1]), decodeURIComponent(match[2])].sort();
-- const canonical = `/ai/compare/${encodeURIComponent(ordered[0])}-vs-${encodeURIComponent(ordered[1])}`;
-- if (req.path !== canonical) return res.redirect(301, canonical);
-- return send(req, res, (await publicPages.aiComparison({ models: ordered })).html);
-- });
--
-- r.get('/ai/models/:model', async (req, res) => {
-- const page = await publicPages.aiComparisonModel(req.params.model);
-- if (!page) return notFound(req, res);
-- return send(req, res, page.html);
-- });
--
-- // The AI section's method, comparison and feed. They must sit before /ai/:ref, which would read them as a kind.
-- r.get('/ai/method', async (req, res) => send(req, res, (await publicPages.aiMethod()).html, 200, 'no-store'));
--
-- r.get('/ai/compare', async (req, res) => {
-- const raw = String(req.query.models || '').split(',').map((x) => x.trim()).filter(Boolean);
-- const errors = [];
-- const unique = [...new Set(raw)];
-- if (unique.length < 2) errors.push('Pick at least two model ids, comma separated.');
-- if (unique.length > 3) errors.push('At most three models can be compared at once.');
-- const page = await publicPages.aiCompare(unique.slice(0, 3), errors);
-- return send(req, res, page.html, 200, 'no-store');
-- });
--
-- r.get('/ai/feed.xml', async (req, res) => {
-- const feed = await publicPages.aiFeed();
-- res.status(200).set('Cache-Control', 'public, max-age=120').type('application/rss+xml; charset=utf-8').send(rssFeed({ ...feed, base: ctx.config.baseUrl }));
++ // The AI model comparison moved to OpenVibe.Compare (the owner, 2026-11-13: "This kinda shit is supposed to be a part
++ // of openvibe.compare"). Every /ai page answers 301 there with its path and query; the Arena's rounds and votes
++ // (http/arena.js, mounted before these routes) and the staff page /ai/exchange below stay here. Report keeps the
++ // ledgers and the JSON API (/api/v1/ai/*) Compare reads.
++ r.get(/^\/ai(?:\/.*)?$/, (req, res, next) => {
++ if (/^\/ai\/(?:arena|exchange)(?:\/|$)/.test(req.path)) return next();
++ return res.redirect(301, `${COMPARE}${req.originalUrl}`);
});
// The AI Exchange's daily aggregates (plan G-P9), staff-only, behind REPORT_AI_EXCHANGE. The gate is the signed-in
@@@ -382,15 +158,6 @@ function createPageRoutes(ctx) {
}
});
-- r.get('/ai/:ref', async (req, res) => {
-- const ref = String(req.params.ref || '');
-- const page = bench.isBenchmarkId(ref)
-- ? await publicPages.aiBenchmark(ref)
-- : await publicPages.aiBoard(ref, bench.normaliseTab(req.query.tab));
-- if (!page) return notFound(req, res);
-- return send(req, res, page.html, 200, 'no-store');
-- });
--
r.get('/rundown', async (req, res) => {
const [rundown, topics, reports] = await Promise.all([
live.rundown(), live.topics(),
ddiff --git a/server/views/ai-comparison.js b/server/views/ai-comparison.js
ddeleted file mode 100644
iindex 49dfe93..0000000
---- a/server/views/ai-comparison.js
++++ /dev/null
@@@ -1,292 +0,0 @@
--'use strict';
--
--// Report's AI comparison pages: the hub, the full leaderboard, providers, a model and a comparison.
--// One way to show each thing everywhere:
--// - a model is its maker's mark, a readable name and its tags;
--// - a rating is the number with its spread and the votes it rests on, or "Not rated yet";
--// - a price is the cheapest provider's own-page price;
--// - speed and uptime are measured on the Exchange's real traffic, shown only where traffic went.
--// A column or section earns its place: it shows when some row has a value, and a missing value reads in words.
--const kit = require('openvibe-shared/kit');
--const { parts, PARTS_CSS } = require('openvibe-shared/showcase');
--const marks = require('openvibe-shared/ai-marks');
--const { asset } = require('../assets');
--const { layout } = require('./index');
--
--const esc = kit.esc;
--const value = (x) => x == null || !Number.isFinite(Number(x)) ? null : Number(x);
--const idPath = (id) => `/ai/models/${encodeURIComponent(id || '')}`;
--const act = (href, label) => kit.button({ href, label, kind: 'quiet', icon: 'forward' });
--const quiet = (text) => `<span class="ai-quiet">${esc(text)}</span>`;
--const money = (x) => value(x) == null ? null : Number(x) === 0 ? 'Free' : kit.num(x, 'money');
--const ms = (x) => value(x) == null ? null : kit.num(x, 'ms');
--const share = (x) => { const v = x != null && typeof x === 'object' ? x.value : x; return value(v) == null ? null : `${(Number(v) * 100).toFixed(Number(v) > 0 && Number(v) < 0.01 ? 2 : 1)}%`; };
--const sourceHint = (s) => { const x = s && typeof s === 'object' ? s : { name: s }; return `Source: ${x.name || 'not listed'}${x.date ? ` · ${String(x.date).slice(0, 10)}` : ''}${x.sample_size != null ? ` · n = ${x.sample_size}` : ''}`; };
--
--// ── A model ──
--function makerName(row) {
-- const key = marks.family(row && row.maker) || marks.family(row && row.id);
-- return key ? marks.NAMES[key] : (row && row.maker) || null;
--}
--// "openai/gpt-5-nano" reads as "gpt-5-nano" beside OpenAI's mark; the full id stays in the link and the title.
--const shortName = (id) => String(id || '').replace(/^[^/]+\//, '') || String(id || '');
--function tags(row) {
-- const t = [];
-- if (typeof row.routed === 'boolean') t.push(kit.badge(row.routed ? 'Routed here' : 'Not routed here'));
-- if (row.weights === 'open' || row.weights === 'open_weights') t.push(kit.badge('Open weights'));
-- if (row.uncensored) t.push(kit.badge('18+', 'warn'));
-- if (row.community) t.push(kit.badge('Community machines'));
-- return t.join('');
--}
--function modelCell(row, { size = 28 } = {}) {
-- const maker = makerName(row);
-- return `<a class="ai-model" href="${idPath(row.id)}" title="${esc(row.id)}">${marks.mark(row.maker || row.id, { size, label: false })}`
-- + `<span class="ai-model-text"><b>${esc(shortName(row.id))}</b><small>${esc(maker || 'Maker not listed')}${tags(row) ? ` ${tags(row)}` : ''}</small></span></a>`;
--}
--
--// ── Numbers on a model ──
--const rated = (row) => row && row.rating && value(row.rating.value) != null;
--function ratingCell(row, { range = false } = {}) {
-- if (!rated(row)) return quiet('Not rated yet');
-- const v = Math.round(Number(row.rating.value));
-- const [lo, hi] = (row.rating.interval || []).map(value);
-- const n = value(row.rating.sample_size);
-- const spread = lo != null && hi != null ? (range ? `${Math.round(lo)}–${Math.round(hi)}` : `± ${Math.round((hi - lo) / 2)}`) : '';
-- const votes = n != null ? `${n.toLocaleString('en-US')} ${n === 1 ? 'game' : 'games'}` : '';
-- return `<span class="ai-rating" title="${esc(sourceHint(row.rating))}"><b>${v}</b>${spread || votes ? `<small>${esc([spread, votes].filter(Boolean).join(' · '))}</small>` : ''}</span>`;
--}
--const rankText = (row, i) => row.rank && row.rank.min != null ? (row.rank.min === row.rank.max ? String(row.rank.min) : `${row.rank.min}–${row.rank.max}`) : (rated(row) ? String(i + 1) : '–');
--const offersOf = (row) => (row && row.provider_offers || []).filter(Boolean);
--const measured = (o) => o.routed !== false && Number(o.requests_7d) > 0;
--function cheapest(row) {
-- const priced = offersOf(row).filter((o) => o.price && value(o.price.out) != null);
-- return priced.sort((a, b) => Number(a.price.out) - Number(b.price.out))[0] || null;
--}
--function fastest(row) {
-- const timed = offersOf(row).filter((o) => measured(o) && o.speed && value(o.speed.p50_ms) != null);
-- return timed.sort((a, b) => Number(a.speed.p50_ms) - Number(b.speed.p50_ms))[0] || null;
--}
--function priceCell(row) {
-- const o = cheapest(row);
-- if (!o) return quiet('No price listed');
-- const n = offersOf(row).filter((x) => x.price && value(x.price.out) != null).length;
-- const via = n > 1 ? `cheapest of ${n} providers` : `via ${providerName(o)}`;
-- return `<span class="ai-price" title="${esc(sourceHint(o.source || { name: o.provider_name }))}"><b>${esc(money(o.price.out))}</b> out · ${esc(money(o.price.in) || '–')} in<small>per 1M tokens, ${esc(via)}</small></span>`;
--}
--function speedCell(row) {
-- const o = fastest(row);
-- if (!o) return quiet('No traffic yet');
-- return `<span title="${esc(sourceHint({ name: 'OpenVibe.AI, real traffic', sample_size: o.requests_7d }))}"><b>${esc(ms(o.speed.p50_ms))}</b><small>typical reply · p95 ${esc(ms(o.speed.p95_ms) || '–')}</small></span>`;
--}
--function uptimeCell(row) {
-- const o = fastest(row) || offersOf(row).find(measured);
-- if (!o || share(o.uptime) == null) return quiet('No traffic yet');
-- return `<b${Number(o.uptime) < 0.95 ? ' class="ai-bad"' : ''}>${esc(share(o.uptime))}</b><small>${esc(Number(o.requests_7d).toLocaleString('en-US'))} requests in 7 days</small>`;
--}
--
--// ── The leaderboard table ──
--function board(rows, { full = false } = {}) {
-- const has = (fn) => rows.some(fn);
-- const columns = [
-- has(rated) && { key: 'rank', label: 'Rank', num: true, html: true },
-- { key: 'model', label: 'Model', primary: true, html: true },
-- has(rated) && { key: 'rating', label: 'Rating', html: true },
-- has((r) => cheapest(r)) && { key: 'price', label: 'Price', html: true },
-- has((r) => fastest(r)) && { key: 'speed', label: 'Reply time', html: true },
-- full && has((r) => offersOf(r).some((o) => measured(o) && share(o.uptime) != null)) && { key: 'uptime', label: 'Uptime 7 d', html: true },
-- full && has((r) => r.benchmark && value(r.benchmark.value) != null) && { key: 'bench', label: 'Bench score', html: true },
-- ].filter(Boolean);
-- const data = rows.map((r, i) => ({
-- rank: `<span class="ai-rank${rated(r) && i < 3 ? ' is-top' : ''}">${esc(rankText(r, i))}</span>`,
-- model: modelCell(r),
-- rating: ratingCell(r),
-- price: priceCell(r),
-- speed: speedCell(r),
-- uptime: uptimeCell(r),
-- bench: r.benchmark && value(r.benchmark.value) != null ? `<b>${esc(kit.num(r.benchmark.value))}</b>` : quiet('No runs yet'),
-- }));
-- const none = rows.length && !has(rated) ? `<p class="ai-note">No model has blind votes or runs yet, so nothing is ranked: these are the models with their prices and measured speed. <a href="https://openvibe.exchange/arena">Vote in the Arena</a> to rate them.</p>` : '';
-- const wrapped = data.map((d) => Object.fromEntries(Object.entries(d).map(([k, v]) => [k, k === 'model' ? v : `<span class="ai-cell">${v}</span>`])));
-- return none + kit.table({ columns, rows: wrapped, label: 'AI models', empty: 'No model matches these filters.' }).replace('class="ovk-table"', 'class="ovk-table ai-board"');
--}
--
--function page(title, description, body, path) {
-- return layout({ title, description, body: `<link rel="stylesheet" href="${asset('css/ai-comparison.css')}"><style>${marks.CSS}\n${PARTS_CSS}</style><div class="ai-page">${body}</div>`, active: '/ai', path });
--}
--const dataLinks = () => `<p class="ai-data">Data: <a href="/api/v1/ai/leaderboard">JSON</a> · <a href="/api/v1/ai/leaderboard?format=csv&amp;limit=100">CSV</a> · <a href="/ai/method">How it is measured</a></p>`;
--
--// ── /ai ──
--const PICK_LABELS = { overall: 'Best overall', value: 'Best value', fastest: 'Fastest', open: 'Best open weights', code: 'Best for code', writing: 'Best for writing', uncensored: 'Best uncensored 18+', free: 'Best free' };
--function pickNumber(id, row) {
-- if (id === 'value' || id === 'free') { const o = cheapest(row); return !o ? '' : Number(o.price.out) === 0 ? 'Free' : `${money(o.price.out)} per 1M out`; }
-- if (id === 'fastest') { const o = fastest(row); return o ? `${ms(o.speed.p50_ms)} typical reply` : ''; }
-- return rated(row) ? `Rating ${Math.round(row.rating.value)}` : '';
--}
--function hub({ rows = [], picks = [], providers = [], others = 0, month = new Date().toLocaleDateString('en-US', { month: 'long', year: 'numeric', timeZone: 'UTC' }), changed = [], tasks = [], arena = null } = {}) {
-- const ratedRows = rows.filter(rated);
-- const providerNames = new Set(providers.map(providerName).filter(Boolean));
-- const measuredOffers = providers.filter(measured);
-- const made = picks.filter((p) => p.model);
-- const pending = picks.filter((p) => !p.model).map((p) => PICK_LABELS[p.id] || p.id);
-- const pickList = made.map((p) => `<li><a class="ai-pick" href="${idPath(p.model.id)}"><span class="ai-pick-k">${esc(PICK_LABELS[p.id] || p.id)}</span>`
-- + `<span class="ai-pick-m">${marks.mark(p.model.maker || p.model.id, { size: 32, label: false })}<b>${esc(shortName(p.model.id))}</b></span>`
-- + `${pickNumber(p.id, p.model) ? `<span class="ai-pick-n">${esc(pickNumber(p.id, p.model))}</span>` : ''}<small>${esc(p.why || '')}</small></a></li>`).join('');
-- const pairs = ratedRows.slice(0, 16).reduce((a, r, i, all) => { if (i % 2 === 0 && all[i + 1]) a.push([r, all[i + 1]]); return a; }, []).slice(0, 8);
-- const sourceDate = rows.map((r) => r.source && r.source.date).find(Boolean) || 'date not reported by source';
-- const faq = [
-- ['How are ratings made?', `${ratedRows.length} models have a rating from blind pairwise votes and open runs on OpenVibe.Bench (Bradley-Terry with bootstrap intervals), dated ${sourceDate}.`],
-- ['Where do prices come from?', `Each provider's own price page, read by OpenVibe.AI's pricebook; ${providers.length} provider and model offers are listed.`],
-- ['What does the rank band mean?', 'Models whose rating intervals overlap share a band: the data cannot tell them apart yet.'],
-- ['Why does a model say "Not rated yet"?', 'No blind votes or runs on it so far. Its prices and measured speed are still listed.'],
-- ['How is speed measured?', `On real requests through OpenVibe.Exchange over seven days: ${measuredOffers.length} provider endpoints had traffic.`],
-- ['How often does this change?', 'Ratings and prices refresh within two minutes of the sources changing.'],
-- ];
-- const body = `<header class="ai-head"><p class="ai-kicker">AI comparison · ${esc(month)}</p><h1>AI model leaderboard — ${esc(month)}</h1>`
-- + `<p class="ai-lede">${rows.length} models from ${providerNames.size} providers. Ratings come from blind votes, prices from each provider's own page, and speed and uptime from real traffic.</p>`
-- + kit.find({ action: '/ai/leaderboard', placeholder: 'Search a model or maker', label: 'Search models' })
-- + kit.stats([[ratedRows.length, 'rated'], [rows.length, 'models listed'], [providerNames.size, 'providers'], [measuredOffers.length, 'measured endpoints']], { label: 'The leaderboard in numbers' })
-- + `</header>`
-- + (pickList || pending.length ? kit.section({ title: 'Top picks', body: `${pickList ? `<ul class="ai-picks">${pickList}</ul>` : ''}${pending.length ? `<p class="ai-fine">Not enough data yet for ${esc(pending.join(', '))}.</p>` : ''}` }) : '')
-- // A phone shows the parts below the top picks one at a time as tabs (the owner, 11-11: "self-contained tabs, so
-- // the page isn't so freaking huge … at least on mobile"); every part stays in the HTML, a wide screen stacks them.
-- + parts({ name: 'board', label: 'The leaderboard\'s parts', items: [
-- { id: 'leaderboard', label: 'Leaderboard', count: rows.length, html: kit.section({ title: 'Leaderboard', count: rows.length, actions: [act('/ai/leaderboard', rows.length > 25 ? `All ${rows.length} models` : 'Filter and sort')], body: board(rows.slice(0, 25)) + (others ? `<p class="ai-fine">Chat models only. ${others} embedding and speech ${others === 1 ? 'model is' : 'models are'} in the <a href="/ai/leaderboard?type=all">full list</a>.</p>` : '') + dataLinks() }) },
-- { id: 'answers', label: 'Answers', html: (arena && (arena.answers || []).length ? kit.section({ title: 'One prompt, many answers', body: `<p class="ai-prompt">${esc(arena.prompt || '')}</p><div class="ai-answers">${arena.answers.slice(0, 3).map((a) => `<article>${modelCell({ id: a.model || 'Model', maker: a.maker })}<p>${esc(a.text || '')}</p></article>`).join('')}</div>` }) : '') },
-- { id: 'tasks', label: 'By task', count: tasks.length || '', html: (tasks.length ? kit.section({ title: 'Boards by task', body: `<ul class="ai-chips">${tasks.slice(0, 16).map((x) => `<li><a href="/ai/${encodeURIComponent(x.id)}">${esc(x.name || x.id)}</a></li>`).join('')}</ul>` }) : '') },
-- { id: 'providers', label: 'Providers', count: measuredOffers.length || '', html: (measuredOffers.length ? kit.section({ title: 'Providers', actions: [act('/ai/providers', 'Every provider')], body: providerTable(measuredOffers.slice(0, 6)) }) : '') },
-- { id: 'compare', label: 'Compare', html: (pairs.length ? kit.section({ title: 'Popular comparisons', body: `<ul class="ai-chips">${pairs.map(([a, b]) => `<li><a href="/ai/compare/${encodeURIComponent(a.id)}-vs-${encodeURIComponent(b.id)}">${esc(shortName(a.id))} vs ${esc(shortName(b.id))}</a></li>`).join('')}</ul>` }) : '') },
-- { id: 'changes', label: 'Changes', html: (changed.length ? kit.section({ title: 'What changed this month', body: `<ul>${changed.slice(0, 8).map((x) => `<li>${esc(x)}</li>`).join('')}</ul>` }) : '') },
-- { id: 'questions', label: 'Questions', html: kit.section({ title: 'Questions', body: faq.map(([q, a]) => kit.fold({ title: q, body: `<p>${esc(a)}</p>` })).join('') }) },
-- ] })
-- + `<script type="application/ld+json">${JSON.stringify({ '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faq.map(([q, a]) => ({ '@type': 'Question', name: q, acceptedAnswer: { '@type': 'Answer', text: a } })) }).replace(/</g, '\\u003c')}</script>`
-- + `<script type="application/ld+json">${JSON.stringify({ '@context': 'https://schema.org', '@type': 'Dataset', name: 'OpenVibe AI model leaderboard', url: 'https://openvibe.report/ai', variableMeasured: ['Bench rating', 'Provider price', 'Provider reply time', 'Provider uptime'], distribution: { '@type': 'DataDownload', contentUrl: 'https://openvibe.report/api/v1/ai/leaderboard', encodingFormat: 'application/json' } }).replace(/</g, '\\u003c')}</script>`;
-- return page(`AI model leaderboard — ${month}`, `${rows.length} AI models compared: ratings from blind votes, provider prices and measured speed.`, body, '/ai');
--}
--
--// ── /ai/leaderboard ──
--function leaderboard({ rows = [], filters = {} } = {}) {
-- const select = (name, label, opts) => `<label class="ai-filter"><span>${esc(label)}</span><select name="${name}">${opts.map(([v, t]) => `<option value="${esc(v)}"${String(filters[name] || '') === v ? ' selected' : ''}>${esc(t)}</option>`).join('')}</select></label>`;
-- const text = (name, label, placeholder) => `<label class="ai-filter"><span>${esc(label)}</span><input name="${name}" value="${esc(filters[name] || '')}" placeholder="${esc(placeholder)}"></label>`;
-- const form = `<form class="ai-filters" action="/ai/leaderboard">`
-- + `<label class="ai-filter ai-filter--q"><span>Search</span><input name="q" type="search" value="${esc(filters.q || '')}" placeholder="Model or maker"></label>`
-- + select('type', 'Type', [['chat', 'Chat models'], ['embedding', 'Embeddings'], ['stt', 'Speech to text'], ['all', 'All']])
-- + select('sort', 'Sort by', [['rating', 'Rating'], ['price', 'Output price'], ['speed', 'Reply time'], ['name', 'Name']])
-- + select('weights', 'Weights', [['', 'Any'], ['open', 'Open weights'], ['closed', 'Closed']])
-- + select('price', 'Price', [['', 'Any price'], ['free', 'Free'], ['low', 'Low'], ['mid', 'Mid'], ['high', 'High']])
-- + text('maker', 'Maker', 'e.g. openai')
-- + text('kind', 'Task', 'e.g. design.page')
-- + select('status', 'Status', [['current', 'Current'], ['all', 'All']])
-- + select('uncensored', '18+', [['', 'Any'], ['true', 'Uncensored only']])
-- + select('community', 'Runs on', [['', 'Anywhere'], ['true', 'Community machines']])
-- + `<button class="ovk-btn ovk-btn--primary">Apply</button></form>`;
-- const body = `<header class="ai-head ai-head--page"><p class="ai-kicker"><a href="/ai">AI comparison</a></p><h1>AI model leaderboard</h1>`
-- + `<p class="ai-lede">${rows.length} ${rows.length === 1 ? 'model' : 'models'}${rows.filter(rated).length ? `, ${rows.filter(rated).length} rated` : ''}. Rated models first, then the rest with their prices.</p></header>`
-- + form + board(rows, { full: true }) + dataLinks();
-- return page('AI model leaderboard', 'Every AI model with its rating, provider prices and measured speed; filter and sort.', body, '/ai/leaderboard');
--}
--
--// ── /ai/providers ──
--const ownServer = (p) => /^(local|whisper)$/i.test(String(p.provider || '')) || /on this host|\blocal\b/i.test(String(p.provider_name || ''));
--const providerName = (p) => ownServer(p) ? 'OpenVibe server' : String(p.provider_name || p.provider || 'Provider').replace(/\s*\((?:the |an? )[^)]*\)\s*$/i, '');
--const providerMark = (p) => ownServer(p) ? marks.mark('openvibe', { size: 24, label: false }) : marks.mark(p.provider || p.provider_name, { size: 24, label: false });
--function providerTable(list) {
-- // Status is a word (the state-in-words rule): Up, Degraded with the warning tone, Down with the bad one, each with
-- // its plain reason and since when. The column shows only when some offer is not up (a column earns its place).
-- const notUp = (p) => p.routed !== false && ['down', 'degraded'].includes(String(p.status || 'up'));
-- const statusCell = (p) => {
-- if (p.routed === false) return quiet('Not routed here');
-- if (!notUp(p)) return 'Up';
-- const down = String(p.status) === 'down';
-- const why = p.status_reason ? `<small>${esc(p.status_reason)}</small>` : '';
-- const since = p.status_since ? `<small>since ${esc(String(p.status_since).slice(0, 16).replace('T', ' '))} UTC</small>` : '';
-- return `<b class="${down ? 'ai-bad' : 'ai-warn'}">${down ? 'Down' : 'Degraded'}</b>${why}${since}`;
-- };
-- const rows = list.map((p) => ({
-- provider: `<span class="ai-prov">${providerMark(p)}<b>${esc(providerName(p))}</b></span>`,
-- model: `<a href="${idPath(p.model)}">${esc(shortName(p.model))}</a>`,
-- status: statusCell(p),
-- price: p.price && value(p.price.out) != null ? `<b>${esc(money(p.price.out))}</b> out · ${esc(money(p.price.in) || '–')} in` : quiet('No price listed'),
-- reply: p.routed === false ? quiet('Not routed here') : measured(p) && p.speed && value(p.speed.p50_ms) != null ? `<b>${esc(ms(p.speed.p50_ms))}</b><small>p95 ${esc(ms(p.speed.p95_ms) || '–')}</small>` : quiet('Not measured yet'),
-- errors: p.routed === false ? quiet('Not routed here') : measured(p) && share(p.error_rate) != null ? `<b class="${Number(typeof p.error_rate === 'object' ? p.error_rate.value : p.error_rate) > 0.05 ? 'ai-bad' : ''}">${esc(share(p.error_rate))}</b>` : quiet('Not measured yet'),
-- uptime: p.routed === false ? quiet('Not routed here') : measured(p) && share(p.uptime) != null ? `<b class="${Number(p.uptime) < 0.95 ? 'ai-bad' : ''}">${esc(share(p.uptime))}</b>` : quiet('Not measured yet'),
-- requests: p.routed === false ? quiet('Not routed here') : measured(p) ? Number(p.requests_7d).toLocaleString('en-US') : quiet('Not measured yet'),
-- }));
-- return kit.table({
-- columns: [
-- { key: 'provider', label: 'Provider', primary: true, html: true },
-- { key: 'model', label: 'Model', html: true },
-- list.some(notUp) && { key: 'status', label: 'Status', html: true },
-- { key: 'price', label: 'Price per 1M', html: true },
-- { key: 'reply', label: 'Reply time', html: true },
-- { key: 'errors', label: 'Errors', html: true },
-- { key: 'uptime', label: 'Uptime 7 d', html: true },
-- { key: 'requests', label: 'Requests 7 d', html: true },
-- ],
-- rows: rows.map((d) => Object.fromEntries(Object.entries(d).map(([k, v]) => [k, k === 'provider' ? v : `<span class="ai-cell">${v}</span>`]))), label: 'Providers', empty: 'No provider is listed yet.',
-- });
--}
--function providers({ providers: list = [] } = {}) {
-- const sorted = [...list].sort((a, b) => (Number(b.requests_7d) || 0) - (Number(a.requests_7d) || 0));
-- const body = `<header class="ai-head ai-head--page"><p class="ai-kicker"><a href="/ai">AI comparison</a></p><h1>AI providers</h1>`
-- + `<p class="ai-lede">Every model and provider offer in the market. Prices come from each provider's own page. Reply time, errors and uptime are measured on real requests over the last seven days; offers outside the router say “Not routed here”, while routed offers without measurements say “Not measured yet”. Status is measured from the Exchange catalogue: down while an offer\'s circuit is open, degraded when more than 5% of at least 10 requests in the last hour failed, with the plain reason.</p></header>`
-- + providerTable(sorted);
-- return page('AI providers', 'AI providers compared: price per million tokens, reply time, error rate and uptime measured on real traffic.', body, '/ai/providers');
--}
--
--// ── /ai/models/:id ──
--function model({ id, detail = {}, row = null, providers: list = [], sourceDate = null } = {}) {
-- detail = detail || {};
-- const self = row || { id, provider_offers: list.filter((p) => p.model === id) };
-- const offers = offersOf(self).length ? offersOf(self) : list.filter((p) => p.model === id);
-- const tasks = (self.task_ratings || []).filter((t) => t.rating && value(t.rating.value) != null);
-- const maker = makerName(self);
-- const facts = kit.stats([
-- [rated(self) ? Math.round(self.rating.value) : 'Not rated yet', 'rating'],
-- [cheapest({ provider_offers: offers }) ? money(cheapest({ provider_offers: offers }).price.out) : null, 'cheapest output / 1M'],
-- [fastest({ provider_offers: offers }) ? ms(fastest({ provider_offers: offers }).speed.p50_ms) : null, 'typical reply'],
-- [offers.length || null, offers.length === 1 ? 'provider' : 'providers'],
-- ], { label: `${id} in numbers` });
-- const taskTable = tasks.length ? kit.table({
-- columns: [{ key: 'task', label: 'Task', primary: true, html: true }, { key: 'rank', label: 'Rank' }, { key: 'rating', label: 'Rating', html: true }],
-- rows: tasks.map((t) => ({ task: `<a href="/ai/${encodeURIComponent(t.kind)}">${esc(t.kind)}</a>`, rank: t.rank ? (t.rank.min === t.rank.max ? String(t.rank.min) : `${t.rank.min}–${t.rank.max}`) : 'Not enough data', rating: ratingCell({ rating: t.rating }, { range: true }) })),
-- label: 'Task ratings',
-- }) : `<p class="ai-fine">Not rated on any task yet: no blind votes or runs on this model so far.</p>`;
-- const benchmarks = (detail.benchmarks || []).length ? kit.table({
-- columns: [{ key: 'name', label: 'Benchmark', primary: true }, { key: 'score', label: 'Score' }, { key: 'runs', label: 'Runs' }, { key: 'last', label: 'Last run' }],
-- rows: detail.benchmarks.map((b) => ({ name: b.title || b.benchmark_id, score: b.score == null ? 'Not measured' : String(b.score), runs: b.runs == null ? 'Not reported' : String(b.runs), last: b.last_run ? String(b.last_run).slice(0, 10) : '–' })),
-- label: 'Benchmarks', caption: `OpenVibe.Bench${sourceDate ? ` · snapshot ${String(sourceDate).slice(0, 10)}` : ''}`,
-- }) : '';
-- const body = `<header class="ai-head ai-head--model"><p class="ai-kicker"><a href="/ai">AI comparison</a></p>`
-- + `<div class="ai-model-head">${marks.mark(self.maker || id, { size: 56 })}<div><h1>${esc(shortName(id))}</h1><p class="ai-lede">${esc(maker || 'Maker not listed')}${tags(self) ? ` ${tags(self)}` : ''} · <code>${esc(id)}</code></p></div></div>`
-- + facts + `</header>`
-- + kit.section({ title: 'Providers', count: offers.length, body: providerTable(offers) })
-- + kit.section({ title: 'Task ratings', count: tasks.length || null, body: taskTable + benchmarks })
-- + `<aside class="ai-try"><a class="ovk-btn ovk-btn--primary" href="https://openvibe.chat/vibe?models=${encodeURIComponent(id)}">Try it in Chat</a><a class="ovk-btn ovk-btn--quiet" href="https://openvibe.exchange/models/${encodeURIComponent(id)}">Use it from code</a></aside>`
-- + `<script type="application/ld+json">${JSON.stringify({ '@context': 'https://schema.org', '@type': 'Product', name: id, brand: maker || 'Unknown maker' }).replace(/</g, '\\u003c')}</script>`;
-- return page(`${shortName(id)} — AI model`, `${id}: rating, prices across providers and measured speed.`, body, idPath(id));
--}
--
--// ── /ai/compare ──
--function compare({ models = [], options = [] } = {}) {
-- const two = models.length === 2;
-- const facts = (m) => [
-- ['Rating', ratingCell(m, { range: true })],
-- ['Price', priceCell(m)],
-- ['Reply time', speedCell(m)],
-- ['Providers', offersOf(m).length ? String(offersOf(m).length) : quiet('None listed')],
-- ];
-- const cards = models.map((m) => `<article class="ai-vs-card">${modelCell(m, { size: 40 })}<dl>${facts(m).map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${v}</dd></div>`).join('')}</dl></article>`).join('');
-- const list = `<datalist id="ai-models">${options.slice(0, 500).map((o) => `<option value="${esc(o)}"></option>`).join('')}</datalist>`;
-- const form = `<form class="ai-filters ai-vs-form" action="/ai/compare"><label class="ai-filter"><span>First model</span><input name="a" list="ai-models" value="${esc(models[0] && models[0].id || '')}" required></label>`
-- + `<label class="ai-filter"><span>Second model</span><input name="b" list="ai-models" value="${esc(models[1] && models[1].id || '')}" required></label><button class="ovk-btn ovk-btn--primary">Compare</button>${list}</form>`;
-- const body = `<header class="ai-head ai-head--page"><p class="ai-kicker"><a href="/ai">AI comparison</a></p><h1>${two ? `${esc(shortName(models[0].id))} vs ${esc(shortName(models[1].id))}` : 'Compare AI models'}</h1>`
-- + `${two ? '' : '<p class="ai-lede">Choose two models to compare: their ratings, prices and measured speed side by side.</p>'}</header>`
-- + (cards ? `<div class="ai-vs">${cards}</div>` : '') + form;
-- return page(two ? `${shortName(models[0].id)} vs ${shortName(models[1].id)}` : 'Compare AI models', 'Compare AI models side by side: ratings, prices and measured speed.', body, '/ai/compare');
--}
--
--function arena() {
-- return page('AI Arena', 'Compare anonymous AI answers and vote.', `<header class="ai-head ai-head--page"><p class="ai-kicker"><a href="/ai">AI comparison</a></p><h1>AI Arena</h1><p class="ai-lede">Two anonymous answers to one prompt; you pick the better one, then see which models wrote them. Every vote moves the ratings here.</p></header><p><a class="ovk-btn ovk-btn--primary" href="https://openvibe.exchange/arena">Vote in the Arena</a></p>`, '/ai/arena');
--}
--
--module.exports = { hub, leaderboard, providers, model, compare, arena };
ddiff --git a/server/views/index.js b/server/views/index.js
iindex f567952..c727bf3 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -97,7 +97,6 @@ const NAV = [
{ label: 'You', href: '/you', icon: 'fa-user' },
{ label: 'Numbers', href: '/stats', icon: 'fa-chart-line' },
{ label: 'The Rundown', href: '/rundown', icon: 'fa-newspaper' },
-- { label: 'AI', href: '/ai', icon: 'fa-robot' },
{ label: 'API', href: '/developers', icon: 'fa-code', more: true },
];
@@@ -116,7 +115,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' }, { label: 'AI', href: '/ai', icon: 'fa-robot' }],
++ appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Numbers', href: '/stats', icon: 'fa-chart-line' }, { label: 'Rundown', href: '/rundown', icon: 'fa-newspaper' }],
history: { type: 'page', title: title || NAME },
// Sign-in is this site's own (openvibe-sdk/sso at /auth): the navbar's buttons and its silent check use it.
silentLogin: `${BASE}/auth/login?silent=1&next={url}`,
@@@ -147,7 +146,6 @@ function layout({ title, description, canonical = null, path = null, robots = 'i
<link rel="stylesheet" href="${esc(ovServe.url('room.css'))}">
<link rel="stylesheet" href="${esc(ovServe.url('showcase.css'))}">
<link rel="stylesheet" href="${esc(asset('css/app.css'))}">
--${path && String(path).startsWith('/ai') ? `<link rel="alternate" type="application/rss+xml" title="AI: new results" href="${esc(`${BASE}/ai/feed.xml`)}">` : ''}
<script src="${esc(ovServe.url('kit-web.js'))}" defer></script>
<script src="${esc(ovServe.url('kit-data-web.js'))}" defer></script>
<script src="${esc(ovServe.url('motion.js'))}" defer></script>
@@@ -281,7 +279,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', text: 'Which AI does each job best, what it costs and how fast it is: Bench results from every room', href: '/ai', icon: 'ov:ai' },
++ { title: 'AI models', text: 'Which AI does each job best, what it costs and how fast it is: the comparison lives on OpenVibe.Compare', href: 'https://openvibe.compare/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: [
@@@ -843,353 +841,6 @@ function scoreCostChart(entries) {
const TABS = [{ key: 'elo', label: 'Ranking' }, { key: 'pareto', label: 'Quality and cost' }, { key: 'cost', label: 'Cost per solved' }];
--function aiIndex({ overview, kinds, boards = [], rankings = [] }) {
-- const o = readData(overview) || {};
-- const list = kinds && kinds.data && Array.isArray(kinds.data.kinds) ? kinds.data.kinds : [];
-- const benches = Array.isArray(o.list) ? o.list : [];
-- const lastByKind = byKindFromOverview(o);
-- const items = list.map((kind, i) => ({ kind, rows: rowsOf(boards[i]), available: Boolean(boards[i]) }));
-- const rated = items.filter((x) => played(x.rows)).sort((a, b) => gamesOf(b.rows) - gamesOf(a.rows));
-- const waiting = items.filter((x) => !played(x.rows));
-- const leaders = rated.length ? kit.table({
-- label: 'Task kinds and their leaders', columns: [
-- { key: 'job', label: 'Job', primary: true, html: true }, { key: 'leader', label: 'Leader', html: true },
-- { key: 'quality', label: 'Quality', num: true, html: true }, { key: 'games', label: 'Games', num: true, fmt: 'int' },
-- { key: 'cost', label: 'Cost/run', num: true, html: true },
-- { key: 'speed', label: 'Speed/run', num: true, html: true }, { key: 'last', label: 'Last run', html: true },
-- ],
-- rows: rated.map(({ kind, rows }) => {
-- const top = rows[0];
-- return {
-- job: `<a href="${aiPath(kind.id)}">${esc(kind.name || kind.id)}</a>`,
-- leader: `${modelLink(top.subject)}${flag(top)}`,
-- quality: rating(top),
-- games: gamesOf(rows),
-- cost: moneyCell(top.median_cost_usd),
-- speed: speedCell(top.median_ms),
-- last: lastByKind.get(kind.id) ? `<span title="${esc(lastByKind.get(kind.id))}">${esc(dayTime(lastByKind.get(kind.id)))}</span>` : '—',
-- };
-- }),
-- }) : '';
-- const waitRows = waiting.map(({ kind, available }) => kit.row({ href: aiPath(kind.id), title: kind.name || kind.id, sub: `${roomName(kind.room)} · ${available ? howRated(kind) : 'board unavailable'}`, trail: kit.icon('chevron') }));
-- const waitList = waitRows.length ? kit.list(waitRows, { flush: true }) : '';
-- const benchRow = (b, i) => {
-- const r = rankings[i] && readData(rankings[i]);
-- const top = r && r.rankings && Array.isArray(r.rankings.entries) ? r.rankings.entries[0] : null;
-- return {
-- benchmark: `<a href="${aiPath(b.id)}">${esc(b.title)}</a>`,
-- kind: esc(b.kind || '—'),
-- leader: top ? `${modelLink(top.model)} ${scoreCell(top.score)}` : '—',
-- runs: b.runs == null ? 0 : b.runs,
-- last: b.last_run ? esc(dayTime(b.last_run)) : 'No runs yet',
-- };
-- };
-- const activeBench = benches.filter((b) => Number(b.runs) > 0);
-- const idleBench = benches.filter((b) => !(Number(b.runs) > 0));
-- const benchTable = activeBench.length ? kit.table({
-- label: 'Benchmarks with published runs', columns: [
-- { key: 'benchmark', label: 'Benchmark', primary: true, html: true }, { key: 'kind', label: 'Task kind' },
-- { key: 'leader', label: 'Leader', html: true }, { key: 'runs', label: 'Runs', num: true, fmt: 'int' }, { key: 'last', label: 'Last run', html: true },
-- ], rows: benches.map((b, i) => (Number(b.runs) > 0 ? benchRow(b, i) : null)).filter(Boolean),
-- }) : '';
-- const idleRows = benches.map((b, i) => (Number(b.runs) > 0 ? null : kit.row({ href: aiPath(b.id), title: b.title, sub: `${b.kind || 'benchmark'} · no run yet`, trail: kit.icon('chevron') }))).filter(Boolean);
-- const idleList = idleRows.length ? kit.list(idleRows, { flush: true }) : '';
-- const none = !rated.length && list.length;
-- const body = kit.head({ title: 'AI', sub: 'Which AI does each job best, what it costs, and how fast it is', sticky: false })
-- + benchState(overview, kinds, ...rankings, ...boards)
-- + `<p class="rp-fine">Every number on this page is a published Bench result or vote. ${esc(METHOD)} <a href="/ai/method">How the runs are judged</a> · <a href="/ai/compare">Compare models</a> · <a href="/ai/feed.xml">New results (RSS)</a></p>`
-- + (!list.length && !benches.length ? kit.empty({ icon: 'trophy', title: 'Bench is unavailable', text: 'The boards come back as soon as Bench answers.' }) : '')
-- + (o.benchmarks != null || o.runs != null ? kit.stats([
-- [o.benchmarks == null ? '—' : exact(o.benchmarks), o.benchmarks === 1 ? 'benchmark' : 'benchmarks'],
-- [o.tasks == null ? '—' : exact(o.tasks), o.tasks === 1 ? 'task' : 'tasks'],
-- [o.runs == null ? '—' : exact(o.runs), o.runs === 1 ? 'run' : 'runs'],
-- [o.models == null ? '—' : exact(o.models), o.models === 1 ? 'model' : 'models'],
-- [o.last_run ? dayTime(o.last_run) : 'No runs yet', 'last run'],
-- ], { label: 'AI in numbers' }) : (rated.length ? kit.stats([
-- [rated.length, rated.length === 1 ? 'job rated' : 'jobs rated'],
-- [rated.reduce((sum, x) => sum + gamesOf(x.rows), 0), 'games played'],
-- [list.length, 'jobs tracked'],
-- ], { label: 'The boards in numbers' }) : ''))
-- + (none ? kit.notice({ kind: 'info', text: `No model is rated on a task kind yet. ${list.length} jobs are set up; each board fills as people vote blind on two answers, or as graders score runs.` }) : '')
-- + (leaders ? kit.section({ title: 'Task kinds', count: rated.length, body: leaders }) : '')
-- + (benchTable ? kit.section({ title: 'Benchmarks with published runs', count: activeBench.length, body: benchTable }) : '')
-- + (idleList ? kit.fold({ title: 'Waiting for their first run', meta: String(idleRows.length), body: idleList }) : '')
-- + (waitList ? (none ? kit.fold({ title: 'Jobs being rated', meta: String(waiting.length), body: waitList }) : kit.fold({ title: 'Waiting for their first results', meta: String(waiting.length), body: waitList })) : '')
-- + `<p class="rp-fine">The open data is Bench's: <a href="${BENCH}/docs">its API and datasets</a> stay on bench.openvibe.services.</p>`;
-- return layout({ title: 'AI', description: 'Which AI does each job best, and what it costs: OpenVibe.Bench results, rendered by OpenVibe.Report.', body, active: '/ai', path: '/ai', agent: true });
--}
--
--function aiBoard({ kindId, kind, tab, board, cost, kinds, benchmarks, rankings = [], runs, overview }) {
-- const name = kind && kind.name || kindId;
-- const rows = rowsOf(board);
-- const o = readData(overview) || {};
-- const lastByKind = byKindFromOverview(o);
-- const kindLast = lastByKind.get(kindId) || null;
-- const latest = readData(runs);
-- const latestRows = latest && Array.isArray(latest.runs) ? latest.runs : [];
-- // The registry's verified runs on this kind's public benchmarks: a second, task-level reading even while the
-- // blind board has no votes. A model's numbers across the kind's benchmarks are medians, never a sum of scores.
-- const kindBenchmarks = (() => {
-- const b = readData(benchmarks);
-- return b && Array.isArray(b.benchmarks) ? b.benchmarks.filter((x) => x.kind === kindId) : [];
-- })();
-- const byModel = new Map();
-- kindBenchmarks.forEach((b, i) => {
-- const r = rankings[i] && readData(rankings[i]);
-- const entries = r && r.rankings && Array.isArray(r.rankings.entries) ? r.rankings.entries : [];
-- for (const e of entries) {
-- if (!byModel.has(e.model)) byModel.set(e.model, { model: e.model, scores: [], costs: [], times: [], runs: 0, last_run: null });
-- const x = byModel.get(e.model);
-- if (n(e.score) != null) x.scores.push(n(e.score));
-- if (n(e.cost_usd) != null) x.costs.push(n(e.cost_usd));
-- if (n(e.ms) != null) x.times.push(n(e.ms));
-- x.runs += Number(e.runs) || 0;
-- if (e.last_run && (!x.last_run || e.last_run > x.last_run)) x.last_run = e.last_run;
-- }
-- });
-- const median = (list) => { const v = list.filter((x) => x != null).sort((a, b) => a - b); if (!v.length) return null; const m = Math.floor(v.length / 2); return v.length % 2 ? v[m] : (v[m - 1] + v[m]) / 2; };
-- const registryRows = [...byModel.values()].map((x) => ({ model: x.model, score: median(x.scores), cost: median(x.costs), speed: median(x.times), runs: x.runs, last_run: x.last_run })).sort((a, b) => (b.score == null ? -1 : b.score) - (a.score == null ? -1 : a.score) || a.model.localeCompare(b.model));
-- const registryTable = registryRows.length ? kit.table({ caption: 'Published runs on this kind', columns: [
-- { key: 'model', label: 'Model', primary: true, html: true }, { key: 'score', label: 'Median score', num: true, html: true },
-- { key: 'runs', label: 'Runs', num: true, fmt: 'int' }, { key: 'cost', label: 'Cost/run', num: true, html: true },
-- { key: 'speed', label: 'Speed/run', num: true, html: true }, { key: 'last', label: 'Last run', html: true },
-- ], rows: registryRows.map((r) => ({ model: modelLink(r.model), score: scoreText(r.score), runs: r.runs, cost: moneyCell(r.cost), speed: speedCell(r.speed), last: r.last_run ? esc(dayTime(r.last_run)) : '—' })) }) : '';
-- let content;
-- if (!board) content = kit.empty({ icon: 'trophy', title: 'Bench is unavailable', text: 'This board comes back as soon as Bench answers.' });
-- else if (!played(rows) && registryRows.length) content = kit.notice({ kind: 'info', text: `No blind vote on this task kind yet. ${registryRows.length} ${registryRows.length === 1 ? 'model has' : 'models have'} verified runs below.` });
-- else if (!played(rows)) content = emptyFor(kind);
-- else if (tab === 'pareto') {
-- const points = paretoRows(board, cost);
-- content = paretoChart(points) + (points.length ? kit.table({ caption: 'The numbers', columns: [
-- { key: 'm', label: 'Model', primary: true, html: true }, { key: 'q', label: 'Quality', num: true, html: true },
-- { key: 'c', label: 'Cost per solved', num: true, html: true }, { key: 'speed', label: 'Speed/run', num: true, html: true },
-- ], rows: points.map((r) => ({ m: `${modelLink(r.subject)}${r.frontier ? ` ${kit.badge('frontier', 'accent')}` : ''}`, q: share(r.quality), c: usd(r.costPerSolved), speed: speedCell(r.median_ms) })) }) : '');
-- } else if (tab === 'cost') {
-- const crows = rowsOf(cost).length ? rowsOf(cost) : rows;
-- content = kit.table({ caption: 'Cost per solved task', columns: [
-- { key: 'm', label: 'Model', primary: true, html: true }, { key: 'solved', label: 'Solved', num: true, fmt: 'int' },
-- { key: 'entries', label: 'Entries', num: true, fmt: 'int' }, { key: 'total', label: 'Total cost', num: true, html: true },
-- { key: 'per', label: 'Cost per solved', num: true, html: true }, { key: 'speed', label: 'Speed/run', num: true, html: true },
-- ], rows: crows.map((r) => ({ m: modelLink(r.subject), solved: r.solved, entries: r.entries, total: usd(r.total_cost_usd), per: usd(r.cost_per_solved), speed: speedCell(r.median_ms) })) });
-- } else {
-- const floor = Math.floor((Math.min(...rows.map((r) => n(r.ci_lower) ?? n(r.elo) ?? 0)) - 25) / 50) * 50;
-- content = kit.rank({ label: `${name}: ranking`, base: floor, fmt: 'int', items: rows.map((r) => {
-- const lo = n(r.ci_lower), hi = n(r.ci_upper), elo = n(r.elo);
-- const costText = n(r.median_cost_usd) != null ? `${usd(r.median_cost_usd)}/run` : null;
-- const speedText = n(r.median_ms) != null ? `${msText(r.median_ms)}/run` : null;
-- return { name: r.subject, href: modelPath(r.subject), value: elo, interval: lo != null && hi != null && elo != null ? Math.round(Math.max(hi - elo, elo - lo)) : null,
-- note: [n(r.games) != null ? `${exact(r.games)} games` : '', n(r.win_rate) != null ? `${share(r.win_rate)} won` : '', costText, speedText, provisional(r) ? 'provisional' : ''].filter(Boolean).join(' · ') };
-- }) }) + `<p class="rp-fine">${esc(METHOD)}</p>`
-- + kit.table({ caption: 'Quality, price and speed side by side', columns: [
-- { key: 'm', label: 'Model', primary: true, html: true }, { key: 'q', label: 'Quality', num: true, html: true },
-- { key: 'games', label: 'Games', num: true, fmt: 'int' }, { key: 'won', label: 'Won', num: true, html: true },
-- { key: 'cost', label: 'Cost/run', num: true, html: true }, { key: 'speed', label: 'Speed/run', num: true, html: true },
-- ], rows: rows.map((r) => ({ m: modelLink(r.subject), q: rating(r), games: r.games, won: share(r.win_rate), cost: moneyCell(r.median_cost_usd), speed: speedCell(r.median_ms) })) });
-- }
-- const lastRunTable = latestRows.length ? kit.table({
-- caption: 'Latest runs of this kind', columns: [
-- { key: 'model', label: 'Model', primary: true, html: true }, { key: 'score', label: 'Score', num: true, html: true },
-- { key: 'cost', label: 'Cost', num: true, html: true }, { key: 'speed', label: 'Time', num: true, html: true },
-- { key: 'when', label: 'Published', html: true }, { key: 'how', label: 'How', html: true },
-- ],
-- rows: latestRows.map((r) => ({ model: modelLink(r.model), score: scoreCell(r.score), cost: usd(r.cost_usd), speed: speedCell(r.ms), when: esc(dayTime(r.created_at)), how: provenanceCell(r.provenance) })),
-- }) : '<p class="rp-fine">No run of this task kind has been published yet.</p>';
-- const head = kit.head({ title: name, sub: kind ? `${roomName(kind.room)} · ${howRated(kind)}${kindLast ? ` · ${lastRunText(kindLast)}` : ''}` : 'Bench results', back: { href: '/ai', label: 'AI' },
-- tabs: played(rows) ? kit.tabs({ base: aiPath(kindId), items: TABS, active: tab, label: 'Board view' }) : '' });
-- const body = head + benchState(kinds, board, cost, benchmarks, runs, overview)
-- + `<section aria-label="${esc(TABS.find((t) => t.key === tab)?.label || 'Board')}">${content}</section>`
-- + (registryRows.length ? kit.section({ title: 'Verified runs on this task kind', count: registryRows.length, body: (registryRows.length > 1 ? scoreCostChart(registryRows.map((r) => ({ model: r.model, score: r.score, cost_usd: r.cost, ms: r.speed, runs: r.runs, last_run: r.last_run }))) : '') + registryTable }) : '')
-- + kit.section({ title: 'Latest runs', count: latestRows.length, body: lastRunTable })
-- + datasetLink(kindId);
-- return layout({ title: `${name}: AI`, description: `${name}: which AI does it best, what it costs and how fast it is.`, body, active: '/ai', path: aiPath(kindId), agent: true });
--}
--
--function aiBenchmark({ benchmark, rankings, runs }) {
-- const b = readData(benchmark);
-- const rank = readData(rankings);
-- const latest = readData(runs);
-- const entries = rank && rank.rankings && Array.isArray(rank.rankings.entries) ? rank.rankings.entries : [];
-- const latestRows = latest && Array.isArray(latest.runs) ? latest.runs : [];
-- const meta = b && b.benchmark ? b.benchmark : null;
-- const definition = b && b.definition ? b.definition : null;
-- const tasks = definition && Array.isArray(definition.tasks) ? definition.tasks : [];
-- const version = b && b.version ? b.version.version : null;
-- const last = entries.reduce((at, e) => (!at || (e.last_run && e.last_run > at) ? e.last_run : at), null)
-- || (latestRows[0] && latestRows[0].created_at) || null;
-- if (!meta) {
-- const body = kit.head({ title: 'Benchmark', sub: 'Bench results', back: { href: '/ai', label: 'AI' }, sticky: false })
-- + benchState(benchmark) + kit.empty({ icon: 'trophy', title: 'Bench is unavailable', text: 'This benchmark comes back as soon as Bench answers.' });
-- return layout({ title: 'AI benchmark', description: 'A benchmark of AI runs from OpenVibe.Bench.', body, active: '/ai', path: '/ai', agent: true });
-- }
-- const chart = scoreCostChart(entries);
-- const leaderboard = entries.length ? kit.table({
-- caption: 'Verified results', columns: [
-- { key: 'model', label: 'Model', primary: true, html: true }, { key: 'score', label: 'Score', num: true, html: true },
-- { key: 'runs', label: 'Runs', num: true, fmt: 'int' }, { key: 'range', label: 'Best / worst', num: true, html: true },
-- { key: 'cost', label: 'Cost/run', num: true, html: true }, { key: 'speed', label: 'Speed/run', num: true, html: true },
-- { key: 'solved', label: 'Cost per solved', num: true, html: true }, { key: 'last', label: 'Last run', html: true },
-- ],
-- rows: entries.map((e) => ({
-- model: modelLink(e.model), score: scoreCell(e.score), runs: e.runs,
-- range: `${scoreCell(e.max_score)} / ${scoreCell(e.min_score)}`, cost: moneyCell(e.cost_usd), speed: speedCell(e.ms),
-- solved: moneyCell(e.cost_per_solved), last: e.last_run ? esc(dayTime(e.last_run)) : '—',
-- })),
-- }) : kit.empty({ icon: 'trophy', title: 'No verified results yet', text: 'This benchmark has no verified run yet. Submitted runs are labelled on the latest-runs list and never mixed into the ranking.' });
-- const latestTable = latestRows.length ? kit.table({
-- caption: 'Latest runs', columns: [
-- { key: 'model', label: 'Model', primary: true, html: true }, { key: 'score', label: 'Score', num: true, html: true },
-- { key: 'cost', label: 'Cost', num: true, html: true }, { key: 'speed', label: 'Time', num: true, html: true },
-- { key: 'when', label: 'Published', html: true }, { key: 'how', label: 'How', html: true },
-- ],
-- rows: latestRows.map((r) => ({ model: modelLink(r.model), score: scoreCell(r.score), cost: usd(r.cost_usd), speed: speedCell(r.ms), when: esc(dayTime(r.created_at)), how: provenanceCell(r.provenance) })),
-- }) : '<p class="rp-fine">No run has been published on this benchmark yet.</p>';
-- const body = kit.head({ title: meta.title, sub: `${meta.kind || 'Benchmark'}${version ? ` · version ${version}` : ''} · ${tasks.length} ${tasks.length === 1 ? 'task' : 'tasks'} · ${lastRunText(last)}`, back: { href: '/ai', label: 'AI' }, sticky: false })
-- + benchState(benchmark, rankings, runs)
-- + (meta.description ? `<p class="rp-lede">${esc(meta.description)}</p>` : '')
-- + kit.stats([[entries.length, entries.length === 1 ? 'model ranked' : 'models ranked'], [latestRows.length, 'latest runs'], [tasks.length, tasks.length === 1 ? 'task' : 'tasks'], [last ? dayTime(last) : 'No runs yet', 'last run']], { label: `${meta.title} in numbers` })
-- + chart + leaderboard
-- + kit.section({ title: 'Latest runs', count: latestRows.length, body: latestTable })
-- + `<p class="rp-fine"><a href="/ai/method">How the runs are judged</a> · <a href="/ai/compare?models=${entries.slice(0, 3).map((e) => encodeURIComponent(e.model)).join(',')}">Compare the leaders</a> · <a href="${BENCH}/api/v1/datasets/benchmarks.jsonl">Bench's open data</a></p>`;
-- return layout({ title: `${meta.title}: AI benchmark`, description: `${meta.title}: verified AI results, cost and speed from OpenVibe.Bench.`, body, active: '/ai', path: aiPath(meta.id), agent: true });
--}
--
--function aiModel({ model, detail, legacy }) {
-- const d = readData(detail) || null;
-- const l = readData(legacy) || null;
-- const runs = d && Array.isArray(d.recent_runs) ? d.recent_runs : [];
-- const benchmarks = d && Array.isArray(d.benchmarks) ? d.benchmarks : [];
-- const tasks = d && Array.isArray(d.tasks) ? d.tasks : [];
-- const ratings = l && Array.isArray(l.ratings) ? l.ratings : [];
-- const entries = l && Array.isArray(l.entries) ? l.entries : [];
-- const hasRuns = Boolean(d && d.runs > 0);
-- const history = runs.filter((r) => n(r.score) != null && ts(r.created_at) != null).sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)));
-- const historyChart = history.length >= 2 ? kit.chart({
-- type: 'line', title: 'Score over time', note: 'Every public run, newest last.',
-- x: { time: true, label: 'Published' }, y: { label: 'Score (0–100)', zero: false, fmt: (v) => `${Math.round(v)}` },
-- series: [{ name: model, points: history.map((r) => [r.created_at, n(r.score)]) }], height: 220,
-- }) : '';
-- const best = tasks.slice(0, 5);
-- const worst = tasks.length > 5 ? tasks.slice(-5).reverse() : [];
-- const taskRow = (t) => ({
-- task: `<span title="${esc(t.benchmark_title)}">${esc(t.task_id)}</span>`,
-- benchmark: `<a href="${aiPath(t.benchmark_id)}">${esc(t.benchmark_title)}</a>`,
-- score: scoreCell(t.score), runs: t.runs, last: t.last_run ? esc(dayTime(t.last_run)) : '—',
-- });
-- const taskColumns = [
-- { key: 'task', label: 'Task', primary: true, html: true }, { key: 'benchmark', label: 'Benchmark', html: true },
-- { key: 'score', label: 'Median score', num: true, html: true }, { key: 'runs', label: 'Runs', num: true, fmt: 'int' },
-- { key: 'last', label: 'Last run', html: true },
-- ];
-- const taskTable = best.length ? kit.table({ caption: 'Best tasks', columns: taskColumns, rows: best.map(taskRow) }) : '';
-- const worstTable = worst.length ? kit.table({ caption: 'Worst tasks', columns: taskColumns, rows: worst.map(taskRow) }) : '';
-- const benchmarkRows = benchmarks.map((b) => ({
-- benchmark: `<a href="${aiPath(b.benchmark_id)}">${esc(b.title)}</a>`, kind: esc(b.kind || '—'), runs: b.runs,
-- score: scoreCell(b.score), range: `${scoreCell(b.best)} / ${scoreCell(b.worst)}`, cost: moneyCell(b.cost_usd), speed: speedCell(b.ms), last: b.last_run ? esc(dayTime(b.last_run)) : '—',
-- }));
-- const runTitles = new Map(benchmarks.map((b) => [b.benchmark_id, b.title]));
-- const runRows = runs.slice(0, 12).map((r) => ({
-- benchmark: `<a href="${aiPath(r.benchmark_id)}">${esc(runTitles.get(r.benchmark_id) || r.benchmark_id)}</a>`, score: scoreCell(r.score), cost: usd(r.cost_usd), speed: speedCell(r.ms),
-- when: esc(dayTime(r.created_at)), how: provenanceCell(r.provenance),
-- }));
-- const legacyRows = ratings.map((r) => ({ job: `<a href="${aiPath(r.kind_id)}">${esc(r.kind_id)}</a>`, rating: rating(r), games: r.games, won: share(r.win_rate) }));
-- const body = kit.head({ title: model, sub: hasRuns ? `${exact(d.runs)} verified ${d.runs === 1 ? 'run' : 'runs'} · ${lastRunText(d.last_run)}` : 'No verified runs yet', back: { href: '/ai', label: 'AI' }, sticky: false })
-- + benchState(detail, legacy)
-- + (hasRuns ? kit.stats([
-- [exact(d.runs), d.runs === 1 ? 'run' : 'runs'], [benchmarks.length, benchmarks.length === 1 ? 'benchmark' : 'benchmarks'],
-- [d.best_task ? scoreText(d.best_task.score) : '—', 'best task'], [d.worst_task ? scoreText(d.worst_task.score) : '—', 'worst task'],
-- [d.total_cost_usd == null ? '—' : usd(d.total_cost_usd), 'spent on runs'], [d.last_run ? dayTime(d.last_run) : 'No runs yet', 'last run'],
-- ], { label: `${model} in numbers` }) : '')
-- + (historyChart ? kit.section({ title: 'Runs over time', body: historyChart }) : '')
-- + (benchmarkRows.length ? kit.section({ title: 'By benchmark', count: benchmarks.length, body: kit.table({ caption: 'By benchmark', columns: [
-- { key: 'benchmark', label: 'Benchmark', primary: true, html: true }, { key: 'kind', label: 'Task kind' }, { key: 'runs', label: 'Runs', num: true, fmt: 'int' },
-- { key: 'score', label: 'Score', num: true, html: true }, { key: 'range', label: 'Best / worst', num: true, html: true }, { key: 'cost', label: 'Cost/run', num: true, html: true },
-- { key: 'speed', label: 'Speed/run', num: true, html: true }, { key: 'last', label: 'Last run', html: true },
-- ], rows: benchmarkRows }) }) : '')
-- + (taskTable ? kit.section({ title: 'Best and worst tasks', count: best.length + worst.length, body: taskTable + worstTable }) : '')
-- + (runRows.length ? kit.section({ title: 'Recent runs', count: runRows.length, body: kit.table({ caption: 'Recent runs', columns: [
-- { key: 'benchmark', label: 'Benchmark', primary: true, html: true }, { key: 'score', label: 'Score', num: true, html: true },
-- { key: 'cost', label: 'Cost', num: true, html: true }, { key: 'speed', label: 'Time', num: true, html: true }, { key: 'when', label: 'Published', html: true }, { key: 'how', label: 'How', html: true },
-- ], rows: runRows }) }) : '')
-- + (!hasRuns ? kit.empty({ icon: 'trophy', title: 'No verified runs yet', text: 'This model appears on a benchmark once a verified run is published. Its blind-board ratings, when it has any, are below.' }) : '')
-- + (legacyRows.length ? kit.section({ title: 'Blind-board ratings', count: legacyRows.length, body: kit.table({ caption: 'Ratings by job', columns: [
-- { key: 'job', label: 'Job', primary: true, html: true }, { key: 'rating', label: 'Rating', num: true, html: true }, { key: 'games', label: 'Games', num: true, fmt: 'int' }, { key: 'won', label: 'Won', num: true, html: true },
-- ], rows: legacyRows }) }) : '')
-- + (entries.length && !hasRuns ? `<p class="rp-fine">${exact(entries.length)} ${entries.length === 1 ? 'entry' : 'entries'} on Bench’s blind boards.</p>` : '')
-- + `<p class="rp-fine"><a href="/ai/compare?models=${encodeURIComponent(model)}">Compare ${esc(model)} with another model</a> · <a href="/ai/method">How the runs are judged</a> · <a href="${BENCH}/api/v1/models/${encodeURIComponent(model)}">Bench’s own record</a></p>`;
-- return layout({ title: `${model}: AI model`, description: `${model} across OpenVibe benchmark jobs: score, cost, speed, runs over time and its best and worst tasks.`, body, active: '/ai', path: modelPath(model), agent: true });
--}
--
--function aiCompare({ models = [], details = [], legacy = [], errors = [] }) {
-- const rowsByBenchmark = new Map();
-- const names = [];
-- const cards = [];
-- models.forEach((model, i) => {
-- const d = readData(details[i]) || null;
-- const l = readData(legacy[i]) || null;
-- names.push(model);
-- const list = d && Array.isArray(d.benchmarks) ? d.benchmarks : [];
-- for (const b of list) {
-- if (!rowsByBenchmark.has(b.benchmark_id)) rowsByBenchmark.set(b.benchmark_id, { title: b.title, cells: new Map() });
-- rowsByBenchmark.get(b.benchmark_id).cells.set(model, b);
-- }
-- cards.push({
-- model, runs: d ? d.runs : 0, cost: d ? d.total_cost_usd : null, last: d ? d.last_run : null,
-- ratingCount: l && Array.isArray(l.ratings) ? l.ratings.length : 0,
-- });
-- });
-- const rows = [...rowsByBenchmark.entries()].map(([id, x]) => {
-- const row = { benchmark: `<a href="${aiPath(id)}">${esc(x.title)}</a>` };
-- for (const model of models) {
-- const b = x.cells.get(model);
-- row[model] = b ? `<b>${scoreCell(b.score)}</b> <small>${esc(usd(b.cost_usd))}/run · ${esc(msText(b.ms))} · ${exact(b.runs)} runs</small>` : '—';
-- }
-- return row;
-- });
-- const columns = [{ key: 'benchmark', label: 'Benchmark', primary: true, html: true }, ...models.map((m) => ({ key: m, label: m, html: true }))];
-- const form = `<form class="rp-ai-compare-form" method="get" action="/ai/compare" role="search" aria-label="Compare models">
--<label for="rp-compare-models">Two or three model ids, comma separated</label>
--<input id="rp-compare-models" name="models" value="${esc(models.join(', '))}" placeholder="gpt-5, claude-opus-4" autocomplete="off">
--<button class="rp-btn" type="submit">Compare</button></form>`;
-- const cardHtml = cards.length ? kit.stats(cards.map((c) => [`${c.runs} · ${c.cost == null ? '—' : usd(c.cost)} · ${c.last ? day(c.last) : 'no runs'}`, c.model]), { label: 'Models in this comparison' }) : '';
-- const body = kit.head({ title: 'Compare AI models', sub: 'The same benchmarks side by side: score, cost per run, speed and the last run', back: { href: '/ai', label: 'AI' }, sticky: false })
-- + benchState(...details, ...legacy)
-- + form
-- + (errors.length ? kit.notice({ kind: 'warn', text: errors.join(' ') }) : '')
-- + (models.length < 2 ? kit.empty({ icon: 'trophy', title: 'Pick two or three models', text: 'Put their Bench ids in the box above; the comparison is built from their published runs.' }) : '')
-- + (rows.length ? kit.section({ title: 'Side by side', body: kit.table({ caption: 'Models side by side', columns, rows }) }) : (models.length >= 2 ? kit.empty({ icon: 'trophy', title: 'No shared benchmarks yet', text: 'These models have no verified run on a public benchmark in common.' }) : ''))
-- + (cardHtml ? `<p class="rp-fine">${esc('Each card: verified runs · total run cost · last run, then the model.')}</p>${cardHtml}` : '')
-- + `<p class="rp-fine">Cost and speed are the medians of each model's runs on that benchmark; a score is the median of its run scores. <a href="/ai/method">How the runs are judged</a>.</p>`;
-- return layout({ title: 'Compare AI models', description: 'Compare two or three AI models on the same OpenVibe benchmarks: score, cost and speed.', body, active: '/ai', path: '/ai/compare', agent: true });
--}
--
--function aiMethod({ overview, kinds }) {
-- const o = readData(overview) || {};
-- const list = kinds && kinds.data && Array.isArray(kinds.data.kinds) ? kinds.data.kinds : [];
-- const byJudge = new Map();
-- for (const k of list) {
-- if (!byJudge.has(k.judge)) byJudge.set(k.judge, []);
-- byJudge.get(k.judge).push(k);
-- }
-- const judgeRows = [...byJudge.entries()].map(([judge, ks]) => ({
-- how: `<b>${esc(JUDGE_SHORT[judge] || judge)}</b>`, judge: esc(judge), jobs: esc(ks.map((k) => k.name || k.id).join(', ')),
-- }));
-- const body = kit.head({ title: 'How the AI results are judged', sub: 'The method behind every score, price and speed on these pages', back: { href: '/ai', label: 'AI' }, sticky: false })
-- + benchState(overview, kinds)
-- + `<section class="rp-ai-method"><h2>What a score means</h2>
--<p>A score is a run’s result on one benchmark, from 0 to 100. Bench’s frozen benchmark version names its tasks, its judges and how the task scores combine; the default is the mean of the task scores. A model’s score on a benchmark is the median of its <b>verified</b> runs, so one bad run does not decide the page. The best and worst columns are the runs themselves, not a second estimate.</p>
--<h2>Quality, price and speed</h2>
--<p><b>Quality</b> is that score. <b>Price</b> is the median cost of a run in US dollars, as the service that ran it published it; <b>cost per solved</b> is the run’s cost divided by its success rate (score ÷ 100), so it is the price of one solved task. <b>Cost per solved</b> is the same figure, named plainly. <b>Speed</b> is the median wall-clock time of a run. The quality-against-cost chart marks the frontier: the models nothing beats on both.</p>
--<h2>How runs are judged</h2>
--<p>Each task kind has one main judge. A benchmark may also name more judges in its definition; the exact list is in its public definition at <code>/api/v1/benchmarks/:id</code>.</p>
--${judgeRows.length ? kit.table({ caption: 'Judges in use', columns: [{ key: 'how', label: 'How', html: true }, { key: 'judge', label: 'Bench judge' }, { key: 'jobs', label: 'Task kinds' }], rows: judgeRows }) : `<p class="rp-fine">${esc('Bench did not answer its kind list; the judge rules above are its published contract.')}</p>`}
--<h2>Verified and submitted</h2>
--<p>A <b>verified</b> run was executed by a service (openvibe.codes, Run, a Node worker); a <b>submitted</b> run was posted with a person’s token. The rankings read verified runs only, and a submitted run is labelled wherever the latest runs are listed.</p>
--<h2>How often, and what is missing</h2>
--<p>${o.runs == null ? 'Bench did not answer the public counts.' : `${exact(o.benchmarks)} public ${o.benchmarks === 1 ? 'benchmark' : 'benchmarks'}, ${exact(o.tasks)} ${o.tasks === 1 ? 'task' : 'tasks'}, ${exact(o.runs)} ${o.runs === 1 ? 'run' : 'runs'} from ${exact(o.models)} ${o.models === 1 ? 'model' : 'models'}; the newest run was ${o.last_run ? esc(dayTime(o.last_run)) : 'never published'}.`}</p>
--<p class="rp-fine">A model with no verified run has no score here; a benchmark with no run says so. The public reads are Bench’s API at <a href="${BENCH}/docs">bench.openvibe.services/docs</a>; the open data is <a href="${BENCH}/api/v1/datasets/benchmarks.jsonl">benchmarks.jsonl</a> and the per-kind datasets. <a href="/ai/feed.xml">New results (RSS)</a> · <a href="/api/v1/ai/results">the same as JSON</a>.</p>`;
-- return layout({ title: 'How the AI results are judged', description: 'The method behind OpenVibe.Bench’s scores, costs and speeds, and how to read them.', body, active: '/ai', path: '/ai/method', agent: true });
--}
--
// ── The AI Exchange (plan G-P9): Billing's settlements, summed by day, model and access ────────────────────────
// Money is integer nano-USD and stays exact: this formatter works in BigInt, never in floats, and the title carries
// the exact nano-USD the authority sent.
@@@ -1468,4 +1119,4 @@ 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, sessionReport, sharingChip, topic, episode, speaker, recap, channel, you, youSignedOut, me, meSignedOut, meError, publicStats, publicStatsPrivate, developers, aiIndex, aiBoard, aiBenchmark, aiModel, aiCompare, aiMethod, aiExchange, staffOnly, usdNano, paretoRows, paretoChart, notFound, layout, setRelease, configure };
++module.exports = { home, stats, rundown, sessionReport, sharingChip, topic, episode, speaker, recap, channel, you, youSignedOut, me, meSignedOut, meError, publicStats, publicStatsPrivate, developers, aiExchange, staffOnly, usdNano, paretoRows, paretoChart, notFound, layout, setRelease, configure };
ddiff --git a/test/ai-catalogue-join.test.js b/test/ai-catalogue-join.test.js
iindex af0dfdb..82357b0 100644
---- a/test/ai-catalogue-join.test.js
++++ b/test/ai-catalogue-join.test.js
@@@ -27,36 +27,34 @@ const { boot, check, done, stubFetch, jsonResponse } = require('./helpers/boot')
};
const t = await boot({ fetchImpl, env: { OV_BENCH_INTERNAL_URL: 'http://127.0.0.1:5230', OV_AI_INTERNAL_URL: 'http://127.0.0.1:5231' } });
try {
-- await check('a dated Bench name meets its catalogue model: maker and prices', async () => {
-- const r = await t.get('/ai/leaderboard');
++ // The pages moved to OpenVibe.Compare (2026-11-13); the join is checked through the JSON API Compare reads.
++ const rowsOf = async () => {
++ const r = await t.get('/api/v1/ai/leaderboard?limit=100');
assert.strictEqual(r.status, 200);
-- const row = r.text.slice(r.text.indexOf('gpt-5-nano-2025-08-07'));
-- assert.ok(r.text.includes('gpt-5-nano-2025-08-07'), 'the rated model');
-- assert.ok(row.includes('$0.05') && row.includes('$0.4'), 'its catalogue prices');
-- assert.ok(r.text.includes('OpenAI') && r.text.includes('ov-aim--openai'), 'its maker, named and marked');
++ return r.json().rows;
++ };
++ const prices = (row) => row.provider_offers.flatMap((o) => (o.price ? [o.price.in, o.price.out] : [])).filter((x) => x != null).map(Number);
++ await check('a dated Bench name meets its catalogue model: maker and prices', async () => {
++ const row = (await rowsOf()).find((x) => x.id === 'gpt-5-nano-2025-08-07');
++ assert.ok(row, 'the rated model');
++ assert.ok(prices(row).includes(0.4) && prices(row).includes(0.05), 'its catalogue prices');
++ assert.match(String(row.maker), /openai/i, 'its maker');
});
await check('a catalogue model without Bench games is listed, not rated', async () => {
-- const r = await t.get('/ai/leaderboard');
-- assert.ok(r.text.includes('deepseek/deepseek-chat'), 'the unrated model is listed');
-- assert.ok(r.text.indexOf('gpt-5-nano-2025-08-07') < r.text.indexOf('deepseek/deepseek-chat'), 'rated models come first');
-- assert.ok(r.text.includes('$0.28'), 'with its real price');
++ const rows = await rowsOf();
++ const ids = rows.map((x) => x.id);
++ assert.ok(ids.includes('deepseek/deepseek-chat'), 'the unrated model is listed');
++ assert.ok(ids.indexOf('gpt-5-nano-2025-08-07') < ids.indexOf('deepseek/deepseek-chat'), 'rated models come first');
++ assert.ok(prices(rows.find((x) => x.id === 'deepseek/deepseek-chat')).includes(0.28), 'with its real price');
});
await check('a base rating with no games is not a rating', async () => {
-- const r = await t.get('/api/v1/ai/leaderboard');
-- const zero = r.json().rows.find((x) => x.id === 'model-zero');
++ const zero = (await rowsOf()).find((x) => x.id === 'model-zero');
assert.strictEqual(zero, undefined, 'a sparse Bench-only subject is withheld, including its count');
-- const page = await t.get('/ai/models/model-zero');
-- assert.ok(page.text.includes('Not rated on any task yet'), 'the model page says so');
-- });
-- await check('unrated models list what the Exchange routes first, then the cheapest; a note in brackets is not a name', async () => {
-- const views = require('../server/views/ai-comparison');
-- const html = views.providers({ providers: [{ provider: 'x', provider_name: 'Abliteration.ai (the uncensored tier)', model: 'm', price: { in: 0, out: 0 }, routed: false }] });
-- assert.ok(html.includes('Abliteration.ai<') && !html.includes('the uncensored tier'), 'the provider is named plainly');
-- assert.ok(html.includes('Free') && !html.includes('$0.00'), 'a price of zero reads Free');
});
-- await check('the providers page lists every offer', async () => {
-- const r = await t.get('/ai/providers');
-- assert.ok(r.text.includes('OpenAI') && r.text.includes('DeepSeek'), 'both providers');
++ await check('the AI pages answer 301 to OpenVibe.Compare with their path and query', async () => {
++ const r = await t.get('/ai/providers?x=1', { redirect: 'manual' });
++ assert.strictEqual(r.status, 301);
++ assert.strictEqual(r.headers.get('location'), 'https://openvibe.compare/ai/providers?x=1');
});
} finally {
await t.close();
ddiff --git a/test/ai-market.test.js b/test/ai-market.test.js
iindex 1a76552..3bc6aeb 100644
---- a/test/ai-market.test.js
++++ b/test/ai-market.test.js
@@@ -47,11 +47,9 @@ const catalogue = { models:[{ slug:'openai/gpt-5-nano', offers:[{provider:'opena
return source(url,opts);
}});
try {
-- await check('pages list every market model, cheapest price, route marks and measured-only routed rows',async()=>{
-- const r=await t.get('/ai/leaderboard');assert.strictEqual(r.status,200);assert(r.text.includes('qwen/unrated'));assert(r.text.includes('$0.01'));assert(r.text.includes('Routed here'));assert(r.text.includes('Not routed here'));
-- assert(r.text.indexOf('gpt-5-nano-2025-08-07')<r.text.indexOf('qwen/unrated'));
-- const p=await t.get('/ai/providers');assert(p.text.includes('Cheaper fixture'));assert(/1\.2[0-9]*\s*s/.test(p.text));assert(p.text.includes('Not routed here'));
-- const api=await t.get('/api/v1/ai/leaderboard');assert(api.json().rows.some(r=>r.id==='qwen/unrated'&&!r.routed));
++ await check('the API lists every market model, routed and not, rated first',async()=>{
++ const api=await t.get('/api/v1/ai/leaderboard?limit=100');assert.strictEqual(api.status,200);const rows=api.json().rows;const ids=rows.map(r=>r.id);
++ assert(rows.some(r=>r.id==='qwen/unrated'&&!r.routed),'the unrouted market model is listed');assert(ids.includes('gpt-5-nano-2025-08-07'),'the rated one too');
});
} finally { await t.close(); }
done();
ddiff --git a/test/ai-moved.test.js b/test/ai-moved.test.js
nnew file mode 100644
iindex 0000000..a76dcd3
---- /dev/null
++++ b/test/ai-moved.test.js
@@@ -0,0 +1,41 @@
++'use strict';
++/**
++ * The AI model comparison moved to OpenVibe.Compare on 2026-11-13 (the owner: "This kinda shit is supposed to be a part
++ * of openvibe.compare"). Every /ai page answers 301 there with its path and query; the Arena's rounds and votes and the
++ * staff page /ai/exchange stay; the bar has no AI tab; the sitemap lists no /ai page; the JSON API Compare reads stays.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('every /ai page answers 301 to the same path on OpenVibe.Compare', async () => {
++ for (const path of ['/ai', '/ai/leaderboard?kind=chat.arena', '/ai/providers', '/ai/models/openai%2Fgpt-5-nano', '/ai/compare/a-vs-b',
++ '/ai/compare?a=x&b=y', '/ai/method', '/ai/feed.xml', '/ai/chat.arena', '/ai/bmk_abc']) {
++ const r = await t.get(path);
++ assert.strictEqual(r.status, 301, path);
++ assert.strictEqual(r.headers.get('location'), `https://openvibe.compare${path}`, path);
++ }
++ });
++ await check('the Arena and the staff page stay on Report', async () => {
++ const arena = await t.get('/ai/arena');
++ assert.notStrictEqual(arena.status, 301, 'the Arena is served here');
++ const staff = await t.get('/ai/exchange');
++ assert.notStrictEqual(staff.status, 301, 'the staff page is not moved');
++ });
++ await check('no AI tab, no /ai page in the sitemap, the JSON API stays', async () => {
++ const home = await t.get('/');
++ assert.ok(!/href="\/ai"/.test(home.text), 'the bar has no AI tab');
++ const map = await t.get('/sitemap.xml');
++ assert.ok(!/openvibe\.report\/ai[/<]/.test(map.text), 'no /ai page in the sitemap');
++ const llms = await t.get('/llms.txt');
++ assert.match(llms.text, /https:\/\/openvibe\.compare\/ai/, 'llms.txt names where the comparison lives');
++ const api = await t.get('/api/v1/ai/picks');
++ assert.notStrictEqual(api.status, 301, 'the API is not moved');
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})().catch((error) => { console.error(error); process.exitCode = 1; });
ddiff --git a/test/ai-pages.test.js b/test/ai-pages.test.js
ddeleted file mode 100644
iindex 788cf6f..0000000
---- a/test/ai-pages.test.js
++++ /dev/null
@@@ -1,187 +0,0 @@
--'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 (['/api/v1/boards/design.page', '/api/v1/ratings/design.page'].includes(u.pathname)) {
-- const type = u.pathname.includes('/ratings/') ? 'elo' : u.searchParams.get('type');
-- const rows = {
-- elo: [
-- { subject: 'openai/gpt-4o', elo: 1043, ci_lower: 1005, ci_upper: 1081, games: 20, win_rate: 0.75, provisional: false },
-- { subject: 'model-b', elo: 980, ci_lower: 940, ci_upper: 1020, games: 20, win_rate: 0.25, provisional: false },
-- ],
-- 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: 20, 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 renders the comparison hub and structured data', async () => {
-- const r = await t.get('/ai');
-- assert.strictEqual(r.status, 200);
-- for (const s of ['AI model leaderboard', 'openai/gpt-4o', 'Best overall', 'Dataset', 'FAQPage']) assert.ok(r.text.includes(s), s);
-- assert.ok(!r.text.includes('Page <design>'));
-- // A phone shows the parts below the top picks as tabs, the leaderboard first; every part stays in the HTML.
-- assert.ok(r.text.includes('<div class="ov-parts" data-parts='), 'the parts are tabs on a phone');
-- assert.ok(r.text.includes('<label for="ovp-board-leaderboard">Leaderboard <span class="ov-parts-n">'), 'the leaderboard tab names its count');
-- assert.ok(r.text.indexOf('ovp-board-leaderboard') < r.text.indexOf('ovp-board-questions'), 'the leaderboard first, the questions last');
-- assert.ok(r.text.includes('.ov-parts-bar{display:flex}'), 'its look is in the page, so nothing jumps');
-- });
--
-- await check('Shared Elo shows ranks, intervals and win rates', async () => {
-- const r = await t.get('/ai/design.page');
-- assert.strictEqual(r.status, 200);
-- for (const s of ['<ol class="ovk-rank"', '<b>1,043</b><small>± 38</small>', '75% won', 'Ranking', '95% interval', 'dataset JSONL']) assert.ok(r.text.includes(s), s);
-- const data = await t.get('/api/v1/ai/ratings/design.page');
-- assert.strictEqual(data.status, 200, data.text);
-- assert.strictEqual(data.json().board.rows[0].elo, 1043);
-- assert.strictEqual(data.json().board.rows[0].games, 20);
-- assert.strictEqual(data.headers.get('access-control-allow-origin'), '*');
-- const preflight = await t.get('/api/v1/ai/ratings/design.page', { method: 'OPTIONS' });
-- assert.strictEqual(preflight.status, 204);
-- assert.match(preflight.headers.get('access-control-allow-headers'), /traceparent/);
-- });
--
-- 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 ['ovk-chart--scatter', '<svg', 'class="ovk-chart-front"', 'Quality against cost per solved task', 'Cost per solved task (USD', 'frontier', 'model-b', 'dataset JSONL', '<table']) assert.ok(r.text.includes(s), s);
-- assert.ok(r.text.includes('data-label="Quality">80%</td>') && r.text.includes('data-label="Cost per solved">$0.10</td>'), 'the exact numbers, not dashes');
-- });
--
-- 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') && model.text.includes('1005–1081'));
-- });
--
-- 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('the comparison pages remain rendered during a Bench outage', 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);
-- }
-- });
-- } finally { await t.close(); }
--
-- await check('the comparison hub replaces the old AI landing page', async () => {
-- const fresh = await boot({
-- fetchImpl: async (url, opts) => {
-- const u = new URL(String(url));
-- if (u.port !== '5230') return source(url, opts);
-- if (u.pathname === '/api/v1/kinds') return jsonResponse({ count: 2, kinds: [{ id: 'design.page', room: 'design', name: 'Page', judge: 'human_pairwise' }, { id: 'speech.asr', room: 'speech', name: 'Speech', judge: 'automatic_grader' }] });
-- if ((/^\/api\/v1\/(boards|ratings)\//.test(u.pathname))) return jsonResponse({ board: { rows: [] } });
-- return jsonResponse({}, 404);
-- },
-- env: { OV_BENCH_INTERNAL_URL: 'http://127.0.0.1:5230' },
-- });
-- try {
-- const r = await fresh.get('/ai');
-- assert.ok(r.text.includes('AI model leaderboard') && r.text.includes('Best overall') && r.text.includes('FAQPage'), 'new hub and structured data render');
-- assert.ok(!r.text.includes('No model is rated on a task kind yet'), 'old landing page copy is gone');
-- const b = await fresh.get('/ai/design.page');
-- assert.ok(b.text.includes('No votes yet') && b.text.includes('href="https://openvibe.design/"') && b.text.includes('Vote on Design'), 'a blind-vote job sends people to vote');
-- assert.ok(!b.text.includes('class="ovk-tabs'), 'an empty board has no tabs');
-- const g = await fresh.get('/ai/speech.asr');
-- assert.ok(g.text.includes('No results yet') && !g.text.includes('Vote on'), 'a graded job has nothing to vote on');
-- } finally { await fresh.close(); }
-- });
--
-- await check('an unjudged model does not become a rated leader', async () => {
-- const unplayed = await boot({
-- fetchImpl: async (url, opts) => {
-- const u = new URL(String(url));
-- if (u.port !== '5230') return source(url, opts);
-- if (u.pathname === '/api/v1/kinds') return jsonResponse({ count: 1, kinds: [{ id: 'design.page', room: 'design', name: 'Page', judge: 'human_pairwise' }] });
-- if ((/^\/api\/v1\/(boards|ratings)\//.test(u.pathname))) return jsonResponse({ board: { rows: [{ subject: 'gpt-5-nano-2025-08-07', elo: 1000, games: 0, provisional: true }] } });
-- return jsonResponse({}, 404);
-- },
-- env: { OV_BENCH_INTERNAL_URL: 'http://127.0.0.1:5230' },
-- });
-- try {
-- const r = await unplayed.get('/ai');
-- assert.ok(r.text.includes('AI model leaderboard') && r.text.includes('Best overall'), 'hub remains available without a rating');
-- const b = await unplayed.get('/ai/design.page');
-- assert.ok(b.text.includes('No votes yet') && b.text.includes('Vote on Design') && !b.text.includes('class="ovk-tabs'), 'the board asks for votes');
-- } finally { await unplayed.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('AI model leaderboard') && r.text.includes('Leaderboard'));
-- } finally { await empty.close(); }
-- });
--
-- await check('comparison pages show fixture measurements, filter parameters and canonical model pairs', async () => {
-- const fixture = await boot({
-- aiCatalogue: { catalog: async () => ({ generated_at: '2026-11-09T00:00:00Z', models: [{ slug: 'maker/model-a', maker: 'Maker A', weights: 'open', status: 'current', offers: [{ provider: 'Provider One', price: { in: 1, out: 0.5 }, speed: { p50_ms: 120 }, uptime: 0.99, status: 'down', status_reason: 'the provider says the account is out of credit', status_since: '2026-11-09T09:00:00Z' }] }, { slug: 'maker/model-b', maker: 'Maker B', weights: 'closed', status: 'current', offers: [] }] }) },
-- fetchImpl: async (url, opts) => {
-- const u = new URL(String(url));
-- if (u.port === '5230' && u.pathname === '/api/v1/kinds') return jsonResponse({ kinds: [{ id: 'code', name: 'Code' }] });
-- if (u.port === '5230' && u.pathname === '/api/v1/ratings/code') return jsonResponse({ board: { rows: [{ subject: 'maker/model-a', elo: 1250, ci_lower: 1200, ci_upper: 1300, games: 40 }, { subject: 'maker/model-b', elo: 1270, ci_lower: 1250, ci_upper: 1300, games: 20 }] } });
-- if (u.port === '5230' && u.pathname.includes('/summary')) return jsonResponse({ model: 'maker/model-a', runs: 4 });
-- return source(url, opts);
-- }, env: { OV_BENCH_INTERNAL_URL: 'http://127.0.0.1:5230' },
-- });
-- try {
-- const hub = await fixture.get('/ai');
-- assert.ok(hub.text.includes('AI model leaderboard — November 2026'));
-- assert.ok(hub.text.includes('± 50') && hub.text.includes('40 games') && hub.text.includes('1–2') && hub.text.includes('Dataset') && hub.text.includes('FAQPage'));
-- assert.ok(hub.text.includes('Maker A') && !hub.text.includes('letter-avatar'));
-- const filtered = await fixture.get('/ai/leaderboard?maker=Maker%20A&weights=open&price=low&status=current&kind=code');
-- assert.ok(filtered.text.includes('maker/model-a') && !filtered.text.includes('maker/model-b'));
-- const provider = await fixture.get('/ai/providers');
-- assert.ok(provider.text.includes('Provider One') && provider.text.includes('Not measured yet'), 'an offer without requests has no measurement');
-- assert.ok(provider.text.includes('Down') && provider.text.includes('the provider says the account is out of credit') && provider.text.includes('since 2026-11-09 09:00 UTC'), 'the measured status and its reason are shown');
-- assert.strictEqual((await fixture.get('/ai/compare?models=maker%2Fmodel-a,maker%2Fmodel-b')).status, 301);
-- assert.strictEqual((await fixture.get('/ai/compare/maker%2Fmodel-a-vs-maker%2Fmodel-b')).status, 200);
-- assert.strictEqual((await fixture.get('/ai/compare/maker%2Fmodel-b-vs-maker%2Fmodel-a')).status, 301);
-- } finally { await fixture.close(); }
-- });
--
-- done();
--})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/ai-section.test.js b/test/ai-section.test.js
ddeleted file mode 100644
iindex a5a760c..0000000
---- a/test/ai-section.test.js
++++ /dev/null
@@@ -1,164 +0,0 @@
--'use strict';
--
--/**
-- * The AI section (owner, 2026-11-08): Bench's pages moved into Report. The overview, one task kind, one benchmark,
-- * one model, the comparison, the method page and the new-results feeds read only Bench's public answers.
-- */
--const assert = require('assert');
--const { boot, check, done, stubFetch, jsonResponse } = require('./helpers/boot');
--
--const BMK = 'bmk_01M65KA3H4Y4ZENBYYZQA8SRNN';
--const RUN_A = 'brn_01M65KA3H4Y4ZENBYYZQA8SRN1';
--const RUN_B = 'brn_01M65KA3H4Y4ZENBYYZQA8SRN2';
--
--const overview = {
-- kinds: 2, benchmarks: 1, tasks: 2, runs: 3, models: 2, last_run: '2026-11-07T10:00:00.000Z',
-- list: [{ id: BMK, title: 'Playable games arena', kind: 'design.page', description: 'A test benchmark.', latest_version: 1, runs: 3, last_run: '2026-11-07T10:00:00.000Z' }],
-- recent: [
-- { id: RUN_B, benchmark_id: BMK, model: 'model-b', score: 70, cost_usd: 0.04, ms: 2000, provenance: 'verified', created_at: '2026-11-07T10:00:00.000Z' },
-- { id: RUN_A, benchmark_id: BMK, model: 'model-a', score: 90, cost_usd: 0.02, ms: 1000, provenance: 'verified', created_at: '2026-11-06T10:00:00.000Z' },
-- ],
--};
--
--const runs = {
-- count: 2, offset: 0, last_run: '2026-11-07T10:00:00.000Z',
-- runs: [
-- { id: RUN_B, benchmark_id: BMK, model: 'model-b', score: 70, cost_usd: 0.04, ms: 2000, tokens: 20, provenance: 'verified', created_at: '2026-11-07T10:00:00.000Z' },
-- { id: RUN_A, benchmark_id: BMK, model: 'model-a', score: 90, cost_usd: 0.02, ms: 1000, tokens: 10, provenance: 'verified', created_at: '2026-11-06T10:00:00.000Z' },
-- ],
--};
--
--const rankings = { rankings: { benchmark: BMK, metric: 'score', entries: [
-- { model: 'model-a', runs: 2, score: 85, cost_usd: 0.02, ms: 1000, cost_per_solved: 0.024, min_score: 80, max_score: 90, last_run: '2026-11-06T10:00:00.000Z' },
-- { model: 'model-b', runs: 1, score: 70, cost_usd: 0.04, ms: 2000, cost_per_solved: 0.057, min_score: 70, max_score: 70, last_run: '2026-11-07T10:00:00.000Z' },
--] } };
--
--const benchmark = {
-- benchmark: { id: BMK, title: 'Playable games arena', kind: 'design.page', description: 'A test benchmark.' },
-- version: { version: 1, created_at: '2026-10-30T05:07:52.000Z' },
-- definition: { tasks: [{ id: 't1', prompt: 'Build one screen.' }, { id: 't2', prompt: 'Build another.' }] },
-- task_meta: [],
--};
--
--const modelDetail = {
-- model: 'model-a', runs: 2,
-- benchmarks: [{ benchmark_id: BMK, title: 'Playable games arena', kind: 'design.page', runs: 2, score: 85, best: 90, worst: 80, cost_usd: 0.02, ms: 1000, last_run: '2026-11-06T10:00:00.000Z' }],
-- tasks: [
-- { benchmark_id: BMK, benchmark_title: 'Playable games arena', task_id: 't1', runs: 2, score: 90, best: 90, worst: 90, last_run: '2026-11-06T10:00:00.000Z' },
-- { benchmark_id: BMK, benchmark_title: 'Playable games arena', task_id: 't2', runs: 2, score: 80, best: 80, worst: 80, last_run: '2026-11-06T10:00:00.000Z' },
-- ],
-- best_task: { task_id: 't1', score: 90 }, worst_task: { task_id: 't2', score: 80 },
-- total_cost_usd: 0.04, total_ms: 2000, total_tokens: 20, last_run: '2026-11-06T10:00:00.000Z',
-- recent_runs: [
-- ...runs.runs.filter((r) => r.model === 'model-a'),
-- { id: 'brn_01M65KA3H4Y4ZENBYYZQA8SRN3', benchmark_id: BMK, model: 'model-a', score: 80, cost_usd: 0.02, ms: 1200, tokens: 10, provenance: 'verified', created_at: '2026-11-05T10:00:00.000Z' },
-- ],
--};
--
--const legacy = { model: 'model-a', ratings: [{ kind_id: 'design.page', elo: 1043, ci_lower: 1005, ci_upper: 1081, games: 20, win_rate: 0.75 }], entries: [], total_cost_usd: 0.04 };
--
--(async () => {
-- const source = stubFetch();
-- const fetchImpl = async (url, opts) => {
-- const u = new URL(String(url));
-- if (u.port !== '5230') return source(url, opts);
-- const p = u.pathname;
-- if (p === '/api/v1/kinds') return jsonResponse({ count: 1, kinds: [{ id: 'design.page', room: 'design', name: 'Design page arena', judge: 'human_pairwise' }] });
-- if (['/api/v1/boards/design.page', '/api/v1/ratings/design.page'].includes(p)) {
-- const type = p.includes('/ratings/') ? 'elo' : u.searchParams.get('type');
-- const rows = type === 'elo'
-- ? [{ subject: 'model-a', elo: 1043, ci_lower: 1005, ci_upper: 1081, games: 20, win_rate: 0.75, median_cost_usd: 0.02, median_ms: 1000, provisional: false }]
-- : type === 'pareto' ? [{ subject: 'model-a', quality: 0.8 }] : [{ subject: 'model-a', entries: 2, solved: 2, total_cost_usd: 0.04, cost_per_solved: 0.02, median_ms: 1000 }];
-- return jsonResponse({ board: { kind_id: 'design.page', type, rows } });
-- }
-- if (p === '/api/v1/overview') return jsonResponse(overview);
-- if (p === '/api/v1/benchmarks') return jsonResponse({ count: 1, benchmarks: overview.list });
-- if (p === `/api/v1/benchmarks/${BMK}`) return jsonResponse(benchmark);
-- if (p === '/api/v1/rankings') return jsonResponse(rankings);
-- if (p === '/api/v1/runs') return jsonResponse(runs);
-- if (p === '/api/v1/models/model-a') return jsonResponse(legacy);
-- if (p === '/api/v1/models/model-a/summary') return jsonResponse(modelDetail);
-- if (p === '/api/v1/models/model-b/summary') return jsonResponse({ ...modelDetail, model: 'model-b', runs: 1, total_cost_usd: 0.04, recent_runs: runs.runs.filter((r) => r.model === 'model-b') });
-- return jsonResponse({ error: 'no stub for ' + p }, 404);
-- };
-- const t = await boot({ fetchImpl, env: { OV_BENCH_INTERNAL_URL: 'http://127.0.0.1:5230' } });
-- try {
-- await check('the AI overview renders the leaderboard hub from the Bench fixture', async () => {
-- const r = await t.get('/ai');
-- assert.strictEqual(r.status, 200);
-- for (const s of ['AI model leaderboard', 'model-a', '1043', 'Best overall', 'FAQPage', 'Dataset']) assert.ok(r.text.includes(s), s);
-- assert.ok(r.text.includes('href="/ai/leaderboard"') && r.text.includes('/api/v1/ai/leaderboard'));
-- });
--
-- await check('one task kind shows quality, price and speed side by side and its latest runs', async () => {
-- const r = await t.get('/ai/design.page');
-- assert.strictEqual(r.status, 200);
-- for (const s of ['Quality, price and speed side by side', 'Cost/run', 'Speed/run', '$0.02', '1 s', 'Latest runs', 'model-a', 'Nov 7']) assert.ok(r.text.includes(s), s);
-- });
--
-- await check('one benchmark page has a ranking, an SVG and the latest runs', async () => {
-- const r = await t.get(`/ai/${BMK}`);
-- assert.strictEqual(r.status, 200);
-- for (const s of ['Playable games arena', 'Score against cost per run', 'ovk-chart--scatter', 'model-a', '85', '$0.02', 'Cost per solved', 'Latest runs', 'version 1']) assert.ok(r.text.includes(s), s);
-- });
--
-- await check('one model page uses the comparison profile and available Bench task data', async () => {
-- const r = await t.get('/ai/models/model-a');
-- assert.strictEqual(r.status, 200);
-- for (const s of ['Providers', 'Task ratings', 'Playable games arena', 'snapshot', 'Product', 'Try it in Chat', 'Use it from code']) assert.ok(r.text.includes(s), s);
-- assert.ok(!r.text.includes('&lt;span title='), 'a stat is escaped HTML, not markup');
-- });
--
-- await check('the old query compare redirects to a canonical pair page', async () => {
-- const two = await t.get('/ai/compare?models=model-a,model-b');
-- assert.strictEqual(two.status, 301);
-- assert.strictEqual(two.headers.get('location'), '/ai/compare/model-a-vs-model-b');
-- const pair = await t.get('/ai/compare/model-a-vs-model-b');
-- assert.strictEqual(pair.status, 200);
-- assert.ok(pair.text.includes('model-a') && pair.text.includes('model-b'), 'both models');
-- const one = await t.get('/ai/compare?models=model-a');
-- assert.strictEqual(one.status, 200);
-- assert.ok(one.text.includes('Choose two models to compare'), 'ask for a second model');
-- });
--
-- await check('an unknown benchmark id is a 404 once Bench has answered its list', async () => {
-- assert.strictEqual((await t.get('/ai/bmk_01M65KA3H4Y4ZENBYYZQA8SRN9')).status, 404);
-- });
--
-- await check('the method page explains judging, verification and cost', async () => {
-- const r = await t.get('/ai/method');
-- assert.strictEqual(r.status, 200);
-- for (const s of ['What a score means', 'How runs are judged', 'Verified and submitted', 'Cost per solved', 'blind votes', 'benchmarks.jsonl']) assert.ok(r.text.includes(s), s);
-- });
--
-- await check('new results are RSS and JSON, from the same runs', async () => {
-- const rss = await t.get('/ai/feed.xml');
-- assert.strictEqual(rss.status, 200);
-- assert.match(rss.headers.get('content-type') || '', /application\/rss\+xml/);
-- assert.ok(rss.text.includes('<title>model-a on Playable games arena</title>') && rss.text.includes(RUN_A), 'the run is in the feed');
-- const json = await t.get('/api/v1/ai/results');
-- assert.strictEqual(json.status, 200);
-- const body = json.json();
-- assert.strictEqual(body.count, 2);
-- assert.strictEqual(body.results[0].id, RUN_B);
-- assert.strictEqual(body.results[0].benchmark, 'Playable games arena');
-- assert.ok(body.results[0].url.endsWith('/ai/' + BMK));
-- });
--
-- await check('the AI pages have Markdown twins, and the benchmark twin names its page', async () => {
-- for (const [path, expected] of [[`/ai/${BMK}.md`, 'Playable games arena'], ['/ai/method.md', 'How the AI results are judged'], ['/ai/models/model-a.md', 'model-a']]) {
-- const r = await t.get(path);
-- assert.strictEqual(r.status, 200, path);
-- assert.match(r.headers.get('content-type') || '', /^text\/markdown/, path);
-- assert.ok(r.text.includes(expected), `${path}: ${expected}`);
-- }
-- });
--
-- await check('the sitemap lists the AI pages and the benchmark', async () => {
-- const r = await t.get('/sitemap.xml');
-- assert.strictEqual(r.status, 200);
-- for (const s of ['/ai</loc>', '/ai/method', '/ai/compare', `/ai/${BMK}`, '/ai/design.page', '/ai/models/model-a']) assert.ok(r.text.includes(s), s);
-- });
-- } finally { await t.close(); }
-- done();
--})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/discovery.test.js b/test/discovery.test.js
iindex 13c000d..005f54b 100644
---- a/test/discovery.test.js
++++ b/test/discovery.test.js
@@@ -31,7 +31,7 @@ const STATUS_UPDATED = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'ST
assert.strictEqual(r.status, 200);
assert.match(r.headers.get('content-type') || '', /^application\/xml/);
assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=3600');
-- for (const p of ['/', '/stats', '/design', '/ai']) {
++ for (const p of ['/', '/stats', '/design']) { // /ai moved to OpenVibe.Compare (2026-11-13)
assert.ok(r.text.includes(`<loc>https://openvibe.report${p}</loc>`), `no ${p} entry`);
}
for (const p of ['/rundown', '/rundown/t/seattle', '/rundown/t/ai-progress']) {