Skip to content

feat(benchmark): expose vote aggregation on leaderboards - #816

Merged
RapidPoseidon merged 3 commits into
mainfrom
feat(benchmark)/expose-vote-aggregation-on-leaderboards
Aug 14, 2026
Merged

feat(benchmark): expose vote aggregation on leaderboards#816
RapidPoseidon merged 3 commits into
mainfrom
feat(benchmark)/expose-vote-aggregation-on-leaderboards

Conversation

@RapidPoseidon

Copy link
Copy Markdown
Contributor

Why

Whether a leaderboard binarizes its responses was only reachable through a raw PATCH /leaderboard/{id} — it has no dashboard UI and was never surfaced on the SDK. So every SDK-created leaderboard silently inherited the server default of AllVotes, where each individual response counts as its own matchup: a matchup answered 5 times outweighs one answered twice, and within-matchup annotator noise flows straight into the standings.

This came out of a real board (rlb_1RNU9vh78AqXX1 on the Benchmark.ai Image Benchmark) sitting on AllVotes while its three sibling leaderboards were all on MajorityVote. At 5 responses per matchup, binarizing moved the top participants' win rates by 3–5 points.

What

  • New public VoteAggregation enum (MAJORITY_VOTE / ALL_VOTES), exported from rapidata and rapidata.types, following the existing Gender / AgeGroup wrapper-enum pattern.
  • RapidataBenchmark.create_leaderboard(..., vote_aggregation=VoteAggregation.MAJORITY_VOTE)majority is now the SDK default, so new leaderboards binarize unless the caller opts out.
  • RapidataLeaderboard.vote_aggregation reads it back and can change it in place.
  • Docs: new Vote Aggregation section under Leaderboard Configuration in mri_advanced.md.

Notes for the reviewer

The default changes behaviour for existing SDK users. Leaderboards created through create_leaderboard after this lands binarize where they previously did not. Existing leaderboards are untouched.

Switching the aggregation is retroactive. Standings are derived from the raw votes on every read — the binarization lives in the leaderboard service's ClickHouse scoreboard query, grouped by rapidId — so changing it also re-counts already-collected responses. That's why this is a settable property rather than create-only like the prompt-tag filters. (Note: the backend's UpdateLeaderboardEndpoint XML doc claims "Only affects future runs", which is wrong and ships in the published OpenAPI spec. Worth a separate backend fix.)

The value resolves lazily. QueryLeaderboardsByBenchmarkEndpoint does not carry voteAggregation in its output — unlike the sibling QueryLeaderboardsEndpoint, get-by-id, and create endpoints — so a leaderboard obtained from benchmark.leaderboards fetches it once on first access rather than guessing the default. An unresolved value is omitted from the update payload so an unrelated setter (name, level_of_detail) can't rewrite it. Adding the field to that endpoint's Output in rapidata-backend would remove the extra request; happy to open that PR if you want it.

Open question: the backend default is still AllVotes, so leaderboards created through the API or dashboard don't get this. Should that flip too?

Verification

  • uv run pytest tests/ → 112 passed. The 4 failures in tests/rapidata_client/audience/ are pre-existing on main (verified on a clean tree).
  • uv run pyright src/rapidata/rapidata_client → 0 errors.
  • uv run black src/rapidata/rapidata_client → clean (unrelated pre-existing reformatting left out of this diff).
  • uv run --group docs mkdocs build → builds; reference page auto-generates for the new module.

🔗 Session: session-86eedabe

🤖 Generated with Claude Code

Whether a leaderboard binarizes its responses was only reachable through a
raw PATCH against the API — it is absent from the dashboard UI and was never
surfaced on the SDK, so every SDK-created leaderboard silently inherited the
server default of AllVotes. With AllVotes each individual response counts as
its own matchup, so a matchup answered 5 times outweighs one answered twice
and within-matchup annotator noise flows straight into the standings.

Adds a public `VoteAggregation` enum and threads it through
`create_leaderboard`, defaulting to `MAJORITY_VOTE`: each matchup collapses to
a single win for the majority side, ties split 0.5/0.5, so every matchup
weighs the same. `RapidataLeaderboard.vote_aggregation` reads it back and can
change it in place.

Standings are derived from the raw votes on every read (the binarization lives
in the leaderboard service's ClickHouse scoreboard query, keyed by rapidId),
so switching the aggregation also re-counts already-collected responses. That
is why this is a settable property rather than create-only like the prompt-tag
filters.

The value resolves lazily: `QueryLeaderboardsByBenchmarkEndpoint` does not
carry `voteAggregation` in its output — unlike the sibling list, get-by-id, and
create endpoints — so a leaderboard read from `benchmark.leaderboards` fetches
it once on first access instead of guessing the default. An unresolved value is
omitted from the update payload so an unrelated setter cannot rewrite it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: lino <68745352+LinoGiger@users.noreply.github.com>
Comment on lines +169 to +193
@vote_aggregation.setter
def vote_aggregation(self, vote_aggregation: VoteAggregation):
"""
Sets how the responses on a single matchup are aggregated.

Standings are derived from the raw responses on every read, so switching this
also changes how already-collected responses are counted — no re-evaluation
needed.
"""
with tracer.start_as_current_span(
"RapidataLeaderboard.vote_aggregation.setter"
):
if not isinstance(vote_aggregation, VoteAggregation):
raise ValueError(
"Vote aggregation must be one of: "
+ ", ".join(
f"VoteAggregation.{member.name}" for member in VoteAggregation
)
)

logger.debug(
f"Setting vote aggregation to {vote_aggregation.name} for leaderboard {self.name}"
)
self.__vote_aggregation = vote_aggregation
self._update_config()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i feel like these setters are overused, can you get rid of them in this entire class and just expose a patch endpoint?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — all four setters are gone (name, level_of_detail, min_responses_per_matchup, vote_aggregation), replaced by a single update() in 1846f99:

leaderboard.update(
    name="Realism v2",
    level_of_detail="high",
    min_responses_per_matchup=5,
    vote_aggregation=VoteAggregation.MAJORITY_VOTE,
)

It only sends the arguments you actually pass, which fixes a real bug the old setters had: each one built a full PATCH from the object's current in-memory state, so renaming a leaderboard also resent its response budget and min-responses, and a stale field could overwrite a value changed elsewhere. Now an omitted field is left alone by patch semantics, and several changes go out in one request. Local state is only updated after the request succeeds.

Also removed _custom_config, which was an unused second path to the same PATCH.

The properties are read-only now, so this is a breaking change for anyone assigning to them — flagged as BREAKING CHANGE: in the commit. Added tests/rapidata_client/benchmark/test_leaderboard_update.py covering the per-field validation, the only-named-fields behaviour, and that the properties reject assignment.

Comment thread docs/mri_advanced.md
Comment on lines +137 to +170
### Vote Aggregation

Each matchup — one comparison of two models on one prompt — collects several
responses (see `min_responses_per_matchup`). `vote_aggregation` decides how those
responses become that matchup's result:

| `VoteAggregation` | Effect |
|---|---|
| `MAJORITY_VOTE` (default) | The matchup collapses to a single win for the side the majority picked, ties split 0.5/0.5. Every matchup weighs the same. |
| `ALL_VOTES` | Every individual response counts as its own matchup, so a heavily-answered matchup outweighs a lightly-answered one. |

`MAJORITY_VOTE` is what you want in almost every case: it removes annotator noise
within a matchup and keeps the standings from being skewed by uneven response
counts across matchups.

```python
from rapidata import VoteAggregation

leaderboard = benchmark.create_leaderboard(
name="Realism",
instruction="Which image is more realistic?",
vote_aggregation=VoteAggregation.ALL_VOTES, # opt out of majority collapsing
)
```

Standings are derived from the raw responses on every read, so switching the
aggregation on an existing leaderboard also changes how its already-collected
responses are counted — no re-evaluation needed.

```python
print(leaderboard.vote_aggregation) # VoteAggregation.ALL_VOTES
leaderboard.vote_aggregation = VoteAggregation.MAJORITY_VOTE
```

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please don't make this sound so negatively. i think it's fine if for now we just mention that the matchup winner is chosen by majority vote without going into too much detail

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Toned down in d474c7b. Dropped the comparison table and the "this is what you want in almost every case" framing; it now just states that the matchup winner is the side the majority picked (ties split 0.5/0.5) and shows how to opt out:

Each matchup — one comparison of two models on one prompt — collects several responses. By default the matchup's winner is the side the majority of those responses picked, with ties split 0.5/0.5.

Pass vote_aggregation=VoteAggregation.ALL_VOTES to count every individual response as its own matchup instead.

I kept the note that switching it re-counts already-collected responses, since that is genuinely surprising and easy to get wrong — happy to cut it too if you think it is more detail than the page needs.

@LinoGiger
LinoGiger marked this pull request as ready for review August 14, 2026 11:07
RapidPoseidon and others added 2 commits August 14, 2026 11:11
Every mutable leaderboard setting went through its own property setter, each
one issuing a full PATCH built from the object's current state. That made a
rename resend the response budget, and it meant a stale in-memory field could
be written back over a value someone else had changed.

`update()` takes all four settings as optional keyword arguments and sends only
the ones the caller named, so an omitted field is left alone by patch semantics
rather than overwritten with a guess. Several changes now go out in one request.
Local state is refreshed only after the request succeeds.

Removes `_custom_config`, which was an unused second path to the same PATCH.

BREAKING CHANGE: `name`, `level_of_detail`, `min_responses_per_matchup` and
`vote_aggregation` are read-only properties now. Use
`leaderboard.update(<field>=...)` instead of assigning to them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: lino <68745352+LinoGiger@users.noreply.github.com>
State plainly that the matchup winner is chosen by majority vote and how to opt
out, instead of editorialising about what the alternative does to the standings.
Switches the mutation examples over to update().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: lino <68745352+LinoGiger@users.noreply.github.com>
@RapidPoseidon
RapidPoseidon merged commit 6d4f2f8 into main Aug 14, 2026
6 checks passed
@RapidPoseidon
RapidPoseidon deleted the feat(benchmark)/expose-vote-aggregation-on-leaderboards branch August 14, 2026 12:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants