evalcore 2.2.0__tar.gz → 2.4.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 (76) hide show
  1. {evalcore-2.2.0 → evalcore-2.4.0}/CHANGELOG.md +55 -1
  2. {evalcore-2.2.0 → evalcore-2.4.0}/PKG-INFO +15 -14
  3. {evalcore-2.2.0 → evalcore-2.4.0}/README.md +14 -13
  4. {evalcore-2.2.0 → evalcore-2.4.0}/pyproject.toml +1 -1
  5. {evalcore-2.2.0 → evalcore-2.4.0}/src/evalcore/compare.py +64 -6
  6. {evalcore-2.2.0 → evalcore-2.4.0}/src/evalcore/graders/base.py +4 -0
  7. {evalcore-2.2.0 → evalcore-2.4.0}/src/evalcore/models.py +61 -8
  8. {evalcore-2.2.0 → evalcore-2.4.0}/src/evalcore/report.py +9 -1
  9. {evalcore-2.2.0 → evalcore-2.4.0}/src/evalcore/reporters/html.py +13 -1
  10. {evalcore-2.2.0 → evalcore-2.4.0}/src/evalcore/runner.py +35 -1
  11. {evalcore-2.2.0 → evalcore-2.4.0}/src/evalcore/store.py +48 -14
  12. {evalcore-2.2.0 → evalcore-2.4.0}/tests/test_store.py +129 -1
  13. {evalcore-2.2.0 → evalcore-2.4.0}/uv.lock +1 -1
  14. {evalcore-2.2.0 → evalcore-2.4.0}/.github/workflows/ci.yml +0 -0
  15. {evalcore-2.2.0 → evalcore-2.4.0}/.github/workflows/publish.yml +0 -0
  16. {evalcore-2.2.0 → evalcore-2.4.0}/.gitignore +0 -0
  17. {evalcore-2.2.0 → evalcore-2.4.0}/.pre-commit-config.yaml +0 -0
  18. {evalcore-2.2.0 → evalcore-2.4.0}/LICENSE +0 -0
  19. {evalcore-2.2.0 → evalcore-2.4.0}/docs/design.md +0 -0
  20. {evalcore-2.2.0 → evalcore-2.4.0}/examples/__init__.py +0 -0
  21. {evalcore-2.2.0 → evalcore-2.4.0}/examples/quickstart/README.md +0 -0
  22. {evalcore-2.2.0 → evalcore-2.4.0}/examples/quickstart/__init__.py +0 -0
  23. {evalcore-2.2.0 → evalcore-2.4.0}/examples/quickstart/adapter.py +0 -0
  24. {evalcore-2.2.0 → evalcore-2.4.0}/examples/quickstart/datasets/support_reply/v1/cases/angry_complaint.yaml +0 -0
  25. {evalcore-2.2.0 → evalcore-2.4.0}/examples/quickstart/datasets/support_reply/v1/cases/billing_question.yaml +0 -0
  26. {evalcore-2.2.0 → evalcore-2.4.0}/examples/quickstart/datasets/support_reply/v1/cases/double_charge.yaml +0 -0
  27. {evalcore-2.2.0 → evalcore-2.4.0}/examples/quickstart/datasets/support_reply/v1/cases/refund_request.yaml +0 -0
  28. {evalcore-2.2.0 → evalcore-2.4.0}/examples/quickstart/fixtures/support_reply_judge.yaml +0 -0
  29. {evalcore-2.2.0 → evalcore-2.4.0}/examples/quickstart/fixtures/support_reply_pairwise.yaml +0 -0
  30. {evalcore-2.2.0 → evalcore-2.4.0}/examples/quickstart/fixtures/support_reply_replay.yaml +0 -0
  31. {evalcore-2.2.0 → evalcore-2.4.0}/examples/quickstart/graders.py +0 -0
  32. {evalcore-2.2.0 → evalcore-2.4.0}/examples/quickstart/run_eval.py +0 -0
  33. {evalcore-2.2.0 → evalcore-2.4.0}/examples/quickstart/suite.yaml +0 -0
  34. {evalcore-2.2.0 → evalcore-2.4.0}/examples/quickstart/tests/__init__.py +0 -0
  35. {evalcore-2.2.0 → evalcore-2.4.0}/examples/quickstart/tests/test_quickstart.py +0 -0
  36. {evalcore-2.2.0 → evalcore-2.4.0}/justfile +0 -0
  37. {evalcore-2.2.0 → evalcore-2.4.0}/pyrightconfig.json +0 -0
  38. {evalcore-2.2.0 → evalcore-2.4.0}/src/evalcore/__init__.py +0 -0
  39. {evalcore-2.2.0 → evalcore-2.4.0}/src/evalcore/adapters/__init__.py +0 -0
  40. {evalcore-2.2.0 → evalcore-2.4.0}/src/evalcore/adapters/base.py +0 -0
  41. {evalcore-2.2.0 → evalcore-2.4.0}/src/evalcore/adapters/env.py +0 -0
  42. {evalcore-2.2.0 → evalcore-2.4.0}/src/evalcore/adapters/http.py +0 -0
  43. {evalcore-2.2.0 → evalcore-2.4.0}/src/evalcore/adapters/replay.py +0 -0
  44. {evalcore-2.2.0 → evalcore-2.4.0}/src/evalcore/cli.py +0 -0
  45. {evalcore-2.2.0 → evalcore-2.4.0}/src/evalcore/errors.py +0 -0
  46. {evalcore-2.2.0 → evalcore-2.4.0}/src/evalcore/graders/__init__.py +0 -0
  47. {evalcore-2.2.0 → evalcore-2.4.0}/src/evalcore/graders/classification.py +0 -0
  48. {evalcore-2.2.0 → evalcore-2.4.0}/src/evalcore/graders/deterministic.py +0 -0
  49. {evalcore-2.2.0 → evalcore-2.4.0}/src/evalcore/graders/judge.py +0 -0
  50. {evalcore-2.2.0 → evalcore-2.4.0}/src/evalcore/graders/numeric.py +0 -0
  51. {evalcore-2.2.0 → evalcore-2.4.0}/src/evalcore/loader.py +0 -0
  52. {evalcore-2.2.0 → evalcore-2.4.0}/src/evalcore/pairwise.py +0 -0
  53. {evalcore-2.2.0 → evalcore-2.4.0}/src/evalcore/py.typed +0 -0
  54. {evalcore-2.2.0 → evalcore-2.4.0}/src/evalcore/rating.py +0 -0
  55. {evalcore-2.2.0 → evalcore-2.4.0}/src/evalcore/refs.py +0 -0
  56. {evalcore-2.2.0 → evalcore-2.4.0}/src/evalcore/reporters/__init__.py +0 -0
  57. {evalcore-2.2.0 → evalcore-2.4.0}/src/evalcore/reporters/base.py +0 -0
  58. {evalcore-2.2.0 → evalcore-2.4.0}/src/evalcore/reporters/markdown.py +0 -0
  59. {evalcore-2.2.0 → evalcore-2.4.0}/src/evalcore/retry.py +0 -0
  60. {evalcore-2.2.0 → evalcore-2.4.0}/src/evalcore/sweep.py +0 -0
  61. {evalcore-2.2.0 → evalcore-2.4.0}/tests/__init__.py +0 -0
  62. {evalcore-2.2.0 → evalcore-2.4.0}/tests/test_adapters.py +0 -0
  63. {evalcore-2.2.0 → evalcore-2.4.0}/tests/test_cli.py +0 -0
  64. {evalcore-2.2.0 → evalcore-2.4.0}/tests/test_edge_cases.py +0 -0
  65. {evalcore-2.2.0 → evalcore-2.4.0}/tests/test_judge.py +0 -0
  66. {evalcore-2.2.0 → evalcore-2.4.0}/tests/test_judge_extra.py +0 -0
  67. {evalcore-2.2.0 → evalcore-2.4.0}/tests/test_live_clients.py +0 -0
  68. {evalcore-2.2.0 → evalcore-2.4.0}/tests/test_pairwise_extra.py +0 -0
  69. {evalcore-2.2.0 → evalcore-2.4.0}/tests/test_rating.py +0 -0
  70. {evalcore-2.2.0 → evalcore-2.4.0}/tests/test_rating_server.py +0 -0
  71. {evalcore-2.2.0 → evalcore-2.4.0}/tests/test_reporters.py +0 -0
  72. {evalcore-2.2.0 → evalcore-2.4.0}/tests/test_retry.py +0 -0
  73. {evalcore-2.2.0 → evalcore-2.4.0}/tests/test_runner.py +0 -0
  74. {evalcore-2.2.0 → evalcore-2.4.0}/tests/test_sweep_pairwise.py +0 -0
  75. {evalcore-2.2.0 → evalcore-2.4.0}/tests/test_unit.py +0 -0
  76. {evalcore-2.2.0 → evalcore-2.4.0}/uv.toml +0 -0
@@ -6,6 +6,58 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.4.0] - 2026-08-09
10
+
11
+ A run without a baseline can now say whether it passed.
12
+
13
+ ### Added
14
+ - `RunResult.checks`, a `models.ThresholdCheck` the runner fills in: the
15
+ suite's **absolute** guardrails (`min`/`max`) measured against this run.
16
+ Most of a suite's rules are absolute and answerable from one run; only the
17
+ win metric and the relative rules need a baseline. Nothing new to call -
18
+ `compare` is for comparing, and a single run was never a comparison.
19
+ - `compare.check_thresholds(scorecard, thresholds)`, which the runner uses.
20
+
21
+ ### Changed
22
+ - **Breaking:** `GuardrailResult.passed` is now `bool | None`. `None` means
23
+ the rule could not be evaluated - a `must_not_increase` /
24
+ `must_not_decrease` with no baseline. Test `passed is False` for a breach;
25
+ `not passed` now also catches the skipped case. The Markdown and HTML
26
+ reporters render it as `skipped` rather than a breach.
27
+ - `store.score_rows` reports `run.checks` on the gate columns when no
28
+ `Comparison` is passed: `gate_verdict` and the per-metric `guardrail` become
29
+ real, while `gate_win` stays `'none'`. That is deliberate - `gate_win` is
30
+ what marks the three `win_*` fields as never computed rather than measured
31
+ at zero, and an ungated run must not claim a comparison happened. A rule
32
+ that could not run reports `guardrail = 'none'` with the reason in
33
+ `guardrail_gap`, not a false `pass`.
34
+ - A `Comparison` supersedes the run's own checks: it evaluates the same rules
35
+ with a baseline available, so it gets the relative ones too.
36
+
37
+ ## [2.3.0] - 2026-08-08
38
+
39
+ A run now describes its own scores.
40
+
41
+ ### Added
42
+ - `RunResult.graders`, a map of grader name to `models.GraderInfo`
43
+ (`category`, `scale`), filled in by the runner from the graders it built.
44
+ A `Score` names the grader that emitted it and nothing else, so a run could
45
+ not previously answer for itself: a `run.json` written by one CI step and
46
+ published by a later one needed the suite file alongside it, and without one
47
+ every grader reported `unknown` and a suite with a judge could not be
48
+ published at all. Run-grain rather than score-grain - one entry per grader,
49
+ not two fields repeated across a couple of hundred rows.
50
+ - `models.GraderInfo`.
51
+ - `register` also records the category on the decorated class as
52
+ `grader_category`, so a grader instance can answer for itself. The runner
53
+ reads it the same way it already collects `judge_version`.
54
+
55
+ ### Changed
56
+ - `store.score_rows` takes the grader category and judge scale from
57
+ `run.graders`. `grader_types` and `judge_scales` still override it, and are
58
+ how a run written before this release - whose map is empty - publishes
59
+ correctly. `grader_lookups` is unchanged and still builds them from a suite.
60
+
9
61
  ## [2.2.0] - 2026-08-08
10
62
 
11
63
  ### Added
@@ -196,7 +248,9 @@ by semantic versioning: a breaking change to either means a 2.0.
196
248
  rating + ranking with judge agreement, Markdown/HTML reporters, JSON +
197
249
  column-store outbox, and content-hash provenance.
198
250
 
199
- [Unreleased]: https://github.com/scottpmiller/evalcore/compare/2.2.0...HEAD
251
+ [Unreleased]: https://github.com/scottpmiller/evalcore/compare/2.4.0...HEAD
252
+ [2.4.0]: https://github.com/scottpmiller/evalcore/compare/2.3.0...2.4.0
253
+ [2.3.0]: https://github.com/scottpmiller/evalcore/compare/2.2.0...2.3.0
200
254
  [2.2.0]: https://github.com/scottpmiller/evalcore/compare/2.1.0...2.2.0
201
255
  [2.1.0]: https://github.com/scottpmiller/evalcore/compare/2.0.0...2.1.0
202
256
  [2.0.0]: https://github.com/scottpmiller/evalcore/compare/1.0.0...2.0.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evalcore
3
- Version: 2.2.0
3
+ Version: 2.4.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
@@ -513,19 +513,12 @@ print(report.render_comparison(result))
513
513
  store.write_scorecard('candidate.scorecard.json', candidate.scorecard)
514
514
  store.write_comparison('comparison.json', result)
515
515
 
516
- # The rows. grader_lookups supplies the grader category and judge scale a
517
- # Score doesn't carry; the baseline half exports without the comparison,
518
- # since it was not itself gated.
519
- types, scales = store.grader_lookups(suite.graders)
516
+ # The rows. A run carries what its graders are, so nothing else is needed.
517
+ # The baseline half exports without the comparison, since it was not itself
518
+ # gated.
520
519
  exporter = store.JsonlOutboxExporter('outbox.jsonl')
521
- exporter.export_scores(baseline, grader_types=types, judge_scales=scales)
522
- exporter.export_scores(
523
- candidate,
524
- result,
525
- baseline_run_id=baseline.run_id,
526
- grader_types=types,
527
- judge_scales=scales,
528
- )
520
+ exporter.export_scores(baseline)
521
+ exporter.export_scores(candidate, result, baseline_run_id=baseline.run_id)
529
522
 
530
523
  raise SystemExit(0 if result.verdict != 'fail' else 1)
531
524
  ```
@@ -811,9 +804,17 @@ constructor line, so an offline run and a live one share a code path:
811
804
  ```python
812
805
  exporter = store.JsonlOutboxExporter(path) # offline
813
806
  exporter = KafkaOutboxExporter(...) # live, from another package
814
- exporter.export_scores(run, comparison, grader_types=..., judge_scales=...)
807
+ exporter.export_scores(run, comparison)
815
808
  ```
816
809
 
810
+ A `RunResult` carries `graders`, a map of grader name to its category and
811
+ judge scale, so it describes its own scores and an exporter needs nothing but
812
+ the run. That matters most for an artifact: a `run.json` written by one CI
813
+ step and published by a later one used to need the suite file alongside it,
814
+ and without one every grader reported `unknown`. `grader_types` and
815
+ `judge_scales` still override the map, which is how a run written before
816
+ 2.3.0 publishes correctly - `grader_lookups(suite.graders)` builds them.
817
+
817
818
  ---
818
819
 
819
820
  ## The two extension seams (recap)
@@ -481,19 +481,12 @@ print(report.render_comparison(result))
481
481
  store.write_scorecard('candidate.scorecard.json', candidate.scorecard)
482
482
  store.write_comparison('comparison.json', result)
483
483
 
484
- # The rows. grader_lookups supplies the grader category and judge scale a
485
- # Score doesn't carry; the baseline half exports without the comparison,
486
- # since it was not itself gated.
487
- types, scales = store.grader_lookups(suite.graders)
484
+ # The rows. A run carries what its graders are, so nothing else is needed.
485
+ # The baseline half exports without the comparison, since it was not itself
486
+ # gated.
488
487
  exporter = store.JsonlOutboxExporter('outbox.jsonl')
489
- exporter.export_scores(baseline, grader_types=types, judge_scales=scales)
490
- exporter.export_scores(
491
- candidate,
492
- result,
493
- baseline_run_id=baseline.run_id,
494
- grader_types=types,
495
- judge_scales=scales,
496
- )
488
+ exporter.export_scores(baseline)
489
+ exporter.export_scores(candidate, result, baseline_run_id=baseline.run_id)
497
490
 
498
491
  raise SystemExit(0 if result.verdict != 'fail' else 1)
499
492
  ```
@@ -779,9 +772,17 @@ constructor line, so an offline run and a live one share a code path:
779
772
  ```python
780
773
  exporter = store.JsonlOutboxExporter(path) # offline
781
774
  exporter = KafkaOutboxExporter(...) # live, from another package
782
- exporter.export_scores(run, comparison, grader_types=..., judge_scales=...)
775
+ exporter.export_scores(run, comparison)
783
776
  ```
784
777
 
778
+ A `RunResult` carries `graders`, a map of grader name to its category and
779
+ judge scale, so it describes its own scores and an exporter needs nothing but
780
+ the run. That matters most for an artifact: a `run.json` written by one CI
781
+ step and published by a later one used to need the suite file alongside it,
782
+ and without one every grader reported `unknown`. `grader_types` and
783
+ `judge_scales` still override the map, which is how a run written before
784
+ 2.3.0 publishes correctly - `grader_lookups(suite.graders)` builds them.
785
+
785
786
  ---
786
787
 
787
788
  ## The two extension seams (recap)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "evalcore"
3
- version = "2.2.0"
3
+ version = "2.4.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"
@@ -23,11 +23,27 @@ def _metric(card: models.Scorecard, name: str) -> float | None:
23
23
 
24
24
 
25
25
  def _check_guardrail(
26
- rule: dict, baseline: models.Scorecard, candidate: models.Scorecard
26
+ rule: dict, baseline: models.Scorecard | None, candidate: models.Scorecard
27
27
  ) -> models.GuardrailResult:
28
+ """Evaluate one guardrail rule.
29
+
30
+ ``baseline`` is ``None`` when only one run exists. The absolute parts of
31
+ a rule (``min``/``max``) still evaluate; the relative parts
32
+ (``must_not_increase``/``must_not_decrease``) cannot, and are reported
33
+ rather than silently passed - a rule with nothing left to check comes
34
+ back ``passed=None``.
35
+ """
28
36
  metric = rule['metric']
29
37
  cand = _metric(candidate, metric)
30
- base = _metric(baseline, metric)
38
+ base = _metric(baseline, metric) if baseline is not None else None
39
+ relative = [
40
+ name
41
+ for name in ('must_not_increase', 'must_not_decrease')
42
+ if rule.get(name)
43
+ ]
44
+ # A relative rule with no baseline is unevaluable, not satisfied.
45
+ skipped = relative if baseline is None else []
46
+ absolute = 'max' in rule or 'min' in rule
31
47
  if cand is None:
32
48
  return models.GuardrailResult(
33
49
  metric=metric, passed=False, detail='metric absent on candidate'
@@ -51,12 +67,54 @@ def _check_guardrail(
51
67
  ):
52
68
  problems.append(f'decreased {base:.4f} -> {cand:.4f}')
53
69
 
70
+ note = (
71
+ f'{", ".join(skipped)} not evaluated: needs a baseline'
72
+ if skipped
73
+ else ''
74
+ )
54
75
  if problems:
76
+ detail = '; '.join([*problems, note] if note else problems)
55
77
  return models.GuardrailResult(
56
- metric=metric, passed=False, detail='; '.join(problems)
78
+ metric=metric, passed=False, detail=detail
57
79
  )
58
- return models.GuardrailResult(
59
- metric=metric, passed=True, detail=f'{cand:.4f} ok'
80
+ if skipped and not absolute:
81
+ # Nothing was checked, so this is neither a pass nor a breach.
82
+ return models.GuardrailResult(metric=metric, passed=None, detail=note)
83
+ detail = f'{cand:.4f} ok' + (f'; {note}' if note else '')
84
+ return models.GuardrailResult(metric=metric, passed=True, detail=detail)
85
+
86
+
87
+ def check_thresholds(
88
+ scorecard: models.Scorecard, thresholds: dict | None = None
89
+ ) -> models.ThresholdCheck:
90
+ """Measure one run against its suite's absolute thresholds.
91
+
92
+ A gate needs two runs, but a suite's guardrails are mostly absolute - a
93
+ ceiling on the error rate, a floor on a format check - and those are
94
+ answerable from a single run. The runner calls this so a run carries its
95
+ own verdict; a caller with one run should not have to reach for
96
+ ``compare``, which is for comparing.
97
+
98
+ The win metric is deliberately not evaluated: it is a comparison by
99
+ definition, and reporting it here would claim a baseline that does not
100
+ exist. Relative guardrails come back ``passed=None`` for the same reason.
101
+
102
+ Args:
103
+ scorecard: The run to measure.
104
+ thresholds: The suite's ``thresholds`` block.
105
+
106
+ Returns:
107
+ The verdict and one result per configured guardrail. ``verdict`` is
108
+ ``'none'`` when the suite declares no guardrails.
109
+
110
+ """
111
+ rules = (thresholds or {}).get('guardrails', [])
112
+ if not rules:
113
+ return models.ThresholdCheck()
114
+ guardrails = [_check_guardrail(rule, None, scorecard) for rule in rules]
115
+ breached = [g for g in guardrails if g.passed is False]
116
+ return models.ThresholdCheck(
117
+ verdict='fail' if breached else 'pass', guardrails=guardrails
60
118
  )
61
119
 
62
120
 
@@ -105,7 +163,7 @@ def compare(
105
163
  ]
106
164
  win_metric, win = _evaluate_win(thresholds, baseline, candidate)
107
165
 
108
- breached = [g for g in guardrails if not g.passed]
166
+ breached = [g for g in guardrails if g.passed is False]
109
167
  on_regression = thresholds.get('on_regression', 'warn')
110
168
  if breached:
111
169
  verdict = 'fail'
@@ -94,6 +94,10 @@ def register(
94
94
  raise ConfigError(f'grader type {type_name!r} already registered')
95
95
  _REGISTRY[type_name] = cls
96
96
  _CATEGORIES[type_name] = GraderType(category)
97
+ # Also on the class, so an instance can answer for itself. The runner
98
+ # reads it off the graders it built to make the run self-describing,
99
+ # the same way it collects `judge_version`.
100
+ cls.grader_category = GraderType(category)
97
101
  return cls
98
102
 
99
103
  return _decorate
@@ -162,6 +162,60 @@ class Scorecard(pydantic.BaseModel):
162
162
  metrics: dict[str, MetricValue] = pydantic.Field(default_factory=dict)
163
163
 
164
164
 
165
+ class GraderInfo(pydantic.BaseModel):
166
+ """What a grader is, as opposed to what it scored.
167
+
168
+ A ``Score`` names the grader that emitted it and nothing else, so a run
169
+ that did not carry this could not describe its own scores: publishing it
170
+ needed the suite file alongside it, and a ``run.json`` handed to a later
171
+ CI step without one reported every grader as ``unknown``.
172
+
173
+ Run-grain, not score-grain: one entry per grader rather than the same two
174
+ fields repeated on a couple of hundred rows.
175
+ """
176
+
177
+ #: The registered category, a ``graders.GraderType`` value. A plain `str`
178
+ #: here because `graders.base` imports this module, so this module cannot
179
+ #: import it back; ``GraderType`` is a ``StrEnum`` and compares equal.
180
+ category: str = 'unknown'
181
+ #: The scale raw judge points sit on, 0 for a grader that is not a judge.
182
+ #: Normalization is points / scale, so the points are unreadable without
183
+ #: it.
184
+ scale: int = 0
185
+
186
+
187
+ class GuardrailResult(pydantic.BaseModel):
188
+ """Outcome of one guardrail check against the candidate.
189
+
190
+ ``passed`` is tri-state. ``None`` means the rule could not be evaluated -
191
+ a ``must_not_increase`` / ``must_not_decrease`` rule with no baseline to
192
+ compare against. That is neither a pass nor a breach, and conflating it
193
+ with either claims a check that never ran, so callers test
194
+ ``passed is False`` for a breach rather than ``not passed``.
195
+ """
196
+
197
+ metric: str
198
+ passed: bool | None
199
+ detail: str
200
+
201
+
202
+ class ThresholdCheck(pydantic.BaseModel):
203
+ """A run measured against its suite's absolute thresholds.
204
+
205
+ A gate needs two runs, but most of a suite's guardrails are absolute -
206
+ a ceiling on the error rate, a floor on a format check - and those are
207
+ answerable from one run. This is that answer, computed by the runner so
208
+ a single run can say whether it passed without a baseline and without
209
+ the caller re-deriving anything.
210
+
211
+ ``verdict`` is ``'none'`` when the suite declares no thresholds. The win
212
+ metric is never evaluated here: it is a comparison by definition.
213
+ """
214
+
215
+ verdict: typing.Literal['none', 'pass', 'warn', 'fail'] = 'none'
216
+ guardrails: list[GuardrailResult] = pydantic.Field(default_factory=list)
217
+
218
+
165
219
  class RunResult(pydantic.BaseModel):
166
220
  """Everything one run produced: the scorecard plus per-sample results.
167
221
 
@@ -174,12 +228,19 @@ class RunResult(pydantic.BaseModel):
174
228
  over the whole run. The scorecard keeps their values but not which grader
175
229
  emitted them or what it reported, so they are retained here too - a
176
230
  results-store row needs the grader attribution.
231
+
232
+ ``graders`` maps grader name to what that grader is, so the run is
233
+ self-describing: an exporter needs nothing but the run. Empty on runs
234
+ written before 2.3.0, which is why exporters still accept the lookups as
235
+ a fallback.
177
236
  """
178
237
 
179
238
  run_id: str
180
239
  scorecard: Scorecard
181
240
  results: list[CaseResult] = pydantic.Field(default_factory=list)
182
241
  aggregate_scores: list[Score] = pydantic.Field(default_factory=list)
242
+ graders: dict[str, GraderInfo] = pydantic.Field(default_factory=dict)
243
+ checks: ThresholdCheck = pydantic.Field(default_factory=ThresholdCheck)
183
244
 
184
245
 
185
246
  class Rating(pydantic.BaseModel):
@@ -393,14 +454,6 @@ class MetricDelta(pydantic.BaseModel):
393
454
  delta: float | None
394
455
 
395
456
 
396
- class GuardrailResult(pydantic.BaseModel):
397
- """Outcome of one guardrail check against the candidate."""
398
-
399
- metric: str
400
- passed: bool
401
- detail: str
402
-
403
-
404
457
  class Comparison(pydantic.BaseModel):
405
458
  """Candidate-vs-baseline comparison and gate verdict."""
406
459
 
@@ -59,7 +59,15 @@ def render_comparison(comparison: models.Comparison) -> str:
59
59
  if comparison.guardrails:
60
60
  lines += ['', '**Guardrails**', '']
61
61
  for guard in comparison.guardrails:
62
- mark = 'ok' if guard.passed else 'BREACH'
62
+ # Tri-state: None is a rule that could not run (a relative
63
+ # guardrail with no baseline), which is not a breach.
64
+ mark = (
65
+ 'skipped'
66
+ if guard.passed is None
67
+ else 'ok'
68
+ if guard.passed
69
+ else 'BREACH'
70
+ )
63
71
  lines.append(f'- [{mark}] `{guard.metric}` - {guard.detail}')
64
72
  return '\n'.join(lines)
65
73
 
@@ -158,6 +158,7 @@ _CSS = (
158
158
  '.guards li{padding:4px 0;font-size:14px}'
159
159
  '.guards li.ok::before{content:"\\2713 ";color:#1f8f5f}'
160
160
  '.guards li.breach::before{content:"\\2717 ";color:#c0392b}'
161
+ '.guards li.skipped::before{content:"\\2013 ";color:#8a8a8a}'
161
162
  'td.fail{color:#c0392b;font-weight:600}'
162
163
  'details{margin:6px 0}summary{cursor:pointer;color:#68707a;font-size:13px}'
163
164
  'pre{background:#f0f2f4;border-radius:6px;padding:10px;overflow:auto;'
@@ -196,6 +197,17 @@ def _esc(value) -> str:
196
197
  return _html.escape(str(value))
197
198
 
198
199
 
200
+ def _guard_class(guard) -> str:
201
+ """CSS class for one guardrail result.
202
+
203
+ ``passed`` is tri-state: ``None`` is a rule that could not run - a
204
+ relative guardrail with no baseline - which must not render as a breach.
205
+ """
206
+ if guard.passed is None:
207
+ return 'skipped'
208
+ return 'ok' if guard.passed else 'breach'
209
+
210
+
199
211
  @base.register('html')
200
212
  class HtmlReporter:
201
213
  """Render scorecards and comparisons as self-contained HTML."""
@@ -247,7 +259,7 @@ class HtmlReporter:
247
259
  guards = ''
248
260
  if comparison.guardrails:
249
261
  items = ''.join(
250
- f'<li class="{"ok" if g.passed else "breach"}">'
262
+ f'<li class="{_guard_class(g)}">'
251
263
  f'<code>{_esc(g.metric)}</code> &mdash; {_esc(g.detail)}</li>'
252
264
  for g in comparison.guardrails
253
265
  )
@@ -18,7 +18,7 @@ import statistics
18
18
  import time
19
19
  import uuid
20
20
 
21
- from evalcore import loader, models, store
21
+ from evalcore import compare, loader, models, store
22
22
  from evalcore import retry as retry_mod
23
23
  from evalcore.adapters import base as adapters_base
24
24
  from evalcore.graders import base as graders_base
@@ -84,6 +84,35 @@ def _uuid7() -> uuid.UUID:
84
84
  )
85
85
 
86
86
 
87
+ def _grader_info(*grader_groups) -> dict[str, models.GraderInfo]:
88
+ """What each grader is, so the run can describe its own scores.
89
+
90
+ Read off the grader instances rather than the suite config, so a run is
91
+ publishable from the artifact alone - no suite file alongside it. Both
92
+ attributes are duck-typed the way ``judge_version`` is: ``register`` puts
93
+ ``grader_category`` on the class, and only a judge has a ``scale``.
94
+
95
+ Args:
96
+ grader_groups: The per-case and aggregate grader lists.
97
+
98
+ Returns:
99
+ Grader name to its category and judge scale.
100
+
101
+ """
102
+ return {
103
+ grader.name: models.GraderInfo(
104
+ category=str(
105
+ getattr(
106
+ grader, 'grader_category', graders_base.GraderType.UNKNOWN
107
+ )
108
+ ),
109
+ scale=int(getattr(grader, 'scale', 0) or 0),
110
+ )
111
+ for group in grader_groups
112
+ for grader in group
113
+ }
114
+
115
+
87
116
  def _aggregate_metrics(
88
117
  results: list[models.CaseResult], agg_scores: list[models.Score]
89
118
  ) -> dict[str, models.MetricValue]:
@@ -321,6 +350,11 @@ async def run_suite(
321
350
  scorecard=scorecard,
322
351
  results=results,
323
352
  aggregate_scores=agg_scores,
353
+ graders=_grader_info(per_case_graders, aggregate_graders),
354
+ # So a single run can say whether it passed. Absolute rules only -
355
+ # the win metric and the relative guardrails need a baseline, and a
356
+ # gate's Comparison supersedes this when there is one.
357
+ checks=compare.check_thresholds(scorecard, suite.thresholds),
324
358
  )
325
359
 
326
360
 
@@ -287,7 +287,9 @@ def _run_key(scorecard: models.Scorecard) -> dict:
287
287
 
288
288
 
289
289
  def _gate(
290
- comparison: models.Comparison | None, baseline_run_id: str | None
290
+ comparison: models.Comparison | None,
291
+ baseline_run_id: str | None,
292
+ checks: models.ThresholdCheck | None = None,
291
293
  ) -> dict:
292
294
  """The run-grain gate columns, repeated on every row of a gated run.
293
295
 
@@ -297,15 +299,26 @@ def _gate(
297
299
  0 delta genuinely means no change.
298
300
  """
299
301
  if comparison is None:
302
+ # Without a Comparison the run may still have been measured against
303
+ # its suite's absolute thresholds. That is a real verdict, so it is
304
+ # reported - but `gate_win` stays 'none', which is what marks the
305
+ # three win_* fields as never computed rather than measured at zero.
306
+ checks = checks or models.ThresholdCheck()
307
+ breached = [g for g in checks.guardrails if g.passed is False]
300
308
  return {
301
- 'gate_verdict': 'none',
309
+ 'gate_verdict': checks.verdict,
302
310
  'gate_win': 'none',
303
311
  'baseline_run_id': _NO_UUID,
304
312
  'baseline_variant': '',
305
313
  'win_baseline': None,
306
314
  'win_candidate': None,
307
315
  'win_delta': None,
308
- 'gate_summary': '',
316
+ 'gate_summary': (
317
+ 'guardrail breach: '
318
+ + '; '.join(f'{g.metric} ({g.detail})' for g in breached)
319
+ if breached
320
+ else ''
321
+ ),
309
322
  }
310
323
  delta = next(
311
324
  (d for d in comparison.deltas if d.metric == comparison.win_metric),
@@ -332,8 +345,14 @@ def _metric_gate(
332
345
  rail = rails.get(metric)
333
346
  return {
334
347
  'win': bool(comparison and comparison.win_metric == metric),
348
+ # rail.passed is tri-state: None means the rule needed a baseline
349
+ # and could not run, which reads as 'none' rather than a false pass.
335
350
  'guardrail': (
336
- 'none' if rail is None else 'pass' if rail.passed else 'fail'
351
+ 'none'
352
+ if rail is None or rail.passed is None
353
+ else 'pass'
354
+ if rail.passed
355
+ else 'fail'
337
356
  ),
338
357
  'guardrail_gap': rail.detail if rail else '',
339
358
  }
@@ -402,18 +421,33 @@ def score_rows(
402
421
 
403
422
  ``comparison`` stamps the run-grain gate columns and the per-metric ``win``
404
423
  and ``guardrail`` fields; without it the run reads as ungated.
405
- ``grader_types`` and ``judge_scales`` are grader-name lookups the caller
406
- builds from the suite, since a ``Score`` carries neither.
424
+ The grader category and judge scale come from ``run.graders``, which the
425
+ runner fills in - a ``Score`` carries neither, and a run that could not
426
+ answer for itself was not publishable from its own artifact.
427
+ ``grader_types`` and ``judge_scales`` override it, and are how a run
428
+ written before 2.3.0 (whose map is empty) still publishes correctly;
429
+ :func:`grader_lookups` builds them from the suite.
407
430
  """
408
431
  key = _run_key(run.scorecard)
409
- gate = _gate(comparison, baseline_run_id)
410
- rails = (
411
- {rail.metric: rail for rail in comparison.guardrails}
412
- if comparison
413
- else {}
414
- )
415
- types = grader_types or {}
416
- scales = judge_scales or {}
432
+ gate = _gate(comparison, baseline_run_id, run.checks)
433
+ # A Comparison supersedes the run's own checks: it evaluates the same
434
+ # rules with a baseline available, so it gets the relative ones too.
435
+ rails = {
436
+ rail.metric: rail
437
+ for rail in (
438
+ comparison.guardrails if comparison else run.checks.guardrails
439
+ )
440
+ }
441
+ # The run describes its own graders as of 2.3.0. The lookups remain for
442
+ # runs written before that, which carry an empty map - a `run.json` on
443
+ # disk outlives the release that wrote it. An explicit lookup still wins,
444
+ # so a caller can correct a run it did not produce.
445
+ types = {name: info.category for name, info in run.graders.items()}
446
+ scales = {
447
+ name: info.scale for name, info in run.graders.items() if info.scale
448
+ }
449
+ types.update(grader_types or {})
450
+ scales.update(judge_scales or {})
417
451
  rows: list[dict] = []
418
452
  scored: set[str] = set()
419
453
 
@@ -3,9 +3,10 @@
3
3
  import json
4
4
  import pathlib
5
5
  import tempfile
6
+ import typing
6
7
  import unittest
7
8
 
8
- from evalcore import errors, graders, models, store
9
+ from evalcore import compare, errors, graders, models, store
9
10
 
10
11
 
11
12
  def _run(with_failure: bool = False):
@@ -338,6 +339,133 @@ class GraderLookupTests(unittest.TestCase):
338
339
  name = 'non_empty'
339
340
 
340
341
 
342
+ class SelfDescribingRunTests(unittest.TestCase):
343
+ """A run carries what its scores mean, so an artifact is publishable."""
344
+
345
+ @staticmethod
346
+ def _run_with_graders():
347
+ run = _run()
348
+ run.graders = {
349
+ 'det': models.GraderInfo(category='heuristic'),
350
+ 'j': models.GraderInfo(category='llm_as_judge', scale=7),
351
+ 'cls': models.GraderInfo(category='statistical'),
352
+ }
353
+ return run
354
+
355
+ def test_the_run_supplies_the_lookups(self):
356
+ rows = store.score_rows(self._run_with_graders())
357
+ types = {row['grader']: row['grader_type'] for row in rows}
358
+ self.assertEqual(types['det'], 'heuristic')
359
+ self.assertEqual(types['j'], 'llm_as_judge')
360
+
361
+ def test_a_judge_scale_comes_off_the_run(self):
362
+ rows = store.score_rows(self._run_with_graders())
363
+ judged = {r['judge_scale'] for r in rows if r['grader'] == 'j'}
364
+ plain = {r['judge_scale'] for r in rows if r['grader'] == 'det'}
365
+ self.assertEqual(judged, {7})
366
+ self.assertEqual(plain, {0})
367
+
368
+ def test_an_explicit_lookup_still_wins(self):
369
+ """So a caller can correct a run it did not produce."""
370
+ rows = store.score_rows(
371
+ self._run_with_graders(), grader_types={'det': 'statistical'}
372
+ )
373
+ types = {row['grader']: row['grader_type'] for row in rows}
374
+ self.assertEqual(types['det'], 'statistical')
375
+
376
+ def test_a_run_from_before_the_field_still_publishes(self):
377
+ """The map is empty on runs written by older versions, so the
378
+ lookups have to stay."""
379
+ run = _run()
380
+ self.assertEqual(run.graders, {})
381
+ rows = store.score_rows(run, grader_types={'det': 'heuristic'})
382
+ types = {row['grader']: row['grader_type'] for row in rows}
383
+ self.assertEqual(types['det'], 'heuristic')
384
+
385
+ def test_it_survives_the_json_round_trip(self):
386
+ """The whole point: no suite file alongside the artifact."""
387
+ with tempfile.TemporaryDirectory() as tmp:
388
+ path = pathlib.Path(tmp) / 'run.json'
389
+ store.write_run(path, self._run_with_graders())
390
+ reloaded = store.read_run(path)
391
+ self.assertEqual(reloaded.graders['j'].scale, 7)
392
+ rows = store.score_rows(reloaded)
393
+ types = {row['grader']: row['grader_type'] for row in rows}
394
+ self.assertEqual(types['det'], 'heuristic')
395
+ self.assertEqual(types['j'], 'llm_as_judge')
396
+
397
+
398
+ class UngatedVerdictTests(unittest.TestCase):
399
+ """A run with no baseline still reports its absolute thresholds."""
400
+
401
+ THRESHOLDS: typing.ClassVar = {
402
+ 'guardrails': [
403
+ # absolute, breaches: f1 is 0.9
404
+ {'metric': 'f1', 'min': 1.0},
405
+ # absolute, passes
406
+ {'metric': 'passed_check', 'max': 2.0},
407
+ # relative only: unevaluable without a baseline
408
+ {'metric': 'quality.overall', 'must_not_decrease': True},
409
+ ]
410
+ }
411
+
412
+ def _rows(self):
413
+ run = _run()
414
+ # A real value, so the relative rule is unevaluable rather than
415
+ # absent - an absent metric is a breach and outranks the skip.
416
+ run.scorecard.metrics['quality.overall'] = models.MetricValue(
417
+ metric='quality.overall', value=0.8, kind='mean', n=2
418
+ )
419
+ run.checks = compare.check_thresholds(run.scorecard, self.THRESHOLDS)
420
+ return run, store.score_rows(run)
421
+
422
+ def test_the_verdict_is_real(self):
423
+ run, rows = self._rows()
424
+ self.assertEqual(run.checks.verdict, 'fail') # f1 0.9 < min 1.0
425
+ self.assertEqual(rows[0]['gate_verdict'], 'fail')
426
+
427
+ def test_the_win_columns_stay_uncomputed(self):
428
+ """gate_win is what marks the three zeros as never measured."""
429
+ _, rows = self._rows()
430
+ self.assertEqual(rows[0]['gate_win'], 'none')
431
+ self.assertIsNone(rows[0]['win_baseline'])
432
+ self.assertIsNone(rows[0]['win_delta'])
433
+ self.assertEqual(rows[0]['baseline_run_id'], store._NO_UUID)
434
+
435
+ def test_an_absolute_breach_lands_on_its_metric(self):
436
+ _, rows = self._rows()
437
+ rails = {r['metric']: r['guardrail'] for r in rows}
438
+ self.assertEqual(rails['f1'], 'fail')
439
+
440
+ def test_a_relative_rule_is_not_a_pass(self):
441
+ """It could not run, so it reads 'none' with the reason in the gap."""
442
+ _, rows = self._rows()
443
+ row = next(r for r in rows if r['metric'] == 'quality.overall')
444
+ self.assertEqual(row['guardrail'], 'none')
445
+ self.assertIn('needs a baseline', row['guardrail_gap'])
446
+
447
+ def test_a_comparison_supersedes_the_runs_own_checks(self):
448
+ run, _ = self._rows()
449
+ rows = store.score_rows(run, _comparison(), baseline_run_id='B')
450
+ self.assertEqual(rows[0]['gate_verdict'], 'fail')
451
+ self.assertNotEqual(rows[0]['gate_win'], 'none')
452
+ self.assertIsNotNone(rows[0]['win_delta'])
453
+
454
+ def test_an_absent_metric_outranks_the_skip(self):
455
+ """Nothing to measure is a breach, not an unevaluated rule."""
456
+ run = _run()
457
+ check = compare.check_thresholds(
458
+ run.scorecard, {'guardrails': [{'metric': 'nope', 'min': 1.0}]}
459
+ )
460
+ self.assertIs(check.guardrails[0].passed, False)
461
+ self.assertIn('absent', check.guardrails[0].detail)
462
+
463
+ def test_no_thresholds_means_no_verdict(self):
464
+ run = _run()
465
+ self.assertEqual(run.checks.verdict, 'none')
466
+ self.assertEqual(store.score_rows(run)[0]['gate_verdict'], 'none')
467
+
468
+
341
469
  class ScoreExporterProtocolTests(unittest.TestCase):
342
470
  """The seam another package implements to publish to a real store."""
343
471
 
@@ -188,7 +188,7 @@ wheels = [
188
188
 
189
189
  [[package]]
190
190
  name = "evalcore"
191
- version = "2.2.0"
191
+ version = "2.4.0"
192
192
  source = { editable = "." }
193
193
  dependencies = [
194
194
  { name = "pydantic" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes