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.
- {evalcore-2.6.0 → evalcore-2.6.1}/CHANGELOG.md +41 -1
- {evalcore-2.6.0 → evalcore-2.6.1}/PKG-INFO +1 -1
- {evalcore-2.6.0 → evalcore-2.6.1}/pyproject.toml +1 -1
- {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/store.py +103 -11
- {evalcore-2.6.0 → evalcore-2.6.1}/tests/test_store.py +98 -1
- {evalcore-2.6.0 → evalcore-2.6.1}/uv.lock +1 -1
- {evalcore-2.6.0 → evalcore-2.6.1}/.github/workflows/ci.yml +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/.github/workflows/publish.yml +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/.gitignore +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/.pre-commit-config.yaml +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/LICENSE +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/README.md +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/docs/design.md +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/examples/__init__.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/examples/quickstart/README.md +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/examples/quickstart/__init__.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/examples/quickstart/adapter.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/examples/quickstart/datasets/support_reply/v1/cases/angry_complaint.yaml +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/examples/quickstart/datasets/support_reply/v1/cases/billing_question.yaml +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/examples/quickstart/datasets/support_reply/v1/cases/double_charge.yaml +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/examples/quickstart/datasets/support_reply/v1/cases/refund_request.yaml +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/examples/quickstart/fixtures/support_reply_judge.yaml +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/examples/quickstart/fixtures/support_reply_pairwise.yaml +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/examples/quickstart/fixtures/support_reply_replay.yaml +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/examples/quickstart/graders.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/examples/quickstart/run_eval.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/examples/quickstart/suite.yaml +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/examples/quickstart/tests/__init__.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/examples/quickstart/tests/test_quickstart.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/justfile +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/pyrightconfig.json +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/__init__.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/adapters/__init__.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/adapters/base.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/adapters/env.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/adapters/http.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/adapters/replay.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/cli.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/compare.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/errors.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/graders/__init__.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/graders/base.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/graders/classification.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/graders/deterministic.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/graders/judge.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/graders/numeric.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/loader.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/models.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/pairwise.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/plugins.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/py.typed +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/rating.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/refs.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/report.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/reporters/__init__.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/reporters/base.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/reporters/html.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/reporters/markdown.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/retry.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/runner.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/src/evalcore/sweep.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/tests/__init__.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/tests/test_adapters.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/tests/test_cli.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/tests/test_edge_cases.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/tests/test_judge.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/tests/test_judge_extra.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/tests/test_live_clients.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/tests/test_pairwise_extra.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/tests/test_plugins.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/tests/test_rating.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/tests/test_rating_server.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/tests/test_reporters.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/tests/test_retry.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/tests/test_runner.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/tests/test_sweep_pairwise.py +0 -0
- {evalcore-2.6.0 → evalcore-2.6.1}/tests/test_unit.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
@@ -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
|
|
397
|
-
"""
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
|
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':
|
|
580
|
+
'passed': _tristate(score.passed),
|
|
492
581
|
'detail': score.detail or '',
|
|
493
582
|
'case_labels': result.case.labels,
|
|
494
|
-
**
|
|
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':
|
|
603
|
+
'passed': _tristate(score.passed),
|
|
514
604
|
'detail': score.detail or '',
|
|
515
605
|
'case_labels': {},
|
|
516
|
-
**
|
|
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
|
-
**
|
|
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',
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|