Skip to content

Add Voice Arena rating engine (Davidson-BT) - #134

Merged
seribaymadina merged 3 commits into
feat/voice-arenafrom
arena/03-rating-engine
Jun 17, 2026
Merged

Add Voice Arena rating engine (Davidson-BT)#134
seribaymadina merged 3 commits into
feat/voice-arenafrom
arena/03-rating-engine

Conversation

@seribaymadina

@seribaymadina seribaymadina commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Rating engine for arena layer #3, stacked on the schema PR (#119).

Pure functions, no DB/IO: A_WIN/B_WIN/TIE outcomes in, one rating row per model out (matching arena.leaderboard_snapshots).

  • Davidson Bradley-Terry fit (model strengths theta + shared tie parameter nu) by MLE, with a hand-derived gradient and a small ridge to keep separation/refits finite. nu=0 collapses to plain Bradley-Terry.
  • Elo = 1500 + 400/ln10 * theta (display rescale of theta; rating_bt holds the raw strength).
  • 95% CIs via seeded percentile bootstrap; CI fields are null (not NaN) when unavailable.
  • Status (preliminary / usable / established) from CI half-width.
  • Refuses a non-converged fit and skips non-converged bootstrap resamples.

Tests cover the three done-criteria (recovers a known ranking, CIs shrink with votes, ties move nu) plus the convergence and null-CI paths.

Follow-ups (not this PR): calibrate reg and pin/decouple the status thresholds once methodology lands, per-domain tie rate (far future).

Merge after #119.

Summary by CodeRabbit

  • New Features

    • Introduced an arena rating engine that processes model battle outcomes and generates rankings with statistical confidence intervals and status classifications.
  • Tests

    • Added comprehensive unit and integration tests for the rating engine, verifying ranking accuracy, confidence interval shrinkage, tie handling, and bootstrap robustness across edge cases.

Greptile Summary

Adds the Davidson Bradley-Terry rating engine for the Voice Arena leaderboard layer. Pure-function implementation: battle outcomes in, one ModelRating row per model out, matching the arena.leaderboard_snapshots schema from #119.

  • MLE fit via L-BFGS-B with an analytic gradient, L2 ridge for strict convexity, and a hard ConvergenceError guard so a non-converged optimizer never produces ratings.
  • Percentile bootstrap CIs with NaN→None conversion (no silent poison values in the schema), and a classify_status tier based on CI half-width.
  • All three done-criteria tests pass; convergence-guard and CI-assertion regressions from the prior review round are correctly addressed.

Confidence Score: 5/5

Safe to merge; this is a pure-function module with no DB/IO and a well-guarded optimizer path.

All three blocking concerns from the previous round (optimizer convergence check, NaN to None schema correctness, and the CI containment assertion) are fully addressed. The two remaining observations are methodological trade-offs explicitly acknowledged in the PR description and docstrings.

No files require special attention; rating.py is the only complex file and its core MLE and bootstrap paths are well-tested.

Important Files Changed

Filename Overview
runner/src/coval_bench/arena/rating.py Core Davidson-BT engine: MLE fit with convergence guard, percentile bootstrap CIs, and NaN to None conversion all correctly implemented; bootstrap recentering bias when models drop from resamples is a subtle methodological edge case worth noting.
runner/tests/unit/test_arena_rating.py Comprehensive tests covering all done-criteria and edge cases; convergence-guard and CI-assertion regressions from prior review round are correctly addressed.
runner/src/coval_bench/arena/init.py Thin re-export shim; all public symbols correctly listed in all.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["compute_ratings(outcomes)"] --> B["_aggregate(outcomes)\nper-pair win/tie counts"]
    B --> C{n == 0?}
    C -- yes --> D["RatingResult(models=[])"]
    C -- no --> E["_fit(agg, reg)\nL-BFGS-B MLE"]
    E --> F{res.success?}
    F -- no --> G["raise ConvergenceError"]
    F -- yes --> H["recenter theta\nnu = exp(eta)"]
    H --> I["_elo(theta)\n1500 + scale * theta"]
    I --> J["_bootstrap(outcomes, models)\nresample battles x rounds"]
    J --> K{replicate converges?}
    K -- no --> L["skip replicate\nlog warning"]
    K -- yes --> M["collect Elo\nper model"]
    L --> N{more rounds?}
    M --> N
    N -- yes --> J
    N -- no --> O["percentile 2.5/97.5\n(need >= 2 samples)"]
    O --> P["nan to None\ncompute half_width"]
    P --> Q["classify_status\npreliminary/usable/established"]
    Q --> R["sort by Elo desc\nRatingResult"]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A["compute_ratings(outcomes)"] --> B["_aggregate(outcomes)\nper-pair win/tie counts"]
    B --> C{n == 0?}
    C -- yes --> D["RatingResult(models=[])"]
    C -- no --> E["_fit(agg, reg)\nL-BFGS-B MLE"]
    E --> F{res.success?}
    F -- no --> G["raise ConvergenceError"]
    F -- yes --> H["recenter theta\nnu = exp(eta)"]
    H --> I["_elo(theta)\n1500 + scale * theta"]
    I --> J["_bootstrap(outcomes, models)\nresample battles x rounds"]
    J --> K{replicate converges?}
    K -- no --> L["skip replicate\nlog warning"]
    K -- yes --> M["collect Elo\nper model"]
    L --> N{more rounds?}
    M --> N
    N -- yes --> J
    N -- no --> O["percentile 2.5/97.5\n(need >= 2 samples)"]
    O --> P["nan to None\ncompute half_width"]
    P --> Q["classify_status\npreliminary/usable/established"]
    Q --> R["sort by Elo desc\nRatingResult"]
Loading

Reviews (3): Last reviewed commit: "Add regression tests for rating engine h..." | Re-trigger Greptile

@vercel

vercel Bot commented Jun 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
benchmarks Ready Ready Preview, Comment Jun 17, 2026 11:18pm

Request Review

@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 219c85dc-2e6b-4fe2-99d7-69b4606f5562

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch arena/03-rating-engine

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread runner/src/coval_bench/arena/rating.py
Comment thread runner/tests/unit/test_arena_rating.py Outdated
Comment thread runner/src/coval_bench/arena/rating.py
Pure-function rating engine for arena layer #3: Davidson Bradley-Terry MLE
fit (theta + tie parameter nu), Elo conversion, percentile-bootstrap CIs,
and a status classifier. Refuses a non-converged fit and skips non-converged
bootstrap resamples. CI fields are null (not NaN) when unavailable. Unit tests
cover ranking recovery, CI shrinkage with votes, and tie sensitivity.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (1)
runner/tests/unit/test_arena_rating.py (1)

128-225: ⚡ Quick win

Add regression tests for invalid topology/input paths.

Please add tests asserting rejection of disconnected comparison graphs and model_a == model_b outcomes, so these correctness guards stay enforced.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@runner/tests/unit/test_arena_rating.py` around lines 128 - 225, Add two new
test functions to the file to ensure invalid input scenarios are caught: first,
create a test that verifies compute_ratings raises an appropriate error when
given outcomes with model_a equal to model_b (self-comparison), and second,
create a test that verifies compute_ratings raises an appropriate error when
given a disconnected comparison graph where some models cannot be reached from
others. Both tests should use pytest.raises to assert that the invalid inputs
are properly rejected and the correctness guards remain enforced.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@runner/src/coval_bench/arena/rating.py`:
- Around line 315-320: The condition checking for empty samples only handles the
case when vals.size equals zero, but when there is exactly one sample (vals.size
== 1), the code proceeds to calculate the 2.5th and 97.5th percentiles which
both return the same value, creating a false zero-width confidence interval with
ci_low == ci_high. Change the condition from checking if vals.size == 0 to
checking if vals.size < 2 so that cases with fewer than two samples are skipped
and treated as unavailable confidence intervals.
- Around line 163-170: Add a validation check before the pair aggregation logic
(before the line with lo, hi assignment) to reject outcomes where a model plays
against itself. Check if o.model_a equals o.model_b and skip processing that
outcome (continue to the next iteration) to prevent creating invalid self-pair
tuples like (k, k) in the pairs dictionary that would corrupt the statistics
tracking.
- Around line 364-370: The code aggregates outcomes using _aggregate but
immediately calls _fit without validating that the comparison graph is
connected. For disconnected components, cross-cluster Elo ordering is not
identified and becomes arbitrary. After the _aggregate call (which sets the
`agg` variable), add a connectivity check on the aggregated graph before
proceeding with the _fit call. If the graph is disconnected, return early from
the function with an appropriate result rather than attempting to fit the model
with disconnected clusters.
- Around line 351-357: The compute_ratings function does not validate its input
parameters bootstrap_rounds and reg, allowing negative values for
bootstrap_rounds to silently disable CI and negative or non-finite values for
reg to invalidate the optimization objective. Add validation logic at the
beginning of the compute_ratings function to ensure bootstrap_rounds is a
positive integer (greater than 0) and reg is a positive and finite number
(greater than 0 and not NaN or infinity), raising an appropriate exception if
either parameter is invalid.

---

Nitpick comments:
In `@runner/tests/unit/test_arena_rating.py`:
- Around line 128-225: Add two new test functions to the file to ensure invalid
input scenarios are caught: first, create a test that verifies compute_ratings
raises an appropriate error when given outcomes with model_a equal to model_b
(self-comparison), and second, create a test that verifies compute_ratings
raises an appropriate error when given a disconnected comparison graph where
some models cannot be reached from others. Both tests should use pytest.raises
to assert that the invalid inputs are properly rejected and the correctness
guards remain enforced.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5692fa35-6a25-46f5-8495-f1470baf7edf

📥 Commits

Reviewing files that changed from the base of the PR and between 6369947 and 74ba8c7.

📒 Files selected for processing (3)
  • runner/src/coval_bench/arena/__init__.py
  • runner/src/coval_bench/arena/rating.py
  • runner/tests/unit/test_arena_rating.py

Comment on lines +163 to +170
a, b = index[o.model_a], index[o.model_b]
lo, hi = (a, b) if a < b else (b, a)
cell = pairs.setdefault((lo, hi), [0.0, 0.0, 0.0])
if o.outcome == "TIE":
cell[2] += 1.0
else:
winner = a if o.outcome == "A_WIN" else b
cell[0 if winner == lo else 1] += 1.0

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject self-battles before pair aggregation.

Line [164] allows model_a == model_b, which creates a (k, k) pair; then Line [170] collapses directionality and corrupts sufficient statistics. This should fail fast.

Suggested fix
 def _aggregate(outcomes: Sequence[BattleOutcome]) -> _Aggregate:
     """Collapse raw battles into per-pair win/tie counts."""
     models = sorted({o.model_a for o in outcomes} | {o.model_b for o in outcomes})
     index = {m: k for k, m in enumerate(models)}
@@
     pairs: dict[tuple[int, int], list[float]] = {}
     for o in outcomes:
+        if o.model_a == o.model_b:
+            raise ValueError("BattleOutcome requires distinct model_a and model_b")
         a, b = index[o.model_a], index[o.model_b]
         lo, hi = (a, b) if a < b else (b, a)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
a, b = index[o.model_a], index[o.model_b]
lo, hi = (a, b) if a < b else (b, a)
cell = pairs.setdefault((lo, hi), [0.0, 0.0, 0.0])
if o.outcome == "TIE":
cell[2] += 1.0
else:
winner = a if o.outcome == "A_WIN" else b
cell[0 if winner == lo else 1] += 1.0
if o.model_a == o.model_b:
raise ValueError("BattleOutcome requires distinct model_a and model_b")
a, b = index[o.model_a], index[o.model_b]
lo, hi = (a, b) if a < b else (b, a)
cell = pairs.setdefault((lo, hi), [0.0, 0.0, 0.0])
if o.outcome == "TIE":
cell[2] += 1.0
else:
winner = a if o.outcome == "A_WIN" else b
cell[0 if winner == lo else 1] += 1.0
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@runner/src/coval_bench/arena/rating.py` around lines 163 - 170, Add a
validation check before the pair aggregation logic (before the line with lo, hi
assignment) to reject outcomes where a model plays against itself. Check if
o.model_a equals o.model_b and skip processing that outcome (continue to the
next iteration) to prevent creating invalid self-pair tuples like (k, k) in the
pairs dictionary that would corrupt the statistics tracking.

Comment on lines +315 to +320
vals = np.array(samples[k], dtype=np.float64)
if vals.size == 0:
continue
ci_low[k] = float(np.percentile(vals, 2.5))
ci_high[k] = float(np.percentile(vals, 97.5))
return ci_low, ci_high

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Avoid zero-width CI from a single bootstrap sample.

Line [318] and Line [319] run percentile on vals.size == 1, yielding ci_low == ci_high and a false “established” half-width of 0. Treat fewer than two samples as unavailable CI.

Suggested fix
     for k in range(n):
         vals = np.array(samples[k], dtype=np.float64)
-        if vals.size == 0:
+        if vals.size < 2:
             continue
         ci_low[k] = float(np.percentile(vals, 2.5))
         ci_high[k] = float(np.percentile(vals, 97.5))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
vals = np.array(samples[k], dtype=np.float64)
if vals.size == 0:
continue
ci_low[k] = float(np.percentile(vals, 2.5))
ci_high[k] = float(np.percentile(vals, 97.5))
return ci_low, ci_high
vals = np.array(samples[k], dtype=np.float64)
if vals.size < 2:
continue
ci_low[k] = float(np.percentile(vals, 2.5))
ci_high[k] = float(np.percentile(vals, 97.5))
return ci_low, ci_high
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@runner/src/coval_bench/arena/rating.py` around lines 315 - 320, The condition
checking for empty samples only handles the case when vals.size equals zero, but
when there is exactly one sample (vals.size == 1), the code proceeds to
calculate the 2.5th and 97.5th percentiles which both return the same value,
creating a false zero-width confidence interval with ci_low == ci_high. Change
the condition from checking if vals.size == 0 to checking if vals.size < 2 so
that cases with fewer than two samples are skipped and treated as unavailable
confidence intervals.

Comment on lines +351 to +357
def compute_ratings(
outcomes: Sequence[BattleOutcome],
*,
bootstrap_rounds: int = 1000,
seed: int = 0,
reg: float = 0.1,
) -> RatingResult:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate bootstrap_rounds and reg inputs.

Line [354] and Line [356] accept invalid values currently: negative bootstrap_rounds silently disables CI, and negative/non-finite reg can invalidate the optimization objective.

Suggested fix
 def compute_ratings(
@@
 ) -> RatingResult:
@@
+    if bootstrap_rounds < 0:
+        raise ValueError("bootstrap_rounds must be >= 0")
+    if not math.isfinite(reg) or reg < 0.0:
+        raise ValueError("reg must be finite and >= 0")
+
     agg = _aggregate(outcomes)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@runner/src/coval_bench/arena/rating.py` around lines 351 - 357, The
compute_ratings function does not validate its input parameters bootstrap_rounds
and reg, allowing negative values for bootstrap_rounds to silently disable CI
and negative or non-finite values for reg to invalidate the optimization
objective. Add validation logic at the beginning of the compute_ratings function
to ensure bootstrap_rounds is a positive integer (greater than 0) and reg is a
positive and finite number (greater than 0 and not NaN or infinity), raising an
appropriate exception if either parameter is invalid.

Comment on lines +364 to +370
agg = _aggregate(outcomes)
n = len(agg.models)
if n == 0:
return RatingResult(tie_param=0.0, models=[])

theta, nu = _fit(agg, reg)
elo = _elo(theta)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard against disconnected comparison graphs before fitting.

Line [364] aggregates outcomes, but Line [369] fits immediately without connectivity validation. For disconnected components, cross-cluster Elo ordering is not identified and can be arbitrarily ridge-pinned.

Suggested fix
+def _assert_connected(agg: _Aggregate) -> None:
+    n = len(agg.models)
+    if n <= 1:
+        return
+    adj: list[list[int]] = [[] for _ in range(n)]
+    for a, b in zip(agg.idx_i, agg.idx_j, strict=False):
+        if a == b:
+            continue
+        adj[int(a)].append(int(b))
+        adj[int(b)].append(int(a))
+
+    seen = {0}
+    stack = [0]
+    while stack:
+        u = stack.pop()
+        for v in adj[u]:
+            if v not in seen:
+                seen.add(v)
+                stack.append(v)
+    if len(seen) != n:
+        raise ConvergenceError("comparison graph is disconnected; cross-component strengths are undefined")
@@
 def compute_ratings(
@@
     agg = _aggregate(outcomes)
     n = len(agg.models)
     if n == 0:
         return RatingResult(tie_param=0.0, models=[])
+    _assert_connected(agg)
 
     theta, nu = _fit(agg, reg)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@runner/src/coval_bench/arena/rating.py` around lines 364 - 370, The code
aggregates outcomes using _aggregate but immediately calls _fit without
validating that the comparison graph is connected. For disconnected components,
cross-cluster Elo ordering is not identified and becomes arbitrary. After the
_aggregate call (which sets the `agg` variable), add a connectivity check on the
aggregated graph before proceeding with the _fit call. If the graph is
disconnected, return early from the function with an appropriate result rather
than attempting to fit the model with disconnected clusters.

Skip self-battle outcomes in aggregation, treat fewer than two bootstrap
samples as no CI (avoids a false zero-width 'established' interval), and
validate bootstrap_rounds/reg. Document the disconnected-graph limitation and
that adaptive pairing is the intended upstream remedy.
Cover the self-battle skip, single-sample CI falling back to preliminary, and
the bootstrap_rounds/reg input validation.
@seribaymadina
seribaymadina merged commit 22f09e5 into feat/voice-arena Jun 17, 2026
3 checks passed
seribaymadina added a commit that referenced this pull request Jun 19, 2026
* Add Voice Arena rating engine (Davidson-BT)

Pure-function rating engine for arena layer #3: Davidson Bradley-Terry MLE
fit (theta + tie parameter nu), Elo conversion, percentile-bootstrap CIs,
and a status classifier. Refuses a non-converged fit and skips non-converged
bootstrap resamples. CI fields are null (not NaN) when unavailable. Unit tests
cover ranking recovery, CI shrinkage with votes, and tie sensitivity.

* Harden arena rating engine inputs and CI

Skip self-battle outcomes in aggregation, treat fewer than two bootstrap
samples as no CI (avoids a false zero-width 'established' interval), and
validate bootstrap_rounds/reg. Document the disconnected-graph limitation and
that adaptive pairing is the intended upstream remedy.

* Add regression tests for rating engine hardening

Cover the self-battle skip, single-sample CI falling back to preliminary, and
the bootstrap_rounds/reg input validation.
github-merge-queue Bot pushed a commit that referenced this pull request Jun 19, 2026
* Add Voice Arena rating engine (Davidson-BT) (#134)

* Add Voice Arena rating engine (Davidson-BT)

Pure-function rating engine for arena layer #3: Davidson Bradley-Terry MLE
fit (theta + tie parameter nu), Elo conversion, percentile-bootstrap CIs,
and a status classifier. Refuses a non-converged fit and skips non-converged
bootstrap resamples. CI fields are null (not NaN) when unavailable. Unit tests
cover ranking recovery, CI shrinkage with votes, and tie sensitivity.

* Harden arena rating engine inputs and CI

Skip self-battle outcomes in aggregation, treat fewer than two bootstrap
samples as no CI (avoids a false zero-width 'established' interval), and
validate bootstrap_rounds/reg. Document the disconnected-graph limitation and
that adaptive pairing is the intended upstream remedy.

* Add regression tests for rating engine hardening

Cover the self-battle skip, single-sample CI falling back to preliminary, and
the bootstrap_rounds/reg input validation.

* Treat zero-variance bootstrap as no CI; require reg > 0

A fully-separated dataset refits identically every resample, giving a zero-width
interval that wrongly read as 'established'; skip it so it reads preliminary.
Also require reg > 0, since the ridge the engine relies on for convergence is
absent at reg = 0.
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.

1 participant