evalcore 0.1.0__py3-none-any.whl
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-0.1.0.dist-info/METADATA +828 -0
- evalcore-0.1.0.dist-info/RECORD +34 -0
- evalcore-0.1.0.dist-info/WHEEL +4 -0
- evalcore-0.1.0.dist-info/entry_points.txt +2 -0
- evalcore-0.1.0.dist-info/licenses/LICENSE +28 -0
- evalkit/__init__.py +39 -0
- evalkit/adapters/__init__.py +15 -0
- evalkit/adapters/_env.py +24 -0
- evalkit/adapters/base.py +40 -0
- evalkit/adapters/http.py +96 -0
- evalkit/adapters/replay.py +41 -0
- evalkit/cli.py +655 -0
- evalkit/compare.py +137 -0
- evalkit/graders/__init__.py +18 -0
- evalkit/graders/base.py +77 -0
- evalkit/graders/classification.py +107 -0
- evalkit/graders/deterministic.py +127 -0
- evalkit/graders/judge.py +635 -0
- evalkit/graders/numeric.py +91 -0
- evalkit/loader.py +129 -0
- evalkit/models.py +390 -0
- evalkit/pairwise.py +324 -0
- evalkit/py.typed +0 -0
- evalkit/rating.py +1323 -0
- evalkit/refs.py +71 -0
- evalkit/report.py +186 -0
- evalkit/reporters/__init__.py +33 -0
- evalkit/reporters/base.py +159 -0
- evalkit/reporters/html.py +426 -0
- evalkit/reporters/markdown.py +74 -0
- evalkit/retry.py +85 -0
- evalkit/runner.py +283 -0
- evalkit/store.py +286 -0
- evalkit/sweep.py +93 -0
evalkit/compare.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Comparison / regression engine - candidate vs baseline -> gate verdict.
|
|
2
|
+
|
|
3
|
+
Generic across all consumers. Two ideas:
|
|
4
|
+
|
|
5
|
+
* **Guardrails** - metrics that must hold regardless of the headline result
|
|
6
|
+
(e.g. ``false_negative_rate`` must stay under a ceiling and must not increase
|
|
7
|
+
vs. baseline). A guardrail breach is a hard ``fail``.
|
|
8
|
+
* **Win metric** - the headline signal (``f1`` for ``/validation``; a pairwise
|
|
9
|
+
win-rate for judged suites). A regression beyond the band is a ``warn``.
|
|
10
|
+
|
|
11
|
+
Verdict: any guardrail breach -> ``fail``; else win regressed -> ``warn``; else
|
|
12
|
+
``pass``. The on-regression policy is configurable per suite.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from evalkit import models
|
|
16
|
+
|
|
17
|
+
_EPS = 1e-9
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _metric(card: models.Scorecard, name: str) -> float | None:
|
|
21
|
+
found = card.metrics.get(name)
|
|
22
|
+
return found.value if found else None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _check_guardrail(
|
|
26
|
+
rule: dict, baseline: models.Scorecard, candidate: models.Scorecard
|
|
27
|
+
) -> models.GuardrailResult:
|
|
28
|
+
metric = rule['metric']
|
|
29
|
+
cand = _metric(candidate, metric)
|
|
30
|
+
base = _metric(baseline, metric)
|
|
31
|
+
if cand is None:
|
|
32
|
+
return models.GuardrailResult(
|
|
33
|
+
metric=metric, passed=False, detail='metric absent on candidate'
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
problems: list[str] = []
|
|
37
|
+
if 'max' in rule and cand > rule['max'] + _EPS:
|
|
38
|
+
problems.append(f'{cand:.4f} > max {rule["max"]}')
|
|
39
|
+
if 'min' in rule and cand < rule['min'] - _EPS:
|
|
40
|
+
problems.append(f'{cand:.4f} < min {rule["min"]}')
|
|
41
|
+
if (
|
|
42
|
+
rule.get('must_not_increase')
|
|
43
|
+
and base is not None
|
|
44
|
+
and (cand > base + _EPS)
|
|
45
|
+
):
|
|
46
|
+
problems.append(f'increased {base:.4f} -> {cand:.4f}')
|
|
47
|
+
if (
|
|
48
|
+
rule.get('must_not_decrease')
|
|
49
|
+
and base is not None
|
|
50
|
+
and (cand < base - _EPS)
|
|
51
|
+
):
|
|
52
|
+
problems.append(f'decreased {base:.4f} -> {cand:.4f}')
|
|
53
|
+
|
|
54
|
+
if problems:
|
|
55
|
+
return models.GuardrailResult(
|
|
56
|
+
metric=metric, passed=False, detail='; '.join(problems)
|
|
57
|
+
)
|
|
58
|
+
return models.GuardrailResult(
|
|
59
|
+
metric=metric, passed=True, detail=f'{cand:.4f} ok'
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _evaluate_win(
|
|
64
|
+
thresholds: dict, baseline: models.Scorecard, candidate: models.Scorecard
|
|
65
|
+
) -> tuple[str | None, str]:
|
|
66
|
+
metric = thresholds.get('win_metric')
|
|
67
|
+
if not metric:
|
|
68
|
+
return None, 'neutral'
|
|
69
|
+
base = _metric(baseline, metric)
|
|
70
|
+
cand = _metric(candidate, metric)
|
|
71
|
+
if base is None or cand is None:
|
|
72
|
+
return metric, 'neutral'
|
|
73
|
+
higher_better = thresholds.get('win_higher_is_better', True)
|
|
74
|
+
min_delta = thresholds.get('win_min_delta', 0.0)
|
|
75
|
+
delta = cand - base if higher_better else base - cand
|
|
76
|
+
if delta > min_delta:
|
|
77
|
+
return metric, 'improved'
|
|
78
|
+
if delta < -min_delta:
|
|
79
|
+
return metric, 'regressed'
|
|
80
|
+
return metric, 'neutral'
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def compare(
|
|
84
|
+
baseline: models.Scorecard,
|
|
85
|
+
candidate: models.Scorecard,
|
|
86
|
+
thresholds: dict | None = None,
|
|
87
|
+
) -> models.Comparison:
|
|
88
|
+
"""Compare two scorecards and produce a gate verdict."""
|
|
89
|
+
thresholds = thresholds or {}
|
|
90
|
+
|
|
91
|
+
deltas: list[models.MetricDelta] = []
|
|
92
|
+
for metric in sorted(set(baseline.metrics) | set(candidate.metrics)):
|
|
93
|
+
base = _metric(baseline, metric)
|
|
94
|
+
cand = _metric(candidate, metric)
|
|
95
|
+
delta = cand - base if base is not None and cand is not None else None
|
|
96
|
+
deltas.append(
|
|
97
|
+
models.MetricDelta(
|
|
98
|
+
metric=metric, baseline=base, candidate=cand, delta=delta
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
guardrails = [
|
|
103
|
+
_check_guardrail(rule, baseline, candidate)
|
|
104
|
+
for rule in thresholds.get('guardrails', [])
|
|
105
|
+
]
|
|
106
|
+
win_metric, win = _evaluate_win(thresholds, baseline, candidate)
|
|
107
|
+
|
|
108
|
+
breached = [g for g in guardrails if not g.passed]
|
|
109
|
+
on_regression = thresholds.get('on_regression', 'warn')
|
|
110
|
+
if breached:
|
|
111
|
+
verdict = 'fail'
|
|
112
|
+
elif win == 'regressed':
|
|
113
|
+
verdict = 'fail' if on_regression == 'fail' else 'warn'
|
|
114
|
+
else:
|
|
115
|
+
verdict = 'pass'
|
|
116
|
+
|
|
117
|
+
if breached:
|
|
118
|
+
summary = 'guardrail breach: ' + '; '.join(
|
|
119
|
+
f'{g.metric} ({g.detail})' for g in breached
|
|
120
|
+
)
|
|
121
|
+
elif win_metric:
|
|
122
|
+
summary = f'{win_metric} {win}'
|
|
123
|
+
else:
|
|
124
|
+
summary = 'no win metric configured'
|
|
125
|
+
|
|
126
|
+
return models.Comparison(
|
|
127
|
+
project=candidate.project,
|
|
128
|
+
suite=candidate.suite,
|
|
129
|
+
baseline_variant=baseline.variant.name,
|
|
130
|
+
candidate_variant=candidate.variant.name,
|
|
131
|
+
win_metric=win_metric,
|
|
132
|
+
win=win,
|
|
133
|
+
verdict=verdict,
|
|
134
|
+
deltas=deltas,
|
|
135
|
+
guardrails=guardrails,
|
|
136
|
+
summary=summary,
|
|
137
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Grader registry and built-in graders.
|
|
2
|
+
|
|
3
|
+
Two extension shapes (see ``base``):
|
|
4
|
+
|
|
5
|
+
* ``base.Grader`` - per-case: ``grade(case, output) -> [Score]`` (may be
|
|
6
|
+
async). Averaged by the runner. Used for deterministic checks (length,
|
|
7
|
+
format, regex) and LLM-judge rubric scoring.
|
|
8
|
+
* ``base.AggregateGrader`` - whole-run: ``aggregate(results) -> [Score]``.
|
|
9
|
+
Used for metrics that only exist over a set (precision/recall/F1, win-rate).
|
|
10
|
+
|
|
11
|
+
Importing this package registers the built-in grader types. Consumers register
|
|
12
|
+
custom graders with ``base.register`` and load them via the CLI ``--plugins``
|
|
13
|
+
flag, proving the generic/custom seam.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from evalkit.graders import base, classification, deterministic, judge, numeric
|
|
17
|
+
|
|
18
|
+
__all__ = ['base', 'classification', 'deterministic', 'judge', 'numeric']
|
evalkit/graders/base.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Grader protocols and the type registry.
|
|
2
|
+
|
|
3
|
+
A grader spec is a plain dict from the suite config: ``{type, name, ...}``.
|
|
4
|
+
``build_graders`` turns a list of specs into grader instances, split into the
|
|
5
|
+
per-case and aggregate buckets the runner needs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import typing
|
|
9
|
+
|
|
10
|
+
from evalkit import models
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@typing.runtime_checkable
|
|
14
|
+
class Grader(typing.Protocol):
|
|
15
|
+
"""Per-case grader. Scores are averaged across cases by the runner."""
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
|
|
19
|
+
def grade(
|
|
20
|
+
self, case: models.Case, output: models.Output
|
|
21
|
+
) -> list[models.Score]: ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@typing.runtime_checkable
|
|
25
|
+
class AggregateGrader(typing.Protocol):
|
|
26
|
+
"""Whole-run grader for set-level metrics (P/R/F1, win-rate, ...)."""
|
|
27
|
+
|
|
28
|
+
name: str
|
|
29
|
+
|
|
30
|
+
def aggregate(
|
|
31
|
+
self, results: list[models.CaseResult]
|
|
32
|
+
) -> list[models.Score]: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_REGISTRY: dict[str, type] = {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def register(type_name: str) -> typing.Callable[[type], type]:
|
|
39
|
+
"""Class decorator registering a grader under a suite-config ``type``."""
|
|
40
|
+
|
|
41
|
+
def _decorate(cls: type) -> type:
|
|
42
|
+
if type_name in _REGISTRY:
|
|
43
|
+
raise ValueError(f'grader type {type_name!r} already registered')
|
|
44
|
+
_REGISTRY[type_name] = cls
|
|
45
|
+
return cls
|
|
46
|
+
|
|
47
|
+
return _decorate
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_graders(
|
|
51
|
+
specs: list[dict],
|
|
52
|
+
) -> tuple[list[Grader], list[AggregateGrader]]:
|
|
53
|
+
"""Instantiate grader specs, partitioned into per-case and aggregate.
|
|
54
|
+
|
|
55
|
+
Each spec's ``type`` selects a registered class; remaining keys (minus
|
|
56
|
+
``type``) are passed as keyword arguments to its constructor.
|
|
57
|
+
"""
|
|
58
|
+
per_case: list[Grader] = []
|
|
59
|
+
aggregate: list[AggregateGrader] = []
|
|
60
|
+
for spec in specs:
|
|
61
|
+
spec = dict(spec)
|
|
62
|
+
type_name = spec.pop('type')
|
|
63
|
+
if type_name not in _REGISTRY:
|
|
64
|
+
raise ValueError(
|
|
65
|
+
f'unknown grader type {type_name!r}; '
|
|
66
|
+
f'known: {sorted(_REGISTRY)}'
|
|
67
|
+
)
|
|
68
|
+
grader = _REGISTRY[type_name](**spec)
|
|
69
|
+
if isinstance(grader, AggregateGrader):
|
|
70
|
+
aggregate.append(grader)
|
|
71
|
+
elif isinstance(grader, Grader):
|
|
72
|
+
per_case.append(grader)
|
|
73
|
+
else: # pragma: no cover - defensive
|
|
74
|
+
raise TypeError(
|
|
75
|
+
f'{type_name!r} is neither Grader nor AggregateGrader'
|
|
76
|
+
)
|
|
77
|
+
return per_case, aggregate
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Built-in classification (aggregate) grader.
|
|
2
|
+
|
|
3
|
+
For structured-verdict suites - e.g. a moderation or intent classifier -
|
|
4
|
+
where each case has a ground-truth label. Computes the set-level metrics that
|
|
5
|
+
matter operationally: precision/recall/F1, and the two error rates a security
|
|
6
|
+
gate lives or dies on - false-negative rate (a malicious item passed) and
|
|
7
|
+
false-positive rate (a benign item blocked).
|
|
8
|
+
|
|
9
|
+
The ``positive`` class is the one we care about catching (e.g. malicious +
|
|
10
|
+
suspicious). Predicted and expected labels are mapped to positive/negative via
|
|
11
|
+
the configured label sets, so the grader is reusable for any binary verdict.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from evalkit import models, refs
|
|
15
|
+
from evalkit.graders import base
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _safe_div(numerator: float, denominator: float) -> float:
|
|
19
|
+
return numerator / denominator if denominator else 0.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@base.register('classification')
|
|
23
|
+
class Classification:
|
|
24
|
+
"""Binary precision/recall/F1 + FN/FP rates over a labeled dataset."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
predicted_ref: str,
|
|
29
|
+
expected_ref: str,
|
|
30
|
+
positive_labels: list[str],
|
|
31
|
+
negative_labels: list[str] | None = None,
|
|
32
|
+
name: str = 'classification',
|
|
33
|
+
):
|
|
34
|
+
self.name = name
|
|
35
|
+
self.predicted_ref = predicted_ref
|
|
36
|
+
self.expected_ref = expected_ref
|
|
37
|
+
self.positive = {label.lower() for label in positive_labels}
|
|
38
|
+
self.negative = {label.lower() for label in (negative_labels or [])}
|
|
39
|
+
|
|
40
|
+
def _is_positive(self, label) -> bool | None:
|
|
41
|
+
"""Map a raw label to positive (True) / negative (False) / unknown."""
|
|
42
|
+
if label is None:
|
|
43
|
+
return None
|
|
44
|
+
token = str(label).lower()
|
|
45
|
+
if token in self.positive:
|
|
46
|
+
return True
|
|
47
|
+
if token in self.negative:
|
|
48
|
+
return False
|
|
49
|
+
# Unlisted labels default to negative so a stray verdict can't
|
|
50
|
+
# masquerade as a catch; counted via the ``errors`` metric below.
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
def aggregate(
|
|
54
|
+
self, results: list[models.CaseResult]
|
|
55
|
+
) -> list[models.Score]:
|
|
56
|
+
tp = fp = fn = tn = errors = 0
|
|
57
|
+
for result in results:
|
|
58
|
+
if result.output.error:
|
|
59
|
+
errors += 1
|
|
60
|
+
continue
|
|
61
|
+
context = {
|
|
62
|
+
'output': result.output.fields,
|
|
63
|
+
'case': result.case.model_dump(),
|
|
64
|
+
'expected': result.case.expected or {},
|
|
65
|
+
'input': result.case.input,
|
|
66
|
+
}
|
|
67
|
+
predicted = self._is_positive(
|
|
68
|
+
refs.resolve_ref(context, self.predicted_ref)
|
|
69
|
+
)
|
|
70
|
+
actual = self._is_positive(
|
|
71
|
+
refs.resolve_ref(context, self.expected_ref)
|
|
72
|
+
)
|
|
73
|
+
if predicted is None or actual is None:
|
|
74
|
+
errors += 1
|
|
75
|
+
continue
|
|
76
|
+
if actual and predicted:
|
|
77
|
+
tp += 1
|
|
78
|
+
elif actual and not predicted:
|
|
79
|
+
fn += 1
|
|
80
|
+
elif not actual and predicted:
|
|
81
|
+
fp += 1
|
|
82
|
+
else:
|
|
83
|
+
tn += 1
|
|
84
|
+
|
|
85
|
+
precision = _safe_div(tp, tp + fp)
|
|
86
|
+
recall = _safe_div(tp, tp + fn)
|
|
87
|
+
f1 = _safe_div(2 * precision * recall, precision + recall)
|
|
88
|
+
fnr = _safe_div(fn, fn + tp)
|
|
89
|
+
fpr = _safe_div(fp, fp + tn)
|
|
90
|
+
accuracy = _safe_div(tp + tn, tp + tn + fp + fn)
|
|
91
|
+
|
|
92
|
+
def agg(metric: str, value: float) -> models.Score:
|
|
93
|
+
return models.Score(
|
|
94
|
+
grader=self.name, metric=metric, value=value, kind='aggregate'
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return [
|
|
98
|
+
agg('precision', precision),
|
|
99
|
+
agg('recall', recall),
|
|
100
|
+
agg('f1', f1),
|
|
101
|
+
agg('false_negative_rate', fnr),
|
|
102
|
+
agg('false_positive_rate', fpr),
|
|
103
|
+
agg('accuracy', accuracy),
|
|
104
|
+
agg('support_positive', float(tp + fn)),
|
|
105
|
+
agg('support_negative', float(tn + fp)),
|
|
106
|
+
agg('errors', float(errors)),
|
|
107
|
+
]
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Built-in deterministic (per-case) graders.
|
|
2
|
+
|
|
3
|
+
Cheap, free, fully reproducible - the first tier. They read a field off the
|
|
4
|
+
output via a ``$ref`` selector and emit a 1.0/0.0 score plus ``passed`` so the
|
|
5
|
+
runner aggregates them into a pass-rate.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
from evalkit import models, refs
|
|
11
|
+
from evalkit.graders import base
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _context(case: models.Case, output: models.Output) -> dict:
|
|
15
|
+
return {
|
|
16
|
+
'input': case.input,
|
|
17
|
+
'expected': case.expected or {},
|
|
18
|
+
'output': output.fields,
|
|
19
|
+
'case': case.model_dump(),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _score(name: str, metric: str, case_id: str, ok: bool, detail: str):
|
|
24
|
+
return models.Score(
|
|
25
|
+
grader=name,
|
|
26
|
+
metric=metric,
|
|
27
|
+
value=1.0 if ok else 0.0,
|
|
28
|
+
passed=ok,
|
|
29
|
+
detail=detail,
|
|
30
|
+
case_id=case_id,
|
|
31
|
+
kind='per_case',
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@base.register('max_chars')
|
|
36
|
+
class MaxChars:
|
|
37
|
+
"""Assert a text field is at most ``maximum`` characters long."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, field: str, maximum: int, name: str = 'max_chars'):
|
|
40
|
+
self.name = name
|
|
41
|
+
self.field = field
|
|
42
|
+
self.maximum = maximum
|
|
43
|
+
|
|
44
|
+
def grade(
|
|
45
|
+
self, case: models.Case, output: models.Output
|
|
46
|
+
) -> list[models.Score]:
|
|
47
|
+
value = refs.resolve_ref(_context(case, output), self.field)
|
|
48
|
+
length = len(value) if isinstance(value, str) else 0
|
|
49
|
+
ok = length <= self.maximum
|
|
50
|
+
return [
|
|
51
|
+
_score(
|
|
52
|
+
self.name,
|
|
53
|
+
self.name,
|
|
54
|
+
case.id,
|
|
55
|
+
ok,
|
|
56
|
+
f'len={length} max={self.maximum}',
|
|
57
|
+
)
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@base.register('regex_absent')
|
|
62
|
+
class RegexAbsent:
|
|
63
|
+
"""Assert a text field does NOT match ``pattern`` (e.g. no tokens)."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, field: str, pattern: str, name: str = 'regex_absent'):
|
|
66
|
+
self.name = name
|
|
67
|
+
self.field = field
|
|
68
|
+
self.regex = re.compile(pattern)
|
|
69
|
+
|
|
70
|
+
def grade(
|
|
71
|
+
self, case: models.Case, output: models.Output
|
|
72
|
+
) -> list[models.Score]:
|
|
73
|
+
value = refs.resolve_ref(_context(case, output), self.field)
|
|
74
|
+
text = value if isinstance(value, str) else ''
|
|
75
|
+
hit = self.regex.search(text)
|
|
76
|
+
ok = hit is None
|
|
77
|
+
detail = 'clean' if ok else f'matched {hit.group(0)!r}'
|
|
78
|
+
return [_score(self.name, self.name, case.id, ok, detail)]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@base.register('regex_present')
|
|
82
|
+
class RegexPresent:
|
|
83
|
+
"""Assert a text field matches EVERY pattern in ``patterns`` (all-of).
|
|
84
|
+
|
|
85
|
+
The positive twin of :class:`RegexAbsent`: the cheap "did it include all
|
|
86
|
+
the required constructs" gate (e.g. a form's action URL, required hidden
|
|
87
|
+
fields). Patterns are compiled case-insensitively; a single ``pattern``
|
|
88
|
+
string is accepted as shorthand for a one-element list.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
patterns: list[str] | None = None,
|
|
94
|
+
field: str = 'html',
|
|
95
|
+
pattern: str | None = None,
|
|
96
|
+
name: str = 'regex_present',
|
|
97
|
+
):
|
|
98
|
+
self.name = name
|
|
99
|
+
self.field = field
|
|
100
|
+
raw = list(patterns or ([] if pattern is None else [pattern]))
|
|
101
|
+
self.regexes = [(p, re.compile(p, re.IGNORECASE)) for p in raw]
|
|
102
|
+
|
|
103
|
+
def grade(
|
|
104
|
+
self, case: models.Case, output: models.Output
|
|
105
|
+
) -> list[models.Score]:
|
|
106
|
+
value = refs.resolve_ref(_context(case, output), self.field)
|
|
107
|
+
text = value if isinstance(value, str) else ''
|
|
108
|
+
missing = [p for p, regex in self.regexes if not regex.search(text)]
|
|
109
|
+
ok = not missing
|
|
110
|
+
detail = 'all present' if ok else f'missing {missing}'
|
|
111
|
+
return [_score(self.name, self.name, case.id, ok, detail)]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@base.register('non_empty')
|
|
115
|
+
class NonEmpty:
|
|
116
|
+
"""Assert a field resolves to a non-empty value."""
|
|
117
|
+
|
|
118
|
+
def __init__(self, field: str, name: str = 'non_empty'):
|
|
119
|
+
self.name = name
|
|
120
|
+
self.field = field
|
|
121
|
+
|
|
122
|
+
def grade(
|
|
123
|
+
self, case: models.Case, output: models.Output
|
|
124
|
+
) -> list[models.Score]:
|
|
125
|
+
value = refs.resolve_ref(_context(case, output), self.field)
|
|
126
|
+
ok = bool(value)
|
|
127
|
+
return [_score(self.name, self.name, case.id, ok, f'value={value!r}')]
|