evalcore 2.6.0__tar.gz → 2.6.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 (78) hide show
  1. {evalcore-2.6.0 → evalcore-2.6.1}/CHANGELOG.md +41 -1
  2. {evalcore-2.6.0 → evalcore-2.6.1}/PKG-INFO +1 -1
  3. {evalcore-2.6.0 → evalcore-2.6.1}/pyproject.toml +1 -1
  4. {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/store.py +103 -11
  5. {evalcore-2.6.0 → evalcore-2.6.1}/tests/test_store.py +98 -1
  6. {evalcore-2.6.0 → evalcore-2.6.1}/uv.lock +1 -1
  7. {evalcore-2.6.0 → evalcore-2.6.1}/.github/workflows/ci.yml +0 -0
  8. {evalcore-2.6.0 → evalcore-2.6.1}/.github/workflows/publish.yml +0 -0
  9. {evalcore-2.6.0 → evalcore-2.6.1}/.gitignore +0 -0
  10. {evalcore-2.6.0 → evalcore-2.6.1}/.pre-commit-config.yaml +0 -0
  11. {evalcore-2.6.0 → evalcore-2.6.1}/LICENSE +0 -0
  12. {evalcore-2.6.0 → evalcore-2.6.1}/README.md +0 -0
  13. {evalcore-2.6.0 → evalcore-2.6.1}/docs/design.md +0 -0
  14. {evalcore-2.6.0 → evalcore-2.6.1}/examples/__init__.py +0 -0
  15. {evalcore-2.6.0 → evalcore-2.6.1}/examples/quickstart/README.md +0 -0
  16. {evalcore-2.6.0 → evalcore-2.6.1}/examples/quickstart/__init__.py +0 -0
  17. {evalcore-2.6.0 → evalcore-2.6.1}/examples/quickstart/adapter.py +0 -0
  18. {evalcore-2.6.0 → evalcore-2.6.1}/examples/quickstart/datasets/support_reply/v1/cases/angry_complaint.yaml +0 -0
  19. {evalcore-2.6.0 → evalcore-2.6.1}/examples/quickstart/datasets/support_reply/v1/cases/billing_question.yaml +0 -0
  20. {evalcore-2.6.0 → evalcore-2.6.1}/examples/quickstart/datasets/support_reply/v1/cases/double_charge.yaml +0 -0
  21. {evalcore-2.6.0 → evalcore-2.6.1}/examples/quickstart/datasets/support_reply/v1/cases/refund_request.yaml +0 -0
  22. {evalcore-2.6.0 → evalcore-2.6.1}/examples/quickstart/fixtures/support_reply_judge.yaml +0 -0
  23. {evalcore-2.6.0 → evalcore-2.6.1}/examples/quickstart/fixtures/support_reply_pairwise.yaml +0 -0
  24. {evalcore-2.6.0 → evalcore-2.6.1}/examples/quickstart/fixtures/support_reply_replay.yaml +0 -0
  25. {evalcore-2.6.0 → evalcore-2.6.1}/examples/quickstart/graders.py +0 -0
  26. {evalcore-2.6.0 → evalcore-2.6.1}/examples/quickstart/run_eval.py +0 -0
  27. {evalcore-2.6.0 → evalcore-2.6.1}/examples/quickstart/suite.yaml +0 -0
  28. {evalcore-2.6.0 → evalcore-2.6.1}/examples/quickstart/tests/__init__.py +0 -0
  29. {evalcore-2.6.0 → evalcore-2.6.1}/examples/quickstart/tests/test_quickstart.py +0 -0
  30. {evalcore-2.6.0 → evalcore-2.6.1}/justfile +0 -0
  31. {evalcore-2.6.0 → evalcore-2.6.1}/pyrightconfig.json +0 -0
  32. {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/__init__.py +0 -0
  33. {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/adapters/__init__.py +0 -0
  34. {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/adapters/base.py +0 -0
  35. {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/adapters/env.py +0 -0
  36. {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/adapters/http.py +0 -0
  37. {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/adapters/replay.py +0 -0
  38. {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/cli.py +0 -0
  39. {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/compare.py +0 -0
  40. {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/errors.py +0 -0
  41. {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/graders/__init__.py +0 -0
  42. {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/graders/base.py +0 -0
  43. {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/graders/classification.py +0 -0
  44. {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/graders/deterministic.py +0 -0
  45. {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/graders/judge.py +0 -0
  46. {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/graders/numeric.py +0 -0
  47. {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/loader.py +0 -0
  48. {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/models.py +0 -0
  49. {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/pairwise.py +0 -0
  50. {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/plugins.py +0 -0
  51. {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/py.typed +0 -0
  52. {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/rating.py +0 -0
  53. {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/refs.py +0 -0
  54. {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/report.py +0 -0
  55. {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/reporters/__init__.py +0 -0
  56. {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/reporters/base.py +0 -0
  57. {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/reporters/html.py +0 -0
  58. {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/reporters/markdown.py +0 -0
  59. {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/retry.py +0 -0
  60. {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/runner.py +0 -0
  61. {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/sweep.py +0 -0
  62. {evalcore-2.6.0 → evalcore-2.6.1}/tests/__init__.py +0 -0
  63. {evalcore-2.6.0 → evalcore-2.6.1}/tests/test_adapters.py +0 -0
  64. {evalcore-2.6.0 → evalcore-2.6.1}/tests/test_cli.py +0 -0
  65. {evalcore-2.6.0 → evalcore-2.6.1}/tests/test_edge_cases.py +0 -0
  66. {evalcore-2.6.0 → evalcore-2.6.1}/tests/test_judge.py +0 -0
  67. {evalcore-2.6.0 → evalcore-2.6.1}/tests/test_judge_extra.py +0 -0
  68. {evalcore-2.6.0 → evalcore-2.6.1}/tests/test_live_clients.py +0 -0
  69. {evalcore-2.6.0 → evalcore-2.6.1}/tests/test_pairwise_extra.py +0 -0
  70. {evalcore-2.6.0 → evalcore-2.6.1}/tests/test_plugins.py +0 -0
  71. {evalcore-2.6.0 → evalcore-2.6.1}/tests/test_rating.py +0 -0
  72. {evalcore-2.6.0 → evalcore-2.6.1}/tests/test_rating_server.py +0 -0
  73. {evalcore-2.6.0 → evalcore-2.6.1}/tests/test_reporters.py +0 -0
  74. {evalcore-2.6.0 → evalcore-2.6.1}/tests/test_retry.py +0 -0
  75. {evalcore-2.6.0 → evalcore-2.6.1}/tests/test_runner.py +0 -0
  76. {evalcore-2.6.0 → evalcore-2.6.1}/tests/test_sweep_pairwise.py +0 -0
  77. {evalcore-2.6.0 → evalcore-2.6.1}/tests/test_unit.py +0 -0
  78. {evalcore-2.6.0 → evalcore-2.6.1}/uv.toml +0 -0
@@ -6,6 +6,45 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.6.1] - 2026-09-12
10
+
11
+ The facts 2.6.0 added reach a results store.
12
+
13
+ Shipped as a patch. It changes what published rows contain, so the 1.0.0
14
+ policy would call it a minor; it goes out as 2.6.1 as a deliberate exception,
15
+ because it completes 2.6.0 rather than adding to it. Same call 2.4.2 made, for
16
+ the same reason.
17
+
18
+ ### Added
19
+ - `score_rows` exports the direction and range 2.6.0 introduced. That release
20
+ gave every metric both and then kept them in memory: the row shape this
21
+ module flattens a run into carried neither, so nothing reached a results
22
+ store and no consumer downstream could use them. Five columns on every row
23
+ shape, including the failed-invocation row that names no metric at all.
24
+ - `value_range` (`none` / `bounded` / `unbounded_above` /
25
+ `unbounded_below` / `unbounded`), `value_minimum` and `value_maximum`.
26
+ The discriminator is what makes "declared unbounded" and "never declared"
27
+ different answers, which two nullable floats cannot express; it is the
28
+ idiom `metric_kind = 'none'` already uses beside a filled-in `value`. All
29
+ five members are spelled out because `MetricRange` can express all five -
30
+ `range: {max: 5}` on a numeric field produces `unbounded_below` - and a
31
+ consumer storing this as an enum pays a migration to add a member later.
32
+ - `metric_direction` and `higher_is_better`. `metric_direction` carries the
33
+ same four values `gate_win` does, `none` among them, meaning never
34
+ computed: an ungated run has no baseline, and `neutral` would report
35
+ "measured, and it did not move" about a number nothing was measured
36
+ against. `higher_is_better` is `'true'|'false'|'unknown'`, and `unknown`
37
+ is narrower than it looks - `compare()` defaults a metric nothing
38
+ mentions to higher-is-better, so it reads `unknown` only when a suite
39
+ declared the metric neutral or there was no comparison at all.
40
+
41
+ ### Changed
42
+ - `_passed` is now `_tristate`, taking the spelling of its third state as an
43
+ argument. `passed` keeps `'null'`, which it has published since the row
44
+ shape existed; `higher_is_better` uses `'unknown'`, which reads as "nobody
45
+ declared one" rather than as a missing value. Private; no caller outside
46
+ this module.
47
+
9
48
  ## [2.6.0] - 2026-09-12
10
49
 
11
50
  A metric says which way is good and what its values sit on.
@@ -431,7 +470,8 @@ by semantic versioning: a breaking change to either means a 2.0.
431
470
  rating + ranking with judge agreement, Markdown/HTML reporters, JSON +
432
471
  column-store outbox, and content-hash provenance.
433
472
 
434
- [Unreleased]: https://github.com/scottpmiller/evalcore/compare/2.6.0...HEAD
473
+ [Unreleased]: https://github.com/scottpmiller/evalcore/compare/2.6.1...HEAD
474
+ [2.6.1]: https://github.com/scottpmiller/evalcore/compare/2.6.0...2.6.1
435
475
  [2.6.0]: https://github.com/scottpmiller/evalcore/compare/2.5.0...2.6.0
436
476
  [2.5.0]: https://github.com/scottpmiller/evalcore/compare/2.4.3...2.5.0
437
477
  [2.4.3]: https://github.com/scottpmiller/evalcore/compare/2.4.2...2.4.3
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: evalcore
3
- Version: 2.6.0
3
+ Version: 2.6.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.6.0"
3
+ version = "2.6.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"
@@ -259,6 +259,13 @@ _NO_JUDGES = {
259
259
  'judge_scale': 0,
260
260
  }
261
261
 
262
+ #: A metric whose grader declared no range. ``value_range`` is the
263
+ #: discriminator, in the same idiom as ``metric_kind = 'none'`` sitting beside
264
+ #: a filled-in ``value``: the two floats here are padding, not measurements,
265
+ #: and a consumer that reads them without the discriminator learns that every
266
+ #: undeclared metric runs 0..0.
267
+ _NO_RANGE = {'value_range': 'none', 'value_minimum': 0.0, 'value_maximum': 0.0}
268
+
262
269
 
263
270
  def _run_key(scorecard: models.Scorecard) -> dict:
264
271
  """The reproducibility key carried on every store row.
@@ -336,13 +343,63 @@ def _gate(
336
343
  }
337
344
 
338
345
 
346
+ def _metric_range(value_range: models.MetricRange | None) -> dict:
347
+ """What the metric's values sit on, as three null-free columns.
348
+
349
+ ``value_range`` says which of the two floats mean anything, and covers
350
+ every shape a ``MetricRange`` can take rather than the shapes anything
351
+ currently emits:
352
+
353
+ ``none``
354
+ No range was declared at all. Render the raw number.
355
+ ``bounded``
356
+ Both ends known. ``0..1`` is the case that makes a percentage safe.
357
+ ``unbounded_above``
358
+ ``value_minimum`` holds, no ceiling. A count, a cost, an elapsed
359
+ time - not a fraction of anything.
360
+ ``unbounded_below``
361
+ ``value_maximum`` holds, no floor.
362
+ ``unbounded``
363
+ A range object carrying neither end, which says less than declaring
364
+ nothing but is still a different thing from it.
365
+
366
+ The discriminator is why this is not two nullable floats: "declared
367
+ unbounded" and "never declared" are different answers, and a null in
368
+ both columns cannot tell them apart. The two open-ended members exist
369
+ because ``MetricRange`` can express them - ``range: {max: 5}`` on a
370
+ numeric field gives exactly the third case - and because a consumer
371
+ storing this as an enum pays an ALTER to add a member later, so the
372
+ degenerate cases are cheaper spelled out now than discovered in
373
+ production.
374
+ """
375
+ if value_range is None:
376
+ return dict(_NO_RANGE)
377
+ low, high = value_range.minimum, value_range.maximum
378
+ kind = (
379
+ 'bounded'
380
+ if low is not None and high is not None
381
+ else 'unbounded_above'
382
+ if low is not None
383
+ else 'unbounded_below'
384
+ if high is not None
385
+ else 'unbounded'
386
+ )
387
+ return {
388
+ 'value_range': kind,
389
+ 'value_minimum': float(low) if low is not None else 0.0,
390
+ 'value_maximum': float(high) if high is not None else 0.0,
391
+ }
392
+
393
+
339
394
  def _metric_gate(
340
395
  metric: str,
341
396
  comparison: models.Comparison | None,
342
397
  rails: dict[str, models.GuardrailResult],
398
+ deltas: dict[str, models.MetricDelta] | None = None,
343
399
  ) -> dict:
344
400
  """The gate columns that belong to one metric rather than to the run."""
345
401
  rail = rails.get(metric)
402
+ delta = (deltas or {}).get(metric)
346
403
  return {
347
404
  'win': bool(comparison and comparison.win_metric == metric),
348
405
  # rail.passed is tri-state: None means the rule needed a baseline
@@ -355,6 +412,20 @@ def _metric_gate(
355
412
  else 'fail'
356
413
  ),
357
414
  'guardrail_gap': rail.detail if rail else '',
415
+ # 'none' is the fourth state the model does not have, and means the
416
+ # same thing gate_win = 'none' does at run grain: never computed. A
417
+ # direction is a comparison, so an ungated run cannot have one, and
418
+ # writing 'neutral' there would report "measured, and it did not
419
+ # move" for a number nothing was measured against.
420
+ 'metric_direction': delta.direction if delta else 'none',
421
+ # Which way is good, as a tri-state string for the same reason
422
+ # `passed` is one: 'unknown' is a real answer, not a missing value.
423
+ # It is narrower than it looks - compare() defaults a metric nothing
424
+ # mentions to higher-is-better, so this only reads 'unknown' when a
425
+ # suite declared the metric neutral or there was no comparison.
426
+ 'higher_is_better': _tristate(
427
+ delta.higher_is_better if delta else None, absent='unknown'
428
+ ),
358
429
  }
359
430
 
360
431
 
@@ -393,14 +464,19 @@ def _judges(score: models.Score, scale: int) -> dict:
393
464
  }
394
465
 
395
466
 
396
- def _passed(value: bool | None) -> str:
397
- """``passed`` as a tri-state string: deterministic true/false, judge null.
467
+ def _tristate(value: bool | None, absent: str = 'null') -> str:
468
+ """A three-state bool as a string, for a table with no Nullable columns.
398
469
 
399
- A string rather than a bool-or-null because it is not a missing
400
- measurement: a judge has no pass line by design, so 'null' is one of three
401
- real states.
470
+ The third state is a real answer rather than a missing measurement, and
471
+ both callers mean something by it: a judge has no pass line by design,
472
+ and a metric the suite declared neutral has no good direction by design.
473
+
474
+ They spell it differently because their columns do. ``passed`` has said
475
+ ``'null'`` since the row shape was first published, and a store's
476
+ polarity column reads better as ``'unknown'`` - "nobody declared one" -
477
+ where ``'null'`` would look like a missing value.
402
478
  """
403
- return 'true' if value is True else 'false' if value is False else 'null'
479
+ return 'true' if value is True else 'false' if value is False else absent
404
480
 
405
481
 
406
482
  def score_rows(
@@ -438,6 +514,12 @@ def score_rows(
438
514
  comparison.guardrails if comparison else run.checks.guardrails
439
515
  )
440
516
  }
517
+ # Keyed once rather than scanned per row: a run is (cases x samples x
518
+ # metrics) rows against one list of deltas.
519
+ deltas = {
520
+ delta.metric: delta
521
+ for delta in (comparison.deltas if comparison else [])
522
+ }
441
523
  # The run describes its own graders as of 2.3.0. The lookups remain for
442
524
  # runs written before that, which carry an empty map - a `run.json` on
443
525
  # disk outlives the release that wrote it. An explicit lookup still wins,
@@ -467,9 +549,16 @@ def score_rows(
467
549
  'passed': 'null',
468
550
  'detail': '',
469
551
  'case_labels': result.case.labels,
552
+ **_NO_RANGE,
470
553
  'win': False,
471
554
  'guardrail': 'none',
472
555
  'guardrail_gap': '',
556
+ # This row names no metric at all, so there is nothing to
557
+ # have a range or a direction about. Spelled out rather
558
+ # than routed through _metric_gate, which would look the
559
+ # empty string up.
560
+ 'metric_direction': 'none',
561
+ 'higher_is_better': 'unknown',
473
562
  **invocation,
474
563
  **_NO_JUDGES,
475
564
  **gate,
@@ -488,10 +577,11 @@ def score_rows(
488
577
  'metric': score.metric,
489
578
  'metric_kind': score.kind,
490
579
  'value': score.value,
491
- 'passed': _passed(score.passed),
580
+ 'passed': _tristate(score.passed),
492
581
  'detail': score.detail or '',
493
582
  'case_labels': result.case.labels,
494
- **_metric_gate(score.metric, comparison, rails),
583
+ **_metric_range(score.value_range),
584
+ **_metric_gate(score.metric, comparison, rails, deltas),
495
585
  **invocation,
496
586
  **_judges(score, scales.get(score.grader, 0)),
497
587
  **gate,
@@ -510,10 +600,11 @@ def score_rows(
510
600
  'metric': score.metric,
511
601
  'metric_kind': 'aggregate',
512
602
  'value': score.value,
513
- 'passed': _passed(score.passed),
603
+ 'passed': _tristate(score.passed),
514
604
  'detail': score.detail or '',
515
605
  'case_labels': {},
516
- **_metric_gate(score.metric, comparison, rails),
606
+ **_metric_range(score.value_range),
607
+ **_metric_gate(score.metric, comparison, rails, deltas),
517
608
  **_NO_INVOCATION,
518
609
  **_NO_JUDGES,
519
610
  **gate,
@@ -537,7 +628,8 @@ def score_rows(
537
628
  'passed': 'null',
538
629
  'detail': '',
539
630
  'case_labels': {},
540
- **_metric_gate(metric, comparison, rails),
631
+ **_NO_RANGE,
632
+ **_metric_gate(metric, comparison, rails, deltas),
541
633
  **_NO_INVOCATION,
542
634
  **_NO_JUDGES,
543
635
  **gate,
@@ -104,7 +104,12 @@ def _comparison():
104
104
  verdict='fail',
105
105
  deltas=[
106
106
  models.MetricDelta(
107
- metric='passed_check', baseline=0.5, candidate=1.0, delta=0.5
107
+ metric='passed_check',
108
+ baseline=0.5,
109
+ candidate=1.0,
110
+ delta=0.5,
111
+ higher_is_better=True,
112
+ direction='improved',
108
113
  )
109
114
  ],
110
115
  guardrails=[
@@ -293,6 +298,98 @@ class RowTests(unittest.TestCase):
293
298
  self.assertEqual(row['guardrail_gap'], 'metric absent on candidate')
294
299
 
295
300
 
301
+ class MetricFactRowTests(unittest.TestCase):
302
+ """Range and direction reach the row shape, or say they did not."""
303
+
304
+ def _rows(self, comparison=None):
305
+ return {
306
+ row['metric']: row for row in store.score_rows(_run(), comparison)
307
+ }
308
+
309
+ def _scored(self, value_range):
310
+ """A run whose one per-case score declares ``value_range``."""
311
+ run = _run()
312
+ run.results[0].scores[0].value_range = value_range
313
+ return {row['metric']: row for row in store.score_rows(run)}[
314
+ run.results[0].scores[0].metric
315
+ ]
316
+
317
+ def test_bounded_range_reaches_the_row(self):
318
+ row = self._scored(models.MetricRange(minimum=0.0, maximum=1.0))
319
+ self.assertEqual(row['value_range'], 'bounded')
320
+ self.assertEqual(row['value_minimum'], 0.0)
321
+ self.assertEqual(row['value_maximum'], 1.0)
322
+
323
+ def test_unbounded_is_a_different_answer_from_undeclared(self):
324
+ # The distinction two nullable floats could not carry: a count with
325
+ # no ceiling versus a metric nobody described.
326
+ unbounded = self._scored(models.MetricRange(minimum=0.0, maximum=None))
327
+ undeclared = self._scored(None)
328
+ self.assertEqual(unbounded['value_range'], 'unbounded_above')
329
+ self.assertEqual(undeclared['value_range'], 'none')
330
+ self.assertEqual(
331
+ unbounded['value_minimum'], undeclared['value_minimum']
332
+ )
333
+ self.assertEqual(
334
+ unbounded['value_maximum'], undeclared['value_maximum']
335
+ )
336
+
337
+ def test_a_range_open_at_the_bottom_keeps_the_end_it_has(self):
338
+ # `range: {max: 5}` on a numeric field produces exactly this, and
339
+ # collapsing it to 'none' would throw away a declared ceiling.
340
+ row = self._scored(models.MetricRange(minimum=None, maximum=5.0))
341
+ self.assertEqual(row['value_range'], 'unbounded_below')
342
+ self.assertEqual(row['value_maximum'], 5.0)
343
+ self.assertEqual(row['value_minimum'], 0.0)
344
+
345
+ def test_a_range_carrying_neither_end_is_not_the_same_as_no_range(self):
346
+ declared = self._scored(models.MetricRange())
347
+ undeclared = self._scored(None)
348
+ self.assertEqual(declared['value_range'], 'unbounded')
349
+ self.assertEqual(undeclared['value_range'], 'none')
350
+
351
+ def test_direction_and_polarity_come_from_the_comparison(self):
352
+ row = self._rows(_comparison())['passed_check']
353
+ self.assertEqual(row['metric_direction'], 'improved')
354
+ self.assertEqual(row['higher_is_better'], 'true')
355
+
356
+ def test_an_ungated_run_has_no_direction_at_all(self):
357
+ # 'none' rather than 'neutral', for the same reason gate_win uses it:
358
+ # never computed is not the same as measured and did not move.
359
+ for row in self._rows().values():
360
+ self.assertEqual(row['metric_direction'], 'none')
361
+ self.assertEqual(row['higher_is_better'], 'unknown')
362
+
363
+ def test_a_metric_outside_the_comparison_has_no_direction(self):
364
+ rows = self._rows(_comparison())
365
+ outside = [
366
+ row
367
+ for metric, row in rows.items()
368
+ if metric and metric != 'passed_check'
369
+ ]
370
+ self.assertTrue(outside, 'fixture scores only the win metric')
371
+ for row in outside:
372
+ self.assertEqual(row['metric_direction'], 'none')
373
+
374
+ def test_a_named_but_unscored_metric_carries_no_range(self):
375
+ row = self._rows(_comparison())['never_scored']
376
+ self.assertEqual(row['metric_kind'], 'none')
377
+ self.assertEqual(row['value_range'], 'none')
378
+
379
+ def test_every_row_carries_all_five_columns(self):
380
+ # A store with no nullable columns rejects a row missing one, and
381
+ # the Kafka path has no client to hand that rejection back to.
382
+ columns = {
383
+ 'value_range',
384
+ 'value_minimum',
385
+ 'value_maximum',
386
+ 'metric_direction',
387
+ 'higher_is_better',
388
+ }
389
+ for row in store.score_rows(_run(with_failure=True), _comparison()):
390
+ self.assertLessEqual(columns, set(row), row.get('metric'))
391
+
392
+
296
393
  class GraderLookupTests(unittest.TestCase):
297
394
  def test_maps_registry_types_to_categories(self):
298
395
  types, scales = store.grader_lookups(
@@ -188,7 +188,7 @@ wheels = [
188
188
 
189
189
  [[package]]
190
190
  name = "evalcore"
191
- version = "2.6.0"
191
+ version = "2.6.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
File without changes
File without changes