openvibers/openvibe.bench Commits

Bench: hand benchmark runs to Codes (#26)

OP OpenVibers committed 14 days ago · 84cb2c1635 · 7 files, +111 −17

* Add benchmark run handoff to Codes

* Publish pending benchmark scores and rendered outputs

* Merge remote-tracking branch 'origin/main' into b3-bench-bench

* Publish pending benchmark scores and rendered outputs

Changes

ddiff --git a/migrations/0004_pending_scores.sql b/migrations/0004_pending_scores.sql
nnew file mode 100644
iindex 0000000..90ed187
---- /dev/null
++++ b/migrations/0004_pending_scores.sql
@@@ -0,0 +1,6 @@
++-- phase: expand
++-- B3: verified human-judged runs publish their outputs before a score exists.
++ALTER TABLE bench_runs ALTER COLUMN score DROP NOT NULL;
++ALTER TABLE bench_runs ALTER COLUMN score DROP DEFAULT;
++ALTER TABLE bench_run_tasks ALTER COLUMN score DROP NOT NULL;
++ALTER TABLE bench_run_tasks ALTER COLUMN score DROP DEFAULT;
ddiff --git a/server/app.js b/server/app.js
iindex f85d361..167c691 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -79,7 +79,7 @@ async function createApp(opts = {}) {
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Bench cannot answer account events'); });
const ctx = {
-- config, s, keys, sso, principal, bench, registry, ai, jobs, publisher, accountData, accountSend, log,
++ config, s, keys, sso, principal, bench, registry, ai, jobs, publisher, accountData, accountSend, log, fetchImpl,
random: opts.random || Math.random,
};
ddiff --git a/server/bench/registry.js b/server/bench/registry.js
iindex eedb338..3147317 100644
---- a/server/bench/registry.js
++++ b/server/bench/registry.js
@@@ -15,7 +15,7 @@
*/
const crypto = require('crypto');
const { ids } = require('openvibe-contracts');
--const { contractRun, checkContract } = require('./validate');
++ const { contractRun, checkContract } = require('./validate');
const num = (v, d = 0) => (v == null || v === '' || Number.isNaN(Number(v)) ? d : Number(v));
const iso = (v) => (v == null ? null : (v instanceof Date ? v.toISOString() : String(v)));
@@@ -97,7 +97,7 @@ function wireRun(row, tasks = null) {
settings: json(row.settings, {}),
executor: json(row.executor, {}),
hardware: json(row.hardware, null),
-- score: num(row.score),
++ score: row.score == null ? null : num(row.score),
cost_usd: num(row.cost_usd),
ms: num(row.ms),
tokens: num(row.tokens),
@@@ -132,11 +132,11 @@ function wireTask(row) {
if (!row) return null;
return {
task_id: row.task_id,
-- score: num(row.score),
++ score: row.score == null ? null : num(row.score),
cost_usd: num(row.cost_usd),
ms: num(row.ms),
tokens: num(row.tokens),
-- artifacts: Array.isArray(json(row.artifacts, [])) ? json(row.artifacts, []) : [],
++ artifacts: (Array.isArray(json(row.artifacts, [])) ? json(row.artifacts, []) : []).map((a) => ({ kind: a.kind, url: a.url })).filter((a) => a.url),
transcript_url: row.transcript_url || null,
};
}
@@@ -348,6 +348,15 @@ function createRegistry(s, { log = console } = {}) {
* run, which is badged and kept out of the default rankings.
*/
async function publishRun(doc, { provenance, subject }) {
++ if (doc.id) {
++ const prior = await getRun(String(doc.id));
++ if (prior) {
++ if (prior.benchmark === String(doc.benchmark) && prior.model === String(doc.model) && prior.session === (doc.session || null)) {
++ return { ok: true, run: prior };
++ }
++ return refusal(409, 'bench.run_id_conflict', 'That run id already names a different result.');
++ }
++ }
const benchmarkId = String(doc.benchmark_id || (doc.benchmark || '').split('@')[0] || '');
const version = num(doc.benchmark_version || (doc.benchmark || '').split('@')[1]);
const versionRow = await getVersion(benchmarkId, version);
@@@ -369,7 +378,7 @@ function createRegistry(s, { log = console } = {}) {
// A submitted run has no executor: whatever the body says, the provenance decides.
const executorKind = provenance === 'submitted' ? 'submitted' : String((doc.executor && doc.executor.kind) || 'codes');
const executor = { kind: executorKind, id: String((doc.executor && doc.executor.id) || subject || 'unknown') };
-- const id = s.newId('brn');
++ const id = String(doc.id || s.newId('brn'));
// The published contract has the last word: a run that is not a bench.run@1 document is refused.
const contract = contractRun({
...doc,
@@@ -377,17 +386,17 @@ function createRegistry(s, { log = console } = {}) {
benchmark: `${benchmarkId}@${version}`,
executor,
tasks,
-- score: num(doc.score, 0),
++ score: doc.score == null ? null : num(doc.score, 0),
cost_usd: num(doc.cost_usd, 0),
ms: num(doc.ms, 0),
tokens: num(doc.tokens, 0),
provenance,
}, s.iso());
-- const contractCheck = checkContract('bench.run@1', contract);
++ const contractCheck = checkContract('bench.run@2', contract);
if (!contractCheck.valid) {
return refusal(400, 'bench.invalid', contractCheck.errors.map((e) => e.message).join('; '));
}
-- const score = num(doc.score, 0);
++ const score = doc.score == null ? null : num(doc.score, 0);
const cost = num(doc.cost_usd, 0);
const ms = num(doc.ms, 0);
const tokens = num(doc.tokens, 0);
@@@ -405,7 +414,7 @@ function createRegistry(s, { log = console } = {}) {
await db.exec(
`INSERT INTO bench_run_tasks (id, run_id, task_id, score, cost_usd, ms, tokens, artifacts, transcript_url, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10::timestamptz)`,
-- [s.newId('brt'), id, String(t.task_id), num(t.score), num(t.cost_usd), num(t.ms), num(t.tokens),
++ [s.newId('brt'), id, String(t.task_id), t.score == null ? null : num(t.score), num(t.cost_usd), num(t.ms), num(t.tokens),
JSON.stringify(Array.isArray(t.artifacts) ? t.artifacts : []), t.transcript_url == null ? null : String(t.transcript_url), s.iso()],
);
}
@@@ -483,6 +492,7 @@ function createRegistry(s, { log = console } = {}) {
const medMs = median(times);
const sessions = new Map();
for (const r of list) {
++ if (r.score == null) continue;
const key = r.session || 'default';
if (!sessions.has(key)) sessions.set(key, []);
sessions.get(key).push(r.score);
@@@ -524,7 +534,7 @@ function createRegistry(s, { log = console } = {}) {
params.push(String(model));
if (ref.version != null) { params.push(ref.version); sql += ' AND benchmark_version = $3'; }
const rows = await db.many(sql, params);
-- const scores = rows.map((r) => num(r.score));
++ const scores = rows.filter((r) => r.score != null).map((r) => num(r.score));
if (!scores.length) return { benchmark: ref.id, model: String(model), runs: 0, percentile: null, score: num(score) };
const value = num(score);
const below = scores.filter((v) => v < value).length;
ddiff --git a/server/bench/validate.js b/server/bench/validate.js
iindex ceca54d..a6fbb3f 100644
---- a/server/bench/validate.js
++++ b/server/bench/validate.js
@@@ -90,14 +90,14 @@ function validateRun(doc) {
doc.tasks.forEach((t, i) => {
if (!isObj(t)) { e.add(`/tasks/${i}`, 'must be an object'); return; }
if (!isStr(t.task_id) || !t.task_id.trim()) e.add(`/tasks/${i}/task_id`, 'is required');
-- if (!isNum(t.score) || t.score < 0 || t.score > 100) e.add(`/tasks/${i}/score`, 'must be 0-100');
++ if (t.score != null && (!isNum(t.score) || t.score < 0 || t.score > 100)) e.add(`/tasks/${i}/score`, 'must be null or 0-100');
if (!isNum(t.cost_usd) || t.cost_usd < 0) e.add(`/tasks/${i}/cost_usd`, 'must be 0 or more');
if (!isNum(t.ms) || t.ms < 0) e.add(`/tasks/${i}/ms`, 'must be 0 or more');
if (!isNum(t.tokens) || t.tokens < 0) e.add(`/tasks/${i}/tokens`, 'must be 0 or more');
if (!Array.isArray(t.artifacts)) e.add(`/tasks/${i}/artifacts`, 'must be an array');
});
}
-- if (!isNum(doc.score) || doc.score < 0 || doc.score > 100) e.add('/score', 'must be 0-100');
++ if (doc.score != null && (!isNum(doc.score) || doc.score < 0 || doc.score > 100)) e.add('/score', 'must be null or 0-100');
if (!isNum(doc.cost_usd) || doc.cost_usd < 0) e.add('/cost_usd', 'must be 0 or more');
if (!isNum(doc.ms) || doc.ms < 0) e.add('/ms', 'must be 0 or more');
if (!isNum(doc.tokens) || doc.tokens < 0) e.add('/tokens', 'must be 0 or more');
@@@ -118,7 +118,7 @@ function contractRun(doc, createdAt) {
const tasks = (Array.isArray(doc.tasks) ? doc.tasks : []).map((t) => {
const out = {
task_id: String(t.task_id || ''),
-- score: num(t.score),
++ score: t.score == null ? null : num(t.score),
cost_usd: num(t.cost_usd),
ms: Math.round(num(t.ms)),
tokens: Math.round(num(t.tokens)),
@@@ -142,7 +142,7 @@ function contractRun(doc, createdAt) {
},
executor: { kind: String(executor.kind || ''), id: String(executor.id || '') },
tasks,
-- score: num(doc.score),
++ score: doc.score == null ? null : num(doc.score),
cost_usd: num(doc.cost_usd),
ms: Math.round(num(doc.ms)),
tokens: Math.round(num(doc.tokens)),
ddiff --git a/server/http/registry.js b/server/http/registry.js
iindex d0cb695..8742122 100644
---- a/server/http/registry.js
++++ b/server/http/registry.js
@@@ -23,12 +23,18 @@ const { http } = require('openvibe-contracts');
const { asyncRouter } = require('./router');
const { sameOrigin } = require('./same-origin');
const { validateBenchmark, validateRun } = require('../bench/validate');
++const { createServiceTokenClient } = require('openvibe-sdk/auth');
function createRegistryApi(ctx) {
const { config, principal, registry } = ctx;
++ const fetchImpl = ctx.fetchImpl || globalThis.fetch;
++ const codesTokens = config.oauth.clientSecret ? createServiceTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret, audience: 'openvibe.codes', scope: 'codes.task.start', fetch: fetchImpl,
++ }) : null;
const r = asyncRouter();
-- r.use(express.json({ limit: '256kb' }));
++ r.use(express.json({ limit: '16mb' }));
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
r.use(principal.middleware);
@@@ -165,6 +171,37 @@ function createRegistryApi(ctx) {
} catch (err) { return failed(res, err); }
});
++ // Hand execution to Codes, which owns the person's keys and the benchmark task lifecycle.
++ r.post('/benchmarks/:id/run', actor('bench.run.publish'), async (req, res) => {
++ const p = req.principal;
++ if (p.kind !== 'user') return problem(res, 403, 'bench.person_required', 'Sign in as the person who will run this benchmark.');
++ if (!config.codesRunUrl) return problem(res, 503, 'bench.runner_unavailable', 'Codes benchmark runs are not configured.');
++ const b = req.body && typeof req.body === 'object' ? req.body : {};
++ const models = Array.isArray(b.models) ? b.models : [];
++ const harnesses = Array.isArray(b.harnesses) ? b.harnesses : [];
++ const repeat = Number(b.repeat);
++ if (!models.length || !harnesses.length || models.length > 20 || harnesses.length > 20 || !Number.isInteger(repeat) || repeat < 1 || repeat > 20) {
++ return invalid(res, 'models and harnesses must be non-empty arrays (at most 20 each), and repeat must be 1–20');
++ }
++ const definition = await registry.getDefinition(String(req.params.id), { reveal: false });
++ if (!definition) return problem(res, 404, 'bench.benchmark_not_found', 'No such benchmark.');
++ const costEstimate = (definition.definition.tasks || []).length * models.length * harnesses.length * repeat
++ * Number(definition.definition.limits && definition.definition.limits.usd || 0);
++ if (costEstimate > 1 && b.confirm !== true) return res.status(409).json({ code: 'bench.confirm_cost', cost_estimate_usd: costEstimate, confirm_required: true });
++ try {
++ if (!codesTokens) return problem(res, 503, 'bench.runner_unavailable', 'Bench has no Network client credentials for Codes.');
++ const codesRunApi = new URL(`/api/v1/benchmarks/${encodeURIComponent(String(req.params.id))}/run`, config.codesRunUrl).toString();
++ const response = await fetchImpl(codesRunApi, {
++ method: 'POST', headers: { ...(await codesTokens.authHeaders()), 'content-type': 'application/json', 'x-ov-subject': p.subject, 'x-ov-user-token': p.token },
++ body: JSON.stringify({ benchmark: String(req.params.id), models, harnesses, repeat }),
++ signal: AbortSignal.timeout(15000),
++ });
++ const body = await response.json().catch(() => ({}));
++ if (!response.ok) return problem(res, response.status < 500 ? response.status : 502, body.code || 'bench.runner_failed', body.detail || body.message || 'Codes could not start this benchmark.');
++ return res.status(202).json(body);
++ } catch (err) { return failed(res, err); }
++ });
++
// ── Runs ─────────────────────────────────────────────────
r.post('/runs', actor('bench.run.publish'), async (req, res) => {
const b = req.body && typeof req.body === 'object' ? req.body : {};
ddiff --git a/server/http/render.js b/server/http/render.js
iindex a66bab6..25eb5f7 100644
---- a/server/http/render.js
++++ b/server/http/render.js
@@@ -18,7 +18,24 @@ function createRenderRoutes(ctx) {
const row = await ctx.s.db.maybe(
'SELECT e.id, e.payload, e.error, r.kind_id FROM bench_entries e JOIN bench_rounds r ON r.id = e.round_id WHERE e.id = $1',
[String(id || '')]);
-- if (!row) return null;
++ if (!row) {
++ const match = /^((?:brn_)[0-9A-HJKMNP-TV-Z]{26})~([A-Za-z0-9_-]{1,120})$/.exec(String(id || ''));
++ if (!match) return null;
++ const taskId = Buffer.from(match[2], 'base64url').toString('utf8');
++ const runTask = await ctx.s.db.maybe(
++ `SELECT t.artifacts, v.definition FROM bench_run_tasks t
++ JOIN bench_runs r ON r.id = t.run_id
++ JOIN bench_benchmark_versions v ON v.benchmark_id = r.benchmark_id AND v.version = r.benchmark_version
++ WHERE t.run_id = $1 AND t.task_id = $2`, [match[1], taskId]);
++ if (!runTask) return null;
++ let artifacts = runTask.artifacts;
++ let definition = runTask.definition;
++ if (typeof artifacts === 'string') { try { artifacts = JSON.parse(artifacts); } catch { artifacts = []; } }
++ if (typeof definition === 'string') { try { definition = JSON.parse(definition); } catch { definition = {}; } }
++ const artifact = (Array.isArray(artifacts) ? artifacts : []).find((a) => a && a.payload);
++ if (!artifact) return null;
++ return { id: String(id), payload: artifact.payload, error: null, kind: kindById(definition && definition.kind) };
++ }
let payload = row.payload;
if (typeof payload === 'string') { try { payload = JSON.parse(payload); } catch { payload = {}; } }
return { id: row.id, payload: payload || {}, error: row.error || null, kind: kindById(row.kind_id) };
ddiff --git a/test/registry.test.js b/test/registry.test.js
iindex 206abef..6af940c 100644
---- a/test/registry.test.js
++++ b/test/registry.test.js
@@@ -102,6 +102,24 @@ function runDoc(benchmark, over = {}) {
assert.strictEqual(submitted.json().run.executor.kind, 'submitted');
});
++ await check('a human judged run publishes null scores and keeps its output on the framed render path', async () => {
++ const id = t.store.newId('brn');
++ const url = `https://bench-render.openvibe.services/render/w/${id}~dDE`;
++ const pending = runDoc(`${benchmarkId}@${version}`, {
++ id, score: null,
++ tasks: [{ task_id: 't1', score: null, cost_usd: 0.01, ms: 1200, tokens: 20,
++ artifacts: [{ kind: 'games.play', url, payload: { text: '<!doctype html><html><body><h1>Rendered benchmark output</h1></body></html>' } }] }],
++ });
++ const published = await t.get('/api/v1/runs', { bearer: publisher, json: pending });
++ assert.strictEqual(published.status, 201, published.text);
++ assert.strictEqual(published.json().run.score, null);
++ assert.strictEqual(published.json().run.tasks[0].score, null);
++ assert.strictEqual(published.json().run.tasks[0].artifacts[0].payload, undefined);
++ const rendered = await t.get(`/render/e/${id}~dDE`);
++ assert.strictEqual(rendered.status, 200, rendered.text);
++ assert.match(rendered.text, /Rendered benchmark output/);
++ });
++
await check('a run is read back with its tasks', async () => {
const list = await t.get(`/api/v1/benchmarks/${benchmarkId}`);
const runs = await t.get(`/api/v1/runs/${list.json().definition ? '' : ''}`);
@@@ -158,6 +176,12 @@ function runDoc(benchmark, over = {}) {
assert.strictEqual(anonRun.status, 401, anonRun.text);
});
++ await check('a signed-in person sees a clear unavailable response until Codes runs are configured', async () => {
++ const response = await t.get(`/api/v1/benchmarks/${benchmarkId}/run`, { as: alice, json: { models: ['m'], harnesses: ['h'], repeat: 1 } });
++ assert.strictEqual(response.status, 503, response.text);
++ assert.strictEqual(response.json().code, 'bench.runner_unavailable');
++ });
++
await check('the compare endpoint puts two runs side by side', async () => {
const runs = await t.get(`/api/v1/compare?runs=nope`);
assert.strictEqual(runs.status, 404, runs.text);