evalcore 2.5.0__tar.gz → 2.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. {evalcore-2.5.0 → evalcore-2.6.0}/CHANGELOG.md +53 -1
  2. {evalcore-2.5.0 → evalcore-2.6.0}/PKG-INFO +49 -2
  3. {evalcore-2.5.0 → evalcore-2.6.0}/README.md +48 -1
  4. {evalcore-2.5.0 → evalcore-2.6.0}/examples/quickstart/graders.py +5 -0
  5. {evalcore-2.5.0 → evalcore-2.6.0}/pyproject.toml +1 -1
  6. {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/compare.py +128 -9
  7. {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/graders/classification.py +23 -5
  8. {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/graders/deterministic.py +3 -0
  9. {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/graders/judge.py +16 -1
  10. {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/graders/numeric.py +25 -0
  11. {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/models.py +52 -1
  12. {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/runner.py +26 -0
  13. evalcore-2.6.0/tests/test_unit.py +446 -0
  14. {evalcore-2.5.0 → evalcore-2.6.0}/uv.lock +1 -1
  15. evalcore-2.5.0/tests/test_unit.py +0 -239
  16. {evalcore-2.5.0 → evalcore-2.6.0}/.github/workflows/ci.yml +0 -0
  17. {evalcore-2.5.0 → evalcore-2.6.0}/.github/workflows/publish.yml +0 -0
  18. {evalcore-2.5.0 → evalcore-2.6.0}/.gitignore +0 -0
  19. {evalcore-2.5.0 → evalcore-2.6.0}/.pre-commit-config.yaml +0 -0
  20. {evalcore-2.5.0 → evalcore-2.6.0}/LICENSE +0 -0
  21. {evalcore-2.5.0 → evalcore-2.6.0}/docs/design.md +0 -0
  22. {evalcore-2.5.0 → evalcore-2.6.0}/examples/__init__.py +0 -0
  23. {evalcore-2.5.0 → evalcore-2.6.0}/examples/quickstart/README.md +0 -0
  24. {evalcore-2.5.0 → evalcore-2.6.0}/examples/quickstart/__init__.py +0 -0
  25. {evalcore-2.5.0 → evalcore-2.6.0}/examples/quickstart/adapter.py +0 -0
  26. {evalcore-2.5.0 → evalcore-2.6.0}/examples/quickstart/datasets/support_reply/v1/cases/angry_complaint.yaml +0 -0
  27. {evalcore-2.5.0 → evalcore-2.6.0}/examples/quickstart/datasets/support_reply/v1/cases/billing_question.yaml +0 -0
  28. {evalcore-2.5.0 → evalcore-2.6.0}/examples/quickstart/datasets/support_reply/v1/cases/double_charge.yaml +0 -0
  29. {evalcore-2.5.0 → evalcore-2.6.0}/examples/quickstart/datasets/support_reply/v1/cases/refund_request.yaml +0 -0
  30. {evalcore-2.5.0 → evalcore-2.6.0}/examples/quickstart/fixtures/support_reply_judge.yaml +0 -0
  31. {evalcore-2.5.0 → evalcore-2.6.0}/examples/quickstart/fixtures/support_reply_pairwise.yaml +0 -0
  32. {evalcore-2.5.0 → evalcore-2.6.0}/examples/quickstart/fixtures/support_reply_replay.yaml +0 -0
  33. {evalcore-2.5.0 → evalcore-2.6.0}/examples/quickstart/run_eval.py +0 -0
  34. {evalcore-2.5.0 → evalcore-2.6.0}/examples/quickstart/suite.yaml +0 -0
  35. {evalcore-2.5.0 → evalcore-2.6.0}/examples/quickstart/tests/__init__.py +0 -0
  36. {evalcore-2.5.0 → evalcore-2.6.0}/examples/quickstart/tests/test_quickstart.py +0 -0
  37. {evalcore-2.5.0 → evalcore-2.6.0}/justfile +0 -0
  38. {evalcore-2.5.0 → evalcore-2.6.0}/pyrightconfig.json +0 -0
  39. {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/__init__.py +0 -0
  40. {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/adapters/__init__.py +0 -0
  41. {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/adapters/base.py +0 -0
  42. {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/adapters/env.py +0 -0
  43. {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/adapters/http.py +0 -0
  44. {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/adapters/replay.py +0 -0
  45. {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/cli.py +0 -0
  46. {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/errors.py +0 -0
  47. {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/graders/__init__.py +0 -0
  48. {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/graders/base.py +0 -0
  49. {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/loader.py +0 -0
  50. {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/pairwise.py +0 -0
  51. {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/plugins.py +0 -0
  52. {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/py.typed +0 -0
  53. {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/rating.py +0 -0
  54. {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/refs.py +0 -0
  55. {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/report.py +0 -0
  56. {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/reporters/__init__.py +0 -0
  57. {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/reporters/base.py +0 -0
  58. {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/reporters/html.py +0 -0
  59. {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/reporters/markdown.py +0 -0
  60. {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/retry.py +0 -0
  61. {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/store.py +0 -0
  62. {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/sweep.py +0 -0
  63. {evalcore-2.5.0 → evalcore-2.6.0}/tests/__init__.py +0 -0
  64. {evalcore-2.5.0 → evalcore-2.6.0}/tests/test_adapters.py +0 -0
  65. {evalcore-2.5.0 → evalcore-2.6.0}/tests/test_cli.py +0 -0
  66. {evalcore-2.5.0 → evalcore-2.6.0}/tests/test_edge_cases.py +0 -0
  67. {evalcore-2.5.0 → evalcore-2.6.0}/tests/test_judge.py +0 -0
  68. {evalcore-2.5.0 → evalcore-2.6.0}/tests/test_judge_extra.py +0 -0
  69. {evalcore-2.5.0 → evalcore-2.6.0}/tests/test_live_clients.py +0 -0
  70. {evalcore-2.5.0 → evalcore-2.6.0}/tests/test_pairwise_extra.py +0 -0
  71. {evalcore-2.5.0 → evalcore-2.6.0}/tests/test_plugins.py +0 -0
  72. {evalcore-2.5.0 → evalcore-2.6.0}/tests/test_rating.py +0 -0
  73. {evalcore-2.5.0 → evalcore-2.6.0}/tests/test_rating_server.py +0 -0
  74. {evalcore-2.5.0 → evalcore-2.6.0}/tests/test_reporters.py +0 -0
  75. {evalcore-2.5.0 → evalcore-2.6.0}/tests/test_retry.py +0 -0
  76. {evalcore-2.5.0 → evalcore-2.6.0}/tests/test_runner.py +0 -0
  77. {evalcore-2.5.0 → evalcore-2.6.0}/tests/test_store.py +0 -0
  78. {evalcore-2.5.0 → evalcore-2.6.0}/tests/test_sweep_pairwise.py +0 -0
  79. {evalcore-2.5.0 → evalcore-2.6.0}/uv.toml +0 -0
@@ -6,6 +6,57 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.6.0] - 2026-09-12
10
+
11
+ A metric says which way is good and what its values sit on.
12
+
13
+ ### Added
14
+ - `MetricRange` and a `value_range` field on `Score` and `MetricValue`.
15
+ Nothing about a float says what it is on: `0.86` is 86% if the metric runs
16
+ 0..1 and 4.3 out of 5 if it runs 1..5. A consumer holding only the number
17
+ guesses from magnitude, which is how a run whose costs happened to stay
18
+ under a dollar gets rendered as percentages, and how the same metric gets
19
+ classified differently depending on the window you look at.
20
+ - Built-in graders declare their own ranges. Deterministic checks are 0..1;
21
+ `classification` separates its confusion-matrix metrics (0..1) from
22
+ `support_*` and `errors` (0..unbounded); judge metrics are 0..1, being
23
+ points over the scale, except `disagreement`, a spread in raw points,
24
+ which is 0..scale-1.
25
+ - `range:` on a `numeric` field spec, as `{min, max}` or a two-element list.
26
+ That grader surfaces whatever the adapter put in the field, so it is the
27
+ one place the engine cannot know. It is a separate key from the `min`/`max`
28
+ bounds beside it, which are a pass/fail threshold rather than a domain: a
29
+ cost that must stay under a dollar can still cost five.
30
+ - `maximum=None` is a positive statement - unbounded above, so not a
31
+ fraction of anything - and is different from carrying no range at all,
32
+ which says only that nobody declared one. Nothing is inferred from
33
+ observed values.
34
+ - `direction` and `higher_is_better` on `MetricDelta`. A delta's sign and its
35
+ meaning are different questions - `f1` rising is an improvement, and
36
+ `false_negative_rate` rising is a regression - and until now the engine only
37
+ knew the difference for the single win metric, via `win_higher_is_better`.
38
+ Every other metric came out of `compare()` as a bare number, so anything
39
+ downstream that wanted to rank, colour or summarize deltas had to keep its
40
+ own list of which metrics are inverted, or get it wrong.
41
+ - `thresholds.metrics`, an optional map declaring polarity per metric:
42
+ `lower_is_better`, `higher_is_better`, or `neutral` for one that moves
43
+ without either direction being a result.
44
+ - **Polarity is inferred from the guardrails when it is not declared**, which
45
+ is what makes this useful without editing a single existing suite. A `max`
46
+ or `must_not_increase` rule is only ever written about a metric you want
47
+ low, and `min`/`must_not_decrease` about one you want high, so a gated
48
+ metric has already stated its direction. Precedence, strongest first: an
49
+ explicit `metrics:` entry, then `win_higher_is_better` for the win metric,
50
+ then the guardrails. A metric none of them mention defaults to
51
+ higher-is-better; a metric fenced in on both sides by a band resolves to
52
+ `neutral` rather than falling through to that default, since a band says
53
+ neither direction is the good one.
54
+
55
+ ### Changed
56
+ - `_evaluate_win` derives the win verdict through the same call that fills in
57
+ each `MetricDelta.direction`, so `Comparison.win` and the win metric's own
58
+ delta row cannot disagree about the same number.
59
+
9
60
  ## [2.5.0] - 2026-08-11
10
61
 
11
62
  A suite can declare the modules it needs imported.
@@ -380,7 +431,8 @@ by semantic versioning: a breaking change to either means a 2.0.
380
431
  rating + ranking with judge agreement, Markdown/HTML reporters, JSON +
381
432
  column-store outbox, and content-hash provenance.
382
433
 
383
- [Unreleased]: https://github.com/scottpmiller/evalcore/compare/2.5.0...HEAD
434
+ [Unreleased]: https://github.com/scottpmiller/evalcore/compare/2.6.0...HEAD
435
+ [2.6.0]: https://github.com/scottpmiller/evalcore/compare/2.5.0...2.6.0
384
436
  [2.5.0]: https://github.com/scottpmiller/evalcore/compare/2.4.3...2.5.0
385
437
  [2.4.3]: https://github.com/scottpmiller/evalcore/compare/2.4.2...2.4.3
386
438
  [2.4.2]: https://github.com/scottpmiller/evalcore/compare/2.4.1...2.4.2
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: evalcore
3
- Version: 2.5.0
3
+ Version: 2.6.0
4
4
  Summary: A generic, consumer-agnostic evaluation engine for prompt, model, and API outputs.
5
5
  Project-URL: Homepage, https://github.com/scottpmiller/evalcore
6
6
  Project-URL: Repository, https://github.com/scottpmiller/evalcore
@@ -429,6 +429,9 @@ thresholds:
429
429
  win_min_delta: 0.02 # dead band: |delta| <= this -> neutral
430
430
  on_regression: warn # or 'fail' to hard-gate the win metric
431
431
  variants: {baseline: baseline, candidate: candidate} # gate defaults
432
+ metrics: # optional: which way is good, per metric
433
+ generation_cost: lower_is_better
434
+ total_tool_calls: neutral # moves, but neither way is a result
432
435
  guardrails: # hard constraints on the CANDIDATE
433
436
  - metric: false_negative_rate
434
437
  max: 0.10 # absolute ceiling
@@ -441,7 +444,51 @@ thresholds:
441
444
 
442
445
  Guardrail rules compose: `max`, `min`, `must_not_increase`,
443
446
  `must_not_decrease`. A guardrail whose metric is missing on the candidate
444
- fails closed. Pick guardrails for the failures that must never ship, and
447
+ fails closed.
448
+
449
+ Every metric also carries a `value_range`, because nothing about a float says
450
+ what it is on: `0.86` is 86% if the metric runs 0..1 and 4.3 out of 5 if it
451
+ runs 1..5. The grader declares it - deterministic checks are 0..1,
452
+ `classification` puts `support_*` and `errors` on 0..unbounded while its
453
+ confusion-matrix metrics are 0..1, and judge metrics are 0..1 except
454
+ `disagreement`, a spread in raw points, which is 0..scale-1. A custom grader
455
+ passes `value_range=` on the `Score`s it returns.
456
+
457
+ `numeric` is the exception, since it surfaces whatever the adapter put in the
458
+ field, so declare it there:
459
+
460
+ ```yaml
461
+ graders:
462
+ - type: numeric
463
+ fields:
464
+ - {ref: output.cost, range: {min: 0, max: null}}
465
+ - {ref: output.quality, range: [1, 5]}
466
+ # `range` and `max` are different things - see below
467
+ - {ref: output.latency, range: {min: 0}, max: 2.5}
468
+ ```
469
+
470
+ That last field is the distinction worth knowing. `min`/`max` on a numeric
471
+ field are a **pass/fail threshold** - "fail this case over 2.5 seconds" - and
472
+ `range` is what the number could be at all. A cost that must stay under a
473
+ dollar can still cost five, so the two keys stay separate.
474
+
475
+ `maximum: null` is a statement, not a gap - unbounded above, so not a fraction
476
+ of anything, render it as it is. A metric with no range at all is different
477
+ again: nobody declared one. The engine never infers a range from the values it
478
+ sees, because "everything stayed under 1, so it must be a 0..1 metric" is what
479
+ turns a run whose costs happened to stay cheap into percentages.
480
+
481
+ Every `MetricDelta` carries `direction` (`improved`/`regressed`/`neutral`)
482
+ alongside `delta`, because the sign of a number does not say what it means: a
483
+ rise in `f1` is an improvement and a rise in `false_negative_rate` is a
484
+ regression. Direction comes from the first of these that speaks to the metric:
485
+ an explicit `metrics:` entry, `win_higher_is_better` for the win metric, or the
486
+ guardrails themselves - a `max` or `must_not_increase` rule means you want the
487
+ metric low, a `min` or `must_not_decrease` rule means high. So a suite that
488
+ gates a metric already declares its direction and needs no `metrics:` block at
489
+ all. A metric nothing speaks to is assumed higher-is-better; one fenced in on
490
+ both sides by a band, or declared `neutral`, reports `direction: neutral` and
491
+ `higher_is_better: null` rather than guessing. Pick guardrails for the failures that must never ship, and
445
492
  one win metric for the improvement you're hunting; everything else is
446
493
  reported informationally.
447
494
 
@@ -397,6 +397,9 @@ thresholds:
397
397
  win_min_delta: 0.02 # dead band: |delta| <= this -> neutral
398
398
  on_regression: warn # or 'fail' to hard-gate the win metric
399
399
  variants: {baseline: baseline, candidate: candidate} # gate defaults
400
+ metrics: # optional: which way is good, per metric
401
+ generation_cost: lower_is_better
402
+ total_tool_calls: neutral # moves, but neither way is a result
400
403
  guardrails: # hard constraints on the CANDIDATE
401
404
  - metric: false_negative_rate
402
405
  max: 0.10 # absolute ceiling
@@ -409,7 +412,51 @@ thresholds:
409
412
 
410
413
  Guardrail rules compose: `max`, `min`, `must_not_increase`,
411
414
  `must_not_decrease`. A guardrail whose metric is missing on the candidate
412
- fails closed. Pick guardrails for the failures that must never ship, and
415
+ fails closed.
416
+
417
+ Every metric also carries a `value_range`, because nothing about a float says
418
+ what it is on: `0.86` is 86% if the metric runs 0..1 and 4.3 out of 5 if it
419
+ runs 1..5. The grader declares it - deterministic checks are 0..1,
420
+ `classification` puts `support_*` and `errors` on 0..unbounded while its
421
+ confusion-matrix metrics are 0..1, and judge metrics are 0..1 except
422
+ `disagreement`, a spread in raw points, which is 0..scale-1. A custom grader
423
+ passes `value_range=` on the `Score`s it returns.
424
+
425
+ `numeric` is the exception, since it surfaces whatever the adapter put in the
426
+ field, so declare it there:
427
+
428
+ ```yaml
429
+ graders:
430
+ - type: numeric
431
+ fields:
432
+ - {ref: output.cost, range: {min: 0, max: null}}
433
+ - {ref: output.quality, range: [1, 5]}
434
+ # `range` and `max` are different things - see below
435
+ - {ref: output.latency, range: {min: 0}, max: 2.5}
436
+ ```
437
+
438
+ That last field is the distinction worth knowing. `min`/`max` on a numeric
439
+ field are a **pass/fail threshold** - "fail this case over 2.5 seconds" - and
440
+ `range` is what the number could be at all. A cost that must stay under a
441
+ dollar can still cost five, so the two keys stay separate.
442
+
443
+ `maximum: null` is a statement, not a gap - unbounded above, so not a fraction
444
+ of anything, render it as it is. A metric with no range at all is different
445
+ again: nobody declared one. The engine never infers a range from the values it
446
+ sees, because "everything stayed under 1, so it must be a 0..1 metric" is what
447
+ turns a run whose costs happened to stay cheap into percentages.
448
+
449
+ Every `MetricDelta` carries `direction` (`improved`/`regressed`/`neutral`)
450
+ alongside `delta`, because the sign of a number does not say what it means: a
451
+ rise in `f1` is an improvement and a rise in `false_negative_rate` is a
452
+ regression. Direction comes from the first of these that speaks to the metric:
453
+ an explicit `metrics:` entry, `win_higher_is_better` for the win metric, or the
454
+ guardrails themselves - a `max` or `must_not_increase` rule means you want the
455
+ metric low, a `min` or `must_not_decrease` rule means high. So a suite that
456
+ gates a metric already declares its direction and needs no `metrics:` block at
457
+ all. A metric nothing speaks to is assumed higher-is-better; one fenced in on
458
+ both sides by a band, or declared `neutral`, reports `direction: neutral` and
459
+ `higher_is_better: null` rather than guessing. Pick guardrails for the failures that must never ship, and
413
460
  one win metric for the improvement you're hunting; everything else is
414
461
  reported informationally.
415
462
 
@@ -47,6 +47,10 @@ class AcknowledgesCustomer:
47
47
  grader=self.name,
48
48
  metric=self.name,
49
49
  value=1.0 if ok else 0.0,
50
+ # 1/0 per case, so the run mean is a share of cases. A
51
+ # grader knows this about its own output; nothing
52
+ # downstream can work it out from the numbers alone.
53
+ value_range=models.MetricRange(minimum=0.0, maximum=1.0),
50
54
  passed=ok,
51
55
  detail=detail,
52
56
  case_id=case.id,
@@ -83,6 +87,7 @@ class DistinctReplyRate:
83
87
  grader=self.name,
84
88
  metric=self.name,
85
89
  value=rate,
90
+ value_range=models.MetricRange(minimum=0.0, maximum=1.0),
86
91
  detail=f'{distinct} distinct across {len(by_case)} cases',
87
92
  kind='aggregate',
88
93
  )
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "evalcore"
3
- version = "2.5.0"
3
+ version = "2.6.0"
4
4
  description = "A generic, consumer-agnostic evaluation engine for prompt, model, and API outputs."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -118,6 +118,107 @@ def check_thresholds(
118
118
  )
119
119
 
120
120
 
121
+ _POLARITY_WORDS = {
122
+ 'higher_is_better': True,
123
+ 'higher': True,
124
+ 'up': True,
125
+ 'lower_is_better': False,
126
+ 'lower': False,
127
+ 'down': False,
128
+ 'neutral': None,
129
+ 'none': None,
130
+ }
131
+
132
+
133
+ def _declared_polarity(thresholds: dict) -> dict[str, bool | None]:
134
+ """Polarity as the suite states it, under ``thresholds.metrics``.
135
+
136
+ A metric mapped to ``neutral`` is present here with a ``None`` value,
137
+ which is not the same as being absent: it says the suite considered the
138
+ metric and decided it has no good direction, and that beats inference.
139
+ An unrecognised word is ignored rather than guessed at.
140
+ """
141
+ declared: dict[str, bool | None] = {}
142
+ for metric, value in (thresholds.get('metrics') or {}).items():
143
+ if isinstance(value, bool):
144
+ declared[metric] = value
145
+ elif isinstance(value, str):
146
+ word = value.strip().lower()
147
+ if word in _POLARITY_WORDS:
148
+ declared[metric] = _POLARITY_WORDS[word]
149
+ return declared
150
+
151
+
152
+ def _inferred_polarity(rules: list[dict]) -> dict[str, bool | None]:
153
+ """Polarity the guardrail rules already imply.
154
+
155
+ A ceiling (``max``, ``must_not_increase``) is only worth writing about a
156
+ metric you want low; a floor (``min``, ``must_not_decrease``) about one
157
+ you want high. So a suite that gates a metric has usually stated its
158
+ direction already without meaning to, and this reads it back - which is
159
+ what lets existing suites get correct directions with no edits.
160
+
161
+ A metric whose rules imply both - a band, ``min`` and ``max`` together -
162
+ resolves to ``None``, not to the default. Being fenced in on both sides
163
+ is a statement that neither direction is the good one, and letting it
164
+ fall through to higher-is-better would turn the one case we know is
165
+ ambiguous into a confident wrong answer.
166
+ """
167
+ votes: dict[str, set[bool]] = {}
168
+ for rule in rules:
169
+ metric = rule.get('metric')
170
+ if not metric:
171
+ continue
172
+ seen = votes.setdefault(metric, set())
173
+ if 'max' in rule or rule.get('must_not_increase'):
174
+ seen.add(False)
175
+ if 'min' in rule or rule.get('must_not_decrease'):
176
+ seen.add(True)
177
+ return {
178
+ metric: next(iter(seen)) if len(seen) == 1 else None
179
+ for metric, seen in votes.items()
180
+ if seen
181
+ }
182
+
183
+
184
+ def _polarity(thresholds: dict) -> dict[str, bool | None]:
185
+ """Every metric the suite says something about, weakest source first.
186
+
187
+ Inference from the guardrails is the fallback; ``win_higher_is_better``
188
+ beats it for the win metric because it is a statement rather than a
189
+ reading; an explicit ``metrics:`` entry beats both. Metrics named by none
190
+ of the three are absent, and callers treat absent as higher-is-better.
191
+ """
192
+ resolved: dict[str, bool | None] = dict(
193
+ _inferred_polarity(thresholds.get('guardrails', []))
194
+ )
195
+ win_metric = thresholds.get('win_metric')
196
+ if win_metric:
197
+ resolved[win_metric] = bool(
198
+ thresholds.get('win_higher_is_better', True)
199
+ )
200
+ resolved.update(_declared_polarity(thresholds))
201
+ return resolved
202
+
203
+
204
+ def _direction(
205
+ delta: float | None, higher_is_better: bool | None, min_delta: float = 0.0
206
+ ) -> str:
207
+ """Turn a signed delta into what it means for this metric.
208
+
209
+ ``min_delta`` is the dead band: a move smaller than it is noise, not a
210
+ result.
211
+ """
212
+ if delta is None or higher_is_better is None:
213
+ return 'neutral'
214
+ effective = delta if higher_is_better else -delta
215
+ if effective > min_delta:
216
+ return 'improved'
217
+ if effective < -min_delta:
218
+ return 'regressed'
219
+ return 'neutral'
220
+
221
+
121
222
  def _evaluate_win(
122
223
  thresholds: dict, baseline: models.Scorecard, candidate: models.Scorecard
123
224
  ) -> tuple[str | None, str]:
@@ -128,14 +229,13 @@ def _evaluate_win(
128
229
  cand = _metric(candidate, metric)
129
230
  if base is None or cand is None:
130
231
  return metric, 'neutral'
131
- higher_better = thresholds.get('win_higher_is_better', True)
132
- min_delta = thresholds.get('win_min_delta', 0.0)
133
- delta = cand - base if higher_better else base - cand
134
- if delta > min_delta:
135
- return metric, 'improved'
136
- if delta < -min_delta:
137
- return metric, 'regressed'
138
- return metric, 'neutral'
232
+ # Same call the metric's own MetricDelta makes, so the headline verdict
233
+ # and that row cannot disagree about the same number.
234
+ return metric, _direction(
235
+ cand - base,
236
+ _polarity(thresholds).get(metric, True),
237
+ thresholds.get('win_min_delta', 0.0),
238
+ )
139
239
 
140
240
 
141
241
  def compare(
@@ -146,14 +246,33 @@ def compare(
146
246
  """Compare two scorecards and produce a gate verdict."""
147
247
  thresholds = thresholds or {}
148
248
 
249
+ polarity = _polarity(thresholds)
250
+ win_metric = thresholds.get('win_metric')
251
+
149
252
  deltas: list[models.MetricDelta] = []
150
253
  for metric in sorted(set(baseline.metrics) | set(candidate.metrics)):
151
254
  base = _metric(baseline, metric)
152
255
  cand = _metric(candidate, metric)
153
256
  delta = cand - base if base is not None and cand is not None else None
257
+ # Absent from the map means nothing in the suite spoke to this
258
+ # metric, and higher-is-better is the convention almost every score
259
+ # follows. A declared 'neutral' is present with a None value and is
260
+ # a different answer: say nothing.
261
+ higher_is_better = polarity.get(metric, True)
154
262
  deltas.append(
155
263
  models.MetricDelta(
156
- metric=metric, baseline=base, candidate=cand, delta=delta
264
+ metric=metric,
265
+ baseline=base,
266
+ candidate=cand,
267
+ delta=delta,
268
+ higher_is_better=higher_is_better,
269
+ direction=_direction(
270
+ delta,
271
+ higher_is_better,
272
+ thresholds.get('win_min_delta', 0.0)
273
+ if metric == win_metric
274
+ else 0.0,
275
+ ),
157
276
  )
158
277
  )
159
278
 
@@ -89,11 +89,29 @@ class Classification:
89
89
  fpr = _safe_div(fp, fp + tn)
90
90
  accuracy = _safe_div(tp + tn, tp + tn + fp + fn)
91
91
 
92
- def agg(metric: str, value: float) -> models.Score:
92
+ fraction = models.MetricRange(minimum=0.0, maximum=1.0)
93
+ # Unbounded above, and never negative: these count the rows behind
94
+ # the fractions rather than being one. maximum=None says that
95
+ # positively, which is what stops a consumer reading 0.9 support as
96
+ # 90% of something.
97
+ tally = models.MetricRange(minimum=0.0, maximum=None)
98
+
99
+ def agg(
100
+ metric: str,
101
+ value: float,
102
+ value_range: models.MetricRange = fraction,
103
+ ) -> models.Score:
93
104
  return models.Score(
94
- grader=self.name, metric=metric, value=value, kind='aggregate'
105
+ grader=self.name,
106
+ metric=metric,
107
+ value=value,
108
+ value_range=value_range,
109
+ kind='aggregate',
95
110
  )
96
111
 
112
+ # One grader, two shapes of number, which is why the range sits on
113
+ # the score and not on the grader.
114
+ count = tally
97
115
  return [
98
116
  agg('precision', precision),
99
117
  agg('recall', recall),
@@ -101,7 +119,7 @@ class Classification:
101
119
  agg('false_negative_rate', fnr),
102
120
  agg('false_positive_rate', fpr),
103
121
  agg('accuracy', accuracy),
104
- agg('support_positive', float(tp + fn)),
105
- agg('support_negative', float(tn + fp)),
106
- agg('errors', float(errors)),
122
+ agg('support_positive', float(tp + fn), count),
123
+ agg('support_negative', float(tn + fp), count),
124
+ agg('errors', float(errors), count),
107
125
  ]
@@ -21,10 +21,13 @@ def _context(case: models.Case, output: models.Output) -> dict:
21
21
 
22
22
 
23
23
  def _score(name: str, metric: str, case_id: str, ok: bool, detail: str):
24
+ # 1.0/0.0 per case, so the run-level mean is the share of cases that
25
+ # passed: 0..1 whatever the check itself was looking at.
24
26
  return models.Score(
25
27
  grader=name,
26
28
  metric=metric,
27
29
  value=1.0 if ok else 0.0,
30
+ value_range=models.MetricRange(minimum=0.0, maximum=1.0),
28
31
  passed=ok,
29
32
  detail=detail,
30
33
  case_id=case_id,
@@ -543,11 +543,18 @@ class RubricJudge:
543
543
  metric: str,
544
544
  value: float | None,
545
545
  judges: list[models.JudgeDetail] | None = None,
546
+ value_range: models.MetricRange | None = None,
546
547
  ) -> models.Score:
548
+ # A judge score is points / scale, so 0..1 by construction.
549
+ # `disagreement` is the exception and passes its own range: it
550
+ # is a spread in RAW points, so it runs 0..scale-1, and reading
551
+ # it as a fraction understates it by that factor.
547
552
  return models.Score(
548
553
  grader=self.name,
549
554
  metric=metric,
550
555
  value=value,
556
+ value_range=value_range
557
+ or models.MetricRange(minimum=0.0, maximum=1.0),
551
558
  detail=detail,
552
559
  case_id=case.id,
553
560
  kind='per_case',
@@ -634,7 +641,15 @@ class RubricJudge:
634
641
  spreads.append(max(raws) - min(raws))
635
642
  mean_spread = sum(spreads) / len(spreads) if spreads else 0.0
636
643
  max_spread = max(spreads) if spreads else 0.0
637
- out.append(score(f'{self.name}.disagreement', mean_spread))
644
+ out.append(
645
+ score(
646
+ f'{self.name}.disagreement',
647
+ mean_spread,
648
+ value_range=models.MetricRange(
649
+ minimum=0.0, maximum=float(self.scale - 1)
650
+ ),
651
+ )
652
+ )
638
653
  out.append(
639
654
  score(
640
655
  f'{self.name}.flagged',
@@ -26,6 +26,24 @@ def _context(case: models.Case, output: models.Output) -> dict:
26
26
  }
27
27
 
28
28
 
29
+ def _range(spec: dict) -> models.MetricRange | None:
30
+ """The metric's declared range, if the suite gave one.
31
+
32
+ Deliberately a separate key from the ``min``/``max`` bounds beside it.
33
+ Those are a pass/fail threshold - ``max: 1.0`` on a cost means "fail
34
+ over a dollar" - and the range is what the number could be at all. A
35
+ cost that must stay under a dollar can still cost five.
36
+ """
37
+ declared = spec.get('range')
38
+ if isinstance(declared, dict):
39
+ return models.MetricRange(
40
+ minimum=declared.get('min'), maximum=declared.get('max')
41
+ )
42
+ if isinstance(declared, (list, tuple)) and len(declared) == 2:
43
+ return models.MetricRange(minimum=declared[0], maximum=declared[1])
44
+ return None
45
+
46
+
29
47
  class _Field:
30
48
  """One resolved field spec: a ``$ref`` selector, a metric name, bounds."""
31
49
 
@@ -37,6 +55,12 @@ class _Field:
37
55
  self.maximum = spec.get('max')
38
56
  # Metric name defaults to the ref's leaf (``output.cost`` -> ``cost``).
39
57
  self.metric = spec.get('name') or self.ref.rsplit('.', 1)[-1]
58
+ # The one grader that cannot know its own range: the value is
59
+ # whatever the adapter put in the field, so a dollar cost, a token
60
+ # count and a rate all arrive as a bare float. The suite declares it
61
+ # or nobody does, and nobody is an honest answer, not a reason to
62
+ # guess.
63
+ self.value_range = _range(spec)
40
64
 
41
65
 
42
66
  def _as_float(value) -> float | None:
@@ -69,6 +93,7 @@ class Numeric:
69
93
  grader=self.name,
70
94
  metric=field.metric,
71
95
  value=value,
96
+ value_range=field.value_range,
72
97
  passed=passed,
73
98
  detail=detail,
74
99
  case_id=case.id,
@@ -77,6 +77,36 @@ class JudgeDetail(pydantic.BaseModel):
77
77
  overall: float | None = None
78
78
 
79
79
 
80
+ class MetricRange(pydantic.BaseModel):
81
+ """The values a metric can take, when the grader knows them.
82
+
83
+ Nothing about a float says what it is on. ``0.86`` is 86% if the metric
84
+ runs 0..1 and 4.3 out of 5 if it runs 1..5, and a consumer holding only
85
+ the number has to guess - usually from magnitude, which is how a run
86
+ whose costs happened to stay under a dollar gets rendered as
87
+ percentages, and how the same metric gets classified differently
88
+ depending on the window you look at.
89
+
90
+ ``maximum=None`` is a real answer rather than a missing one: the metric
91
+ is unbounded above - a count, a cost, an elapsed time - so it is not a
92
+ fraction of anything and should be shown as it is. That is different
93
+ from carrying no range at all, which says only that nobody declared one.
94
+ """
95
+
96
+ #: Frozen because a range is a value, not a record: two graders that
97
+ #: declare 0..1 for the same metric agree, and aggregation compares them
98
+ #: by equality in a set.
99
+ model_config = pydantic.ConfigDict(frozen=True)
100
+
101
+ minimum: float | None = None
102
+ maximum: float | None = None
103
+
104
+ @property
105
+ def is_fraction(self) -> bool:
106
+ """0..1, so the value can be rendered as a percentage."""
107
+ return self.minimum == 0.0 and self.maximum == 1.0
108
+
109
+
80
110
  class Score(pydantic.BaseModel):
81
111
  """A single metric emitted by a grader for one case (or aggregate).
82
112
 
@@ -88,6 +118,9 @@ class Score(pydantic.BaseModel):
88
118
  grader: str
89
119
  metric: str
90
120
  value: float | None = None
121
+ #: what the value is on; see :class:`MetricRange`. ``None`` means the
122
+ #: grader did not declare one, not that the metric is unbounded.
123
+ value_range: MetricRange | None = None
91
124
  passed: bool | None = None
92
125
  detail: str | None = None
93
126
  case_id: str | None = None
@@ -128,6 +161,10 @@ class MetricValue(pydantic.BaseModel):
128
161
  kind: typing.Literal['mean', 'aggregate']
129
162
  n: int
130
163
  stdev: float | None = None
164
+ #: carried up from the scores behind it, unchanged; see
165
+ #: :class:`MetricRange`. A mean sits on the same range its observations
166
+ #: did, so averaging does not move it.
167
+ value_range: MetricRange | None = None
131
168
 
132
169
 
133
170
  class Scorecard(pydantic.BaseModel):
@@ -446,12 +483,26 @@ class PairwiseAgreement(pydantic.BaseModel):
446
483
 
447
484
 
448
485
  class MetricDelta(pydantic.BaseModel):
449
- """Per-metric baseline->candidate comparison."""
486
+ """Per-metric baseline->candidate comparison.
487
+
488
+ ``delta`` is the arithmetic; ``direction`` is what it means. They are not
489
+ the same thing and only the second one is safe to colour green: a rise in
490
+ ``f1`` is an improvement and a rise in ``false_negative_rate`` is a
491
+ regression, and nothing about the number says which.
492
+
493
+ ``higher_is_better`` is the fact the direction was derived from, kept so a
494
+ consumer storing these rows does not have to re-derive it. ``None`` means
495
+ the suite declared the metric ``neutral``, or the engine had nothing to
496
+ infer from and would rather say nothing than guess - in which case
497
+ ``direction`` is ``neutral`` whatever the delta was.
498
+ """
450
499
 
451
500
  metric: str
452
501
  baseline: float | None
453
502
  candidate: float | None
454
503
  delta: float | None
504
+ higher_is_better: bool | None = None
505
+ direction: typing.Literal['improved', 'regressed', 'neutral'] = 'neutral'
455
506
 
456
507
 
457
508
  class Comparison(pydantic.BaseModel):
@@ -113,6 +113,28 @@ def _grader_info(*grader_groups) -> dict[str, models.GraderInfo]:
113
113
  }
114
114
 
115
115
 
116
+ def _metric_range(scores: list[models.Score]) -> models.MetricRange | None:
117
+ """The range of a metric, carried up from the scores that produced it.
118
+
119
+ Declared or nothing. A mean sits on the same range its observations did,
120
+ so aggregation moves the value but never the range.
121
+
122
+ There is deliberately no inference from the values themselves. "Every
123
+ observation landed inside 0..1, so the metric runs 0..1" is what renders
124
+ a run whose costs happened to stay under a dollar as percentages, and it
125
+ answers differently depending on which window you ask about. A grader
126
+ that knows its range says so in one keyword; one that does not is not
127
+ improved by a guess that looks the same as knowledge downstream.
128
+
129
+ Two graders declaring different ranges for one metric is a bug in the
130
+ suite, not something to reconcile, so that comes back ``None`` too.
131
+ """
132
+ declared = {
133
+ score.value_range for score in scores if score.value_range is not None
134
+ }
135
+ return declared.pop() if len(declared) == 1 else None
136
+
137
+
116
138
  def _aggregate_metrics(
117
139
  results: list[models.CaseResult], agg_scores: list[models.Score]
118
140
  ) -> dict[str, models.MetricValue]:
@@ -120,8 +142,10 @@ def _aggregate_metrics(
120
142
  metrics: dict[str, models.MetricValue] = {}
121
143
 
122
144
  by_metric: dict[str, list[float]] = {}
145
+ scores_by_metric: dict[str, list[models.Score]] = {}
123
146
  for result in results:
124
147
  for score in result.scores:
148
+ scores_by_metric.setdefault(score.metric, []).append(score)
125
149
  if score.value is not None:
126
150
  by_metric.setdefault(score.metric, []).append(score.value)
127
151
  for metric, values in by_metric.items():
@@ -131,6 +155,7 @@ def _aggregate_metrics(
131
155
  stdev=statistics.stdev(values) if len(values) > 1 else None,
132
156
  kind='mean',
133
157
  n=len(values),
158
+ value_range=_metric_range(scores_by_metric[metric]),
134
159
  )
135
160
 
136
161
  for score in agg_scores:
@@ -139,6 +164,7 @@ def _aggregate_metrics(
139
164
  value=score.value,
140
165
  kind='aggregate',
141
166
  n=len(results),
167
+ value_range=_metric_range([score]),
142
168
  )
143
169
  return metrics
144
170