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.
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']
@@ -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}')]