rulelab-cli 0.1.2__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.
rulelab/rules.py ADDED
@@ -0,0 +1,225 @@
1
+ """Split an AGENTS.md / CLAUDE.md file into rules that can be removed one at a time."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Literal
10
+
11
+ Granularity = Literal["section", "bullet"]
12
+
13
+ _HEADING = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$")
14
+ _FENCE = re.compile(r"^\s{0,3}(```|~~~)")
15
+ _BULLET = re.compile(r"^( {0,1})([-*+]|\d{1,3}[.)])\s+(.*)$")
16
+
17
+
18
+ def approx_tokens(text: str) -> int:
19
+ """Rough token count (characters / 4). Labelled as approximate everywhere it is shown."""
20
+ stripped = text.strip()
21
+ return math.ceil(len(stripped) / 4) if stripped else 0
22
+
23
+
24
+ @dataclass
25
+ class Rule:
26
+ id: str
27
+ title: str
28
+ text: str
29
+ start_line: int # 1-based, inclusive
30
+ end_line: int # 1-based, inclusive
31
+
32
+ @property
33
+ def tokens(self) -> int:
34
+ return approx_tokens(self.text)
35
+
36
+ def to_dict(self) -> dict:
37
+ return {
38
+ "id": self.id,
39
+ "title": self.title,
40
+ "text": self.text,
41
+ "start_line": self.start_line,
42
+ "end_line": self.end_line,
43
+ "tokens": self.tokens,
44
+ }
45
+
46
+
47
+ @dataclass
48
+ class Segment:
49
+ lines: list[str]
50
+ rule_id: str | None # None = always kept (titles, headings in bullet mode, blank lines)
51
+
52
+
53
+ @dataclass
54
+ class RuleFile:
55
+ path: str
56
+ text: str
57
+ granularity: Granularity
58
+ segments: list[Segment] = field(default_factory=list)
59
+ rules: list[Rule] = field(default_factory=list)
60
+
61
+ @property
62
+ def tokens(self) -> int:
63
+ return approx_tokens(self.text)
64
+
65
+ def render(self, without: set[str] | frozenset[str] = frozenset()) -> str:
66
+ """Return the file text with the given rule ids removed."""
67
+ unknown = set(without) - {r.id for r in self.rules}
68
+ if unknown:
69
+ raise ValueError(f"unknown rule id(s): {', '.join(sorted(unknown))}")
70
+ out: list[str] = []
71
+ for seg in self.segments:
72
+ if seg.rule_id is not None and seg.rule_id in without:
73
+ continue
74
+ out.extend(seg.lines)
75
+ text = "\n".join(out)
76
+ text = re.sub(r"\n{3,}", "\n\n", text).strip("\n")
77
+ return text + "\n" if text else ""
78
+
79
+ def rule(self, rule_id: str) -> Rule:
80
+ for r in self.rules:
81
+ if r.id == rule_id:
82
+ return r
83
+ raise KeyError(rule_id)
84
+
85
+
86
+ def _code_mask(lines: list[str]) -> list[bool]:
87
+ """True for lines inside (or delimiting) fenced code blocks."""
88
+ mask: list[bool] = []
89
+ fence: str | None = None
90
+ for line in lines:
91
+ m = _FENCE.match(line)
92
+ if fence is None and m:
93
+ fence = m.group(1)
94
+ mask.append(True)
95
+ elif fence is not None:
96
+ mask.append(True)
97
+ if line.strip().startswith(fence):
98
+ fence = None
99
+ else:
100
+ mask.append(False)
101
+ return mask
102
+
103
+
104
+ def _headings(lines: list[str], mask: list[bool]) -> list[tuple[int, int, str]]:
105
+ out = []
106
+ for i, line in enumerate(lines):
107
+ if mask[i]:
108
+ continue
109
+ m = _HEADING.match(line)
110
+ if m:
111
+ out.append((i, len(m.group(1)), m.group(2).strip()))
112
+ return out
113
+
114
+
115
+ def _short(text: str, limit: int = 72) -> str:
116
+ text = re.sub(r"\s+", " ", text).strip()
117
+ text = re.sub(r"[*_`]", "", text)
118
+ return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
119
+
120
+
121
+ def _split_sections(lines: list[str], mask: list[bool]) -> list[tuple[int, int, str | None]]:
122
+ """Return (start, end_exclusive, title) chunks; title None means fixed preamble."""
123
+ heads = _headings(lines, mask)
124
+ if not heads:
125
+ return [(0, len(lines), "Whole file")]
126
+ levels = [lvl for _, lvl, _ in heads]
127
+ split_level = min(levels)
128
+ # A single top-level title (e.g. "# AGENTS.md") is not a rule: split one level below it.
129
+ if levels.count(split_level) == 1 and heads[0][1] == split_level and len(set(levels)) > 1:
130
+ split_level = min(lvl for lvl in levels if lvl > split_level)
131
+ starts = [(i, t) for i, lvl, t in heads if lvl == split_level]
132
+ chunks: list[tuple[int, int, str | None]] = []
133
+ first = starts[0][0]
134
+ if first > 0:
135
+ pre = lines[:first]
136
+ body = [ln for j, ln in enumerate(pre) if ln.strip() and not _HEADING.match(ln)]
137
+ chunks.append((0, first, "Preamble" if body else None))
138
+ for k, (i, title) in enumerate(starts):
139
+ end = starts[k + 1][0] if k + 1 < len(starts) else len(lines)
140
+ chunks.append((i, end, title))
141
+ return chunks
142
+
143
+
144
+ def _split_bullets(lines: list[str], mask: list[bool]) -> list[tuple[int, int, str | None]]:
145
+ """Each top-level bullet item (with its nested lines) and each prose paragraph is a rule.
146
+
147
+ Headings and blank lines are kept in every configuration.
148
+ """
149
+ chunks: list[tuple[int, int, str | None]] = []
150
+ i, n = 0, len(lines)
151
+ while i < n:
152
+ line = lines[i]
153
+ if not mask[i] and (not line.strip() or _HEADING.match(line)):
154
+ chunks.append((i, i + 1, None))
155
+ i += 1
156
+ continue
157
+ start = i
158
+ bullet = None if mask[i] else _BULLET.match(line)
159
+ if bullet:
160
+ title = _short(bullet.group(3))
161
+ i += 1
162
+ while i < n:
163
+ nxt = lines[i]
164
+ if mask[i]:
165
+ i += 1
166
+ continue
167
+ if _HEADING.match(nxt) or _BULLET.match(nxt):
168
+ break
169
+ if not nxt.strip():
170
+ # a blank line ends the item unless the next non-blank line is indented
171
+ j = i
172
+ while j < n and not lines[j].strip():
173
+ j += 1
174
+ if j < n and lines[j].startswith((" ", "\t")) and not _BULLET.match(lines[j]):
175
+ i = j
176
+ continue
177
+ break
178
+ i += 1
179
+ else:
180
+ # prose paragraph or code block: runs until a blank line, heading or bullet
181
+ title = None
182
+ while i < n:
183
+ nxt = lines[i]
184
+ if not mask[i] and (not nxt.strip() or _HEADING.match(nxt) or _BULLET.match(nxt)):
185
+ if i > start:
186
+ break
187
+ if title is None and not mask[i] and nxt.strip():
188
+ title = _short(nxt)
189
+ i += 1
190
+ title = title or "Code block"
191
+ chunks.append((start, i, title))
192
+ return chunks
193
+
194
+
195
+ def parse_rules(text: str, granularity: Granularity = "section", path: str = "AGENTS.md") -> RuleFile:
196
+ """Split ``text`` into rules. Every line belongs to exactly one segment."""
197
+ if granularity not in ("section", "bullet"):
198
+ raise ValueError("granularity must be 'section' or 'bullet'")
199
+ lines = text.replace("\r\n", "\n").split("\n")
200
+ if lines and lines[-1] == "":
201
+ lines = lines[:-1]
202
+ mask = _code_mask(lines)
203
+ chunks = _split_sections(lines, mask) if granularity == "section" else _split_bullets(lines, mask)
204
+ rf = RuleFile(path=path, text=text, granularity=granularity)
205
+ k = 0
206
+ for start, end, title in chunks:
207
+ seg_lines = lines[start:end]
208
+ if title is None or not any(ln.strip() for ln in seg_lines):
209
+ rf.segments.append(Segment(seg_lines, None))
210
+ continue
211
+ k += 1
212
+ rid = f"R{k}"
213
+ body = "\n".join(seg_lines).strip("\n")
214
+ # trailing blank lines are layout, not content
215
+ last = end
216
+ while last > start and not lines[last - 1].strip():
217
+ last -= 1
218
+ rf.rules.append(Rule(rid, title, body, start + 1, last))
219
+ rf.segments.append(Segment(seg_lines, rid))
220
+ return rf
221
+
222
+
223
+ def load_rules(path: str | Path, granularity: Granularity = "section") -> RuleFile:
224
+ p = Path(path)
225
+ return parse_rules(p.read_text(encoding="utf-8"), granularity, path=p.name)
rulelab/runner.py ADDED
@@ -0,0 +1,94 @@
1
+ """The interface rulelab needs from an agent runner, plus a deterministic fake for tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from dataclasses import dataclass
7
+ from typing import Protocol
8
+
9
+
10
+ @dataclass
11
+ class RunOutcome:
12
+ success: bool | None # None = infrastructure error, excluded from statistics
13
+ input_tokens: int = 0
14
+ output_tokens: int = 0
15
+ cost_usd: float = 0.0
16
+ turns: int = 0
17
+ duration_s: float = 0.0
18
+ error: str | None = None
19
+
20
+
21
+ class Runner(Protocol):
22
+ """Runs one agent attempt on one task with a given context file (or none)."""
23
+
24
+ model: str
25
+ estimate_note: str
26
+
27
+ def meta(self) -> dict:
28
+ """Metadata stored with the results (engine, repo, hardware...)."""
29
+ ...
30
+
31
+ def task_ids(self) -> list[str]: ...
32
+
33
+ def estimate_run_cost(self) -> float:
34
+ """Expected USD for one agent run (0 for local models)."""
35
+ ...
36
+
37
+ def run(self, task_id: str, context: str | None, repeat: int) -> RunOutcome: ...
38
+
39
+
40
+ class FakeRunner:
41
+ """Deterministic runner for tests and offline development.
42
+
43
+ ``effects`` maps a marker substring to a success-probability shift: if the marker is present
44
+ in the context, the task succeeds more (positive) or less (negative) often.
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ tasks: list[str] | None = None,
50
+ base_rate: float = 0.5,
51
+ effects: dict[str, float] | None = None,
52
+ cost_per_run: float = 0.01,
53
+ model: str = "fake/model",
54
+ fail_on: set[tuple[str, int]] | None = None,
55
+ ) -> None:
56
+ self.tasks = tasks or [f"task-{i}" for i in range(1, 5)]
57
+ self.base_rate = base_rate
58
+ self.effects = effects or {}
59
+ self.cost_per_run = cost_per_run
60
+ self.model = model
61
+ self.fail_on = fail_on or set()
62
+ self.calls: list[tuple[str, str | None, int]] = []
63
+ self.estimate_note = "fake runner"
64
+
65
+ def meta(self) -> dict:
66
+ return {"engine": "fake"}
67
+
68
+ def task_ids(self) -> list[str]:
69
+ return list(self.tasks)
70
+
71
+ def estimate_run_cost(self) -> float:
72
+ return self.cost_per_run
73
+
74
+ def run(self, task_id: str, context: str | None, repeat: int) -> RunOutcome:
75
+ self.calls.append((task_id, context, repeat))
76
+ if (task_id, repeat) in self.fail_on:
77
+ return RunOutcome(None, error="simulated infrastructure error")
78
+ p = self.base_rate
79
+ ctx = context or ""
80
+ for marker, shift in self.effects.items():
81
+ if marker in ctx:
82
+ p += shift
83
+ digest = hashlib.sha256(f"{task_id}|{repeat}|{ctx}".encode()).digest()
84
+ u = int.from_bytes(digest[:8], "big") / 2**64
85
+ ctx_tokens = len(ctx) // 4
86
+ turns = 5
87
+ return RunOutcome(
88
+ success=u < p,
89
+ input_tokens=turns * (2000 + ctx_tokens),
90
+ output_tokens=400,
91
+ cost_usd=self.cost_per_run,
92
+ turns=turns,
93
+ duration_s=0.0,
94
+ )
rulelab/stats.py ADDED
@@ -0,0 +1,180 @@
1
+ """Effect estimates with bootstrap confidence intervals and per-rule verdicts.
2
+
3
+ Effect of a rule = success(full file) - success(full file without that rule), averaged over tasks.
4
+ Positive means the rule helps. Uncertainty comes from a two-level bootstrap:
5
+
6
+ 1. resample tasks with replacement (task difficulty varies a lot), then
7
+ 2. for each sampled task, draw each configuration's success rate from its
8
+ Beta(successes + 1/2, failures + 1/2) posterior (Jeffreys prior) instead of
9
+ trusting 3/3 or 0/3 as exact.
10
+
11
+ Step 2 matters with small samples: plain resampling of 3 identical outcomes gives a
12
+ zero-width interval and would turn "we saw nothing" into "no effect".
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import random
18
+ from dataclasses import asdict, dataclass
19
+ from statistics import fmean
20
+ from typing import Iterable, Literal
21
+
22
+ Verdict = Literal["helps", "no_effect", "hurts", "inconclusive"]
23
+
24
+ DEFAULT_MARGIN = 0.10 # +/- 10 percentage points counts as "no effect"
25
+ DEFAULT_ITERATIONS = 4000
26
+ CONFIDENCE = 0.95
27
+
28
+
29
+ @dataclass
30
+ class Cell:
31
+ successes: int = 0
32
+ failures: int = 0
33
+
34
+ @property
35
+ def n(self) -> int:
36
+ return self.successes + self.failures
37
+
38
+ @property
39
+ def rate(self) -> float:
40
+ return self.successes / self.n if self.n else float("nan")
41
+
42
+
43
+ @dataclass
44
+ class Effect:
45
+ estimate: float # observed mean difference over shared tasks
46
+ low: float
47
+ high: float
48
+ tasks: int
49
+ runs_a: int
50
+ runs_b: int
51
+ verdict: Verdict
52
+
53
+ def to_dict(self) -> dict:
54
+ return asdict(self)
55
+
56
+
57
+ def cells(runs: Iterable[dict], config_id: str) -> dict[str, Cell]:
58
+ """Success/failure counts per task for one configuration. Errored runs are skipped."""
59
+ out: dict[str, Cell] = {}
60
+ for r in runs:
61
+ if r["config"] != config_id or r.get("success") is None:
62
+ continue
63
+ c = out.setdefault(r["task"], Cell())
64
+ if r["success"]:
65
+ c.successes += 1
66
+ else:
67
+ c.failures += 1
68
+ return out
69
+
70
+
71
+ def classify(low: float, high: float, margin: float = DEFAULT_MARGIN) -> Verdict:
72
+ if low > 0:
73
+ return "helps"
74
+ if high < 0:
75
+ return "hurts"
76
+ if low >= -margin and high <= margin:
77
+ return "no_effect"
78
+ return "inconclusive"
79
+
80
+
81
+ def _percentile(sorted_vals: list[float], q: float) -> float:
82
+ if not sorted_vals:
83
+ return float("nan")
84
+ pos = q * (len(sorted_vals) - 1)
85
+ lo = int(pos)
86
+ hi = min(lo + 1, len(sorted_vals) - 1)
87
+ frac = pos - lo
88
+ return sorted_vals[lo] * (1 - frac) + sorted_vals[hi] * frac
89
+
90
+
91
+ def success_effect(
92
+ runs: list[dict],
93
+ config_a: str,
94
+ config_b: str,
95
+ *,
96
+ iterations: int = DEFAULT_ITERATIONS,
97
+ margin: float = DEFAULT_MARGIN,
98
+ seed: int = 0,
99
+ ) -> Effect:
100
+ """Bootstrap CI for mean_t[p_a(t) - p_b(t)] over tasks run under both configurations."""
101
+ a, b = cells(runs, config_a), cells(runs, config_b)
102
+ tasks = sorted(set(a) & set(b))
103
+ runs_a = sum(a[t].n for t in tasks)
104
+ runs_b = sum(b[t].n for t in tasks)
105
+ if not tasks:
106
+ return Effect(float("nan"), float("nan"), float("nan"), 0, runs_a, runs_b, "inconclusive")
107
+ observed = fmean(a[t].rate - b[t].rate for t in tasks)
108
+ rng = random.Random(seed)
109
+ draws: list[float] = []
110
+ for _ in range(iterations):
111
+ diffs = []
112
+ for _ in tasks:
113
+ t = tasks[rng.randrange(len(tasks))]
114
+ pa = rng.betavariate(a[t].successes + 0.5, a[t].failures + 0.5)
115
+ pb = rng.betavariate(b[t].successes + 0.5, b[t].failures + 0.5)
116
+ diffs.append(pa - pb)
117
+ draws.append(fmean(diffs))
118
+ draws.sort()
119
+ alpha = (1 - CONFIDENCE) / 2
120
+ low, high = _percentile(draws, alpha), _percentile(draws, 1 - alpha)
121
+ return Effect(observed, low, high, len(tasks), runs_a, runs_b, classify(low, high, margin))
122
+
123
+
124
+ @dataclass
125
+ class MeanCI:
126
+ mean: float
127
+ low: float
128
+ high: float
129
+ n: int
130
+
131
+ def to_dict(self) -> dict:
132
+ return asdict(self)
133
+
134
+
135
+ def metric_by_task(runs: list[dict], config_id: str, key: str) -> dict[str, list[float]]:
136
+ out: dict[str, list[float]] = {}
137
+ for r in runs:
138
+ if r["config"] != config_id or r.get("success") is None:
139
+ continue
140
+ out.setdefault(r["task"], []).append(float(r.get(key) or 0))
141
+ return out
142
+
143
+
144
+ def metric_delta(
145
+ runs: list[dict],
146
+ config_a: str,
147
+ config_b: str,
148
+ key: str,
149
+ *,
150
+ iterations: int = DEFAULT_ITERATIONS,
151
+ seed: int = 0,
152
+ ) -> MeanCI:
153
+ """Bootstrap CI for the per-run difference of a numeric field (tokens, cost), paired by task."""
154
+ a, b = metric_by_task(runs, config_a, key), metric_by_task(runs, config_b, key)
155
+ tasks = sorted(set(a) & set(b))
156
+ if not tasks:
157
+ return MeanCI(float("nan"), float("nan"), float("nan"), 0)
158
+ observed = fmean(fmean(a[t]) - fmean(b[t]) for t in tasks)
159
+ rng = random.Random(seed)
160
+ draws = []
161
+ for _ in range(iterations):
162
+ diffs = []
163
+ for _ in tasks:
164
+ t = tasks[rng.randrange(len(tasks))]
165
+ xa = [a[t][rng.randrange(len(a[t]))] for _ in a[t]]
166
+ xb = [b[t][rng.randrange(len(b[t]))] for _ in b[t]]
167
+ diffs.append(fmean(xa) - fmean(xb))
168
+ draws.append(fmean(diffs))
169
+ draws.sort()
170
+ alpha = (1 - CONFIDENCE) / 2
171
+ n = sum(len(a[t]) + len(b[t]) for t in tasks)
172
+ return MeanCI(observed, _percentile(draws, alpha), _percentile(draws, 1 - alpha), n)
173
+
174
+
175
+ def success_rate(runs: list[dict], config_id: str) -> tuple[float, int, int]:
176
+ """(rate, successes, valid runs) for one configuration, pooled over tasks."""
177
+ cs = cells(runs, config_id).values()
178
+ s = sum(c.successes for c in cs)
179
+ n = sum(c.n for c in cs)
180
+ return (s / n if n else float("nan"), s, n)