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
rigorloop/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""RigorLoop: statistically-sound agentic build loops over dev/val/test splits."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _package_version() -> str:
|
|
7
|
+
try:
|
|
8
|
+
return version("rigorloop")
|
|
9
|
+
except PackageNotFoundError:
|
|
10
|
+
try:
|
|
11
|
+
from rigorloop._version import __version__
|
|
12
|
+
|
|
13
|
+
return __version__
|
|
14
|
+
except ImportError:
|
|
15
|
+
return "0.0.0+unknown"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
__version__ = _package_version()
|
rigorloop/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The functional core: pure, effect-free decision logic."""
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Pure parsing of rigorloop.toml text into a typed RunConfig.
|
|
2
|
+
|
|
3
|
+
"Parse, don't validate": the shell reads the raw file; past this boundary the
|
|
4
|
+
core never re-checks configuration validity."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import tomllib
|
|
9
|
+
|
|
10
|
+
from rigorloop.core.types import (
|
|
11
|
+
NOTHING,
|
|
12
|
+
AgentConfig,
|
|
13
|
+
Check,
|
|
14
|
+
ConfigParseError,
|
|
15
|
+
CustomPython,
|
|
16
|
+
Err,
|
|
17
|
+
ExactMatch,
|
|
18
|
+
GuidanceSolution,
|
|
19
|
+
InvalidValue,
|
|
20
|
+
JsonEquality,
|
|
21
|
+
LlmJudge,
|
|
22
|
+
LoopConfig,
|
|
23
|
+
MissingKey,
|
|
24
|
+
NormalizedMatch,
|
|
25
|
+
Nothing,
|
|
26
|
+
NumericTolerance,
|
|
27
|
+
Ok,
|
|
28
|
+
Option,
|
|
29
|
+
RegexMatch,
|
|
30
|
+
Result,
|
|
31
|
+
RunConfig,
|
|
32
|
+
ScriptSolution,
|
|
33
|
+
SkillSolution,
|
|
34
|
+
SolutionKind,
|
|
35
|
+
Some,
|
|
36
|
+
SplitConfig,
|
|
37
|
+
SplitRatios,
|
|
38
|
+
TaskConfig,
|
|
39
|
+
TomlSyntax,
|
|
40
|
+
ValidationConfig,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
DEFAULT_MODEL = "claude-sonnet-5"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _get_str(
|
|
47
|
+
table: dict[str, object], key: str, qualified: str, default: Option[str]
|
|
48
|
+
) -> Result[str, ConfigParseError]:
|
|
49
|
+
match table.get(key), default:
|
|
50
|
+
case None, Nothing():
|
|
51
|
+
return Err(MissingKey(qualified))
|
|
52
|
+
case None, Some(value):
|
|
53
|
+
return Ok(value)
|
|
54
|
+
case str(value), _:
|
|
55
|
+
return Ok(value)
|
|
56
|
+
case other, _:
|
|
57
|
+
return Err(InvalidValue(qualified, f"expected a string, got {other!r}"))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _get_int(
|
|
61
|
+
table: dict[str, object], key: str, qualified: str, default: int
|
|
62
|
+
) -> Result[int, ConfigParseError]:
|
|
63
|
+
value = table.get(key, default)
|
|
64
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
65
|
+
return Err(InvalidValue(qualified, f"expected an integer, got {value!r}"))
|
|
66
|
+
if value <= 0:
|
|
67
|
+
return Err(InvalidValue(qualified, f"must be positive, got {value}"))
|
|
68
|
+
return Ok(value)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _get_float(
|
|
72
|
+
table: dict[str, object], key: str, qualified: str, default: float
|
|
73
|
+
) -> Result[float, ConfigParseError]:
|
|
74
|
+
value = table.get(key, default)
|
|
75
|
+
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
76
|
+
return Err(InvalidValue(qualified, f"expected a number, got {value!r}"))
|
|
77
|
+
return Ok(float(value))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _get_bool(
|
|
81
|
+
table: dict[str, object], key: str, qualified: str, default: bool
|
|
82
|
+
) -> Result[bool, ConfigParseError]:
|
|
83
|
+
value = table.get(key, default)
|
|
84
|
+
if not isinstance(value, bool):
|
|
85
|
+
return Err(InvalidValue(qualified, f"expected a boolean, got {value!r}"))
|
|
86
|
+
return Ok(value)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _table(data: dict[str, object], key: str) -> dict[str, object]:
|
|
90
|
+
value = data.get(key)
|
|
91
|
+
return value if isinstance(value, dict) else {}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _parse_kind(name: str) -> Result[SolutionKind, ConfigParseError]:
|
|
95
|
+
match name:
|
|
96
|
+
case "script":
|
|
97
|
+
return Ok(ScriptSolution())
|
|
98
|
+
case "skill":
|
|
99
|
+
return Ok(SkillSolution())
|
|
100
|
+
case "guidance":
|
|
101
|
+
return Ok(GuidanceSolution())
|
|
102
|
+
case other:
|
|
103
|
+
return Err(
|
|
104
|
+
InvalidValue("task.solution_kind", f"expected script|skill|guidance, got {other!r}")
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _parse_task(data: dict[str, object]) -> Result[TaskConfig, ConfigParseError]:
|
|
109
|
+
table = _table(data, "task")
|
|
110
|
+
description = _get_str(table, "description_file", "task.description_file", NOTHING)
|
|
111
|
+
if isinstance(description, Err):
|
|
112
|
+
return description
|
|
113
|
+
examples = _get_str(table, "examples_file", "task.examples_file", NOTHING)
|
|
114
|
+
if isinstance(examples, Err):
|
|
115
|
+
return examples
|
|
116
|
+
kind_name = _get_str(table, "solution_kind", "task.solution_kind", Some("script"))
|
|
117
|
+
if isinstance(kind_name, Err):
|
|
118
|
+
return kind_name
|
|
119
|
+
kind = _parse_kind(kind_name.value)
|
|
120
|
+
if isinstance(kind, Err):
|
|
121
|
+
return kind
|
|
122
|
+
return Ok(TaskConfig(description.value, kind.value, examples.value))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _parse_split(data: dict[str, object]) -> Result[SplitConfig, ConfigParseError]:
|
|
126
|
+
table = _table(data, "split")
|
|
127
|
+
seed = _get_int(table, "seed", "split.seed", 17)
|
|
128
|
+
if isinstance(seed, Err):
|
|
129
|
+
return seed
|
|
130
|
+
raw = table.get("ratios", [0.6, 0.2, 0.2])
|
|
131
|
+
if (
|
|
132
|
+
not isinstance(raw, list)
|
|
133
|
+
or len(raw) != 3
|
|
134
|
+
or not all(isinstance(x, int | float) and not isinstance(x, bool) for x in raw)
|
|
135
|
+
):
|
|
136
|
+
return Err(InvalidValue("split.ratios", f"expected three numbers, got {raw!r}"))
|
|
137
|
+
ratios = SplitRatios(float(raw[0]), float(raw[1]), float(raw[2]))
|
|
138
|
+
return Ok(SplitConfig(ratios, seed.value))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _parse_loop(data: dict[str, object]) -> Result[LoopConfig, ConfigParseError]:
|
|
142
|
+
table = _table(data, "loop")
|
|
143
|
+
max_loops = _get_int(table, "max_loops", "loop.max_loops", 12)
|
|
144
|
+
if isinstance(max_loops, Err):
|
|
145
|
+
return max_loops
|
|
146
|
+
executors = _get_int(table, "executors_per_loop", "loop.executors_per_loop", 4)
|
|
147
|
+
if isinstance(executors, Err):
|
|
148
|
+
return executors
|
|
149
|
+
in_prompt = _get_int(table, "dev_examples_in_prompt", "loop.dev_examples_in_prompt", 30)
|
|
150
|
+
if isinstance(in_prompt, Err):
|
|
151
|
+
return in_prompt
|
|
152
|
+
max_fail = _get_int(
|
|
153
|
+
table, "max_consecutive_eval_failures", "loop.max_consecutive_eval_failures", 5
|
|
154
|
+
)
|
|
155
|
+
if isinstance(max_fail, Err):
|
|
156
|
+
return max_fail
|
|
157
|
+
detail = _get_int(table, "strategy_full_detail_loops", "loop.strategy_full_detail_loops", 4)
|
|
158
|
+
if isinstance(detail, Err):
|
|
159
|
+
return detail
|
|
160
|
+
return Ok(
|
|
161
|
+
LoopConfig(max_loops.value, executors.value, in_prompt.value, max_fail.value, detail.value)
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _parse_validation(data: dict[str, object]) -> Result[ValidationConfig, ConfigParseError]:
|
|
166
|
+
table = _table(data, "validation")
|
|
167
|
+
val_every = _get_int(table, "val_every", "validation.val_every", 3)
|
|
168
|
+
if isinstance(val_every, Err):
|
|
169
|
+
return val_every
|
|
170
|
+
max_peeks = _get_int(table, "max_peeks", "validation.max_peeks", 10)
|
|
171
|
+
if isinstance(max_peeks, Err):
|
|
172
|
+
return max_peeks
|
|
173
|
+
min_gap = _get_int(table, "min_loops_between_peeks", "validation.min_loops_between_peeks", 2)
|
|
174
|
+
if isinstance(min_gap, Err):
|
|
175
|
+
return min_gap
|
|
176
|
+
patience = _get_int(table, "patience", "validation.patience", 2)
|
|
177
|
+
if isinstance(patience, Err):
|
|
178
|
+
return patience
|
|
179
|
+
target: Option[float] = NOTHING
|
|
180
|
+
if "target_pass_rate" in table:
|
|
181
|
+
parsed = _get_float(table, "target_pass_rate", "validation.target_pass_rate", 0.0)
|
|
182
|
+
if isinstance(parsed, Err):
|
|
183
|
+
return parsed
|
|
184
|
+
if not 0.0 < parsed.value <= 1.0:
|
|
185
|
+
return Err(InvalidValue("validation.target_pass_rate", "must be in (0, 1]"))
|
|
186
|
+
target = Some(parsed.value)
|
|
187
|
+
return Ok(
|
|
188
|
+
ValidationConfig(val_every.value, max_peeks.value, min_gap.value, patience.value, target)
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _parse_agents(data: dict[str, object]) -> Result[AgentConfig, ConfigParseError]:
|
|
193
|
+
table = _table(data, "agents")
|
|
194
|
+
model = _get_str(table, "model", "agents.model", Some(DEFAULT_MODEL))
|
|
195
|
+
if isinstance(model, Err):
|
|
196
|
+
return model
|
|
197
|
+
timeout = _get_float(table, "timeout_s", "agents.timeout_s", 300.0)
|
|
198
|
+
if isinstance(timeout, Err):
|
|
199
|
+
return timeout
|
|
200
|
+
if timeout.value <= 0:
|
|
201
|
+
return Err(InvalidValue("agents.timeout_s", "must be positive"))
|
|
202
|
+
return Ok(AgentConfig(model.value, timeout.value))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _parse_check(index: int, table: dict[str, object]) -> Result[Check, ConfigParseError]:
|
|
206
|
+
where = f"checks[{index}]"
|
|
207
|
+
kind = _get_str(table, "type", f"{where}.type", NOTHING)
|
|
208
|
+
if isinstance(kind, Err):
|
|
209
|
+
return kind
|
|
210
|
+
match kind.value:
|
|
211
|
+
case "exact_match":
|
|
212
|
+
return Ok(ExactMatch())
|
|
213
|
+
case "normalized_match":
|
|
214
|
+
lowercase = _get_bool(table, "lowercase", f"{where}.lowercase", True)
|
|
215
|
+
strip = _get_bool(table, "strip", f"{where}.strip", True)
|
|
216
|
+
collapse = _get_bool(table, "collapse_whitespace", f"{where}.collapse_whitespace", True)
|
|
217
|
+
if isinstance(lowercase, Err):
|
|
218
|
+
return lowercase
|
|
219
|
+
if isinstance(strip, Err):
|
|
220
|
+
return strip
|
|
221
|
+
if isinstance(collapse, Err):
|
|
222
|
+
return collapse
|
|
223
|
+
return Ok(NormalizedMatch(lowercase.value, strip.value, collapse.value))
|
|
224
|
+
case "json_equality":
|
|
225
|
+
return Ok(JsonEquality())
|
|
226
|
+
case "regex_match":
|
|
227
|
+
pattern = _get_str(table, "pattern", f"{where}.pattern", NOTHING)
|
|
228
|
+
if isinstance(pattern, Err):
|
|
229
|
+
return pattern
|
|
230
|
+
return Ok(RegexMatch(pattern.value))
|
|
231
|
+
case "numeric_tolerance":
|
|
232
|
+
atol = _get_float(table, "atol", f"{where}.atol", 1e-6)
|
|
233
|
+
if isinstance(atol, Err):
|
|
234
|
+
return atol
|
|
235
|
+
rtol = _get_float(table, "rtol", f"{where}.rtol", 1e-6)
|
|
236
|
+
if isinstance(rtol, Err):
|
|
237
|
+
return rtol
|
|
238
|
+
return Ok(NumericTolerance(atol.value, rtol.value))
|
|
239
|
+
case "custom_python":
|
|
240
|
+
path = _get_str(table, "script_path", f"{where}.script_path", NOTHING)
|
|
241
|
+
if isinstance(path, Err):
|
|
242
|
+
return path
|
|
243
|
+
return Ok(CustomPython(path.value))
|
|
244
|
+
case "llm_judge":
|
|
245
|
+
rubric = _get_str(table, "rubric", f"{where}.rubric", NOTHING)
|
|
246
|
+
if isinstance(rubric, Err):
|
|
247
|
+
return rubric
|
|
248
|
+
n_samples = _get_int(table, "n_samples", f"{where}.n_samples", 3)
|
|
249
|
+
if isinstance(n_samples, Err):
|
|
250
|
+
return n_samples
|
|
251
|
+
threshold = _get_float(table, "pass_threshold", f"{where}.pass_threshold", 0.5)
|
|
252
|
+
if isinstance(threshold, Err):
|
|
253
|
+
return threshold
|
|
254
|
+
if not 0.0 < threshold.value <= 1.0:
|
|
255
|
+
return Err(InvalidValue(f"{where}.pass_threshold", "must be in (0, 1]"))
|
|
256
|
+
return Ok(LlmJudge(rubric.value, n_samples.value, threshold.value))
|
|
257
|
+
case other:
|
|
258
|
+
return Err(InvalidValue(f"{where}.type", f"unknown check type {other!r}"))
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _parse_checks(data: dict[str, object]) -> Result[tuple[Check, ...], ConfigParseError]:
|
|
262
|
+
raw = data.get("checks")
|
|
263
|
+
if raw is None:
|
|
264
|
+
return Err(MissingKey("checks"))
|
|
265
|
+
if not isinstance(raw, list) or not all(isinstance(t, dict) for t in raw) or not raw:
|
|
266
|
+
return Err(InvalidValue("checks", "expected a non-empty array of [[checks]] tables"))
|
|
267
|
+
parsed = [_parse_check(i, t) for i, t in enumerate(raw)]
|
|
268
|
+
failures = [p for p in parsed if isinstance(p, Err)]
|
|
269
|
+
if failures:
|
|
270
|
+
return failures[0]
|
|
271
|
+
return Ok(tuple(p.value for p in parsed if isinstance(p, Ok)))
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def parse_config(toml_text: str) -> Result[RunConfig, ConfigParseError]:
|
|
275
|
+
# tomllib.loads is a pure parse; the exception is converted to a Result at
|
|
276
|
+
# this boundary and never propagates inward.
|
|
277
|
+
try:
|
|
278
|
+
data: dict[str, object] = tomllib.loads(toml_text)
|
|
279
|
+
except tomllib.TOMLDecodeError as exc:
|
|
280
|
+
return Err(TomlSyntax(str(exc)))
|
|
281
|
+
|
|
282
|
+
task = _parse_task(data)
|
|
283
|
+
if isinstance(task, Err):
|
|
284
|
+
return task
|
|
285
|
+
split = _parse_split(data)
|
|
286
|
+
if isinstance(split, Err):
|
|
287
|
+
return split
|
|
288
|
+
loop = _parse_loop(data)
|
|
289
|
+
if isinstance(loop, Err):
|
|
290
|
+
return loop
|
|
291
|
+
validation = _parse_validation(data)
|
|
292
|
+
if isinstance(validation, Err):
|
|
293
|
+
return validation
|
|
294
|
+
agents = _parse_agents(data)
|
|
295
|
+
if isinstance(agents, Err):
|
|
296
|
+
return agents
|
|
297
|
+
checks = _parse_checks(data)
|
|
298
|
+
if isinstance(checks, Err):
|
|
299
|
+
return checks
|
|
300
|
+
return Ok(
|
|
301
|
+
RunConfig(task.value, split.value, loop.value, validation.value, agents.value, checks.value)
|
|
302
|
+
)
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Pure dataset logic: parsing examples, deduplication, splitting, manifests,
|
|
2
|
+
and statistical power warnings. No I/O; text in, typed values out."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import random
|
|
10
|
+
from collections import Counter
|
|
11
|
+
|
|
12
|
+
from rigorloop.core.types import (
|
|
13
|
+
BadRatios,
|
|
14
|
+
DevExample,
|
|
15
|
+
DuplicateWarning,
|
|
16
|
+
Err,
|
|
17
|
+
Example,
|
|
18
|
+
ExampleDigest,
|
|
19
|
+
ExampleParseError,
|
|
20
|
+
InvalidJsonLine,
|
|
21
|
+
MissingField,
|
|
22
|
+
NotAnObject,
|
|
23
|
+
Ok,
|
|
24
|
+
PowerWarning,
|
|
25
|
+
Result,
|
|
26
|
+
Split,
|
|
27
|
+
SplitError,
|
|
28
|
+
SplitManifest,
|
|
29
|
+
SplitRatios,
|
|
30
|
+
TestExample,
|
|
31
|
+
TooFewExamples,
|
|
32
|
+
ValExample,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
_MIN_EXAMPLES = 5
|
|
36
|
+
_PREVIEW_CHARS = 60
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _content_hash(input_text: str, expected_output: str) -> str:
|
|
40
|
+
digest = hashlib.sha256(f"{input_text}\x1f{expected_output}".encode()).hexdigest()
|
|
41
|
+
return digest[:16]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _example_id(input_text: str) -> str:
|
|
45
|
+
return "ex-" + hashlib.sha256(input_text.encode()).hexdigest()[:12]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _field_as_text(value: object) -> str:
|
|
49
|
+
"""Structured inputs are JSON-encoded; plain strings pass through."""
|
|
50
|
+
return value if isinstance(value, str) else json.dumps(value, sort_keys=True)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _parse_line(line_number: int, line: str) -> Result[Example, ExampleParseError]:
|
|
54
|
+
try:
|
|
55
|
+
obj = json.loads(line)
|
|
56
|
+
except json.JSONDecodeError as exc:
|
|
57
|
+
return Err(InvalidJsonLine(line_number, str(exc)))
|
|
58
|
+
if not isinstance(obj, dict):
|
|
59
|
+
return Err(NotAnObject(line_number))
|
|
60
|
+
if "input" not in obj:
|
|
61
|
+
return Err(MissingField(line_number, "input"))
|
|
62
|
+
if "expected_output" not in obj:
|
|
63
|
+
return Err(MissingField(line_number, "expected_output"))
|
|
64
|
+
input_text = _field_as_text(obj["input"])
|
|
65
|
+
expected = _field_as_text(obj["expected_output"])
|
|
66
|
+
return Ok(Example(_example_id(input_text), input_text, expected))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def parse_examples(jsonl_text: str) -> Result[tuple[Example, ...], ExampleParseError]:
|
|
70
|
+
"""Parse a JSONL document of {"input": ..., "expected_output": ...} records.
|
|
71
|
+
|
|
72
|
+
Blank lines are skipped. The first malformed line aborts the parse."""
|
|
73
|
+
numbered = [
|
|
74
|
+
(i, line) for i, line in enumerate(jsonl_text.splitlines(), start=1) if line.strip()
|
|
75
|
+
]
|
|
76
|
+
parsed = [_parse_line(i, line) for i, line in numbered]
|
|
77
|
+
failures = [r for r in parsed if isinstance(r, Err)]
|
|
78
|
+
if failures:
|
|
79
|
+
return failures[0]
|
|
80
|
+
return Ok(tuple(r.value for r in parsed if isinstance(r, Ok)))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def dedupe_examples(
|
|
84
|
+
examples: tuple[Example, ...],
|
|
85
|
+
) -> tuple[tuple[Example, ...], tuple[DuplicateWarning, ...]]:
|
|
86
|
+
"""Collapse examples with identical input text (first occurrence wins).
|
|
87
|
+
|
|
88
|
+
A duplicate straddling dev and test would silently corrupt the holdout."""
|
|
89
|
+
counts = Counter(ex.example_id for ex in examples)
|
|
90
|
+
first_by_id = {ex.example_id: ex for ex in reversed(examples)}
|
|
91
|
+
seen_order = tuple(dict.fromkeys(ex.example_id for ex in examples))
|
|
92
|
+
unique = tuple(first_by_id[ex_id] for ex_id in seen_order)
|
|
93
|
+
warnings = tuple(
|
|
94
|
+
DuplicateWarning(first_by_id[ex_id].input_text[:_PREVIEW_CHARS], counts[ex_id])
|
|
95
|
+
for ex_id in seen_order
|
|
96
|
+
if counts[ex_id] > 1
|
|
97
|
+
)
|
|
98
|
+
return unique, warnings
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _split_sizes(n: int, ratios: SplitRatios) -> tuple[int, int, int]:
|
|
102
|
+
"""Largest-remainder allocation: disjoint, exhaustive, each split non-empty
|
|
103
|
+
(callers guarantee n >= _MIN_EXAMPLES)."""
|
|
104
|
+
raw = (n * ratios.dev, n * ratios.val, n * ratios.test)
|
|
105
|
+
floors = tuple(max(1, math.floor(x)) for x in raw)
|
|
106
|
+
delta = n - sum(floors)
|
|
107
|
+
by_frac = sorted(range(3), key=lambda i: raw[i] - math.floor(raw[i]), reverse=True)
|
|
108
|
+
bumped = tuple(floors[i] + (1 if delta > 0 and i in by_frac[:delta] else 0) for i in range(3))
|
|
109
|
+
# If the minimum-1 bumps overshot (tiny n), take the excess from the largest split.
|
|
110
|
+
excess = sum(bumped) - n
|
|
111
|
+
largest = max(range(3), key=lambda i: bumped[i])
|
|
112
|
+
sizes = tuple(bumped[i] - (excess if i == largest else 0) for i in range(3))
|
|
113
|
+
return (sizes[0], sizes[1], sizes[2])
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def split_examples(
|
|
117
|
+
examples: tuple[Example, ...], ratios: SplitRatios, seed: int
|
|
118
|
+
) -> Result[Split, SplitError]:
|
|
119
|
+
"""Deterministically partition examples into dev/val/test.
|
|
120
|
+
|
|
121
|
+
Guarantees: disjoint, exhaustive, stable for a given seed, each split
|
|
122
|
+
non-empty. Input order does not matter (examples are sorted before the
|
|
123
|
+
seeded shuffle)."""
|
|
124
|
+
if abs(ratios.dev + ratios.val + ratios.test - 1.0) > 1e-9:
|
|
125
|
+
return Err(BadRatios(f"ratios must sum to 1, got {ratios}"))
|
|
126
|
+
if min(ratios.dev, ratios.val, ratios.test) <= 0:
|
|
127
|
+
return Err(BadRatios("every split ratio must be positive"))
|
|
128
|
+
if len(examples) < _MIN_EXAMPLES:
|
|
129
|
+
return Err(TooFewExamples(len(examples), _MIN_EXAMPLES))
|
|
130
|
+
|
|
131
|
+
ordered = sorted(examples, key=lambda e: e.example_id)
|
|
132
|
+
# random.Random(seed) is a pure function of the seed parameter: same seed,
|
|
133
|
+
# same shuffle, no ambient randomness touched.
|
|
134
|
+
shuffled = random.Random(seed).sample(ordered, k=len(ordered))
|
|
135
|
+
n_dev, n_val, _ = _split_sizes(len(shuffled), ratios)
|
|
136
|
+
return Ok(
|
|
137
|
+
Split(
|
|
138
|
+
dev=tuple(DevExample(e) for e in shuffled[:n_dev]),
|
|
139
|
+
val=tuple(ValExample(e) for e in shuffled[n_dev : n_dev + n_val]),
|
|
140
|
+
test=tuple(TestExample(e) for e in shuffled[n_dev + n_val :]),
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def build_manifest(
|
|
146
|
+
split: Split, ratios: SplitRatios, seed: int, eval_model: str, cli_version: str
|
|
147
|
+
) -> SplitManifest:
|
|
148
|
+
"""Content-hash manifest pinning the split and the evaluating model, so a
|
|
149
|
+
resume can never reshuffle examples across splits."""
|
|
150
|
+
return SplitManifest(
|
|
151
|
+
seed=seed,
|
|
152
|
+
ratios=ratios,
|
|
153
|
+
dev=tuple(_digest(d.example) for d in split.dev),
|
|
154
|
+
val=tuple(_digest(v.example) for v in split.val),
|
|
155
|
+
test=tuple(_digest(t.example) for t in split.test),
|
|
156
|
+
eval_model=eval_model,
|
|
157
|
+
cli_version=cli_version,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _digest(example: Example) -> ExampleDigest:
|
|
162
|
+
return ExampleDigest(
|
|
163
|
+
example.example_id, _content_hash(example.input_text, example.expected_output)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def manifest_matches(manifest: SplitManifest, split: Split) -> bool:
|
|
168
|
+
"""True iff the split's membership and contents match the manifest exactly."""
|
|
169
|
+
return (
|
|
170
|
+
manifest.dev == tuple(_digest(d.example) for d in split.dev)
|
|
171
|
+
and manifest.val == tuple(_digest(v.example) for v in split.val)
|
|
172
|
+
and manifest.test == tuple(_digest(t.example) for t in split.test)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def power_warnings(split: Split, threshold: float = 0.10) -> tuple[PowerWarning, ...]:
|
|
177
|
+
"""Warn when a split is too small to distinguish meaningful pass-rate
|
|
178
|
+
differences. Half-width is the worst-case (p=0.5) 95% margin ~1.96*sqrt(.25/n)."""
|
|
179
|
+
sizes = (("dev", len(split.dev)), ("validation", len(split.val)), ("test", len(split.test)))
|
|
180
|
+
widths = tuple((name, n, 1.96 * math.sqrt(0.25 / n)) for name, n in sizes)
|
|
181
|
+
return tuple(
|
|
182
|
+
PowerWarning(
|
|
183
|
+
split_name=name,
|
|
184
|
+
n=n,
|
|
185
|
+
half_width=hw,
|
|
186
|
+
message=(
|
|
187
|
+
f"{name} set of {n} examples can only distinguish pass-rate "
|
|
188
|
+
f"differences of ~±{round(hw * 100)} points"
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
for name, n, hw in widths
|
|
192
|
+
if hw > threshold
|
|
193
|
+
)
|