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/__init__.py +3 -0
- rulelab/__main__.py +3 -0
- rulelab/_demo/results.json +956 -0
- rulelab/analysis.py +174 -0
- rulelab/cli.py +344 -0
- rulelab/configs.py +39 -0
- rulelab/engine.py +272 -0
- rulelab/errors.py +9 -0
- rulelab/experiment.py +195 -0
- rulelab/report.py +205 -0
- rulelab/rules.py +225 -0
- rulelab/runner.py +94 -0
- rulelab/stats.py +180 -0
- rulelab/templates/report.html.j2 +379 -0
- rulelab_cli-0.1.2.dist-info/METADATA +260 -0
- rulelab_cli-0.1.2.dist-info/RECORD +19 -0
- rulelab_cli-0.1.2.dist-info/WHEEL +4 -0
- rulelab_cli-0.1.2.dist-info/entry_points.txt +3 -0
- rulelab_cli-0.1.2.dist-info/licenses/LICENSE +21 -0
rulelab/engine.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Adapter between rulelab and the commitarena engine (tasks, no-network sandbox, agent loop)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.request
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .errors import RulelabError
|
|
15
|
+
from .runner import RunOutcome
|
|
16
|
+
|
|
17
|
+
# Token model used to estimate cost before anything has run. The whole conversation is resent on
|
|
18
|
+
# every step, so input tokens grow roughly quadratically with the number of steps:
|
|
19
|
+
# input ~= 1,000 * steps + 467 * steps^2 output ~= 150 * steps
|
|
20
|
+
# Fitted to two measured codestral runs on sqlparse (15 steps: ~120k input; 30 steps: 450,755).
|
|
21
|
+
# It assumes the agent uses every step, so it tends to over-estimate.
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def estimate_tokens(steps: int) -> tuple[int, int]:
|
|
25
|
+
return 1_000 * steps + 467 * steps * steps, 150 * steps
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _ca():
|
|
29
|
+
try:
|
|
30
|
+
import commitarena
|
|
31
|
+
except ImportError as e: # pragma: no cover - dependency is declared
|
|
32
|
+
raise RulelabError(
|
|
33
|
+
"the commitarena engine is not installed",
|
|
34
|
+
"Reinstall rulelab: uvx --from git+https://github.com/diegoperezg7/rulelab rulelab",
|
|
35
|
+
) from e
|
|
36
|
+
return commitarena
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def hardware() -> str:
|
|
40
|
+
mem = ""
|
|
41
|
+
try:
|
|
42
|
+
if platform.system() == "Darwin":
|
|
43
|
+
b = int(subprocess.run(["sysctl", "-n", "hw.memsize"], capture_output=True, text=True).stdout)
|
|
44
|
+
chip = subprocess.run(
|
|
45
|
+
["sysctl", "-n", "machdep.cpu.brand_string"], capture_output=True, text=True
|
|
46
|
+
).stdout.strip()
|
|
47
|
+
mem = f"{chip}, {b // 2**30} GB RAM"
|
|
48
|
+
elif Path("/proc/meminfo").exists():
|
|
49
|
+
kb = int(Path("/proc/meminfo").read_text().split()[1])
|
|
50
|
+
mem = f"{kb // 2**20} GB RAM"
|
|
51
|
+
except Exception: # noqa: BLE001 - best effort only
|
|
52
|
+
pass
|
|
53
|
+
return f"{platform.system()} {platform.machine()}" + (f" ({mem})" if mem else "")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _ollama_host() -> str:
|
|
57
|
+
host = os.environ.get("OLLAMA_HOST", "http://localhost:11434").strip().rstrip("/")
|
|
58
|
+
if not host.startswith(("http://", "https://")):
|
|
59
|
+
host = "http://" + host
|
|
60
|
+
return host.replace("://0.0.0.0", "://localhost", 1)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def check_model(spec) -> None:
|
|
64
|
+
"""Fail early with a clear message: missing key, Ollama down, or model not pulled."""
|
|
65
|
+
ca = _ca()
|
|
66
|
+
from commitarena.llm import PROVIDERS # provider -> (base_url, env var)
|
|
67
|
+
|
|
68
|
+
if spec.is_local:
|
|
69
|
+
host = _ollama_host()
|
|
70
|
+
try:
|
|
71
|
+
with urllib.request.urlopen(f"{host}/api/tags", timeout=5) as r:
|
|
72
|
+
names = {m.get("name") for m in json.load(r).get("models", [])}
|
|
73
|
+
except (urllib.error.URLError, OSError, ValueError) as e:
|
|
74
|
+
raise RulelabError(
|
|
75
|
+
f"cannot reach Ollama at {host}",
|
|
76
|
+
"Start it with `ollama serve` (or the Ollama app), or set OLLAMA_HOST.",
|
|
77
|
+
) from e
|
|
78
|
+
wanted = spec.name if ":" in spec.name else f"{spec.name}:latest"
|
|
79
|
+
if wanted not in names and spec.name not in names:
|
|
80
|
+
raise RulelabError(
|
|
81
|
+
f"model '{spec.name}' is not available in Ollama",
|
|
82
|
+
f"Pull it with `ollama pull {spec.name}`. Installed: {', '.join(sorted(n for n in names if n)) or 'none'}.",
|
|
83
|
+
)
|
|
84
|
+
return
|
|
85
|
+
env = PROVIDERS[spec.provider][1]
|
|
86
|
+
if env and not os.environ.get(env):
|
|
87
|
+
raise RulelabError(
|
|
88
|
+
f"missing API key for provider '{spec.provider}'",
|
|
89
|
+
f"Set the {env} environment variable, or use a local model such as ollama/qwen3:4b.",
|
|
90
|
+
)
|
|
91
|
+
if spec.unit_price() is None and spec.provider != "openrouter":
|
|
92
|
+
raise RulelabError(
|
|
93
|
+
f"no price known for {spec.id}, so the budget cannot be enforced",
|
|
94
|
+
"Pass --price IN,OUT (USD per million input/output tokens).",
|
|
95
|
+
)
|
|
96
|
+
del ca
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def check_docker() -> None:
|
|
100
|
+
ca = _ca()
|
|
101
|
+
if shutil.which("docker") is None:
|
|
102
|
+
raise RulelabError("Docker is not installed", "Install Docker Desktop (or Docker Engine) and start it.")
|
|
103
|
+
try:
|
|
104
|
+
ca.ensure_docker()
|
|
105
|
+
except ca.CommitArenaError as e:
|
|
106
|
+
raise RulelabError(str(e)) from e
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class CommitarenaRunner:
|
|
110
|
+
"""Runs one agent attempt per call through commitarena's ``run_task``."""
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
tasks: list,
|
|
115
|
+
model: str,
|
|
116
|
+
*,
|
|
117
|
+
context_name: str = "AGENTS.md",
|
|
118
|
+
inject: str = "both",
|
|
119
|
+
max_steps: int = 30,
|
|
120
|
+
price: tuple[float, float] | None = None,
|
|
121
|
+
tasks_source: str = "",
|
|
122
|
+
price_source: str | None = None,
|
|
123
|
+
) -> None:
|
|
124
|
+
ca = _ca()
|
|
125
|
+
try:
|
|
126
|
+
self.spec = ca.ModelSpec.parse(model, price=price)
|
|
127
|
+
except ca.CommitArenaError as e:
|
|
128
|
+
raise RulelabError(str(e)) from e
|
|
129
|
+
self.model = self.spec.id
|
|
130
|
+
self.tasks = {t.id: t for t in tasks}
|
|
131
|
+
self.context_name = context_name
|
|
132
|
+
self.inject = inject
|
|
133
|
+
self.max_steps = max_steps
|
|
134
|
+
self.tasks_source = tasks_source
|
|
135
|
+
if self.spec.is_local:
|
|
136
|
+
self.price_source = "local model, 0 USD"
|
|
137
|
+
elif price is not None:
|
|
138
|
+
self.price_source = price_source or "--price"
|
|
139
|
+
else:
|
|
140
|
+
self.price_source = "commitarena price table (docs.mistral.ai/inference/pricing, checked 2026-11-03)"
|
|
141
|
+
self.budget = ca.Budget(max_cost=float("inf"))
|
|
142
|
+
pin, pout = self.spec.unit_price() or (0.0, 0.0)
|
|
143
|
+
self.price = (pin, pout)
|
|
144
|
+
self.estimate_note = (
|
|
145
|
+
f"assumes all {max_steps} steps are used: ~{estimate_tokens(max_steps)[0] // 1000}k input tokens "
|
|
146
|
+
f"per run at {pin}/{pout} USD per 1M"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def set_budget(self, max_cost: float, spent: float = 0.0) -> None:
|
|
150
|
+
self.budget.max_cost = max_cost
|
|
151
|
+
self.budget.spent = spent
|
|
152
|
+
|
|
153
|
+
def meta(self) -> dict:
|
|
154
|
+
ca = _ca()
|
|
155
|
+
return {
|
|
156
|
+
"engine": f"commitarena {ca.__version__}",
|
|
157
|
+
"tasks_source": self.tasks_source,
|
|
158
|
+
"inject": self.inject,
|
|
159
|
+
"max_steps": self.max_steps,
|
|
160
|
+
"hardware": hardware(),
|
|
161
|
+
"price_in_per_mtok": self.price[0],
|
|
162
|
+
"price_out_per_mtok": self.price[1],
|
|
163
|
+
"price_source": self.price_source,
|
|
164
|
+
"cost_method": "usage tokens of every model response x price per 1M tokens",
|
|
165
|
+
"task_info": {
|
|
166
|
+
tid: {"repo": str(t.repo), "commit": t.commit, "date": t.date} for tid, t in self.tasks.items()
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
def task_ids(self) -> list[str]:
|
|
171
|
+
return list(self.tasks)
|
|
172
|
+
|
|
173
|
+
def estimate_run_cost(self) -> float:
|
|
174
|
+
if self.spec.is_local:
|
|
175
|
+
return 0.0
|
|
176
|
+
pin, pout = self.price
|
|
177
|
+
tin, tout = estimate_tokens(self.max_steps)
|
|
178
|
+
return (tin * pin + tout * pout) / 1_000_000
|
|
179
|
+
|
|
180
|
+
def run(self, task_id: str, context: str | None, repeat: int) -> RunOutcome:
|
|
181
|
+
ca = _ca()
|
|
182
|
+
ctx = None
|
|
183
|
+
if context is not None:
|
|
184
|
+
ctx = ca.ContextFile(
|
|
185
|
+
content=context,
|
|
186
|
+
name=self.context_name,
|
|
187
|
+
in_repo=self.inject in ("both", "repo"),
|
|
188
|
+
in_prompt=self.inject in ("both", "prompt"),
|
|
189
|
+
)
|
|
190
|
+
try:
|
|
191
|
+
res = ca.run_task(
|
|
192
|
+
self.tasks[task_id],
|
|
193
|
+
self.spec,
|
|
194
|
+
budget=self.budget,
|
|
195
|
+
context=ctx,
|
|
196
|
+
strip_context_files=True,
|
|
197
|
+
agent_config=ca.AgentConfig(max_steps=self.max_steps),
|
|
198
|
+
sandbox_config=ca.SandboxConfig(cpus=2, memory="2g"),
|
|
199
|
+
)
|
|
200
|
+
except ca.BudgetExceeded:
|
|
201
|
+
return RunOutcome(None, error="budget_exceeded")
|
|
202
|
+
except (ca.MissingAPIKey, ca.DockerUnavailable, ca.ModelUnavailable) as e:
|
|
203
|
+
raise RulelabError(str(e)) from e
|
|
204
|
+
except ca.CommitArenaError as e:
|
|
205
|
+
return RunOutcome(None, error=str(e)[:500])
|
|
206
|
+
except (ValueError, OSError, RuntimeError) as e:
|
|
207
|
+
# An engine failure on one attempt (e.g. a malformed command from the model) must not
|
|
208
|
+
# lose the whole experiment: record it as an infrastructure error, excluded from stats.
|
|
209
|
+
return RunOutcome(None, error=f"engine error: {type(e).__name__}: {e}"[:500])
|
|
210
|
+
infra = res.stop_reason in ("model_error", "budget_exceeded") or (res.error and not res.steps)
|
|
211
|
+
return RunOutcome(
|
|
212
|
+
success=None if infra else bool(res.passed),
|
|
213
|
+
input_tokens=res.tokens_in,
|
|
214
|
+
output_tokens=res.tokens_out,
|
|
215
|
+
cost_usd=res.cost_usd,
|
|
216
|
+
turns=res.steps,
|
|
217
|
+
duration_s=res.wall_time_s,
|
|
218
|
+
error=(res.stop_reason if res.stop_reason == "budget_exceeded" else (res.error or None)),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def load_task_list(tasks_file: Path | None, repo: str | None, cache_dir: Path, max_tasks: int) -> tuple[list, str]:
|
|
223
|
+
"""Tasks from an existing tasks.json, or mined from ``repo`` with commitarena (cached)."""
|
|
224
|
+
ca = _ca()
|
|
225
|
+
try:
|
|
226
|
+
if tasks_file is not None:
|
|
227
|
+
return ca.load_tasks(str(tasks_file)), str(tasks_file)
|
|
228
|
+
if repo is None:
|
|
229
|
+
raise RulelabError("pass --repo or --tasks-file", "--repo mines tasks from git history with commitarena.")
|
|
230
|
+
cached = cache_dir / "tasks.json"
|
|
231
|
+
if cached.exists():
|
|
232
|
+
return ca.load_tasks(str(cached)), str(repo)
|
|
233
|
+
from commitarena.mine import mine
|
|
234
|
+
|
|
235
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
236
|
+
tasks, _meta = mine(repo, out=cached, max_tasks=max_tasks)
|
|
237
|
+
return tasks, str(repo)
|
|
238
|
+
except ca.CommitArenaError as e:
|
|
239
|
+
raise RulelabError(str(e)) from e
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def make_runner(
|
|
243
|
+
*,
|
|
244
|
+
repo: str | None = None,
|
|
245
|
+
tasks_file: Path | None = None,
|
|
246
|
+
model: str,
|
|
247
|
+
cache_dir: Path = Path("rulelab-results"),
|
|
248
|
+
max_tasks: int = 6,
|
|
249
|
+
context_name: str = "AGENTS.md",
|
|
250
|
+
inject: str = "both",
|
|
251
|
+
max_steps: int = 30,
|
|
252
|
+
price: tuple[float, float] | None = None,
|
|
253
|
+
price_source: str | None = None,
|
|
254
|
+
check: bool = True,
|
|
255
|
+
) -> CommitarenaRunner:
|
|
256
|
+
runner_probe = CommitarenaRunner([], model, price=price) # validates the model id early
|
|
257
|
+
if check:
|
|
258
|
+
check_model(runner_probe.spec)
|
|
259
|
+
check_docker()
|
|
260
|
+
tasks, source = load_task_list(tasks_file, repo, cache_dir, max_tasks)
|
|
261
|
+
if not tasks:
|
|
262
|
+
raise RulelabError("no tasks found", "Mine more commits (--max-tasks) or pass another --tasks-file.")
|
|
263
|
+
return CommitarenaRunner(
|
|
264
|
+
tasks,
|
|
265
|
+
model,
|
|
266
|
+
context_name=context_name,
|
|
267
|
+
inject=inject,
|
|
268
|
+
max_steps=max_steps,
|
|
269
|
+
price=price,
|
|
270
|
+
tasks_source=source,
|
|
271
|
+
price_source=price_source,
|
|
272
|
+
)
|
rulelab/errors.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""User-facing errors: printed as one clear line plus a hint, never as a traceback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RulelabError(Exception):
|
|
7
|
+
def __init__(self, message: str, hint: str | None = None) -> None:
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
self.hint = hint
|
rulelab/experiment.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Run the leave-one-out experiment under a hard budget and persist results as JSON."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import platform
|
|
8
|
+
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
|
9
|
+
from dataclasses import asdict, dataclass
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Callable
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
from .configs import Config, build_configs, count_runs
|
|
16
|
+
from .rules import RuleFile
|
|
17
|
+
from .runner import RunOutcome, Runner
|
|
18
|
+
|
|
19
|
+
SCHEMA = 1
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BudgetError(Exception):
|
|
23
|
+
"""The experiment would cost more than --max-cost."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, needed: float, max_cost: float, runs: int, per_run: float) -> None:
|
|
26
|
+
self.needed, self.max_cost, self.runs, self.per_run = needed, max_cost, runs, per_run
|
|
27
|
+
self.suggested = math.ceil(round(needed * 100, 6)) / 100
|
|
28
|
+
super().__init__(
|
|
29
|
+
f"This experiment needs about {self.suggested:.2f} USD ({runs} agent runs x {per_run:.4f} USD) "
|
|
30
|
+
f"but --max-cost is {max_cost:.2f} USD. Re-run with --max-cost {self.suggested:.2f} to allow it."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Plan:
|
|
36
|
+
configs: list[Config]
|
|
37
|
+
tasks: list[str]
|
|
38
|
+
repeats: int
|
|
39
|
+
per_run_usd: float
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def runs(self) -> int:
|
|
43
|
+
return count_runs(len(self.configs), len(self.tasks), self.repeats)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def estimated_usd(self) -> float:
|
|
47
|
+
return self.runs * self.per_run_usd
|
|
48
|
+
|
|
49
|
+
def schedule(self) -> list[tuple[int, str, Config]]:
|
|
50
|
+
"""Interleaved order (repeat > task > config) so a partial run stays balanced."""
|
|
51
|
+
return [
|
|
52
|
+
(rep, task, cfg)
|
|
53
|
+
for rep in range(1, self.repeats + 1)
|
|
54
|
+
for task in self.tasks
|
|
55
|
+
for cfg in self.configs
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def make_plan(rule_file: RuleFile, runner: Runner, repeats: int, tasks: list[str] | None = None) -> Plan:
|
|
60
|
+
if repeats < 1:
|
|
61
|
+
raise ValueError("--repeats must be at least 1")
|
|
62
|
+
if not rule_file.rules:
|
|
63
|
+
raise ValueError("no rules found in the file: nothing to ablate")
|
|
64
|
+
available = runner.task_ids()
|
|
65
|
+
if tasks:
|
|
66
|
+
missing = [t for t in tasks if t not in available]
|
|
67
|
+
if missing:
|
|
68
|
+
raise ValueError(f"unknown task(s): {', '.join(missing)}. Available: {', '.join(available)}")
|
|
69
|
+
chosen = list(tasks)
|
|
70
|
+
else:
|
|
71
|
+
chosen = available
|
|
72
|
+
if not chosen:
|
|
73
|
+
raise ValueError("the runner has no tasks")
|
|
74
|
+
return Plan(build_configs(rule_file), chosen, repeats, runner.estimate_run_cost())
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _now() -> str:
|
|
78
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def new_results(rule_file: RuleFile, plan: Plan, runner: Runner, max_cost: float, meta: dict | None = None) -> dict:
|
|
82
|
+
return {
|
|
83
|
+
"schema": SCHEMA,
|
|
84
|
+
"tool": "rulelab",
|
|
85
|
+
"version": __version__,
|
|
86
|
+
"created": _now(),
|
|
87
|
+
"finished": None,
|
|
88
|
+
"status": "running",
|
|
89
|
+
"model": runner.model,
|
|
90
|
+
"meta": {
|
|
91
|
+
"python": platform.python_version(),
|
|
92
|
+
"platform": f"{platform.system()} {platform.machine()}",
|
|
93
|
+
**(meta or {}),
|
|
94
|
+
},
|
|
95
|
+
"file": {
|
|
96
|
+
"path": rule_file.path,
|
|
97
|
+
"granularity": rule_file.granularity,
|
|
98
|
+
"text": rule_file.text,
|
|
99
|
+
"tokens": rule_file.tokens,
|
|
100
|
+
"rules": [r.to_dict() for r in rule_file.rules],
|
|
101
|
+
},
|
|
102
|
+
"configs": [{"id": c.id, "removed": c.removed} for c in plan.configs],
|
|
103
|
+
"tasks": plan.tasks,
|
|
104
|
+
"repeats": plan.repeats,
|
|
105
|
+
"max_cost": max_cost,
|
|
106
|
+
"estimated_usd": plan.estimated_usd,
|
|
107
|
+
"spent_usd": 0.0,
|
|
108
|
+
"runs": [],
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def save(results: dict, path: Path) -> None:
|
|
113
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
tmp = path.with_suffix(".tmp")
|
|
115
|
+
tmp.write_text(json.dumps(results, indent=1, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
116
|
+
tmp.replace(path)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def load(path: Path) -> dict:
|
|
120
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
121
|
+
if data.get("tool") != "rulelab" or "runs" not in data:
|
|
122
|
+
raise ValueError(f"{path} is not a rulelab results file")
|
|
123
|
+
return data
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def execute(
|
|
127
|
+
plan: Plan,
|
|
128
|
+
runner: Runner,
|
|
129
|
+
results: dict,
|
|
130
|
+
out: Path,
|
|
131
|
+
max_cost: float,
|
|
132
|
+
on_run: Callable[[int, int, dict], None] | None = None,
|
|
133
|
+
concurrency: int = 1,
|
|
134
|
+
) -> dict:
|
|
135
|
+
"""Run every scheduled attempt not already in ``results`` (so it can resume).
|
|
136
|
+
|
|
137
|
+
Up to ``concurrency`` attempts run at once. Before starting each one, the measured spend plus
|
|
138
|
+
the expected cost of every attempt in flight (and this one) is checked against ``max_cost``;
|
|
139
|
+
if it does not fit, no new attempts start, the ones in flight finish, and the experiment ends
|
|
140
|
+
with status ``budget_exceeded``. Everything measured is saved after each attempt.
|
|
141
|
+
"""
|
|
142
|
+
done = {(r["config"], r["task"], r["repeat"]) for r in results["runs"]}
|
|
143
|
+
schedule = plan.schedule()
|
|
144
|
+
total = len(schedule)
|
|
145
|
+
todo = [(i, rep, task, cfg) for i, (rep, task, cfg) in enumerate(schedule, start=1) if (cfg.id, task, rep) not in done]
|
|
146
|
+
spent = float(results.get("spent_usd", 0.0))
|
|
147
|
+
results["status"] = "running"
|
|
148
|
+
stop: str | None = None
|
|
149
|
+
finished = 0
|
|
150
|
+
pool = ThreadPoolExecutor(max_workers=max(1, concurrency))
|
|
151
|
+
in_flight: dict = {}
|
|
152
|
+
queue = list(todo)
|
|
153
|
+
try:
|
|
154
|
+
while queue or in_flight:
|
|
155
|
+
while queue and stop is None and len(in_flight) < max(1, concurrency):
|
|
156
|
+
if spent + (len(in_flight) + 1) * plan.per_run_usd > max_cost + 1e-12:
|
|
157
|
+
stop = "budget_exceeded"
|
|
158
|
+
break
|
|
159
|
+
i, rep, task, cfg = queue.pop(0)
|
|
160
|
+
in_flight[pool.submit(runner.run, task, cfg.context, rep)] = (i, rep, task, cfg)
|
|
161
|
+
if not in_flight:
|
|
162
|
+
break
|
|
163
|
+
ready, _ = wait(list(in_flight), return_when=FIRST_COMPLETED)
|
|
164
|
+
for fut in ready:
|
|
165
|
+
i, rep, task, cfg = in_flight.pop(fut)
|
|
166
|
+
outcome: RunOutcome = fut.result()
|
|
167
|
+
spent += outcome.cost_usd
|
|
168
|
+
if outcome.error == "budget_exceeded":
|
|
169
|
+
stop = "budget_exceeded"
|
|
170
|
+
results["spent_usd"] = round(spent, 6)
|
|
171
|
+
continue
|
|
172
|
+
record = {"config": cfg.id, "task": task, "repeat": rep, **asdict(outcome)}
|
|
173
|
+
results["runs"].append(record)
|
|
174
|
+
results["spent_usd"] = round(spent, 6)
|
|
175
|
+
save(results, out)
|
|
176
|
+
finished += 1
|
|
177
|
+
if on_run:
|
|
178
|
+
on_run(len(done) + finished, total, record)
|
|
179
|
+
results["status"] = stop or ("complete" if not queue else "interrupted")
|
|
180
|
+
except KeyboardInterrupt:
|
|
181
|
+
results["status"] = "interrupted"
|
|
182
|
+
pool.shutdown(wait=False, cancel_futures=True)
|
|
183
|
+
finally:
|
|
184
|
+
pool.shutdown(wait=False, cancel_futures=True)
|
|
185
|
+
results["finished"] = _now()
|
|
186
|
+
save(results, out)
|
|
187
|
+
return results
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def check_budget(plan: Plan, max_cost: float, done_runs: int = 0, already_spent: float = 0.0) -> None:
|
|
191
|
+
"""Refuse to start when the whole experiment cannot fit in ``max_cost``."""
|
|
192
|
+
remaining_runs = max(plan.runs - done_runs, 0)
|
|
193
|
+
needed = remaining_runs * plan.per_run_usd + already_spent
|
|
194
|
+
if needed > max_cost + 1e-12:
|
|
195
|
+
raise BudgetError(needed, max_cost, remaining_runs, plan.per_run_usd)
|
rulelab/report.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Static HTML report (ES/EN, light/dark, mobile) with the annotated rules file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from jinja2 import Environment, PackageLoader, select_autoescape
|
|
9
|
+
from markupsafe import Markup, escape
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .analysis import Analysis, file_monthly_usd, file_tokens_per_run
|
|
13
|
+
from .configs import FULL, NONE
|
|
14
|
+
|
|
15
|
+
VERDICT_LABELS = {
|
|
16
|
+
"helps": ("helps", "ayuda"),
|
|
17
|
+
"no_effect": ("no effect", "sin efecto"),
|
|
18
|
+
"hurts": ("hurts", "empeora"),
|
|
19
|
+
"inconclusive": ("inconclusive", "no concluyente"),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
PLURAL_ES = {"helps": "ayudan", "no_effect": "sin efecto", "hurts": "empeoran", "inconclusive": "no concluyentes"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _num(x: float) -> bool:
|
|
26
|
+
return x is not None and not (isinstance(x, float) and math.isnan(x))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def pp(x: float | None, signed: bool = True) -> str:
|
|
30
|
+
if x is None or not _num(x):
|
|
31
|
+
return "n/a"
|
|
32
|
+
v = round(x * 100)
|
|
33
|
+
if v == 0:
|
|
34
|
+
return "0"
|
|
35
|
+
s = f"{v:+d}" if signed else f"{v:d}"
|
|
36
|
+
return s.replace("-", "−")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def pct(x: float | None) -> str:
|
|
40
|
+
if x is None or not _num(x):
|
|
41
|
+
return "n/a"
|
|
42
|
+
return f"{round(x * 100)}%"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def signed_int(x: float | None) -> str:
|
|
46
|
+
if x is None or not _num(x):
|
|
47
|
+
return "n/a"
|
|
48
|
+
v = round(x)
|
|
49
|
+
return (f"{v:+,d}" if v else "0").replace("-", "−")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def usd(x: float | None, digits: int = 2) -> str:
|
|
53
|
+
if x is None or not _num(x):
|
|
54
|
+
return "n/a"
|
|
55
|
+
if 0 < abs(x) < 10 ** (-digits):
|
|
56
|
+
return f"<{10 ** (-digits):.{digits}f}"
|
|
57
|
+
return f"{x:,.{digits}f}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _es(text: str) -> str:
|
|
61
|
+
"""English-formatted number -> Spanish (1,953.5 -> 1.953,5)."""
|
|
62
|
+
return text.replace(",", "\0").replace(".", ",").replace("\0", ".")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _both(en: str, es: str) -> Markup:
|
|
66
|
+
return Markup('<span lang="en">%s</span><span lang="es">%s</span>') % (escape(en), escape(es))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def b_num(x: float | None, digits: int = 0) -> Markup:
|
|
70
|
+
if x is None or not _num(x):
|
|
71
|
+
return _both("n/a", "n/d")
|
|
72
|
+
en = f"{x:,.{digits}f}"
|
|
73
|
+
return _both(en, _es(en))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def b_signed_int(x: float | None) -> Markup:
|
|
77
|
+
en = signed_int(x)
|
|
78
|
+
return _both(en, "n/d" if en == "n/a" else _es(en))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def b_usd(x: float | None, digits: int = 2) -> Markup:
|
|
82
|
+
en = usd(x, digits)
|
|
83
|
+
if en == "n/a":
|
|
84
|
+
return _both("n/a", "n/d")
|
|
85
|
+
return _both(f"{en} USD", f"{_es(en)} US$")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def b_pct(x: float | None) -> Markup:
|
|
89
|
+
en = pct(x)
|
|
90
|
+
return _both(en, "n/d" if en == "n/a" else en.replace("%", "\u00a0%"))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def b_plain(x) -> Markup:
|
|
94
|
+
en = str(x)
|
|
95
|
+
return _both(en, _es(en))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def ci_bar(low: float, high: float, est: float, margin: float, width: int = 168) -> dict:
|
|
99
|
+
"""Geometry for the interval glyph on a fixed -100..+100 pp axis."""
|
|
100
|
+
if not (_num(low) and _num(high)):
|
|
101
|
+
return {"ok": False}
|
|
102
|
+
|
|
103
|
+
def x(v: float) -> float:
|
|
104
|
+
v = max(-1.0, min(1.0, v))
|
|
105
|
+
return round((v + 1) / 2 * width, 1)
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
"ok": True,
|
|
109
|
+
"w": width,
|
|
110
|
+
"x0": x(low),
|
|
111
|
+
"x1": x(high),
|
|
112
|
+
"xe": x(est) if _num(est) else None,
|
|
113
|
+
"z": x(0),
|
|
114
|
+
"m0": x(-margin),
|
|
115
|
+
"m1": x(margin),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _annotated(a: Analysis) -> list[dict]:
|
|
120
|
+
"""Split the original file into blocks: fixed lines and rule blocks with their result."""
|
|
121
|
+
lines = a.results["file"]["text"].replace("\r\n", "\n").split("\n")
|
|
122
|
+
if lines and lines[-1] == "":
|
|
123
|
+
lines = lines[:-1]
|
|
124
|
+
by_start = {r.start_line: r for r in a.rules}
|
|
125
|
+
blocks: list[dict] = []
|
|
126
|
+
i = 1
|
|
127
|
+
fixed: list[str] = []
|
|
128
|
+
while i <= len(lines):
|
|
129
|
+
r = by_start.get(i)
|
|
130
|
+
if r:
|
|
131
|
+
if fixed:
|
|
132
|
+
blocks.append({"rule": None, "text": "\n".join(fixed)})
|
|
133
|
+
fixed = []
|
|
134
|
+
blocks.append({"rule": r, "text": "\n".join(lines[r.start_line - 1 : r.end_line])})
|
|
135
|
+
i = r.end_line + 1
|
|
136
|
+
else:
|
|
137
|
+
fixed.append(lines[i - 1])
|
|
138
|
+
i += 1
|
|
139
|
+
if fixed:
|
|
140
|
+
blocks.append({"rule": None, "text": "\n".join(fixed)})
|
|
141
|
+
# drop empty fixed blocks made only of blank lines between rules
|
|
142
|
+
return [b for b in blocks if b["rule"] or b["text"].strip()]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def render_report(a: Analysis, *, recorded_note: dict | None = None) -> str:
|
|
146
|
+
env = Environment(
|
|
147
|
+
loader=PackageLoader("rulelab", "templates"),
|
|
148
|
+
autoescape=select_autoescape(["html", "j2"]),
|
|
149
|
+
trim_blocks=True,
|
|
150
|
+
lstrip_blocks=True,
|
|
151
|
+
)
|
|
152
|
+
env.filters.update(pp=pp, pct=b_pct, usd=b_usd, signed_int=b_signed_int, num=b_num, plain=b_plain)
|
|
153
|
+
res = a.results
|
|
154
|
+
configs = []
|
|
155
|
+
for c in res["configs"]:
|
|
156
|
+
rate, s, n = a.rates[c["id"]]
|
|
157
|
+
runs = [r for r in res["runs"] if r["config"] == c["id"] and r.get("success") is not None]
|
|
158
|
+
configs.append(
|
|
159
|
+
{
|
|
160
|
+
"id": c["id"],
|
|
161
|
+
"removed": c["removed"],
|
|
162
|
+
"rate": rate,
|
|
163
|
+
"s": s,
|
|
164
|
+
"n": n,
|
|
165
|
+
"planned": len(res["tasks"]) * res["repeats"],
|
|
166
|
+
"tokens": (sum(r["input_tokens"] for r in runs) / len(runs)) if runs else float("nan"),
|
|
167
|
+
"cost": (sum(r["cost_usd"] for r in runs) / len(runs)) if runs else float("nan"),
|
|
168
|
+
}
|
|
169
|
+
)
|
|
170
|
+
rules = [
|
|
171
|
+
{
|
|
172
|
+
"r": r,
|
|
173
|
+
"bar": ci_bar(r.success.low, r.success.high, r.success.estimate, a.margin),
|
|
174
|
+
"share": r.tokens / res["file"]["tokens"] if res["file"]["tokens"] else 0,
|
|
175
|
+
}
|
|
176
|
+
for r in a.rules
|
|
177
|
+
]
|
|
178
|
+
template = env.get_template("report.html.j2")
|
|
179
|
+
return template.render(
|
|
180
|
+
a=a,
|
|
181
|
+
res=res,
|
|
182
|
+
rules=rules,
|
|
183
|
+
configs=configs,
|
|
184
|
+
blocks=_annotated(a),
|
|
185
|
+
verdicts=VERDICT_LABELS,
|
|
186
|
+
plural_es=PLURAL_ES,
|
|
187
|
+
whole_bar=ci_bar(a.whole_file.low, a.whole_file.high, a.whole_file.estimate, a.margin)
|
|
188
|
+
if a.whole_file
|
|
189
|
+
else {"ok": False},
|
|
190
|
+
full_rate=a.rates.get(FULL),
|
|
191
|
+
none_rate=a.rates.get(NONE),
|
|
192
|
+
file_monthly=file_monthly_usd(a),
|
|
193
|
+
file_per_run=file_tokens_per_run(a),
|
|
194
|
+
total_runs=len(res["runs"]),
|
|
195
|
+
planned_runs=len(res["configs"]) * len(res["tasks"]) * res["repeats"],
|
|
196
|
+
recorded=recorded_note,
|
|
197
|
+
version=__version__,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def write_report(a: Analysis, out: Path, *, recorded_note: dict | None = None) -> Path:
|
|
202
|
+
out = Path(out)
|
|
203
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
204
|
+
out.write_text(render_report(a, recorded_note=recorded_note), encoding="utf-8")
|
|
205
|
+
return out
|