rigorloop 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.
- rigorloop/__init__.py +18 -0
- rigorloop/_version.py +24 -0
- rigorloop/core/__init__.py +1 -0
- rigorloop/core/config_calcs.py +302 -0
- rigorloop/core/dataset_calcs.py +193 -0
- rigorloop/core/prompt_calcs.py +329 -0
- rigorloop/core/report_calcs.py +194 -0
- rigorloop/core/scoring_calcs.py +264 -0
- rigorloop/core/strategy_calcs.py +507 -0
- rigorloop/core/types.py +649 -0
- rigorloop/py.typed +0 -0
- rigorloop/shell/__init__.py +1 -0
- rigorloop/shell/agent_calls.py +120 -0
- rigorloop/shell/cli.py +1111 -0
- rigorloop/shell/io_actions.py +497 -0
- rigorloop-0.1.0.dist-info/METADATA +224 -0
- rigorloop-0.1.0.dist-info/RECORD +20 -0
- rigorloop-0.1.0.dist-info/WHEEL +4 -0
- rigorloop-0.1.0.dist-info/entry_points.txt +2 -0
- rigorloop-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Pure scoring logic: check evaluation, aggregation, and the statistics that
|
|
2
|
+
make the loop rigorous (Wilson intervals, bootstrap CIs, McNemar tests)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import random
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from rigorloop.core.types import (
|
|
12
|
+
CandidateScore,
|
|
13
|
+
Check,
|
|
14
|
+
CheckOutcome,
|
|
15
|
+
CheckPassRate,
|
|
16
|
+
CustomPython,
|
|
17
|
+
DeterministicCheck,
|
|
18
|
+
Errored,
|
|
19
|
+
ExactMatch,
|
|
20
|
+
ExampleResult,
|
|
21
|
+
Failed,
|
|
22
|
+
JsonEquality,
|
|
23
|
+
JudgeVerdict,
|
|
24
|
+
LlmJudge,
|
|
25
|
+
NamedOutcome,
|
|
26
|
+
NormalizedMatch,
|
|
27
|
+
NumericTolerance,
|
|
28
|
+
Passed,
|
|
29
|
+
RegexMatch,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
Z_95 = 1.959963984540054
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# --------------------------------------------------------------------------
|
|
36
|
+
# Check names and deterministic evaluation
|
|
37
|
+
# --------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def check_name(check: Check) -> str:
|
|
41
|
+
match check:
|
|
42
|
+
case ExactMatch():
|
|
43
|
+
return "exact_match"
|
|
44
|
+
case NormalizedMatch():
|
|
45
|
+
return "normalized_match"
|
|
46
|
+
case JsonEquality():
|
|
47
|
+
return "json_equality"
|
|
48
|
+
case RegexMatch():
|
|
49
|
+
return "regex_match"
|
|
50
|
+
case NumericTolerance():
|
|
51
|
+
return "numeric_tolerance"
|
|
52
|
+
case CustomPython():
|
|
53
|
+
return "custom_python"
|
|
54
|
+
case LlmJudge():
|
|
55
|
+
return "llm_judge"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def named_checks(checks: tuple[Check, ...]) -> tuple[tuple[str, Check], ...]:
|
|
59
|
+
"""Stable, unique display names: duplicates get #2, #3, … suffixes so two
|
|
60
|
+
checks of the same type can't collide in per-check breakdowns."""
|
|
61
|
+
|
|
62
|
+
def name_at(index: int, check: Check) -> str:
|
|
63
|
+
base = check_name(check)
|
|
64
|
+
prior = sum(1 for c in checks[:index] if check_name(c) == base)
|
|
65
|
+
return base if prior == 0 else f"{base}#{prior + 1}"
|
|
66
|
+
|
|
67
|
+
return tuple((name_at(i, c), c) for i, c in enumerate(checks))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _normalize(text: str, check: NormalizedMatch) -> str:
|
|
71
|
+
lowered = text.lower() if check.lowercase else text
|
|
72
|
+
stripped = lowered.strip() if check.strip else lowered
|
|
73
|
+
return re.sub(r"\s+", " ", stripped) if check.collapse_whitespace else stripped
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _truncate(text: str, limit: int = 120) -> str:
|
|
77
|
+
return text if len(text) <= limit else text[: limit - 1] + "…"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def evaluate_deterministic_check(
|
|
81
|
+
check: DeterministicCheck, expected: str, actual: str
|
|
82
|
+
) -> CheckOutcome:
|
|
83
|
+
match check:
|
|
84
|
+
case ExactMatch():
|
|
85
|
+
return (
|
|
86
|
+
Passed()
|
|
87
|
+
if actual == expected
|
|
88
|
+
else Failed(f"expected {_truncate(expected)!r}, got {_truncate(actual)!r}")
|
|
89
|
+
)
|
|
90
|
+
case NormalizedMatch():
|
|
91
|
+
return (
|
|
92
|
+
Passed()
|
|
93
|
+
if _normalize(actual, check) == _normalize(expected, check)
|
|
94
|
+
else Failed(
|
|
95
|
+
f"normalized mismatch: expected {_truncate(expected)!r}, "
|
|
96
|
+
f"got {_truncate(actual)!r}"
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
case JsonEquality():
|
|
100
|
+
try:
|
|
101
|
+
expected_obj = json.loads(expected)
|
|
102
|
+
except json.JSONDecodeError as exc:
|
|
103
|
+
return Errored(f"expected output is not valid JSON: {exc}")
|
|
104
|
+
try:
|
|
105
|
+
actual_obj = json.loads(actual)
|
|
106
|
+
except json.JSONDecodeError as exc:
|
|
107
|
+
return Failed(f"output is not valid JSON: {exc}")
|
|
108
|
+
return Passed() if actual_obj == expected_obj else Failed("JSON values differ")
|
|
109
|
+
case RegexMatch():
|
|
110
|
+
try:
|
|
111
|
+
pattern = re.compile(check.pattern, re.DOTALL)
|
|
112
|
+
except re.error as exc:
|
|
113
|
+
return Errored(f"invalid pattern {check.pattern!r}: {exc}")
|
|
114
|
+
return (
|
|
115
|
+
Passed()
|
|
116
|
+
if pattern.search(actual) is not None
|
|
117
|
+
else Failed(f"pattern {check.pattern!r} not found in output")
|
|
118
|
+
)
|
|
119
|
+
case NumericTolerance():
|
|
120
|
+
try:
|
|
121
|
+
expected_num = float(expected.strip())
|
|
122
|
+
except ValueError:
|
|
123
|
+
return Errored(f"expected output {_truncate(expected)!r} is not numeric")
|
|
124
|
+
try:
|
|
125
|
+
actual_num = float(actual.strip())
|
|
126
|
+
except ValueError:
|
|
127
|
+
return Failed(f"output {_truncate(actual)!r} is not numeric")
|
|
128
|
+
return (
|
|
129
|
+
Passed()
|
|
130
|
+
if math.isclose(actual_num, expected_num, rel_tol=check.rtol, abs_tol=check.atol)
|
|
131
|
+
else Failed(f"{actual_num} not within tolerance of {expected_num}")
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def judge_outcome(check: LlmJudge, verdicts: tuple[JudgeVerdict, ...], errors: int) -> CheckOutcome:
|
|
136
|
+
"""Aggregate n judge samples into one outcome (majority vote against the
|
|
137
|
+
configured threshold). `errors` counts judge calls that failed outright."""
|
|
138
|
+
if not verdicts:
|
|
139
|
+
return Errored(f"all {errors} judge calls failed")
|
|
140
|
+
pass_fraction = sum(1 for v in verdicts if v.passed) / len(verdicts)
|
|
141
|
+
if pass_fraction >= check.pass_threshold:
|
|
142
|
+
return Passed()
|
|
143
|
+
failing = [v.reason for v in verdicts if not v.passed]
|
|
144
|
+
return Failed(
|
|
145
|
+
f"judge pass fraction {pass_fraction:.2f} < {check.pass_threshold:.2f}: "
|
|
146
|
+
+ _truncate("; ".join(failing))
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# --------------------------------------------------------------------------
|
|
151
|
+
# Statistics
|
|
152
|
+
# --------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def wilson_interval(passes: int, n: int, z: float = Z_95) -> tuple[float, float]:
|
|
156
|
+
"""Wilson score interval for a binomial proportion; honest at small n."""
|
|
157
|
+
if n == 0:
|
|
158
|
+
return (0.0, 1.0)
|
|
159
|
+
p_hat = passes / n
|
|
160
|
+
denom = 1 + z * z / n
|
|
161
|
+
center = (p_hat + z * z / (2 * n)) / denom
|
|
162
|
+
margin = (z / denom) * math.sqrt(p_hat * (1 - p_hat) / n + z * z / (4 * n * n))
|
|
163
|
+
return (max(0.0, center - margin), min(1.0, center + margin))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def bootstrap_ci(
|
|
167
|
+
values: tuple[float, ...], seed: int, n_resamples: int = 2000, alpha: float = 0.05
|
|
168
|
+
) -> tuple[float, float]:
|
|
169
|
+
"""Percentile bootstrap CI for a mean. Resample indices derive from the
|
|
170
|
+
injected seed, so results are reproducible."""
|
|
171
|
+
if not values:
|
|
172
|
+
return (0.0, 0.0)
|
|
173
|
+
rng = random.Random(seed) # pure function of the seed parameter
|
|
174
|
+
n = len(values)
|
|
175
|
+
means = sorted(sum(values[rng.randrange(n)] for _ in range(n)) / n for _ in range(n_resamples))
|
|
176
|
+
lo_index = int((alpha / 2) * n_resamples)
|
|
177
|
+
hi_index = min(n_resamples - 1, int((1 - alpha / 2) * n_resamples))
|
|
178
|
+
return (means[lo_index], means[hi_index])
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def mcnemar_p(vector_a: tuple[bool, ...], vector_b: tuple[bool, ...]) -> float:
|
|
182
|
+
"""Exact two-sided McNemar test on paired pass/fail vectors.
|
|
183
|
+
|
|
184
|
+
Returns the p-value for the null that both candidates have the same
|
|
185
|
+
per-example pass probability. Vectors must be aligned to the same example
|
|
186
|
+
order; unequal lengths are treated as maximally uninformative (p=1)."""
|
|
187
|
+
if len(vector_a) != len(vector_b) or not vector_a:
|
|
188
|
+
return 1.0
|
|
189
|
+
a_only = sum(1 for a, b in zip(vector_a, vector_b, strict=True) if a and not b)
|
|
190
|
+
b_only = sum(1 for a, b in zip(vector_a, vector_b, strict=True) if b and not a)
|
|
191
|
+
discordant = a_only + b_only
|
|
192
|
+
if discordant == 0:
|
|
193
|
+
return 1.0
|
|
194
|
+
k = min(a_only, b_only)
|
|
195
|
+
# Exact binomial: 2 * P(X <= k) under X ~ Bin(discordant, 0.5), capped at 1.
|
|
196
|
+
tail = sum(math.comb(discordant, i) for i in range(k + 1)) / (1 << discordant)
|
|
197
|
+
return min(1.0, 2 * tail)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def significantly_better(
|
|
201
|
+
challenger: tuple[bool, ...], incumbent: tuple[bool, ...], alpha: float = 0.05
|
|
202
|
+
) -> bool:
|
|
203
|
+
"""True iff the challenger beats the incumbent beyond the paired noise band."""
|
|
204
|
+
challenger_wins = sum(challenger) > sum(incumbent)
|
|
205
|
+
return challenger_wins and mcnemar_p(challenger, incumbent) < alpha
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# --------------------------------------------------------------------------
|
|
209
|
+
# Aggregation
|
|
210
|
+
# --------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def example_passed(result: ExampleResult) -> bool:
|
|
214
|
+
"""The headline metric is conjunctive: every configured check must pass."""
|
|
215
|
+
return bool(result.outcomes) and all(isinstance(o.outcome, Passed) for o in result.outcomes)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _per_check_rates(
|
|
219
|
+
results: tuple[ExampleResult, ...], names: tuple[str, ...]
|
|
220
|
+
) -> tuple[CheckPassRate, ...]:
|
|
221
|
+
def rate(name: str) -> CheckPassRate:
|
|
222
|
+
outcomes = [o for r in results for o in r.outcomes if o.check_name == name]
|
|
223
|
+
return CheckPassRate(
|
|
224
|
+
check_name=name,
|
|
225
|
+
passes=sum(1 for o in outcomes if isinstance(o.outcome, Passed)),
|
|
226
|
+
n=len(outcomes),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
return tuple(rate(name) for name in names)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def score_candidate(
|
|
233
|
+
results: tuple[ExampleResult, ...],
|
|
234
|
+
example_order: tuple[str, ...],
|
|
235
|
+
check_names: tuple[str, ...],
|
|
236
|
+
eval_aborted: bool,
|
|
237
|
+
) -> CandidateScore:
|
|
238
|
+
"""Fold per-example results into a CandidateScore.
|
|
239
|
+
|
|
240
|
+
`example_order` is the id order of the FULL evaluation set; examples with
|
|
241
|
+
no result (short-circuited evaluation) count as failures so pass vectors
|
|
242
|
+
stay aligned for paired tests."""
|
|
243
|
+
by_id = {r.example_id: r for r in results}
|
|
244
|
+
pass_vector = tuple(ex_id in by_id and example_passed(by_id[ex_id]) for ex_id in example_order)
|
|
245
|
+
n = len(example_order)
|
|
246
|
+
passes = sum(pass_vector)
|
|
247
|
+
ci_low, ci_high = wilson_interval(passes, n)
|
|
248
|
+
return CandidateScore(
|
|
249
|
+
n=n,
|
|
250
|
+
passes=passes,
|
|
251
|
+
pass_rate=passes / n if n else 0.0,
|
|
252
|
+
ci_low=ci_low,
|
|
253
|
+
ci_high=ci_high,
|
|
254
|
+
per_check=_per_check_rates(results, check_names),
|
|
255
|
+
pass_vector=pass_vector,
|
|
256
|
+
eval_aborted=eval_aborted,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def execution_failure_outcomes(
|
|
261
|
+
check_names: tuple[str, ...], detail: str
|
|
262
|
+
) -> tuple[NamedOutcome, ...]:
|
|
263
|
+
"""When a candidate fails to produce output, every check is Errored."""
|
|
264
|
+
return tuple(NamedOutcome(name, Errored(detail)) for name in check_names)
|