proofstep-core 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.
@@ -0,0 +1,59 @@
1
+ """Proofstep evaluation engine (pure library — no I/O).
2
+
3
+ No HTTP, no database, no provider SDKs. Model access arrives through an injected
4
+ `ModelClient` protocol. That boundary is what makes local mode, CI mode, and server
5
+ mode the same code path, and it is enforced in CI by `.importlinter`.
6
+ """
7
+
8
+ from importlib import metadata as _metadata
9
+
10
+ from proofstep_core.aggregate import aggregate_scores, scores_for
11
+ from proofstep_core.compare import Comparison, ExampleRegression, compare_metrics
12
+ from proofstep_core.dataset import Dataset
13
+ from proofstep_core.gates import GateReport, evaluate_gates
14
+ from proofstep_core.paths import PathError, resolve, resolve_in_context
15
+ from proofstep_core.runner import EvalResult, RunConfig, run_suite
16
+ from proofstep_core.suite import EvalSuite, FunctionEvaluator, evaluate
17
+ from proofstep_core.types import (
18
+ CorpusEvaluator,
19
+ EvalContext,
20
+ Evaluator,
21
+ EvaluatorBase,
22
+ Message,
23
+ ModelClient,
24
+ ModelResponse,
25
+ Task,
26
+ )
27
+
28
+ # Read from the installed distribution rather than written here twice. A hand-maintained
29
+ # copy drifts the first time a release bumps one and not the other — which it already did,
30
+ # reporting 0.1.0.dev0 from a 0.1.0 wheel.
31
+ __version__ = _metadata.version("proofstep-core")
32
+
33
+ __all__ = [
34
+ "Comparison",
35
+ "CorpusEvaluator",
36
+ "Dataset",
37
+ "EvalContext",
38
+ "EvalResult",
39
+ "EvalSuite",
40
+ "Evaluator",
41
+ "EvaluatorBase",
42
+ "ExampleRegression",
43
+ "FunctionEvaluator",
44
+ "GateReport",
45
+ "Message",
46
+ "ModelClient",
47
+ "ModelResponse",
48
+ "PathError",
49
+ "RunConfig",
50
+ "Task",
51
+ "aggregate_scores",
52
+ "compare_metrics",
53
+ "evaluate",
54
+ "evaluate_gates",
55
+ "resolve",
56
+ "resolve_in_context",
57
+ "run_suite",
58
+ "scores_for",
59
+ ]
@@ -0,0 +1,140 @@
1
+ """Roll per-example scores up into metrics.
2
+
3
+ The load-bearing rule here: **errored evaluations are excluded from the mean and
4
+ counted separately.** A judge that timed out is not a failing example, and averaging
5
+ infrastructure failures in as zeros is the fastest way to make a metric untrustworthy
6
+ (docs/EVALUATION_ENGINE.md §1).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections import defaultdict
12
+ from collections.abc import Iterable, Sequence
13
+
14
+ from proofstep_core.stats import bootstrap_ci, mean, stddev
15
+ from proofstep_types import ExampleResult, Metric, Score
16
+
17
+ # A slice is identified by its sorted key/value pairs so it can be a dict key.
18
+ SliceKey = tuple[tuple[str, str], ...]
19
+
20
+
21
+ def _slice_key(slice_: dict[str, str] | None) -> SliceKey:
22
+ return tuple(sorted(slice_.items())) if slice_ else ()
23
+
24
+
25
+ def _unkey(key: SliceKey) -> dict[str, str] | None:
26
+ return dict(key) if key else None
27
+
28
+
29
+ def aggregate_scores(
30
+ results: Iterable[ExampleResult],
31
+ *,
32
+ slice_by: Sequence[str] = (),
33
+ confidence_intervals: bool = True,
34
+ seed: int = 42,
35
+ ) -> list[Metric]:
36
+ """Aggregate every score across every result into metrics.
37
+
38
+ ``slice_by`` names metadata keys to additionally break each metric down by. This
39
+ is what surfaces a rare-class collapse that the aggregate hides: slicing
40
+ ``per_class_recall`` by ``class`` makes the unsubscribe number *visible* even
41
+ when nobody thought to gate on it.
42
+ """
43
+ buckets: dict[tuple[str, SliceKey], list[float]] = defaultdict(list)
44
+ errors: dict[tuple[str, SliceKey], int] = defaultdict(int)
45
+
46
+ for result in results:
47
+ extra = _extra_slices(result, slice_by)
48
+ for score in result.scores:
49
+ own = _slice_key(score.slice)
50
+ for slice_key in {own, *[_merge(own, s) for s in extra]}:
51
+ bucket = (score.metric, slice_key)
52
+ if score.errored:
53
+ errors[bucket] += 1
54
+ elif score.value is not None:
55
+ buckets[bucket].append(score.value)
56
+
57
+ # A metric that produced nothing but errors must still appear, with count 0 —
58
+ # otherwise a gate on it sees "metric missing" and cannot distinguish a typo
59
+ # from a wholly broken evaluator.
60
+ metrics: list[Metric] = []
61
+ for bucket in sorted(set(buckets) | set(errors)):
62
+ metric_key, slice_key = bucket
63
+ values = buckets.get(bucket, [])
64
+ error_count = errors.get(bucket, 0)
65
+ metrics.append(
66
+ _build_metric(
67
+ metric_key,
68
+ values,
69
+ error_count,
70
+ _unkey(slice_key),
71
+ confidence_intervals=confidence_intervals,
72
+ seed=seed,
73
+ )
74
+ )
75
+ return metrics
76
+
77
+
78
+ def _build_metric(
79
+ key: str,
80
+ values: list[float],
81
+ error_count: int,
82
+ slice_: dict[str, str] | None,
83
+ *,
84
+ confidence_intervals: bool,
85
+ seed: int,
86
+ ) -> Metric:
87
+ if not values:
88
+ return Metric(key=key, value=0.0, count=0, error_count=error_count, slice=slice_)
89
+
90
+ ci_low = ci_high = None
91
+ # Bootstrapping a handful of points produces an interval that says nothing;
92
+ # reporting one anyway would imply precision that isn't there.
93
+ if confidence_intervals and len(values) >= 5:
94
+ ci_low, ci_high = bootstrap_ci(values, seed=seed)
95
+
96
+ return Metric(
97
+ key=key,
98
+ value=mean(values),
99
+ count=len(values),
100
+ error_count=error_count,
101
+ stddev=stddev(values),
102
+ ci_low=ci_low,
103
+ ci_high=ci_high,
104
+ slice=slice_,
105
+ )
106
+
107
+
108
+ def _extra_slices(result: ExampleResult, slice_by: Sequence[str]) -> list[SliceKey]:
109
+ keys: list[SliceKey] = []
110
+ for dimension in slice_by:
111
+ value = result.metadata.get(dimension)
112
+ if value is None and result.expected is not None:
113
+ value = result.expected.get(dimension)
114
+ if value is not None:
115
+ keys.append(((dimension, str(value)),))
116
+ return keys
117
+
118
+
119
+ def _merge(a: SliceKey, b: SliceKey) -> SliceKey:
120
+ return tuple(sorted({**dict(a), **dict(b)}.items()))
121
+
122
+
123
+ def scores_for(results: Iterable[ExampleResult], metric_key: str) -> list[float]:
124
+ """Every non-errored value for one metric, in result order.
125
+
126
+ Used by comparison to bootstrap a CI on the delta between two runs.
127
+ """
128
+ return [
129
+ score.value
130
+ for result in results
131
+ for score in result.scores
132
+ if score.metric == metric_key and score.counts_toward_mean and score.value is not None
133
+ ]
134
+
135
+
136
+ def group_by_metric(scores: Iterable[Score]) -> dict[str, list[Score]]:
137
+ grouped: dict[str, list[Score]] = defaultdict(list)
138
+ for score in scores:
139
+ grouped[score.metric].append(score)
140
+ return dict(grouped)