openvibers/openvibe.bench Commits

Ratings as a ranking table, not a stack of cards (#34)

OP OpenVibers committed 14 days ago · 20cfd30f42 · 4 files, +145 −45

The design review was right: twelve large cards, each mostly empty space, are
hard to scan. The board is now a table, in the style of B2's rankings.

  - the columns are Band, Model, Style controlled, Raw and Votes, and the
    rows are ordered by the style-controlled score;
  - every interval is drawn on one scale, the board's lowest lower bound to
    its highest upper bound, so two models that overlap show it at a glance.
    The numbers stay text beside the bar, for the screen reader;
  - on a phone each row folds into two lines: the band and the model, then
    the scores and the votes. No sideways scroll at 320;
  - "How much of the board is style" says what happened in words, with the
    sign in plain words and the number rounded, instead of a signed number;
  - an empty board says "No battles voted yet" and offers a battle.

The fit is unchanged: the same Bradley-Terry with the style covariates, the
same bootstrap, the same bands. The whole suite passes (22/22 files).

Changes

ddiff --git a/public/css/bench.css b/public/css/bench.css
iindex bcf2a3b..611eee2 100644
---- a/public/css/bench.css
++++ b/public/css/bench.css
@@@ -318,22 +318,43 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
.bt-reveal-facts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
--/* The ratings board (B9): raw and style controlled, side by side, with the intervals and the bands. */
++/* The ratings board (B9): a ranking table, one scale for every interval, two lines on a phone. */
.rt-board { max-width: 1180px; margin: 0 auto; padding: clamp(20px, 4vw, 40px) 16px 64px; }
.rt-board > h1 { margin: 0; font-size: clamp(1.8rem, 5vw, 2.8rem); font-weight: 800; letter-spacing: -.03em; line-height: 1.08; }
--.rt-list { list-style: none; margin: 20px 0 0; padding: 0; display: grid; gap: 10px; }
--.rt-row { display: grid; gap: 10px; padding: 14px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); min-width: 0; }
--.rt-cell { display: grid; gap: 2px; min-width: 0; align-content: start; }
--.rt-label { font-size: .68rem; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
--.rt-model { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-weight: 700; overflow-wrap: anywhere; }
--.rt-rating { font-size: 1.15rem; font-weight: 800; font-variant-numeric: tabular-nums; }
--.rt-ci { color: var(--text-muted, #7386a3); font-size: .82rem; font-variant-numeric: tabular-nums; }
--.rt-band { display: inline-block; margin-top: 4px; padding: 2px 10px; border-radius: 999px; background: color-mix(in srgb, var(--room-accent) 14%, transparent); color: var(--room-strong); font-size: .78rem; font-weight: 750; justify-self: start; }
--.rt-votes { font-weight: 700; font-variant-numeric: tabular-nums; }
++.rt-wrap { overflow-x: visible; }
++.rt-table { font-size: .92rem; }
++.rt-table td { vertical-align: middle; }
++.rt-col-band { width: 7.5em; }
++.rt-col-model { width: 20%; }
++.rt-col-controlled { width: 32%; }
++.rt-col-raw { width: 26%; }
++.rt-col-votes { width: 5em; }
++.rt-model { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-weight: 700; overflow-wrap: break-word; }
++.rt-score { font-weight: 800; font-variant-numeric: tabular-nums; }
++.rt-score-muted { color: var(--text-secondary, #96a7c2); font-weight: 650; }
++.rt-num { text-align: right; font-variant-numeric: tabular-nums; }
++.rt-band { display: inline-block; padding: 2px 10px; border-radius: 999px; background: color-mix(in srgb, var(--room-accent) 14%, transparent); color: var(--room-strong); font-size: .78rem; font-weight: 750; white-space: nowrap; }
++.rt-bar { display: block; width: 100%; height: 8px; margin-top: 6px; }
++.rt-bar-track { fill: color-mix(in srgb, var(--text-muted, #7386a3) 24%, transparent); }
++.rt-bar-range { fill: color-mix(in srgb, var(--room-accent) 70%, transparent); }
++.rt-bar-point { fill: var(--room-strong); }
++.rt-col-raw .rt-bar-range { fill: color-mix(in srgb, var(--text-muted, #7386a3) 45%, transparent); }
++.rt-col-raw .rt-bar-point { fill: var(--text-secondary, #96a7c2); }
++.rt-style-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
++.rt-style-list li { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); line-height: 1.5; }
.rt-effects, .rt-data { margin-top: clamp(24px, 4vw, 36px); }
.rt-effects h2, .rt-data h2 { margin: 0 0 10px; font-size: 1.2rem; font-weight: 800; letter-spacing: -.01em; }
.rt-data code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-weight: 700; color: var(--room-strong); overflow-wrap: anywhere; }
--@media (min-width: 900px) {
-- .rt-row { grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1fr) auto; align-items: center; }
++@media (max-width: 560px) {
++ .rt-table, .rt-table tbody { display: block; width: 100%; }
++ .rt-table thead { display: none; }
++ .rt-table tr { display: grid; grid-template-columns: auto 1fr auto; gap: 6px 10px; padding: 12px 14px; border-bottom: 1px solid var(--border, #1f2d47); }
++ .rt-table tbody tr:last-child { border-bottom: 0; }
++ .rt-table td { display: block; padding: 0; border: 0; min-width: 0; width: auto; }
++ .rt-col-band { grid-area: 1 / 1 / 2 / 2; }
++ .rt-col-model { grid-area: 1 / 2 / 2 / 4; }
++ .rt-col-controlled { grid-area: 2 / 1 / 3 / 2; }
++ .rt-col-raw { grid-area: 2 / 2 / 3 / 3; }
++ .rt-col-votes { grid-area: 2 / 3 / 3 / 4; text-align: right; }
++ .rt-bar { height: 6px; margin-top: 4px; }
}
--
ddiff --git a/server/http/ratings.js b/server/http/ratings.js
iindex ef32e82..cdb0f08 100644
---- a/server/http/ratings.js
++++ b/server/http/ratings.js
@@@ -7,6 +7,10 @@
* rating carries a bootstrap interval. Models whose intervals overlap share a band, so the page says "2nd-4th"
* instead of pretending the third place is a fact. The open dump next to it carries every vote without voter ids,
* and the fit can be re-derived from that file alone.
++ *
++ * The board is a table, like B2's rankings: a band, a model, the style-controlled score with its interval on one
++ * scale (so an overlap shows at a glance), the raw score muted, and the vote count. On a phone each row folds
++ * into two lines.
*/
const express = require('express');
@@@ -14,42 +18,100 @@ const { esc, raw } = require('../render/html');
const { send } = require('../render/layout');
const { board, boardView } = require('../rating/board');
--function interval(cell) {
-- if (!cell) return '';
-- const lo = cell.ci_lower == null ? '' : Math.round(cell.ci_lower);
-- const hi = cell.ci_upper == null ? '' : Math.round(cell.ci_upper);
-- return '<span class="rt-rating">' + esc(Math.round(cell.rating)) + '</span>'
-- + '<span class="rt-ci">' + esc(lo + '–' + hi) + '</span>';
++const num = (v, d = 0) => (v == null || Number.isNaN(Number(v)) ? d : Number(v));
++
++/** The rounded score and its interval, as text. */
++function scoreText(cell) {
++ if (!cell) return '—';
++ const lo = cell.ci_lower == null ? null : Math.round(cell.ci_lower);
++ const hi = cell.ci_upper == null ? null : Math.round(cell.ci_upper);
++ return Math.round(cell.rating) + (lo == null ? '' : ' (' + lo + '–' + hi + ')');
++}
++
++/**
++ * The interval bar: one scale for the whole board (its lowest lower bound to its highest upper bound), so two
++ * models that overlap show it at a glance. The numbers stay text next to it.
++ */
++function bar(cell, scale) {
++ if (!cell || !scale || scale.max <= scale.min) return '';
++ const at = (v) => Math.max(0, Math.min(100, ((v - scale.min) / (scale.max - scale.min)) * 100));
++ const lo = at(cell.ci_lower == null ? cell.rating : cell.ci_lower);
++ const hi = at(cell.ci_upper == null ? cell.rating : cell.ci_upper);
++ const point = at(cell.rating);
++ const width = Math.max(0.6, hi - lo);
++ return '<svg class="rt-bar" viewBox="0 0 100 8" preserveAspectRatio="none" aria-hidden="true" focusable="false">'
++ + '<rect class="rt-bar-track" x="0" y="3" width="100" height="2" rx="1"></rect>'
++ + '<rect class="rt-bar-range" x="' + lo.toFixed(2) + '" y="2.6" width="' + width.toFixed(2) + '" height="2.8" rx="1.4"></rect>'
++ + '<rect class="rt-bar-point" x="' + (point - 0.7).toFixed(2) + '" y="0.6" width="1.4" height="6.8" rx="0.7"></rect>'
++ + '</svg>';
++}
++
++/** One plain sentence per covariate: what the style did, in words, rounded. */
++function styleSentence(key, effect) {
++ const points = (v) => Math.round(Math.abs(num(v)));
++ if (key === 'length') {
++ const n = points(effect.elo_per_1000_chars);
++ if (!n) return 'The length of an output made no difference to the votes.';
++ return effect.elo_per_1000_chars < 0
++ ? 'Longer outputs lost about ' + n + ' points per 1,000 characters here.'
++ : 'Longer outputs won about ' + n + ' points per 1,000 characters here.';
++ }
++ if (key === 'markup') {
++ const n = points(effect.elo_per_10_marks);
++ if (!n) return 'How much markup an output wore made no difference to the votes.';
++ return effect.elo_per_10_marks < 0
++ ? 'More markup lost about ' + n + ' points per 10 markup marks here.'
++ : 'More markup won about ' + n + ' points per 10 markup marks here.';
++ }
++ return '';
}
function rows(view) {
if (!view.models.length) {
-- return '<div class="bm-empty"><h1>No votes yet</h1><p>The board fills up as people vote in the battles.</p></div>';
++ return '<div class="bm-empty"><h1>No battles voted yet</h1>'
++ + '<p>The board fills up as people vote in the battles.</p>'
++ + '<p class="bm-actions"><a class="bm-btn" href="/battle">Vote in a battle</a></p></div>';
++ }
++ // The board is ordered by the style-controlled score: the honest one.
++ const models = [...view.models].sort((a, b) => b.controlled.rating - a.controlled.rating || a.model.localeCompare(b.model));
++ let min = Infinity;
++ let max = -Infinity;
++ for (const m of models) {
++ for (const cell of [m.raw, m.controlled]) {
++ if (!cell) continue;
++ min = Math.min(min, num(cell.ci_lower, cell.rating));
++ max = Math.max(max, num(cell.ci_upper, cell.rating));
++ }
}
-- const body = view.models.map((m) => '<li class="rt-row" data-model="' + esc(m.model) + '">'
-- + '<div class="rt-cell rt-cell-model"><span class="rt-label">Model</span>'
-- + '<span class="rt-model">' + esc(m.model) + '</span></div>'
-- + '<div class="rt-cell"><span class="rt-label">Raw</span>' + interval(m.raw)
-- + '<span class="rt-band">' + esc(m.raw.band || '') + '</span></div>'
-- + '<div class="rt-cell"><span class="rt-label">Style controlled</span>' + interval(m.controlled)
-- + '<span class="rt-band">' + esc(m.controlled.band || '') + '</span></div>'
-- + '<div class="rt-cell"><span class="rt-label">Votes</span><span class="rt-votes">' + esc(m.votes) + '</span></div>'
-- + '</li>').join('');
-- return '<ul class="rt-list">' + body + '</ul>';
++ const scale = { min, max };
++ const body = models.map((m) => '<tr data-model="' + esc(m.model) + '">'
++ + '<td class="rt-col-band"><span class="rt-band">' + esc(m.controlled.band || m.raw.band || '') + '</span></td>'
++ + '<td class="rt-col-model"><span class="rt-model">' + esc(m.model) + '</span></td>'
++ + '<td class="rt-col-controlled"><span class="rt-score">' + esc(scoreText(m.controlled)) + '</span>'
++ + bar(m.controlled, scale) + '</td>'
++ + '<td class="rt-col-raw"><span class="rt-score rt-score-muted">' + esc(scoreText(m.raw)) + '</span>'
++ + bar(m.raw, scale) + '</td>'
++ + '<td class="rt-col-votes rt-num">' + esc(m.votes) + '</td>'
++ + '</tr>').join('');
++ return '<div class="br-table-wrap rt-wrap"><table class="br-table rt-table">'
++ + '<caption class="br-sr">Models in rank bands, with the style-controlled score and its interval, the raw score and its interval, and the number of votes</caption>'
++ + '<thead><tr><th scope="col">Band</th><th scope="col">Model</th><th scope="col">Style controlled</th>'
++ + '<th scope="col">Raw</th><th scope="col" class="br-num">Votes</th></tr></thead>'
++ + '<tbody>' + body + '</tbody></table></div>';
}
function effects(view) {
-- const list = Object.entries(view.effects || {});
-- if (!list.length) return '';
-- const items = list.map(([key, e]) => '<div><dt>' + esc(key) + '</dt><dd>'
-- + (e.elo_per_1000_chars != null ? esc(e.elo_per_1000_chars) + ' Elo per 1000 characters' : '')
-- + (e.elo_per_1000_chars != null && e.elo_per_10_marks != null ? '<br>' : '')
-- + (e.elo_per_10_marks != null ? esc(e.elo_per_10_marks) + ' Elo per 10 markup marks' : '')
-- + '</dd></div>').join('');
++ const list = Object.entries(view.effects || {}).map(([key, e]) => styleSentence(key, e)).filter(Boolean);
++ const dropped = (view.dropped_covariates || []).map((key) => key === 'markup'
++ ? 'Markup moved with the length of the outputs here, so it is not counted a second time.'
++ : key + ' was not counted separately.');
++ const sentences = list.concat(dropped);
++ if (!sentences.length) return '';
return '<section class="rt-effects"><h2>How much of the board is style</h2>'
-- + '<dl class="br-facts">' + items + '</dl>'
-- + '<p class="bm-fine">A positive number means the longer (or the more dressed up) output wins votes it did not '
-- + 'earn. The controlled column removes that effect; the raw column is what people actually chose.</p>'
++ + '<ul class="rt-style-list">' + sentences.map((s) => '<li>' + esc(s) + '</li>').join('') + '</ul>'
++ + '<p class="bm-fine">The raw column is what people chose; the style-controlled column is what the models would '
++ + 'score if every output were written the same way. A negative effect means the style lost votes it should not '
++ + 'have lost.</p>'
+ '</section>';
}
@@@ -59,11 +121,13 @@ function ratingsView(view) {
+ '<p class="bm-lede">Every human vote in Bench is one game between two models. The board is fitted twice: on the '
+ 'votes alone (raw) and with the style of each output as covariates (style controlled), so you can see how much '
+ 'of a rank is length and formatting.</p>'
-- + '<p class="bm-fine">' + esc(view.votes) + ' votes, '
-- + esc(view.bootstrap) + ' bootstrap resamples, seed ' + esc(view.seed)
-- + (view.voters && view.voters.downweighted ? ', ' + esc(view.voters.downweighted) + ' voters down-weighted (never named)' : '')
-- + '</p>'
-- + '<div class="rt-table-wrap">' + rows(view) + '</div>'
++ + (view.votes
++ ? '<p class="bm-fine">' + esc(view.votes) + ' votes, '
++ + esc(view.bootstrap) + ' bootstrap resamples, seed ' + esc(view.seed)
++ + (view.voters && view.voters.downweighted ? ', ' + esc(view.voters.downweighted) + ' voters down-weighted (never named)' : '')
++ + '</p>'
++ : '')
++ + rows(view)
+ effects(view)
+ '<section class="rt-data"><h2>The open data</h2>'
+ '<p class="bm-lede">Every vote is published as JSONL, without voter ids: '
ddiff --git a/test/battle.test.js b/test/battle.test.js
iindex 29f6f5e..527ebd1 100644
---- a/test/battle.test.js
++++ b/test/battle.test.js
@@@ -96,6 +96,13 @@ const FORM = { 'content-type': 'application/x-www-form-urlencoded' };
assert.strictEqual(page.status, 200, page.text);
assert.ok(page.text.includes('Raw') && page.text.includes('Style controlled'), 'both columns');
assert.ok(page.text.includes('alpha-'), 'a model on the board');
++ // The board is a table: a band, a model, the scores with their intervals on one scale, and the votes.
++ assert.ok(page.text.includes('class="br-table rt-table"'), 'the ranking table');
++ assert.ok(page.text.includes('>Band<') && page.text.includes('>Votes<'), 'the columns');
++ assert.ok(page.text.includes('class="rt-bar"'), 'the interval bar');
++ assert.ok(page.text.includes('rt-band'), 'the band');
++ // The style is explained in words, not as a number.
++ assert.ok(!page.text.includes('Elo per 1000 characters'), 'no raw effect numbers: ' + page.text.slice(0, 200));
const dump = await t.get('/api/v1/datasets/votes.jsonl');
assert.strictEqual(dump.status, 200, dump.text);
ddiff --git a/test/gallery.test.js b/test/gallery.test.js
iindex b03b862..b3ea68e 100644
---- a/test/gallery.test.js
++++ b/test/gallery.test.js
@@@ -83,6 +83,14 @@ const { boot, check, done } = require('./helpers/boot');
assert.ok(one.entries[0].wrapper_url.includes('/render/w/'), 'the sandboxed frame');
});
++ await check('an empty board says so, and offers a battle', async () => {
++ const page = await t.get('/ratings');
++ assert.strictEqual(page.status, 200, page.text);
++ assert.ok(page.text.includes('No battles voted yet'), 'the empty board: ' + page.text.slice(0, 200));
++ assert.ok(page.text.includes('Vote in a battle'), 'the button');
++ assert.ok(page.text.includes('href="/battle"'), 'it goes to the battle');
++ });
++
await t.close();
done();
})().catch((e) => { console.error(e); process.exit(1); });