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 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
+ )