evalcore 2.3.0__tar.gz → 2.4.1__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.3.0 → evalcore-2.4.1}/CHANGELOG.md +61 -1
  2. {evalcore-2.3.0 → evalcore-2.4.1}/PKG-INFO +1 -1
  3. {evalcore-2.3.0 → evalcore-2.4.1}/pyproject.toml +1 -1
  4. {evalcore-2.3.0 → evalcore-2.4.1}/src/evalcore/compare.py +64 -6
  5. {evalcore-2.3.0 → evalcore-2.4.1}/src/evalcore/graders/judge.py +18 -4
  6. {evalcore-2.3.0 → evalcore-2.4.1}/src/evalcore/models.py +33 -8
  7. {evalcore-2.3.0 → evalcore-2.4.1}/src/evalcore/report.py +9 -1
  8. {evalcore-2.3.0 → evalcore-2.4.1}/src/evalcore/reporters/html.py +13 -1
  9. {evalcore-2.3.0 → evalcore-2.4.1}/src/evalcore/runner.py +5 -1
  10. {evalcore-2.3.0 → evalcore-2.4.1}/src/evalcore/store.py +32 -10
  11. {evalcore-2.3.0 → evalcore-2.4.1}/tests/test_judge.py +33 -2
  12. {evalcore-2.3.0 → evalcore-2.4.1}/tests/test_store.py +73 -1
  13. {evalcore-2.3.0 → evalcore-2.4.1}/uv.lock +1 -1
  14. {evalcore-2.3.0 → evalcore-2.4.1}/.github/workflows/ci.yml +0 -0
  15. {evalcore-2.3.0 → evalcore-2.4.1}/.github/workflows/publish.yml +0 -0
  16. {evalcore-2.3.0 → evalcore-2.4.1}/.gitignore +0 -0
  17. {evalcore-2.3.0 → evalcore-2.4.1}/.pre-commit-config.yaml +0 -0
  18. {evalcore-2.3.0 → evalcore-2.4.1}/LICENSE +0 -0
  19. {evalcore-2.3.0 → evalcore-2.4.1}/README.md +0 -0
  20. {evalcore-2.3.0 → evalcore-2.4.1}/docs/design.md +0 -0
  21. {evalcore-2.3.0 → evalcore-2.4.1}/examples/__init__.py +0 -0
  22. {evalcore-2.3.0 → evalcore-2.4.1}/examples/quickstart/README.md +0 -0
  23. {evalcore-2.3.0 → evalcore-2.4.1}/examples/quickstart/__init__.py +0 -0
  24. {evalcore-2.3.0 → evalcore-2.4.1}/examples/quickstart/adapter.py +0 -0
  25. {evalcore-2.3.0 → evalcore-2.4.1}/examples/quickstart/datasets/support_reply/v1/cases/angry_complaint.yaml +0 -0
  26. {evalcore-2.3.0 → evalcore-2.4.1}/examples/quickstart/datasets/support_reply/v1/cases/billing_question.yaml +0 -0
  27. {evalcore-2.3.0 → evalcore-2.4.1}/examples/quickstart/datasets/support_reply/v1/cases/double_charge.yaml +0 -0
  28. {evalcore-2.3.0 → evalcore-2.4.1}/examples/quickstart/datasets/support_reply/v1/cases/refund_request.yaml +0 -0
  29. {evalcore-2.3.0 → evalcore-2.4.1}/examples/quickstart/fixtures/support_reply_judge.yaml +0 -0
  30. {evalcore-2.3.0 → evalcore-2.4.1}/examples/quickstart/fixtures/support_reply_pairwise.yaml +0 -0
  31. {evalcore-2.3.0 → evalcore-2.4.1}/examples/quickstart/fixtures/support_reply_replay.yaml +0 -0
  32. {evalcore-2.3.0 → evalcore-2.4.1}/examples/quickstart/graders.py +0 -0
  33. {evalcore-2.3.0 → evalcore-2.4.1}/examples/quickstart/run_eval.py +0 -0
  34. {evalcore-2.3.0 → evalcore-2.4.1}/examples/quickstart/suite.yaml +0 -0
  35. {evalcore-2.3.0 → evalcore-2.4.1}/examples/quickstart/tests/__init__.py +0 -0
  36. {evalcore-2.3.0 → evalcore-2.4.1}/examples/quickstart/tests/test_quickstart.py +0 -0
  37. {evalcore-2.3.0 → evalcore-2.4.1}/justfile +0 -0
  38. {evalcore-2.3.0 → evalcore-2.4.1}/pyrightconfig.json +0 -0
  39. {evalcore-2.3.0 → evalcore-2.4.1}/src/evalcore/__init__.py +0 -0
  40. {evalcore-2.3.0 → evalcore-2.4.1}/src/evalcore/adapters/__init__.py +0 -0
  41. {evalcore-2.3.0 → evalcore-2.4.1}/src/evalcore/adapters/base.py +0 -0
  42. {evalcore-2.3.0 → evalcore-2.4.1}/src/evalcore/adapters/env.py +0 -0
  43. {evalcore-2.3.0 → evalcore-2.4.1}/src/evalcore/adapters/http.py +0 -0
  44. {evalcore-2.3.0 → evalcore-2.4.1}/src/evalcore/adapters/replay.py +0 -0
  45. {evalcore-2.3.0 → evalcore-2.4.1}/src/evalcore/cli.py +0 -0
  46. {evalcore-2.3.0 → evalcore-2.4.1}/src/evalcore/errors.py +0 -0
  47. {evalcore-2.3.0 → evalcore-2.4.1}/src/evalcore/graders/__init__.py +0 -0
  48. {evalcore-2.3.0 → evalcore-2.4.1}/src/evalcore/graders/base.py +0 -0
  49. {evalcore-2.3.0 → evalcore-2.4.1}/src/evalcore/graders/classification.py +0 -0
  50. {evalcore-2.3.0 → evalcore-2.4.1}/src/evalcore/graders/deterministic.py +0 -0
  51. {evalcore-2.3.0 → evalcore-2.4.1}/src/evalcore/graders/numeric.py +0 -0
  52. {evalcore-2.3.0 → evalcore-2.4.1}/src/evalcore/loader.py +0 -0
  53. {evalcore-2.3.0 → evalcore-2.4.1}/src/evalcore/pairwise.py +0 -0
  54. {evalcore-2.3.0 → evalcore-2.4.1}/src/evalcore/py.typed +0 -0
  55. {evalcore-2.3.0 → evalcore-2.4.1}/src/evalcore/rating.py +0 -0
  56. {evalcore-2.3.0 → evalcore-2.4.1}/src/evalcore/refs.py +0 -0
  57. {evalcore-2.3.0 → evalcore-2.4.1}/src/evalcore/reporters/__init__.py +0 -0
  58. {evalcore-2.3.0 → evalcore-2.4.1}/src/evalcore/reporters/base.py +0 -0
  59. {evalcore-2.3.0 → evalcore-2.4.1}/src/evalcore/reporters/markdown.py +0 -0
  60. {evalcore-2.3.0 → evalcore-2.4.1}/src/evalcore/retry.py +0 -0
  61. {evalcore-2.3.0 → evalcore-2.4.1}/src/evalcore/sweep.py +0 -0
  62. {evalcore-2.3.0 → evalcore-2.4.1}/tests/__init__.py +0 -0
  63. {evalcore-2.3.0 → evalcore-2.4.1}/tests/test_adapters.py +0 -0
  64. {evalcore-2.3.0 → evalcore-2.4.1}/tests/test_cli.py +0 -0
  65. {evalcore-2.3.0 → evalcore-2.4.1}/tests/test_edge_cases.py +0 -0
  66. {evalcore-2.3.0 → evalcore-2.4.1}/tests/test_judge_extra.py +0 -0
  67. {evalcore-2.3.0 → evalcore-2.4.1}/tests/test_live_clients.py +0 -0
  68. {evalcore-2.3.0 → evalcore-2.4.1}/tests/test_pairwise_extra.py +0 -0
  69. {evalcore-2.3.0 → evalcore-2.4.1}/tests/test_rating.py +0 -0
  70. {evalcore-2.3.0 → evalcore-2.4.1}/tests/test_rating_server.py +0 -0
  71. {evalcore-2.3.0 → evalcore-2.4.1}/tests/test_reporters.py +0 -0
  72. {evalcore-2.3.0 → evalcore-2.4.1}/tests/test_retry.py +0 -0
  73. {evalcore-2.3.0 → evalcore-2.4.1}/tests/test_runner.py +0 -0
  74. {evalcore-2.3.0 → evalcore-2.4.1}/tests/test_sweep_pairwise.py +0 -0
  75. {evalcore-2.3.0 → evalcore-2.4.1}/tests/test_unit.py +0 -0
  76. {evalcore-2.3.0 → evalcore-2.4.1}/uv.toml +0 -0
@@ -6,6 +6,64 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.4.1] - 2026-08-09
10
+
11
+ The judge's identity is now in its provenance pin.
12
+
13
+ Shipped as a patch. Both changes below alter a published value, so the 1.0.0
14
+ policy would call this a minor at least; it goes out as 2.4.1 as a deliberate
15
+ exception, because the old pin was answering a provenance question wrongly
16
+ and the sooner it stops the fewer runs are affected.
17
+
18
+ ### Fixed
19
+ - `Scorecard.judge_version` includes the judge's model:
20
+ `anthropic:claude-sonnet-4-6@v1` rather than `judge@v1`. It was
21
+ `key@judge_version`, so swapping the judge's model while leaving the
22
+ declared `judge_version` alone produced a byte-identical pin - and any
23
+ provenance check reading that field passed a comparison against a baseline
24
+ scored by a different model. The model is the thing most likely to change
25
+ and the thing a declared version is most likely to miss.
26
+
27
+ ### Changed
28
+ - A single judge's key defaults to its provider (`anthropic`) instead of the
29
+ literal `judge`. A panel already defaulted to `key or provider`, so the
30
+ one-judge case was the odd one out, and `judge` named nothing the `grader`
31
+ column did not already say. It appears in the `judges.name` column.
32
+
33
+ **Upgrading:** every suite with a judge re-baselines once, because
34
+ `judge_version` is part of what identifies a comparable run - which is the
35
+ intended behaviour, just paid all at once. Queries filtering
36
+ `judges.name = 'judge'` need the provider instead. Set `key:` on a `judges:`
37
+ entry to pin a name of your own.
38
+
39
+ ## [2.4.0] - 2026-08-09
40
+
41
+ A run without a baseline can now say whether it passed.
42
+
43
+ ### Added
44
+ - `RunResult.checks`, a `models.ThresholdCheck` the runner fills in: the
45
+ suite's **absolute** guardrails (`min`/`max`) measured against this run.
46
+ Most of a suite's rules are absolute and answerable from one run; only the
47
+ win metric and the relative rules need a baseline. Nothing new to call -
48
+ `compare` is for comparing, and a single run was never a comparison.
49
+ - `compare.check_thresholds(scorecard, thresholds)`, which the runner uses.
50
+
51
+ ### Changed
52
+ - **Breaking:** `GuardrailResult.passed` is now `bool | None`. `None` means
53
+ the rule could not be evaluated - a `must_not_increase` /
54
+ `must_not_decrease` with no baseline. Test `passed is False` for a breach;
55
+ `not passed` now also catches the skipped case. The Markdown and HTML
56
+ reporters render it as `skipped` rather than a breach.
57
+ - `store.score_rows` reports `run.checks` on the gate columns when no
58
+ `Comparison` is passed: `gate_verdict` and the per-metric `guardrail` become
59
+ real, while `gate_win` stays `'none'`. That is deliberate - `gate_win` is
60
+ what marks the three `win_*` fields as never computed rather than measured
61
+ at zero, and an ungated run must not claim a comparison happened. A rule
62
+ that could not run reports `guardrail = 'none'` with the reason in
63
+ `guardrail_gap`, not a false `pass`.
64
+ - A `Comparison` supersedes the run's own checks: it evaluates the same rules
65
+ with a baseline available, so it gets the relative ones too.
66
+
9
67
  ## [2.3.0] - 2026-08-08
10
68
 
11
69
  A run now describes its own scores.
@@ -220,7 +278,9 @@ by semantic versioning: a breaking change to either means a 2.0.
220
278
  rating + ranking with judge agreement, Markdown/HTML reporters, JSON +
221
279
  column-store outbox, and content-hash provenance.
222
280
 
223
- [Unreleased]: https://github.com/scottpmiller/evalcore/compare/2.3.0...HEAD
281
+ [Unreleased]: https://github.com/scottpmiller/evalcore/compare/2.4.1...HEAD
282
+ [2.4.1]: https://github.com/scottpmiller/evalcore/compare/2.4.0...2.4.1
283
+ [2.4.0]: https://github.com/scottpmiller/evalcore/compare/2.3.0...2.4.0
224
284
  [2.3.0]: https://github.com/scottpmiller/evalcore/compare/2.2.0...2.3.0
225
285
  [2.2.0]: https://github.com/scottpmiller/evalcore/compare/2.1.0...2.2.0
226
286
  [2.1.0]: https://github.com/scottpmiller/evalcore/compare/2.0.0...2.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evalcore
3
- Version: 2.3.0
3
+ Version: 2.4.1
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "evalcore"
3
- version = "2.3.0"
3
+ version = "2.4.1"
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'
@@ -358,7 +358,11 @@ class RubricJudge:
358
358
  else:
359
359
  self.judges = [
360
360
  {
361
- 'key': 'judge',
361
+ # The provider, not a literal 'judge'. A single judge is
362
+ # a panel of one, so it names itself the same way a panel
363
+ # member does; 'judge' carried no information and made
364
+ # the one-judge case the odd one out.
365
+ 'key': 'anthropic',
362
366
  'provider': 'anthropic',
363
367
  'model': _env_expand(model),
364
368
  'api_key_env': None,
@@ -394,17 +398,27 @@ class RubricJudge:
394
398
 
395
399
  @property
396
400
  def judge_version(self) -> str:
397
- """Provenance pin: ``key@version`` per configured judge (a panel
398
- joins them, comma-separated).
401
+ """Provenance pin: ``key:model@version`` per configured judge (a
402
+ panel joins them, comma-separated).
399
403
 
400
404
  The runner reads this onto ``Scorecard.judge_version`` so a judge
401
405
  model / prompt / scale change surfaces as a re-baseline event rather
402
406
  than hiding in each score's ``detail``. Uses the configured judges,
403
407
  not the mode-filtered active set, so the pin is stable across
404
408
  environments.
409
+
410
+ The model is in the pin because it is the thing most likely to
411
+ change and the thing a declared version is most likely to miss.
412
+ Without it, swapping the judge's model while leaving
413
+ ``judge_version`` alone produced a byte-identical pin - so a
414
+ comparison against a baseline scored by a different model passed
415
+ every provenance check that reads this field.
405
416
  """
406
417
  return ','.join(
407
- f'{j["key"]}@{j["judge_version"]}' for j in self.judges
418
+ f'{j["key"]}:{j["model"]}@{j["judge_version"]}'
419
+ if j['model']
420
+ else f'{j["key"]}@{j["judge_version"]}'
421
+ for j in self.judges
408
422
  )
409
423
 
410
424
  def set_mode(self, mode: str) -> None:
@@ -184,6 +184,38 @@ class GraderInfo(pydantic.BaseModel):
184
184
  scale: int = 0
185
185
 
186
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
+
187
219
  class RunResult(pydantic.BaseModel):
188
220
  """Everything one run produced: the scorecard plus per-sample results.
189
221
 
@@ -208,6 +240,7 @@ class RunResult(pydantic.BaseModel):
208
240
  results: list[CaseResult] = pydantic.Field(default_factory=list)
209
241
  aggregate_scores: list[Score] = pydantic.Field(default_factory=list)
210
242
  graders: dict[str, GraderInfo] = pydantic.Field(default_factory=dict)
243
+ checks: ThresholdCheck = pydantic.Field(default_factory=ThresholdCheck)
211
244
 
212
245
 
213
246
  class Rating(pydantic.BaseModel):
@@ -421,14 +454,6 @@ class MetricDelta(pydantic.BaseModel):
421
454
  delta: float | None
422
455
 
423
456
 
424
- class GuardrailResult(pydantic.BaseModel):
425
- """Outcome of one guardrail check against the candidate."""
426
-
427
- metric: str
428
- passed: bool
429
- detail: str
430
-
431
-
432
457
  class Comparison(pydantic.BaseModel):
433
458
  """Candidate-vs-baseline comparison and gate verdict."""
434
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
@@ -351,6 +351,10 @@ async def run_suite(
351
351
  results=results,
352
352
  aggregate_scores=agg_scores,
353
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),
354
358
  )
355
359
 
356
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
  }
@@ -410,12 +429,15 @@ def score_rows(
410
429
  :func:`grader_lookups` builds them from the suite.
411
430
  """
412
431
  key = _run_key(run.scorecard)
413
- gate = _gate(comparison, baseline_run_id)
414
- rails = (
415
- {rail.metric: rail for rail in comparison.guardrails}
416
- if comparison
417
- else {}
418
- )
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
+ }
419
441
  # The run describes its own graders as of 2.3.0. The lookups remain for
420
442
  # runs written before that, which carry an empty map - a `run.json` on
421
443
  # disk outlives the release that wrote it. An explicit lookup still wins,
@@ -125,7 +125,8 @@ class RubricJudgeTests(unittest.TestCase):
125
125
  replay_path='f.yaml',
126
126
  judge_version='v3',
127
127
  )
128
- self.assertEqual(single.judge_version, 'judge@v3')
128
+ # No model configured (replay-only), so the pin is key@version.
129
+ self.assertEqual(single.judge_version, 'anthropic@v3')
129
130
  panel = judge.RubricJudge(
130
131
  content_ref='output.text',
131
132
  dimensions=DIMENSIONS,
@@ -139,7 +140,37 @@ class RubricJudgeTests(unittest.TestCase):
139
140
  },
140
141
  ],
141
142
  )
142
- self.assertEqual(panel.judge_version, 'claude@v2,gpt@v5')
143
+ self.assertEqual(panel.judge_version, 'claude:m@v2,gpt:openai:g@v5')
144
+
145
+ def test_the_model_is_in_the_pin(self):
146
+ """Swapping the judge's model has to move the pin.
147
+
148
+ Without this, a model swap that left `judge_version` alone produced
149
+ a byte-identical pin, so a comparison against a baseline scored by a
150
+ different model passed every provenance check reading this field.
151
+ """
152
+
153
+ def _judge(model):
154
+ return judge.RubricJudge(
155
+ content_ref='output.text',
156
+ dimensions=DIMENSIONS,
157
+ model=model,
158
+ judge_version='v1',
159
+ )
160
+
161
+ before = _judge('claude-sonnet-4-6').judge_version
162
+ after = _judge('claude-opus-4-1').judge_version
163
+ self.assertEqual(before, 'anthropic:claude-sonnet-4-6@v1')
164
+ self.assertNotEqual(before, after)
165
+
166
+ def test_a_single_judge_names_itself_by_provider(self):
167
+ """Not the literal 'judge', which carried no information."""
168
+ single = judge.RubricJudge(
169
+ content_ref='output.text',
170
+ dimensions=DIMENSIONS,
171
+ replay_path='f.yaml',
172
+ )
173
+ self.assertEqual(single.judges[0]['key'], 'anthropic')
143
174
 
144
175
  def test_transient_client_error_retried_via_set_retry(self):
145
176
  class _RateLimit(Exception):
@@ -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):
@@ -394,6 +395,77 @@ class SelfDescribingRunTests(unittest.TestCase):
394
395
  self.assertEqual(types['j'], 'llm_as_judge')
395
396
 
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
+
397
469
  class ScoreExporterProtocolTests(unittest.TestCase):
398
470
  """The seam another package implements to publish to a real store."""
399
471
 
@@ -188,7 +188,7 @@ wheels = [
188
188
 
189
189
  [[package]]
190
190
  name = "evalcore"
191
- version = "2.3.0"
191
+ version = "2.4.1"
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