Skip to content

Declare a metric's scale only where something backs the claim - #287

Open
borgr wants to merge 4 commits into
evaleval:mainfrom
borgr:fix/metric-bounds-vs-registry
Open

borgr wants to merge 4 commits into
evaleval:mainfrom
borgr:fix/metric-bounds-vs-registry

Conversation

@borgr

@borgr borgr commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

What / source

A metric's declared bounds are a claim about the scale its score was computed on. Four ways that claim was being made without anything to back it, and one check so the first of them cannot recur.

fields.md already states every rule here. Nothing enforced any of them, and the validator never consults the registry at all.

  1. A declared scale contradicting the entry it cites. lm-eval published sacrebleu's BLEU as 31.4 under the canonical id bleu, declaring max_score: 100.0, while that entry says 1.0. The cross-source join metric_id exists for would average it against another source's 0.314, with metric_unit the only thing separating them. bounds_conflicts in tools/verify_metric_ids.py now compares every scale the three converter bounds tables declare against its entry, and the verifier exits non-zero on a disagreement. An entry with no max_score asserts nothing and constrains nothing — 117 of the registry's 397 entries are in that state.

  2. An observed maximum declared as a ceiling, at 22 sites across five adapters and one converter. Two of them had a function called compute_observed_max_scores. The ceiling moved whenever a new model entered the leaderboard, so the same metric_id declared a different scale on every refresh and two records of one metric stopped being comparable — and three of the five adapters run on the daily cron, so it happened without anyone choosing it. Costs, durations, latencies, throughputs, a token count, an Elo rating, and a rank whose maximum was only that day's entrant count now declare no ceiling.

  3. BLEU converted onto the scale its entry declares. BLEU is a brevity penalty times a geometric mean of clipped precisions, so the quantity is on [0, 1] and sacrebleu's ×100 is a rendering of it. Percent to proportion is provable from the definition, so the record declares [0, 1], carries canonical_rescale_factor and the source figure, and the standard error converts with the score — a spread is in the score's units, which is the shape of the bug Put a rescaled score's reported spread on the score's scale #276 fixed in the Papers with Code adapter.

  4. make_metric_config no longer guesses. It defaulted min_score=0.0, max_score=1.0 and built MetricConfig before assigning either, and the model rejects continuous without min_score — so its documented default path raised and it could not emit a continuous metric at all. Both bounds are now the caller's, with a message naming what to pass including float('inf').

Review lane

  • Fast
  • Needs a human — a material change in outcome. The same input now produces different records for BLEU and for every metric that had an observed-maximum ceiling.

Design agreed in: nothing prior, and I want to be straight about that. Every change here applies a rule fields.md already states rather than proposing a new one, and each is a correction of a clear violation — but the outcome change is real and wants a maintainer's eye. Context for how these were found is on #250.

Checklist

  • uv run pytest tests green — 1125 passed, 1 skipped
  • uv run ruff check clean
  • uv run --extra all python tools/verify_metric_ids.py --seed <registry>/seed/metrics.yaml reports 0 metrics whose declared scale contradicts their entry, with the registry unchanged
  • offline tests added: 7 for the verifier, which had none, plus 5 for the schema helper and 4 for the new bounds behaviour
  • count of already-published records this would change: not measured. This is the rung-1 question and I have not answered it. Every BLEU record from the lm-eval converter and every record carrying an observed-maximum ceiling would differ on a re-run. Happy to measure it before this merges if you want the number first.

Decisions & coverage

  • Decision / where: Artificial Analysis' three composite indices — intelligence, coding, math.
    Chose no bounds and no score_type, with bounds_status: unknown / instead of: declaring infinity.
    Confidence: medium. The source publishes no range and the definition fixes none, and I could not establish one offline. fields.md says "not provided" beats a guess, and infinity is its own claim. If AA documents these as 0–100 it is a one-line change back.
    General? yes — the distinction between unbounded and range unknown applies to any leaderboard metric.

  • Decision / where: the registry's bleu entry.
    Chose leave it at [0, 1] and convert the value / instead of: widening the entry to [0, 100].
    Confidence: high. [0, 1] is the quantity's actual range; 0–100 is a percentage rendering. I drafted the entry change first and reverted it.
    General? no.

  • Decision / where: chrf and ter.
    Chose leave them on the harness's percent scale / instead of: converting them too.
    Confidence: high. Neither resolves to a registry entry, so they are published namespaced and there is no canonical scale to convert onto. They now carry the point the rewritten tests were making about per-harness bounds tables.
    General? no.

Coverage: 22 declared-bound sites found by sweep, 22 fixed. 12 canonical metric mappings audited against the registry, 1 conflict found, 1 fixed. 0 dropped.

Operator asked about policy calls? No new canonical id, no schema or validator rule change, no data dropped. The two judgement calls are the AA indices and the registry entry, both above.

Not in this PR

  • make_evaluation_result never supplies the source_data that EvaluationResult requires, so it cannot build a result at all. Same module, separate defect.
  • bfcl, cocoabench and sciarena have no adapter test of their own, so three of the five changed adapters are covered only by the shared publication test.
  • _aggregate_check_score_metadata never looks at uncertainty, so a rescaled score can still keep an unrescaled spread and pass the gate. That check needs the failing count over datastore main first.

`fields.md` takes a metric's bounds from the resolved registry entry when the
metric resolves, and from the harness that computed it only when it does not.
Nothing enforced that, and the validator never consults the registry at all, so
a converter could publish a score under a canonical id while declaring a
different range. A consumer that normalizes from the entry then reads the wrong
number, and `metric_unit` is the only thing separating the two scales.

`bounds_conflicts` compares every scale the three converter bounds tables
declare against the entry its canonical id resolves to, and the verifier now
exits non-zero on a disagreement. An entry with no `max_score` asserts nothing
and constrains nothing — 117 of the registry's 397 entries are in that state,
which is the correct one for a metric whose range depends on the implementation.

Against the pinned revision this reports one conflict: lm-eval's `bleu` declares
`max_score 100.0` under the entry `bleu`, which says `1.0`.

The verifier had no test because its resolution pass needs a sibling checkout.
`normalize`, `registry_index`, `registry_bounds` and `bounds_conflicts` do not,
and an inline seed covers all four — including that `normalize` keeps the `+`
that stops `chrf` resolving to the registry's `chrF++`.
Five adapters took `max_score` from the highest value in the batch they were
publishing. Two of them had a function named `compute_observed_max_scores`, which
says what it was. The ceiling then moves whenever a new model enters the
leaderboard, so the same `metric_id` declares a different scale on every refresh
and two records of one metric stop being comparable. Three of the five run on the
daily cron, so it happened without anyone choosing it.

`fields.md` already forbids it — "Don't invent a nominal ceiling for an
open-ended metric just to have one" — and nothing enforced it.

Every affected quantity has no real ceiling: a dollar cost, a duration, a
latency, a throughput, a token count, an Elo rating, and a leaderboard rank whose
maximum is only how many entrants there were that day. Those now declare
`max_score` as infinity, which the library serializes as `"Infinity"`.

Artificial Analysis' three composite indices are different: the source publishes
no range and the metric's definition fixes none, so they declare no bounds and no
`score_type` at all, carrying `bounds_status: unknown`. "Not provided" is true
where a guessed range is a claim the source never made.

`converters/alpaca_eval` had the same instinct in miniature, with `max_score`
100000.0 on a mean response length.

Sites: arc_agi cost and cost_per_task; cocoabench avg_time_s, avg_cost_usd and
total_cost_usd; sciarena elo, rank and cost_per_100_calls_usd; bfcl rank,
total_cost_usd and three latencies; artificial_analysis three prices, a
throughput and two latencies, plus the three indices; alpaca_eval avg_length.
The helper defaulted `min_score=0.0, max_score=1.0` and documented `[0, 1]` as
what "most evaluations use". It also built `MetricConfig` before assigning either
bound, and the model rejects `continuous` without `min_score` — so the documented
default path raised, and the helper could not emit a continuous metric at all.

That is worth stating plainly, because it answers whether the default could
produce a bad push: it could not produce anything. Every adapter that appeared to
call this defines its own `make_metric_config` locally instead.

Fixing only the crash would have turned a broken helper into a silent one. `[0, 1]`
is a claim about the scale a score was computed on, and a wrong one is a wrong
number no reader can detect, so both bounds are now the caller's to supply and a
`continuous` metric without them raises with a message naming what to pass —
including `float('inf')` for a quantity with no ceiling. The `levels` path needs no
bounds and is unchanged.

`make_evaluation_result` is stale in a second way this does not fix: it never
supplies `source_data`, which `EvaluationResult` requires, so it cannot build a
result even with bounds given. The new test pins that the missing-bounds refusal
happens first, so a caller is told what to supply instead of shown a schema error.
BLEU is a brevity penalty times a geometric mean of clipped precisions, so the
quantity is on `[0, 1]`. sacrebleu's x100 is a rendering of it, and
lm-evaluation-harness calls sacrebleu.

The converter declared `[0, 100]` while publishing the score under the canonical
id `bleu`, whose registry entry says `[0, 1]`. So a sacrebleu 31.4 shipped as
`metric_id: bleu` with `max_score: 100.0`, and the cross-source join that field
exists for would average it against another source's 0.314. `metric_unit` was the
only thing separating them, and a join key should not need a second field to be
read safely.

`fields.md` takes the bounds from the resolved registry entry when the metric
resolves, and converts the source value onto that scale where the mapping is
provable. Percent to proportion is provable from the metric's definition, so the
record now declares `[0, 1]`, carries `canonical_rescale_factor` and the source
figure, and the standard error is converted with the score — a spread is in the
score's units, and left alone it reads as wider than the metric's whole range,
which is the bug evaleval#276 fixed in the Papers with Code adapter.

`chrf` and `ter` are percentages too and neither resolves to an entry, so they
stay namespaced on the scale the harness used and are unchanged. Two tests that
asserted the old contract now assert the new one, and `chrf` carries the point
they were making about per-harness bounds tables.

With this and the entry unchanged, `verify_metric_ids` reports no metric whose
declared scale contradicts the entry it cites.
@borgr
borgr force-pushed the fix/metric-bounds-vs-registry branch from 4aa417f to 3288103 Compare September 6, 2026 11:16
@borgr

borgr commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main, which now carries #190. The one conflict was in converters/alpaca_eval/adapter.py, and it resolved to nothing: #190 rewrote _evaluation_results to loop over metric specs, and its _AVG_LENGTH_METRIC already declares fallback_max=float('inf') with the same reasoning this PR's commit gave. So the alpaca_eval part of "Stop declaring an observed maximum as a metric's ceiling" is already in main and the commit now only touches the five adapters — arc_agi, artificial_analysis, bfcl, cocoabench and sciarena.

All five checks green.

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