Conversation
`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
force-pushed
the
fix/metric-bounds-vs-registry
branch
from
September 6, 2026 11:16
4aa417f to
3288103
Compare
Collaborator
Author
|
Rebased onto All five checks green. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.mdalready states every rule here. Nothing enforced any of them, and the validator never consults the registry at all.A declared scale contradicting the entry it cites. lm-eval published sacrebleu's BLEU as
31.4under the canonical idbleu, declaringmax_score: 100.0, while that entry says1.0. The cross-source joinmetric_idexists for would average it against another source's0.314, withmetric_unitthe only thing separating them.bounds_conflictsintools/verify_metric_ids.pynow compares every scale the three converter bounds tables declare against its entry, and the verifier exits non-zero on a disagreement. An entry with nomax_scoreasserts nothing and constrains nothing — 117 of the registry's 397 entries are in that state.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 samemetric_iddeclared 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.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], carriescanonical_rescale_factorand 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.make_metric_configno longer guesses. It defaultedmin_score=0.0, max_score=1.0and builtMetricConfigbefore assigning either, and the model rejectscontinuouswithoutmin_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 includingfloat('inf').Review lane
Design agreed in: nothing prior, and I want to be straight about that. Every change here applies a rule
fields.mdalready 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 testsgreen — 1125 passed, 1 skippeduv run ruff checkcleanuv run --extra all python tools/verify_metric_ids.py --seed <registry>/seed/metrics.yamlreports 0 metrics whose declared scale contradicts their entry, with the registry unchangedDecisions & coverage
Decision / where: Artificial Analysis' three composite indices — intelligence, coding, math.
Chose no bounds and no
score_type, withbounds_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.mdsays "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
bleuentry.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:
chrfandter.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_resultnever supplies thesource_datathatEvaluationResultrequires, so it cannot build a result at all. Same module, separate defect.bfcl,cocoabenchandsciarenahave no adapter test of their own, so three of the five changed adapters are covered only by the shared publication test._aggregate_check_score_metadatanever 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.