openvibers/openvibe.bench Commits

Arena on stream: audience_poll judgements (one Elo game per poll with voters) (#1)

OP OpenVibers committed 15 days ago · e28710a7e4 · 8 files, +99 −16

Changes

ddiff --git a/migrations/0002_audience_poll.sql b/migrations/0002_audience_poll.sql
nnew file mode 100644
iindex 0000000..c2c3bc8
---- /dev/null
++++ b/migrations/0002_audience_poll.sql
@@@ -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;
ddiff --git a/server/contracts.js b/server/contracts.js
iindex 0ad11bf..4c1bd2e 100644
---- a/server/contracts.js
++++ b/server/contracts.js
@@@ -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' },
},
},
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 39c5000..11ee247 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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);
ddiff --git a/server/kinds.js b/server/kinds.js
iindex 6e90d86..5248373 100644
---- a/server/kinds.js
++++ b/server/kinds.js
@@@ -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']);
ddiff --git a/server/store.js b/server/store.js
iindex 80531f8..d9a09bf 100644
---- a/server/store.js
++++ b/server/store.js
@@@ -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 NULL
AND ($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', {
ddiff --git a/server/views/index.js b/server/views/index.js
iindex fbc3f05..b8c3274 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -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.')}
ddiff --git a/test/api.test.js b/test/api.test.js
iindex 660255f..5d1c3e8 100644
---- a/test/api.test.js
++++ b/test/api.test.js
@@@ -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 });
ddiff --git a/test/judges.test.js b/test/judges.test.js
iindex 19e4bea..0866aa8 100644
---- a/test/judges.test.js
++++ b/test/judges.test.js
@@@ -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();