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/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