Fewer Human Evals, Better Model Rankings

How active arena evals find reliable model winners without grading every possible matchup.

Engineering · May 27, 2026 · Shiv Kampani, CTO

Active arena evals rank model versions with far fewer human reviews by choosing the head-to-head comparison most likely to improve the leaderboard.

That matters because exhaustive arena grading gets expensive quickly. If you have n models and m benchmark tasks, the full grid asks a human to compare every model pair on every task:

Cell [python]5 lines
1def brute_force_comparisons(num_models: int, num_tasks: int) -> int:2    return num_models * (num_models - 1) // 2 * num_tasks3 4 5brute_force_comparisons(10, 20)  # 900

Nine hundred human judgments might be useful for a final benchmark paper. It is a lot for day-to-day model iteration, where the real question is simpler: which model is clearly better, which one is worse, and which models are too close to separate without more evidence?

The goal is not to observe the whole comparison graph. The goal is to spend human attention where it buys the most information.

The product win is not grading everything. It is knowing which next comparison would change the leaderboard the most.

The Basic Arena Unit

Each arena judgment compares two model outputs on one task:

Cell [python]6 lines
1comparison = {2    "model_a": "agent_042",3    "model_b": "agent_039",4    "task_id": "task_yc_founders",5    "grade": 3,6}

The grade is on a -5 to +5 scale:

  • -5 means model B is much better.
  • 0 means the outputs are tied or too close to call.
  • +5 means model A is much better.

The nice thing about this scale is that it stores both direction and margin. A tiny preference should move ratings less than a clear win.

Cell [python]4 lines
1def grade_to_target(grade: int) -> float:2    eps = 0.023    target = (grade + 5) / 104    return min(1 - eps, max(eps, target))

This turns the human grade into a soft preference target. A blowout gets close to 0 or 1. A tie lands near 0.5.

What We Can Safely Assume

The useful assumptions are modest:

  • Transitivity is helpful, but not perfect.
  • We need approximate ordering, not fake precision.
  • Some tasks separate models better than others.
  • Big wins usually contain more signal than tiny wins.
  • Weak models should stop consuming comparisons once the evidence is clear.
  • Models that are too close should be grouped together instead of forced into arbitrary ranks.

Approximate transitivity is the main savings:

Cell [python]4 lines
1# Evidence, not law:2# A beats B3# B beats C4# therefore A probably beats C

This should reduce work, but it should not erase uncertainty. A model can be strong on extraction, weak on code, and tied on writing. The system should remember that tasks are not interchangeable.

Score The Models

A simple Bradley-Terry model is enough for the first serious version. Each model has a latent strength score, and a pairwise comparison estimates the probability that one model beats another.

Cell [python]9 lines
1from math import exp2 3 4def sigmoid(x: float) -> float:5    return 1 / (1 + exp(-x))6 7 8def expected_preference(beta_a: float, beta_b: float, task_discrimination: float) -> float:9    return sigmoid(task_discrimination * (beta_a - beta_b))

Each model stores:

Cell [python]5 lines
1model_state = {2    "beta": 0.0,3    "sigma": 1.0,4    "eval_count": 0,5}

Each task stores:

Cell [python]5 lines
1task_state = {2    "discrimination": 1.0,3    "noise": 1.0,4    "eval_count": 0,5}

beta is the model's strength. sigma is uncertainty. Task discrimination measures whether a task actually separates models. A task where every model fails equally may be hard, but it is not useful for ranking.

The Math Behind The Scores

The friendly version is: every model gets a hidden strength, every task gets a discrimination weight, and every human grade is a noisy measurement of the gap between two models.

Let model have latent strength:

For model against model on task , define the latent gap:

Task has discrimination . The predicted preference for over is:

The human grade is mapped to:

The assumption is deliberately gentle:

So we are not assuming humans are perfect. We are assuming the grade is a noisy signal that points in the right direction on average:

We fit the model with weighted Bradley-Terry loss:

The comparison weight is higher when the task is discriminative and lower when the task is noisy:

where is the estimated noise for task .

Because only differences in matter, we center the scores after fitting:

That normalization does not change any predicted matchup. It just keeps the coordinate system tidy.

Why The Active Rule Is Good

The active arena picks comparisons by information gain. Here is the core reason close comparisons are valuable.

For one comparison, let be a vector with at model , at model , and everywhere else. Then:

The gradient of the log-likelihood is proportional to:

The Fisher information from this comparison is:

The scalar part is:

The term is maximized at . So the system learns most when it compares models whose outcome is uncertain.

Proof.

so is the maximum. That is the whole reason the algorithm prefers close, uncertain comparisons over obvious blowouts.

Update Scores Online

For interactive grading, update scores after every human judgment. This makes the leaderboard feel live without waiting for a full refit.

Cell [python]31 lines
1from math import sqrt2 3 4def update_from_grade(5    beta: dict[str, float],6    sigma: dict[str, float],7    task_discrimination: dict[str, float],8    task_noise: dict[str, float],9    model_a: str,10    model_b: str,11    task_id: str,12    grade: int,13    learning_rate: float = 0.05,14) -> None:15    y = grade_to_target(grade)16 17    disc = task_discrimination[task_id]18    noise = max(task_noise[task_id], 0.25)19 20    p = expected_preference(beta[model_a], beta[model_b], disc)21    error = y - p22 23    task_weight = disc / noise24    step = learning_rate * task_weight * disc * error25 26    beta[model_a] += step27    beta[model_b] -= step28 29    info = task_weight * (disc ** 2) * p * (1 - p)30    sigma[model_a] = 1 / sqrt((1 / sigma[model_a] ** 2) + info)31    sigma[model_b] = 1 / sqrt((1 / sigma[model_b] ** 2) + info)

The online update is fast. Periodically, refit from the full match history with a weighted Bradley-Terry loss so the live updates do not drift over time.

Cell [python]7 lines
1def refit_global_scores(comparisons: list[dict]) -> None:2    """3    Fit beta by minimizing weighted soft-label Bradley-Terry loss.4    Use grade_to_target(grade) as the target.5    Weight each comparison by task discrimination and task noise.6    """7    raise NotImplementedError

Pick The Next Best Comparison

The active arena should not ask a random match. It should ask the match that is most likely to change what we believe.

A candidate is a triple:

Cell [python]1 lines
1candidate = ("model_a", "model_b", "task_id")

The value of that candidate is high when:

  • the expected outcome is uncertain,
  • both models still have high rating uncertainty,
  • the task is reliable,
  • the model pair has not already been over-sampled,
  • the task has not already received enough judgments.
Cell [python]37 lines
1def candidate_value(2    beta: dict[str, float],3    sigma: dict[str, float],4    task_discrimination: dict[str, float],5    task_noise: dict[str, float],6    eval_count: dict[str, int],7    task_eval_count: dict[str, int],8    seen_pair_task: dict[tuple[str, str, str], int],9    model_a: str,10    model_b: str,11    task_id: str,12) -> float:13    disc = task_discrimination[task_id]14    noise = max(task_noise[task_id], 0.25)15 16    p = expected_preference(beta[model_a], beta[model_b], disc)17    outcome_uncertainty = p * (1 - p)18    rating_uncertainty = sigma[model_a] ** 2 + sigma[model_b] ** 219    task_quality = disc / noise20 21    pair_key = tuple(sorted((model_a, model_b)) + [task_id])22    repeat_penalty = 1 / sqrt(1 + seen_pair_task.get(pair_key, 0))23 24    model_coverage = (25        1 / sqrt(1 + eval_count[model_a])26        + 1 / sqrt(1 + eval_count[model_b])27    )28    task_coverage = 1 / sqrt(1 + task_eval_count[task_id])29 30    return (31        outcome_uncertainty32        * rating_uncertainty33        * task_quality34        * repeat_penalty35        * (1 + 0.15 * model_coverage)36        * (1 + 0.15 * task_coverage)37    )

The key term is:

Cell [python]1 lines
1p * (1 - p)

It is largest when p = 0.5. Close comparisons are informative. Obvious blowouts are usually not.

One-Step Optimality

The heuristic above is the version I would ship first. The more mathematical version keeps a covariance matrix over the model scores. Large covariance means we are still uncertain.

For a candidate comparison with vector , the information matrix update is:

where:

Using the Sherman-Morrison identity:

we get:

Now pick any model gap we care about:

For example, might be for model A and for model B. Its current variance is:

After adding candidate comparison , the variance reduction is:

So if we care about a set of tier-boundary gaps , the one-step best comparison is:

That is the proof-backed version of "ask the next most useful question." It is not claiming the greedy policy is globally perfect across every future batch. It is claiming something narrower and more useful: under the current uncertainty estimate, this comparison gives the largest immediate reduction in the uncertainty we care about.

In Python-shaped pseudocode:

Cell [python]11 lines
1def variance_reduction(V, x, c, info):2    numerator = info * float(c.T @ V @ x) ** 23    denominator = 1 + info * float(x.T @ V @ x)4    return numerator / denominator5 6 7def information_gain(V, x, info, important_contrasts):8    return sum(9        variance_reduction(V, x, c, info)10        for c in important_contrasts11    )

Rank By Tiers, Not Fake Precision

Exact rankings waste effort near ties. If two models are within a practical margin, group them together until the evidence says otherwise.

Cell [python]1 lines
1PRACTICAL_MARGIN_ELO = 50

Convert Bradley-Terry scores into an Elo-like display score:

Cell [python]6 lines
1from math import log2 3 4def beta_to_elo(beta_value: float, base: float = 1000) -> float:5    scale = 400 / log(10)6    return base + scale * beta_value

Then build tiers from confidence intervals:

Cell [python]4 lines
1def should_separate(elo_a: float, sigma_a: float, elo_b: float, sigma_b: float) -> bool:2    lower_a = elo_a - 1.96 * sigma_a3    upper_b = elo_b + 1.96 * sigma_b4    return lower_a > upper_b + PRACTICAL_MARGIN_ELO

This gives the leaderboard a more honest shape:

Cell [python]5 lines
1tiers = [2    ["agent_042", "agent_041"],3    ["agent_039", "agent_038", "agent_036"],4    ["agent_030"],5]

If two models are indistinguishable in practice, the product should say that. It is better than pretending rank 3 and rank 4 are meaningfully different because one has two more Elo points.

A Conservative Tier Guarantee

Here is the guarantee I would want the product to stand behind.

Let be the fitted score for model , and let be its confidence radius. Assume the intervals are simultaneous, meaning:

Define:

Only put model in a strictly higher tier than model when:

where is the practical margin.

Theorem. With probability at least , every separated tier boundary is correct up to margin .

Proof. On the event that all confidence intervals contain their true values, and .

If the algorithm separates above , then:

Combining the inequalities:

Therefore:

So the separated tier boundary is real up to the practical margin. Since the simultaneous intervals hold with probability at least , the guarantee holds with probability at least .

This is conservative in the right way. It may keep two truly different models in the same tier if there is not enough evidence. But it will not confidently invent a separation that the intervals do not support.

Update Task Quality

Tasks should also be evaluated. After each comparison, update task noise and discrimination with conservative moving averages:

Cell [python]26 lines
1def ema(old: float, new: float, alpha: float = 0.05) -> float:2    return (1 - alpha) * old + alpha * new3 4 5def update_task_stats(6    task_discrimination: dict[str, float],7    task_noise: dict[str, float],8    task_residual_ema: dict[str, float],9    task_variance_ema: dict[str, float],10    task_id: str,11    observed_target: float,12    predicted_target: float,13) -> None:14    residual = abs(observed_target - predicted_target)15 16    task_residual_ema[task_id] = ema(task_residual_ema[task_id], residual)17    task_variance_ema[task_id] = ema(task_variance_ema[task_id], residual ** 2)18    task_noise[task_id] = sqrt(task_variance_ema[task_id] + 1e-6)19 20    observed_signal = abs(observed_target - 0.5) * 221    reliability = 1 / (1 + task_noise[task_id])22 23    task_discrimination[task_id] = min(24        3.0,25        max(0.25, ema(task_discrimination[task_id], observed_signal * reliability)),26    )

This prevents one dramatic comparison from making a task look permanently magical.

Batch Without Tunnel Vision

When asking for a batch of human grades, do not let one uncertain pair consume the entire batch. Sort candidates by value, then enforce simple diversity limits across models and tasks.

Cell [python]43 lines
1from collections import Counter2from itertools import combinations3 4 5def suggest_batch(models: list[str], tasks: list[str], batch_size: int, state: dict):6    candidates = []7 8    for model_a, model_b in combinations(models, 2):9        for task_id in tasks:10            value = candidate_value(11                beta=state["beta"],12                sigma=state["sigma"],13                task_discrimination=state["task_discrimination"],14                task_noise=state["task_noise"],15                eval_count=state["eval_count"],16                task_eval_count=state["task_eval_count"],17                seen_pair_task=state["seen_pair_task"],18                model_a=model_a,19                model_b=model_b,20                task_id=task_id,21            )22            candidates.append((value, model_a, model_b, task_id))23 24    candidates.sort(reverse=True)25 26    batch = []27    used_models = Counter()28    used_tasks = Counter()29 30    for value, model_a, model_b, task_id in candidates:31        if len(batch) == batch_size:32            break33        if used_models[model_a] >= 4 or used_models[model_b] >= 4:34            continue35        if used_tasks[task_id] >= 4:36            continue37 38        batch.append((model_a, model_b, task_id, value))39        used_models[model_a] += 140        used_models[model_b] += 141        used_tasks[task_id] += 142 43    return batch

This keeps the queue useful and varied.

When To Stop

Stop based on decision quality, not a fixed comparison count.

Cell [python]6 lines
1def should_stop(remaining_budget: int, best_candidate_value: float, min_value: float) -> bool:2    if remaining_budget <= 0:3        return True4    if best_candidate_value < min_value:5        return True6    return False

Another practical rule is tier stability:

Cell [python]4 lines
1def stop_when_stable(recent_tiers: list[list[list[str]]]) -> bool:2    if len(recent_tiers) < 3:3        return False4    return recent_tiers[-1] == recent_tiers[-2] == recent_tiers[-3]

If the same tiers survive several batches, more comparisons may not change the product decision.

How Many Comparisons Can This Save?

For 10 models and 20 tasks, brute force is 900 comparisons. An active arena should often land closer to 100 to 300, depending on how close the models are.

The back-of-the-envelope reason is that distinguishing a gap gets quadratically harder as the gap gets smaller.

Suppose two models differ by Bradley-Terry gap:

For a task with discrimination , the observable logit gap is:

Near , each comparison has Fisher information on the order of:

After effective comparisons, the uncertainty of the gap shrinks like:

To distinguish a practical gap , we want:

Solving for :

If we want confidence across many possible model boundaries, add a log factor:

The shape matters more than the constants:

That is why exact rankings are so expensive. Separating models that are 10 Elo apart can require roughly more signal than separating models that are 50 Elo apart:

The biggest savings come from:

  • skipping inferred relationships when confidence is already high,
  • using every comparison to update global model strength,
  • spending more judgments near uncertain tier boundaries,
  • downweighting noisy tasks,
  • using margin grades instead of binary wins,
  • stopping once rankings are stable enough for the decision.

If the top models are almost tied, the system should either spend more comparisons or honestly keep them in the same tier.

The First Version To Ship

The first useful version does not need a full Bayesian optimizer. It needs a tight loop:

  • Store pairwise comparison records with -5..+5 grades.
  • Update Elo-like scores after each human grade.
  • Track per-model uncertainty.
  • Track per-task noise and discrimination.
  • Suggest high-value model-task pairs.
  • Group close models into practical tiers.
  • Periodically refit scores from the full match history.

The exhaustive arena asks, "Have we graded every possible comparison?"

The active arena asks, "Would the next comparison change what we believe?"

Active evals turn human review from a giant checklist into a focused measurement system.

That is the product difference.

Frequently asked questions

What is Autumn?

People research as a primitive. Autumn resolves fragmented information on the web into an index of every person in the world: relationships, work history, contact info, and digital footprint. Agents query it for whatever you need, from a prospect list to a background check.

How is Autumn different from ZoomInfo, Clay, or Apollo?

Those are databases and workflow tools. You get the rows they already have. Autumn runs live research at request time: agents read news, filings, registries, code, job posts, and social the way an analyst would, then assemble the answer.

You also get what no database has indexed yet. Agents watch incorporation filings, event pages, and launch pages, so new companies show up in Autumn first.

Who is Autumn built for?

Sales teams researching accounts, recruiters sourcing candidates, investors mapping markets, and risk teams screening people and companies. Anyone who needs deep research at scale instead of another static list.

Where does the data come from?

The open web: incorporation filings, LinkedIn, X, GitHub, event pages, news, code, and company sites. Every cell links back to its sources, so you can check any claim in one click.

Can I use Autumn programmatically?

Yes. The same agents are available over the API. Find companies, enrich rows, build profiles, or run screening from inside your own product.

Do I need a credit card to try it?

No. Sign in and run your first research task on the free tier.