openvibers/openvibe.bench Commits
Arena on stream: audience_poll judgements (one Elo game per poll with voters) (#1)
Changes
nnew file mode 100644@@@ -0,0 +1,4 @@++-- phase: expand++ALTER TABLE bench_judgements DROP CONSTRAINT bench_judgements_method;++ALTER TABLE bench_judgements ADD CONSTRAINT bench_judgements_method CHECK (method IN ('human_pairwise', 'audience_poll', 'rubric', 'automatic_grader', 'test_suite', 'game_outcome'));++ALTER TABLE bench_judgements ADD COLUMN vote_counts jsonb;@@@ -100,6 +100,7 @@ const BENCH_SCHEMAS = Object.freeze({pair: { type: ['object', 'null'] },outcome: { type: ['string', 'null'] },score: { type: ['number', 'null'] },++ vote_counts: { type: ['object', 'null'] },at: { type: 'string', format: 'date-time' },},},@@@ -24,7 +24,7 @@* token with bench.* capabilities. A session write must come from bench.openvibe.services itself.*/const express = require('express');--const { http } = require('openvibe-contracts');++const { http, ids } = require('openvibe-contracts');const { asyncRouter } = require('./router');const { sameOrigin } = require('./same-origin');const { kindById } = require('../kinds');@@@ -47,6 +47,14 @@ function createApi(ctx) {};const subjectOf = (req) => (req.principal && req.principal.kind === 'user' ? req.principal.subject : null);++ // A trusted room service may start a task for its signed-in creator. Quotas then belong to that++ // creator, while service judgements continue to belong to the service principal itself.++ const creatorOf = (req) => {++ const person = subjectOf(req);++ if (person) return person;++ const acting = req.principal.requester === 'service:live' ? req.headers['x-ov-subject'] : null;++ return ids.isSubjectId('user', acting) ? acting : req.principal.requester;++ };/** A person (any signed-in user) or a service token with the named capability. */function actor(cap) {@@@ -85,7 +93,7 @@ function createApi(ctx) {// ── Tasks ───────────────────────────────────────────────r.post('/kinds/:kindId/tasks', actor('bench.task.create'), async (req, res) => {const b = req.body && typeof req.body === 'object' ? req.body : {};-- const owner = subjectOf(req) || req.principal.requester;++ const owner = creatorOf(req);const out = await bench.createTask({kindId: String(req.params.kindId || ''),prompt: b.prompt,@@@ -108,7 +116,7 @@ function createApi(ctx) {// ── Rounds ──────────────────────────────────────────────r.post('/tasks/:id/rounds', actor('bench.round.create'), async (req, res) => {const b = req.body && typeof req.body === 'object' ? req.body : {};-- const owner = subjectOf(req) || req.principal.requester;++ const owner = creatorOf(req);try {const out = await bench.createRound({taskId: String(req.params.id || ''),@@@ -180,7 +188,7 @@ function createApi(ctx) {const out = await bench.castJudgement({roundId, kindId: b.kind_id, judge,judgeName: b.judge_name, judgeVersion: b.judge_version,-- pair: b.pair, outcome: b.outcome, score: b.score, subject,++ pair: b.pair, outcome: b.outcome, score: b.score, voteCounts: b.vote_counts, subject,});res.set('Cache-Control', 'private, no-store');return res.status(201).json(out);@@@ -6,7 +6,7 @@* of an entry payload and its `metrics` are the axes a board may show.*/--const JUDGES = Object.freeze(['human_pairwise', 'rubric', 'automatic_grader', 'test_suite', 'game_outcome']);++const JUDGES = Object.freeze(['human_pairwise', 'audience_poll', 'rubric', 'automatic_grader', 'test_suite', 'game_outcome']);/** Pairwise winners as the blind voter saw them; both_bad is a game for neither. */const WINNERS = Object.freeze(['left', 'right', 'tie', 'both_bad']);@@@ -88,7 +88,8 @@ function wireJudgement(row, { withSubject = false } = {}) {const out = {id: row.id, round_id: row.round_id, kind_id: row.kind_id,judge: row.judge, judge_name: row.judge_name || null, judge_version: row.judge_version || null,-- method: row.method, pair, outcome: row.outcome || null, score: row.score == null ? null : num(row.score), at: iso(row.at),++ method: row.method, pair, outcome: row.outcome || null, score: row.score == null ? null : num(row.score),++ vote_counts: row.vote_counts == null ? null : (typeof row.vote_counts === 'string' ? JSON.parse(row.vote_counts) : row.vote_counts), at: iso(row.at),};if (withSubject) out.subject = row.subject || null;return out;@@@ -308,7 +309,7 @@ function createStore(s, { log = console, random = Math.random, config = null } =async function pairwiseGames(kindId, category = CATEGORY_DEFAULT) {const rows = await db.many(`SELECT * FROM bench_judgements-- WHERE kind_id = $1 AND method IN ('human_pairwise', 'game_outcome') AND pair IS NOT NULL++ WHERE kind_id = $1 AND method IN ('human_pairwise', 'game_outcome', 'audience_poll') AND pair IS NOT NULLAND ($2::text = 'all' OR outcome IS NOT NULL)ORDER BY at, id`,[kindId, category],@@@ -318,6 +319,10 @@ function createStore(s, { log = console, random = Math.random, config = null } =const pair = typeof j.pair === 'string' ? JSON.parse(j.pair) : j.pair;if (!pair || !pair.left || !pair.right) continue;if (j.outcome === 'both_bad') continue;++ if (j.method === 'audience_poll') {++ const counts = typeof j.vote_counts === 'string' ? JSON.parse(j.vote_counts) : j.vote_counts;++ if (!counts || !num(counts.voters)) continue;++ }out.push({ a: pair.left, b: pair.right, outcome: j.outcome === 'left' ? 1 : j.outcome === 'right' ? 0 : 0.5 });}return out;@@@ -344,18 +349,21 @@ function createStore(s, { log = console, random = Math.random, config = null } =};}-- async function castJudgement({ roundId, kindId, judge, judgeName = null, judgeVersion = null, pair = null, outcome = null, score = null, subject }) {++ async function castJudgement({ roundId, kindId, judge, judgeName = null, judgeVersion = null, pair = null, outcome = null, score = null, voteCounts = null, subject }) {if (!JUDGES.includes(judge)) throw new BenchError(422, 'bench.invalid_judge', 'Unknown judge.');try {return await s.tx(async () => {const r = await db.maybe('SELECT * FROM bench_rounds WHERE id = $1 FOR UPDATE', [String(roundId || '')]);if (!r) throw new BenchError(404, 'bench.round_not_found', 'No such Bench round.');if (r.status !== 'finished' && r.status !== 'ready') throw new BenchError(409, 'bench.round_not_ready', 'This round is not ready to judge yet.');++ if (judge === 'audience_poll' && kindById(r.kind_id)?.judge !== 'human_pairwise') {++ throw new BenchError(422, 'bench.invalid_judge', 'Audience polls require a pairwise kind.');++ }let left = null;let right = null;-- if (judge === 'human_pairwise' || judge === 'game_outcome') {-- if (r.created_by === subject) throw new BenchError(403, 'bench.creator_cannot_vote', 'You cannot judge a round you started.');++ if (judge === 'human_pairwise' || judge === 'game_outcome' || judge === 'audience_poll') {++ if (judge !== 'audience_poll' && r.created_by === subject) throw new BenchError(403, 'bench.creator_cannot_vote', 'You cannot judge a round you started.');const p = pair && typeof pair === 'object' ? pair : {};if (!p.left || !p.right || p.left === p.right) throw new BenchError(422, 'bench.invalid_pair', 'A pairwise judgement needs two different entries.');const prior = await db.maybe('SELECT 1 AS n FROM bench_judgements WHERE round_id = $1 AND subject = $2', [r.id, subject]);@@@ -364,18 +372,28 @@ function createStore(s, { log = console, random = Math.random, config = null } =if (rows.length !== 2) throw new BenchError(422, 'bench.invalid_pair', 'Those two entries are not both part of this round.');left = wireEntry(rows.find((o) => o.id === p.left));right = wireEntry(rows.find((o) => o.id === p.right));-- const allowed = judge === 'game_outcome' ? ['left', 'right', 'draw'] : WINNERS;++ const allowed = judge === 'game_outcome' ? ['left', 'right', 'draw'] : judge === 'audience_poll' ? ['left', 'right', 'tie'] : WINNERS;if (!allowed.includes(outcome)) throw new BenchError(422, 'bench.invalid_outcome', `The outcome must be ${allowed.join(', ')}.`);++ if (judge === 'audience_poll') {++ const c = voteCounts;++ if (!c || !['left', 'right', 'tie', 'voters'].every((k) => Number.isSafeInteger(c[k]) && c[k] >= 0)++ || c.left + c.right + c.tie !== c.voters || c.voters > 1000000++ || outcome !== (c.left > c.right && c.left > c.tie ? 'left' : c.right > c.left && c.right > c.tie ? 'right' : 'tie')) {++ throw new BenchError(422, 'bench.invalid_vote_counts', 'Poll counts and outcome must agree.');++ }++ voteCounts = { left: c.left, right: c.right, tie: c.tie, voters: c.voters };++ } else if (voteCounts != null) throw new BenchError(422, 'bench.invalid_vote_counts', 'Only an audience poll carries vote counts.');}const id = s.newId('bju');const row = await db.maybe(-- `INSERT INTO bench_judgements (id, round_id, kind_id, judge, judge_name, judge_version, method, pair, outcome, score, subject, at)-- VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11, $12::timestamptz) RETURNING *`,++ `INSERT INTO bench_judgements (id, round_id, kind_id, judge, judge_name, judge_version, method, pair, outcome, score, subject, at, vote_counts)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11, $12::timestamptz, $13::jsonb) RETURNING *`,[id, r.id, r.kind_id, judge, judgeName, judgeVersion, judge, pair ? JSON.stringify(pair) : null,-- outcome || null, score == null ? null : num(score), subject || null, s.iso()],++ outcome || null, score == null ? null : num(score), subject || null, s.iso(), voteCounts ? JSON.stringify(voteCounts) : null],);-- if (judge === 'human_pairwise' || judge === 'game_outcome') {++ // One stream poll is one game, regardless of audience size. The counts are evidence, not Elo weight.++ if (judge === 'human_pairwise' || judge === 'game_outcome' || (judge === 'audience_poll' && voteCounts.voters > 0)) {await applyPairwiseRating(r.id, r.kind_id, left, right, outcome);}await insertOutbox('bench.judgement.recorded', {@@@ -47,7 +47,7 @@ ${ep('POST', '/api/v1/kinds/:kindId/tasks', '<code>{ prompt, task_class?, public${ep('POST', '/api/v1/tasks/:id/rounds', '<code>{ offers?, seed? }</code>. Pins a reproducible round; the job generates entries.')}${ep('POST', '/api/v1/rounds/:id/entries', 'A room submits outside entries (a game match, a test-suite run) with a service token carrying bench.entry.create.')}${ep('GET', '/api/v1/judgements/next?kind=&judge=', 'The next blind pair for this person.')}--${ep('POST', '/api/v1/rounds/:id/judgements', '<code>{ judge?, pair?, outcome?, score? }</code>. Human judges are people; automatic/test/game judges are services with bench.judgement.cast.')}++${ep('POST', '/api/v1/rounds/:id/judgements', '<code>{ judge?, pair?, outcome?, score?, vote_counts? }</code>. Human judges are people; automatic/test/game judges and audience_poll are services with bench.judgement.cast. An audience_poll supplies { left, right, tie, voters }; it changes Elo as one game regardless of audience size. A zero-voter poll is recorded without changing ratings.')}${ep('POST', '/api/v1/evals', 'Create a private task suite (never in the public dataset).')}${ep('POST', '/api/v1/evals/:id/run', 'Run a private eval against chosen offers, under the free daily cap.')}${ep('GET', '/api/v1/evals/:id/results', 'A private eval and its runs.')}@@@ -34,6 +34,22 @@ const { boot, check, done } = require('./helpers/boot');assert.strictEqual(rr.json().round.status, 'queued');});++ await check('Live starts rounds against the streamer quota', async () => {++ const streamer = t.network.addUser('streamer');++ const live = t.network.serviceToken({ sub: 'svc:live', cap: ['bench.task.create', 'bench.round.create'] });++ const other = t.network.serviceToken({ sub: 'svc:other', cap: ['bench.task.create'] });++ const headers = { 'x-ov-subject': streamer.subject };++ const task = await t.get('/api/v1/kinds/chat.reply/tasks', { bearer: live, headers, json: { prompt: 'A stream reply.', public: true } });++ assert.strictEqual(task.status, 201, task.text);++ assert.strictEqual(task.json().task.created_by, streamer.subject);++ const round = await t.get(`/api/v1/tasks/${task.json().task.id}/rounds`, { bearer: live, headers, json: {} });++ assert.strictEqual(round.status, 201, round.text);++ assert.strictEqual(round.json().round.created_by, streamer.subject);++ const unrelated = await t.get('/api/v1/kinds/chat.reply/tasks', { bearer: other, headers, json: { prompt: 'Another task.' } });++ assert.strictEqual(unrelated.status, 201, unrelated.text);++ assert.strictEqual(unrelated.json().task.created_by, 'service:other');++ });++await check('the job generates entries and finishes the round with a receipt', async () => {await t.tick();const r = await t.get(`/api/v1/rounds/${roundId}`, { as: alice });@@@ -107,6 +107,42 @@ const { boot, check, done } = require('./helpers/boot');assert.strictEqual(vote.json().judgement.outcome, 'draw');});++ await check('audience poll: counts are recorded but 100 viewers change Elo by one game', async () => {++ const { roundId, entryIds } = await makeRound('chat.reply', ['poll/one', 'poll/two']);++ const pair = { left: entryIds[0], right: entryIds[1] };++ const invalid = await t.get(`/api/v1/rounds/${roundId}/judgements`, { bearer: judgeSvc,++ json: { judge: 'audience_poll', pair, outcome: 'right', vote_counts: { left: 70, right: 20, tie: 10, voters: 100 } } });++ assert.strictEqual(invalid.status, 422, invalid.text);++ const cast = await t.get(`/api/v1/rounds/${roundId}/judgements`, { bearer: judgeSvc,++ json: { judge: 'audience_poll', pair, outcome: 'left', vote_counts: { left: 70, right: 20, tie: 10, voters: 100, viewer_ids: ['private'] } } });++ assert.strictEqual(cast.status, 201, cast.text);++ assert.deepStrictEqual(cast.json().judgement.vote_counts, { left: 70, right: 20, tie: 10, voters: 100 });++ const rows = await t.ctx.s.db.many("SELECT subject, games FROM bench_ratings WHERE kind_id = 'chat.reply' AND subject IN ('one', 'two') ORDER BY subject");++ assert.deepStrictEqual(rows.map((r) => Number(r.games)), [1, 1]);++ const again = await t.get(`/api/v1/rounds/${roundId}/judgements`, { bearer: judgeSvc,++ json: { judge: 'audience_poll', pair, outcome: 'left', vote_counts: { left: 1, right: 0, tie: 0, voters: 1 } } });++ assert.strictEqual(again.status, 409, again.text);++ });++++ await check('audience poll: zero voters are recorded without a rating game', async () => {++ const { roundId, entryIds } = await makeRound('chat.reply', ['empty/first', 'empty/second']);++ const cast = await t.get(`/api/v1/rounds/${roundId}/judgements`, { bearer: judgeSvc,++ json: { judge: 'audience_poll', pair: { left: entryIds[0], right: entryIds[1] }, outcome: 'tie',++ vote_counts: { left: 0, right: 0, tie: 0, voters: 0 } } });++ assert.strictEqual(cast.status, 201, cast.text);++ const rows = await t.ctx.s.db.many("SELECT * FROM bench_ratings WHERE kind_id = 'chat.reply' AND subject IN ('first', 'second')");++ assert.strictEqual(rows.length, 0);++ });++++ await check('audience poll: !tie wins the plurality', async () => {++ const { roundId, entryIds } = await makeRound('chat.reply', ['tie/a', 'tie/b']);++ const cast = await t.get(`/api/v1/rounds/${roundId}/judgements`, { bearer: judgeSvc,++ json: { judge: 'audience_poll', pair: { left: entryIds[0], right: entryIds[1] }, outcome: 'tie',++ vote_counts: { left: 1, right: 2, tie: 3, voters: 6 } } });++ assert.strictEqual(cast.status, 201, cast.text);++ assert.strictEqual(cast.json().judgement.outcome, 'tie');++ });++await check('stop', async () => { await t.close(); });done();
new file mode 100644
new file mode 100644
@@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
+-- phase: expand
+ALTER TABLE bench_judgements DROP CONSTRAINT bench_judgements_method;
+ALTER TABLE bench_judgements ADD CONSTRAINT bench_judgements_method CHECK (method IN ('human_pairwise', 'audience_poll', 'rubric', 'automatic_grader', 'test_suite', 'game_outcome'));
+ALTER TABLE bench_judgements ADD COLUMN vote_counts jsonb;
@@ -100,6 +100,7 @@ const BENCH_SCHEMAS = Object.freeze({
@@ -100,6 +100,7 @@ const BENCH_SCHEMAS = Object.freeze({
pair: { type: ['object', 'null'] },
pair: { type: ['object', 'null'] },
outcome: { type: ['string', 'null'] },
outcome: { type: ['string', 'null'] },
score: { type: ['number', 'null'] },
score: { type: ['number', 'null'] },
+ vote_counts: { type: ['object', 'null'] },
at: { type: 'string', format: 'date-time' },
at: { type: 'string', format: 'date-time' },
},
},
},
},
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
* token with bench.* capabilities. A session write must come from bench.openvibe.services itself.
* token with bench.* capabilities. A session write must come from bench.openvibe.services itself.
*/
*/
const express = require('express');
const express = require('express');
-const { http } = require('openvibe-contracts');
+const { http, ids } = require('openvibe-contracts');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { sameOrigin } = require('./same-origin');
const { sameOrigin } = require('./same-origin');
const { kindById } = require('../kinds');
const { kindById } = require('../kinds');
@@ -47,6 +47,14 @@ function createApi(ctx) {
@@ -47,6 +47,14 @@ function createApi(ctx) {
};
};
const subjectOf = (req) => (req.principal && req.principal.kind === 'user' ? req.principal.subject : null);
const subjectOf = (req) => (req.principal && req.principal.kind === 'user' ? req.principal.subject : null);
+ // A trusted room service may start a task for its signed-in creator. Quotas then belong to that
+ // creator, while service judgements continue to belong to the service principal itself.
+ const creatorOf = (req) => {
+ const person = subjectOf(req);
+ if (person) return person;
+ const acting = req.principal.requester === 'service:live' ? req.headers['x-ov-subject'] : null;
+ return ids.isSubjectId('user', acting) ? acting : req.principal.requester;
+ };
/** A person (any signed-in user) or a service token with the named capability. */
/** A person (any signed-in user) or a service token with the named capability. */
function actor(cap) {
function actor(cap) {
@@ -85,7 +93,7 @@ function createApi(ctx) {
@@ -85,7 +93,7 @@ function createApi(ctx) {
// ── Tasks ───────────────────────────────────────────────
// ── Tasks ───────────────────────────────────────────────
r.post('/kinds/:kindId/tasks', actor('bench.task.create'), async (req, res) => {
r.post('/kinds/:kindId/tasks', actor('bench.task.create'), async (req, res) => {
const b = req.body && typeof req.body === 'object' ? req.body : {};
const b = req.body && typeof req.body === 'object' ? req.body : {};
- const owner = subjectOf(req) || req.principal.requester;
+ const owner = creatorOf(req);
const out = await bench.createTask({
const out = await bench.createTask({
kindId: String(req.params.kindId || ''),
kindId: String(req.params.kindId || ''),
prompt: b.prompt,
prompt: b.prompt,
@@ -108,7 +116,7 @@ function createApi(ctx) {
@@ -108,7 +116,7 @@ function createApi(ctx) {
// ── Rounds ──────────────────────────────────────────────
// ── Rounds ──────────────────────────────────────────────
r.post('/tasks/:id/rounds', actor('bench.round.create'), async (req, res) => {
r.post('/tasks/:id/rounds', actor('bench.round.create'), async (req, res) => {
const b = req.body && typeof req.body === 'object' ? req.body : {};
const b = req.body && typeof req.body === 'object' ? req.body : {};
- const owner = subjectOf(req) || req.principal.requester;
+ const owner = creatorOf(req);
try {
try {
const out = await bench.createRound({
const out = await bench.createRound({
taskId: String(req.params.id || ''),
taskId: String(req.params.id || ''),
@@ -180,7 +188,7 @@ function createApi(ctx) {
@@ -180,7 +188,7 @@ function createApi(ctx) {
const out = await bench.castJudgement({
const out = await bench.castJudgement({
roundId, kindId: b.kind_id, judge,
roundId, kindId: b.kind_id, judge,
judgeName: b.judge_name, judgeVersion: b.judge_version,
judgeName: b.judge_name, judgeVersion: b.judge_version,
- pair: b.pair, outcome: b.outcome, score: b.score, subject,
+ pair: b.pair, outcome: b.outcome, score: b.score, voteCounts: b.vote_counts, subject,
});
});
res.set('Cache-Control', 'private, no-store');
res.set('Cache-Control', 'private, no-store');
return res.status(201).json(out);
return res.status(201).json(out);
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
* of an entry payload and its `metrics` are the axes a board may show.
* of an entry payload and its `metrics` are the axes a board may show.
*/
*/
-const JUDGES = Object.freeze(['human_pairwise', 'rubric', 'automatic_grader', 'test_suite', 'game_outcome']);
+const JUDGES = Object.freeze(['human_pairwise', 'audience_poll', 'rubric', 'automatic_grader', 'test_suite', 'game_outcome']);
/** Pairwise winners as the blind voter saw them; both_bad is a game for neither. */
/** Pairwise winners as the blind voter saw them; both_bad is a game for neither. */
const WINNERS = Object.freeze(['left', 'right', 'tie', 'both_bad']);
const WINNERS = Object.freeze(['left', 'right', 'tie', 'both_bad']);
@@ -88,7 +88,8 @@ function wireJudgement(row, { withSubject = false } = {}) {
@@ -88,7 +88,8 @@ function wireJudgement(row, { withSubject = false } = {}) {
const out = {
const out = {
id: row.id, round_id: row.round_id, kind_id: row.kind_id,
id: row.id, round_id: row.round_id, kind_id: row.kind_id,
judge: row.judge, judge_name: row.judge_name || null, judge_version: row.judge_version || null,
judge: row.judge, judge_name: row.judge_name || null, judge_version: row.judge_version || null,
- method: row.method, pair, outcome: row.outcome || null, score: row.score == null ? null : num(row.score), at: iso(row.at),
+ method: row.method, pair, outcome: row.outcome || null, score: row.score == null ? null : num(row.score),
+ vote_counts: row.vote_counts == null ? null : (typeof row.vote_counts === 'string' ? JSON.parse(row.vote_counts) : row.vote_counts), at: iso(row.at),
};
};
if (withSubject) out.subject = row.subject || null;
if (withSubject) out.subject = row.subject || null;
return out;
return out;
@@ -308,7 +309,7 @@ function createStore(s, { log = console, random = Math.random, config = null } =
@@ -308,7 +309,7 @@ function createStore(s, { log = console, random = Math.random, config = null } =
async function pairwiseGames(kindId, category = CATEGORY_DEFAULT) {
async function pairwiseGames(kindId, category = CATEGORY_DEFAULT) {
const rows = await db.many(
const rows = await db.many(
`SELECT * FROM bench_judgements
`SELECT * FROM bench_judgements
- WHERE kind_id = $1 AND method IN ('human_pairwise', 'game_outcome') AND pair IS NOT NULL
+ WHERE kind_id = $1 AND method IN ('human_pairwise', 'game_outcome', 'audience_poll') AND pair IS NOT NULL
AND ($2::text = 'all' OR outcome IS NOT NULL)
AND ($2::text = 'all' OR outcome IS NOT NULL)
ORDER BY at, id`,
ORDER BY at, id`,
[kindId, category],
[kindId, category],
@@ -318,6 +319,10 @@ function createStore(s, { log = console, random = Math.random, config = null } =
@@ -318,6 +319,10 @@ function createStore(s, { log = console, random = Math.random, config = null } =
const pair = typeof j.pair === 'string' ? JSON.parse(j.pair) : j.pair;
const pair = typeof j.pair === 'string' ? JSON.parse(j.pair) : j.pair;
if (!pair || !pair.left || !pair.right) continue;
if (!pair || !pair.left || !pair.right) continue;
if (j.outcome === 'both_bad') continue;
if (j.outcome === 'both_bad') continue;
+ if (j.method === 'audience_poll') {
+ const counts = typeof j.vote_counts === 'string' ? JSON.parse(j.vote_counts) : j.vote_counts;
+ if (!counts || !num(counts.voters)) continue;
+ }
out.push({ a: pair.left, b: pair.right, outcome: j.outcome === 'left' ? 1 : j.outcome === 'right' ? 0 : 0.5 });
out.push({ a: pair.left, b: pair.right, outcome: j.outcome === 'left' ? 1 : j.outcome === 'right' ? 0 : 0.5 });
}
}
return out;
return out;
@@ -344,18 +349,21 @@ function createStore(s, { log = console, random = Math.random, config = null } =
@@ -344,18 +349,21 @@ function createStore(s, { log = console, random = Math.random, config = null } =
};
};
}
}
- async function castJudgement({ roundId, kindId, judge, judgeName = null, judgeVersion = null, pair = null, outcome = null, score = null, subject }) {
+ async function castJudgement({ roundId, kindId, judge, judgeName = null, judgeVersion = null, pair = null, outcome = null, score = null, voteCounts = null, subject }) {
if (!JUDGES.includes(judge)) throw new BenchError(422, 'bench.invalid_judge', 'Unknown judge.');
if (!JUDGES.includes(judge)) throw new BenchError(422, 'bench.invalid_judge', 'Unknown judge.');
try {
try {
return await s.tx(async () => {
return await s.tx(async () => {
const r = await db.maybe('SELECT * FROM bench_rounds WHERE id = $1 FOR UPDATE', [String(roundId || '')]);
const r = await db.maybe('SELECT * FROM bench_rounds WHERE id = $1 FOR UPDATE', [String(roundId || '')]);
if (!r) throw new BenchError(404, 'bench.round_not_found', 'No such Bench round.');
if (!r) throw new BenchError(404, 'bench.round_not_found', 'No such Bench round.');
if (r.status !== 'finished' && r.status !== 'ready') throw new BenchError(409, 'bench.round_not_ready', 'This round is not ready to judge yet.');
if (r.status !== 'finished' && r.status !== 'ready') throw new BenchError(409, 'bench.round_not_ready', 'This round is not ready to judge yet.');
+ if (judge === 'audience_poll' && kindById(r.kind_id)?.judge !== 'human_pairwise') {
+ throw new BenchError(422, 'bench.invalid_judge', 'Audience polls require a pairwise kind.');
+ }
let left = null;
let left = null;
let right = null;
let right = null;
- if (judge === 'human_pairwise' || judge === 'game_outcome') {
- if (r.created_by === subject) throw new BenchError(403, 'bench.creator_cannot_vote', 'You cannot judge a round you started.');
+ if (judge === 'human_pairwise' || judge === 'game_outcome' || judge === 'audience_poll') {
+ if (judge !== 'audience_poll' && r.created_by === subject) throw new BenchError(403, 'bench.creator_cannot_vote', 'You cannot judge a round you started.');
const p = pair && typeof pair === 'object' ? pair : {};
const p = pair && typeof pair === 'object' ? pair : {};
if (!p.left || !p.right || p.left === p.right) throw new BenchError(422, 'bench.invalid_pair', 'A pairwise judgement needs two different entries.');
if (!p.left || !p.right || p.left === p.right) throw new BenchError(422, 'bench.invalid_pair', 'A pairwise judgement needs two different entries.');
const prior = await db.maybe('SELECT 1 AS n FROM bench_judgements WHERE round_id = $1 AND subject = $2', [r.id, subject]);
const prior = await db.maybe('SELECT 1 AS n FROM bench_judgements WHERE round_id = $1 AND subject = $2', [r.id, subject]);
@@ -364,18 +372,28 @@ function createStore(s, { log = console, random = Math.random, config = null } =
@@ -364,18 +372,28 @@ function createStore(s, { log = console, random = Math.random, config = null } =
if (rows.length !== 2) throw new BenchError(422, 'bench.invalid_pair', 'Those two entries are not both part of this round.');
if (rows.length !== 2) throw new BenchError(422, 'bench.invalid_pair', 'Those two entries are not both part of this round.');
left = wireEntry(rows.find((o) => o.id === p.left));
left = wireEntry(rows.find((o) => o.id === p.left));
right = wireEntry(rows.find((o) => o.id === p.right));
right = wireEntry(rows.find((o) => o.id === p.right));
- const allowed = judge === 'game_outcome' ? ['left', 'right', 'draw'] : WINNERS;
+ const allowed = judge === 'game_outcome' ? ['left', 'right', 'draw'] : judge === 'audience_poll' ? ['left', 'right', 'tie'] : WINNERS;
if (!allowed.includes(outcome)) throw new BenchError(422, 'bench.invalid_outcome', `The outcome must be ${allowed.join(', ')}.`);
if (!allowed.includes(outcome)) throw new BenchError(422, 'bench.invalid_outcome', `The outcome must be ${allowed.join(', ')}.`);
+ if (judge === 'audience_poll') {
+ const c = voteCounts;
+ if (!c || !['left', 'right', 'tie', 'voters'].every((k) => Number.isSafeInteger(c[k]) && c[k] >= 0)
+ || c.left + c.right + c.tie !== c.voters || c.voters > 1000000
+ || outcome !== (c.left > c.right && c.left > c.tie ? 'left' : c.right > c.left && c.right > c.tie ? 'right' : 'tie')) {
+ throw new BenchError(422, 'bench.invalid_vote_counts', 'Poll counts and outcome must agree.');
+ }
+ voteCounts = { left: c.left, right: c.right, tie: c.tie, voters: c.voters };
+ } else if (voteCounts != null) throw new BenchError(422, 'bench.invalid_vote_counts', 'Only an audience poll carries vote counts.');
}
}
const id = s.newId('bju');
const id = s.newId('bju');
const row = await db.maybe(
const row = await db.maybe(
- `INSERT INTO bench_judgements (id, round_id, kind_id, judge, judge_name, judge_version, method, pair, outcome, score, subject, at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11, $12::timestamptz) RETURNING *`,
+ `INSERT INTO bench_judgements (id, round_id, kind_id, judge, judge_name, judge_version, method, pair, outcome, score, subject, at, vote_counts)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11, $12::timestamptz, $13::jsonb) RETURNING *`,
[id, r.id, r.kind_id, judge, judgeName, judgeVersion, judge, pair ? JSON.stringify(pair) : null,
[id, r.id, r.kind_id, judge, judgeName, judgeVersion, judge, pair ? JSON.stringify(pair) : null,
- outcome || null, score == null ? null : num(score), subject || null, s.iso()],
+ outcome || null, score == null ? null : num(score), subject || null, s.iso(), voteCounts ? JSON.stringify(voteCounts) : null],
);
);
- if (judge === 'human_pairwise' || judge === 'game_outcome') {
+ // One stream poll is one game, regardless of audience size. The counts are evidence, not Elo weight.
+ if (judge === 'human_pairwise' || judge === 'game_outcome' || (judge === 'audience_poll' && voteCounts.voters > 0)) {
await applyPairwiseRating(r.id, r.kind_id, left, right, outcome);
await applyPairwiseRating(r.id, r.kind_id, left, right, outcome);
}
}
await insertOutbox('bench.judgement.recorded', {
await insertOutbox('bench.judgement.recorded', {
@@ -47,7 +47,7 @@ ${ep('POST', '/api/v1/kinds/:kindId/tasks', '<code>{ prompt, task_class?, public
@@ -47,7 +47,7 @@ ${ep('POST', '/api/v1/kinds/:kindId/tasks', '<code>{ prompt, task_class?, public
${ep('POST', '/api/v1/tasks/:id/rounds', '<code>{ offers?, seed? }</code>. Pins a reproducible round; the job generates entries.')}
${ep('POST', '/api/v1/tasks/:id/rounds', '<code>{ offers?, seed? }</code>. Pins a reproducible round; the job generates entries.')}
${ep('POST', '/api/v1/rounds/:id/entries', 'A room submits outside entries (a game match, a test-suite run) with a service token carrying bench.entry.create.')}
${ep('POST', '/api/v1/rounds/:id/entries', 'A room submits outside entries (a game match, a test-suite run) with a service token carrying bench.entry.create.')}
${ep('GET', '/api/v1/judgements/next?kind=&judge=', 'The next blind pair for this person.')}
${ep('GET', '/api/v1/judgements/next?kind=&judge=', 'The next blind pair for this person.')}
-${ep('POST', '/api/v1/rounds/:id/judgements', '<code>{ judge?, pair?, outcome?, score? }</code>. Human judges are people; automatic/test/game judges are services with bench.judgement.cast.')}
+${ep('POST', '/api/v1/rounds/:id/judgements', '<code>{ judge?, pair?, outcome?, score?, vote_counts? }</code>. Human judges are people; automatic/test/game judges and audience_poll are services with bench.judgement.cast. An audience_poll supplies { left, right, tie, voters }; it changes Elo as one game regardless of audience size. A zero-voter poll is recorded without changing ratings.')}
${ep('POST', '/api/v1/evals', 'Create a private task suite (never in the public dataset).')}
${ep('POST', '/api/v1/evals', 'Create a private task suite (never in the public dataset).')}
${ep('POST', '/api/v1/evals/:id/run', 'Run a private eval against chosen offers, under the free daily cap.')}
${ep('POST', '/api/v1/evals/:id/run', 'Run a private eval against chosen offers, under the free daily cap.')}
${ep('GET', '/api/v1/evals/:id/results', 'A private eval and its runs.')}
${ep('GET', '/api/v1/evals/:id/results', 'A private eval and its runs.')}
@@ -34,6 +34,22 @@ const { boot, check, done } = require('./helpers/boot');
@@ -34,6 +34,22 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual(rr.json().round.status, 'queued');
assert.strictEqual(rr.json().round.status, 'queued');
});
});
+ await check('Live starts rounds against the streamer quota', async () => {
+ const streamer = t.network.addUser('streamer');
+ const live = t.network.serviceToken({ sub: 'svc:live', cap: ['bench.task.create', 'bench.round.create'] });
+ const other = t.network.serviceToken({ sub: 'svc:other', cap: ['bench.task.create'] });
+ const headers = { 'x-ov-subject': streamer.subject };
+ const task = await t.get('/api/v1/kinds/chat.reply/tasks', { bearer: live, headers, json: { prompt: 'A stream reply.', public: true } });
+ assert.strictEqual(task.status, 201, task.text);
+ assert.strictEqual(task.json().task.created_by, streamer.subject);
+ const round = await t.get(`/api/v1/tasks/${task.json().task.id}/rounds`, { bearer: live, headers, json: {} });
+ assert.strictEqual(round.status, 201, round.text);
+ assert.strictEqual(round.json().round.created_by, streamer.subject);
+ const unrelated = await t.get('/api/v1/kinds/chat.reply/tasks', { bearer: other, headers, json: { prompt: 'Another task.' } });
+ assert.strictEqual(unrelated.status, 201, unrelated.text);
+ assert.strictEqual(unrelated.json().task.created_by, 'service:other');
+ });
+
await check('the job generates entries and finishes the round with a receipt', async () => {
await check('the job generates entries and finishes the round with a receipt', async () => {
await t.tick();
await t.tick();
const r = await t.get(`/api/v1/rounds/${roundId}`, { as: alice });
const r = await t.get(`/api/v1/rounds/${roundId}`, { as: alice });
@@ -107,6 +107,42 @@ const { boot, check, done } = require('./helpers/boot');
@@ -107,6 +107,42 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual(vote.json().judgement.outcome, 'draw');
assert.strictEqual(vote.json().judgement.outcome, 'draw');
});
});
+ await check('audience poll: counts are recorded but 100 viewers change Elo by one game', async () => {
+ const { roundId, entryIds } = await makeRound('chat.reply', ['poll/one', 'poll/two']);
+ const pair = { left: entryIds[0], right: entryIds[1] };
+ const invalid = await t.get(`/api/v1/rounds/${roundId}/judgements`, { bearer: judgeSvc,
+ json: { judge: 'audience_poll', pair, outcome: 'right', vote_counts: { left: 70, right: 20, tie: 10, voters: 100 } } });
+ assert.strictEqual(invalid.status, 422, invalid.text);
+ const cast = await t.get(`/api/v1/rounds/${roundId}/judgements`, { bearer: judgeSvc,
+ json: { judge: 'audience_poll', pair, outcome: 'left', vote_counts: { left: 70, right: 20, tie: 10, voters: 100, viewer_ids: ['private'] } } });
+ assert.strictEqual(cast.status, 201, cast.text);
+ assert.deepStrictEqual(cast.json().judgement.vote_counts, { left: 70, right: 20, tie: 10, voters: 100 });
+ const rows = await t.ctx.s.db.many("SELECT subject, games FROM bench_ratings WHERE kind_id = 'chat.reply' AND subject IN ('one', 'two') ORDER BY subject");
+ assert.deepStrictEqual(rows.map((r) => Number(r.games)), [1, 1]);
+ const again = await t.get(`/api/v1/rounds/${roundId}/judgements`, { bearer: judgeSvc,
+ json: { judge: 'audience_poll', pair, outcome: 'left', vote_counts: { left: 1, right: 0, tie: 0, voters: 1 } } });
+ assert.strictEqual(again.status, 409, again.text);
+ });
+
+ await check('audience poll: zero voters are recorded without a rating game', async () => {
+ const { roundId, entryIds } = await makeRound('chat.reply', ['empty/first', 'empty/second']);
+ const cast = await t.get(`/api/v1/rounds/${roundId}/judgements`, { bearer: judgeSvc,
+ json: { judge: 'audience_poll', pair: { left: entryIds[0], right: entryIds[1] }, outcome: 'tie',
+ vote_counts: { left: 0, right: 0, tie: 0, voters: 0 } } });
+ assert.strictEqual(cast.status, 201, cast.text);
+ const rows = await t.ctx.s.db.many("SELECT * FROM bench_ratings WHERE kind_id = 'chat.reply' AND subject IN ('first', 'second')");
+ assert.strictEqual(rows.length, 0);
+ });
+
+ await check('audience poll: !tie wins the plurality', async () => {
+ const { roundId, entryIds } = await makeRound('chat.reply', ['tie/a', 'tie/b']);
+ const cast = await t.get(`/api/v1/rounds/${roundId}/judgements`, { bearer: judgeSvc,
+ json: { judge: 'audience_poll', pair: { left: entryIds[0], right: entryIds[1] }, outcome: 'tie',
+ vote_counts: { left: 1, right: 2, tie: 3, voters: 6 } } });
+ assert.strictEqual(cast.status, 201, cast.text);
+ assert.strictEqual(cast.json().judgement.outcome, 'tie');
+ });
+
await check('stop', async () => { await t.close(); });
await check('stop', async () => { await t.close(); });
done();
done();