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.
- {evalcore-2.5.0 → evalcore-2.6.0}/CHANGELOG.md +53 -1
- {evalcore-2.5.0 → evalcore-2.6.0}/PKG-INFO +49 -2
- {evalcore-2.5.0 → evalcore-2.6.0}/README.md +48 -1
- {evalcore-2.5.0 → evalcore-2.6.0}/examples/quickstart/graders.py +5 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/pyproject.toml +1 -1
- {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/compare.py +128 -9
- {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/graders/classification.py +23 -5
- {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/graders/deterministic.py +3 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/graders/judge.py +16 -1
- {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/graders/numeric.py +25 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/models.py +52 -1
- {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/runner.py +26 -0
- evalcore-2.6.0/tests/test_unit.py +446 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/uv.lock +1 -1
- evalcore-2.5.0/tests/test_unit.py +0 -239
- {evalcore-2.5.0 → evalcore-2.6.0}/.github/workflows/ci.yml +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/.github/workflows/publish.yml +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/.gitignore +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/.pre-commit-config.yaml +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/LICENSE +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/docs/design.md +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/examples/__init__.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/examples/quickstart/README.md +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/examples/quickstart/__init__.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/examples/quickstart/adapter.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/examples/quickstart/datasets/support_reply/v1/cases/angry_complaint.yaml +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/examples/quickstart/datasets/support_reply/v1/cases/billing_question.yaml +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/examples/quickstart/datasets/support_reply/v1/cases/double_charge.yaml +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/examples/quickstart/datasets/support_reply/v1/cases/refund_request.yaml +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/examples/quickstart/fixtures/support_reply_judge.yaml +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/examples/quickstart/fixtures/support_reply_pairwise.yaml +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/examples/quickstart/fixtures/support_reply_replay.yaml +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/examples/quickstart/run_eval.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/examples/quickstart/suite.yaml +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/examples/quickstart/tests/__init__.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/examples/quickstart/tests/test_quickstart.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/justfile +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/pyrightconfig.json +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/__init__.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/adapters/__init__.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/adapters/base.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/adapters/env.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/adapters/http.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/adapters/replay.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/cli.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/errors.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/graders/__init__.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/graders/base.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/loader.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/pairwise.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/plugins.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/py.typed +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/rating.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/refs.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/report.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/reporters/__init__.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/reporters/base.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/reporters/html.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/reporters/markdown.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/retry.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/store.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/src/evalcore/sweep.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/tests/__init__.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/tests/test_adapters.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/tests/test_cli.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/tests/test_edge_cases.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/tests/test_judge.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/tests/test_judge_extra.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/tests/test_live_clients.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/tests/test_pairwise_extra.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/tests/test_plugins.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/tests/test_rating.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/tests/test_rating_server.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/tests/test_reporters.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/tests/test_retry.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/tests/test_runner.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/tests/test_store.py +0 -0
- {evalcore-2.5.0 → evalcore-2.6.0}/tests/test_sweep_pairwise.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
)
|
|
@@ -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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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
|
|