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/analysis.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Turn raw runs into per-rule verdicts, token counts and projected monthly cost."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from statistics import fmean
|
|
7
|
+
|
|
8
|
+
from .configs import FULL, NONE, minus_id
|
|
9
|
+
from .stats import DEFAULT_MARGIN, Effect, MeanCI, metric_delta, success_effect, success_rate
|
|
10
|
+
|
|
11
|
+
# Reference price used to project monthly cost when the measured model is local (free).
|
|
12
|
+
# Claude Sonnet 5.5 input list price on OpenRouter, checked 3 Nov 2026 (USD per 1M tokens).
|
|
13
|
+
REFERENCE_PRICE_IN = 2.0
|
|
14
|
+
REFERENCE_PRICE_LABEL = "Claude Sonnet 5.5 input price on OpenRouter (3 Nov 2026)"
|
|
15
|
+
REFERENCE_PRICE_LABEL_ES = "precio de entrada de Claude Sonnet 5.5 en OpenRouter (3 nov 2026)"
|
|
16
|
+
DEFAULT_SESSIONS_PER_MONTH = 1000
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class RuleResult:
|
|
21
|
+
id: str
|
|
22
|
+
title: str
|
|
23
|
+
text: str
|
|
24
|
+
start_line: int
|
|
25
|
+
end_line: int
|
|
26
|
+
tokens: int # approximate tokens in the rule text
|
|
27
|
+
success: Effect # full - minus(rule): positive = removing the rule loses success
|
|
28
|
+
input_tokens: MeanCI # full - minus(rule), per run
|
|
29
|
+
cost: MeanCI # full - minus(rule), USD per run
|
|
30
|
+
tokens_per_run: float # rule tokens x mean model calls per run (the file is resent each call)
|
|
31
|
+
monthly_usd: float # projected cost of carrying the rule
|
|
32
|
+
|
|
33
|
+
def to_dict(self) -> dict:
|
|
34
|
+
d = {k: v for k, v in self.__dict__.items() if k not in ("success", "input_tokens", "cost")}
|
|
35
|
+
d["success"] = self.success.to_dict()
|
|
36
|
+
d["input_tokens"] = self.input_tokens.to_dict()
|
|
37
|
+
d["cost"] = self.cost.to_dict()
|
|
38
|
+
return d
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Analysis:
|
|
43
|
+
results: dict
|
|
44
|
+
rules: list[RuleResult]
|
|
45
|
+
whole_file: Effect | None # full - none
|
|
46
|
+
whole_file_tokens: MeanCI | None
|
|
47
|
+
rates: dict[str, tuple[float, int, int]]
|
|
48
|
+
mean_turns: float
|
|
49
|
+
price_in: float
|
|
50
|
+
price_label: str
|
|
51
|
+
price_label_es: str
|
|
52
|
+
sessions_per_month: int
|
|
53
|
+
margin: float
|
|
54
|
+
errors: int
|
|
55
|
+
counts: dict[str, int]
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def verdict_counts(self) -> dict[str, int]:
|
|
59
|
+
out = {"helps": 0, "no_effect": 0, "hurts": 0, "inconclusive": 0}
|
|
60
|
+
for r in self.rules:
|
|
61
|
+
out[r.success.verdict] += 1
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def overall_rate(self) -> float:
|
|
66
|
+
s = sum(v[1] for v in self.rates.values())
|
|
67
|
+
n = sum(v[2] for v in self.rates.values())
|
|
68
|
+
return s / n if n else float("nan")
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def floor_or_ceiling(self) -> str | None:
|
|
72
|
+
"""'floor' when almost nothing passes, 'ceiling' when almost everything does."""
|
|
73
|
+
r = self.overall_rate
|
|
74
|
+
if r != r:
|
|
75
|
+
return None
|
|
76
|
+
if r <= 0.05:
|
|
77
|
+
return "floor"
|
|
78
|
+
if r >= 0.95:
|
|
79
|
+
return "ceiling"
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def low_power(self) -> bool:
|
|
84
|
+
return self.verdict_counts["inconclusive"] >= max(1, len(self.rules) // 2)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _model_price_in(results: dict) -> float:
|
|
88
|
+
return float(results.get("meta", {}).get("price_in_per_mtok") or 0.0)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def analyze(
|
|
92
|
+
results: dict,
|
|
93
|
+
*,
|
|
94
|
+
sessions_per_month: int = DEFAULT_SESSIONS_PER_MONTH,
|
|
95
|
+
price_in: float | None = None,
|
|
96
|
+
margin: float = DEFAULT_MARGIN,
|
|
97
|
+
seed: int = 0,
|
|
98
|
+
) -> Analysis:
|
|
99
|
+
runs = results["runs"]
|
|
100
|
+
config_ids = [c["id"] for c in results["configs"]]
|
|
101
|
+
valid = [r for r in runs if r.get("success") is not None]
|
|
102
|
+
errors = len(runs) - len(valid)
|
|
103
|
+
|
|
104
|
+
full_turns = [r.get("turns") or 0 for r in valid if r["config"] == FULL]
|
|
105
|
+
mean_turns = fmean(full_turns) if full_turns else 0.0
|
|
106
|
+
|
|
107
|
+
if price_in is None:
|
|
108
|
+
model_price = _model_price_in(results)
|
|
109
|
+
if model_price > 0:
|
|
110
|
+
price_in, price_label = model_price, f"{results.get('model')} input price"
|
|
111
|
+
price_label_es = f"precio de entrada de {results.get('model')}"
|
|
112
|
+
else:
|
|
113
|
+
price_in, price_label = REFERENCE_PRICE_IN, REFERENCE_PRICE_LABEL
|
|
114
|
+
price_label_es = REFERENCE_PRICE_LABEL_ES
|
|
115
|
+
else:
|
|
116
|
+
price_label = price_label_es = "--price-in"
|
|
117
|
+
|
|
118
|
+
rules: list[RuleResult] = []
|
|
119
|
+
for i, rd in enumerate(results["file"]["rules"]):
|
|
120
|
+
cid = minus_id(rd["id"])
|
|
121
|
+
if cid not in config_ids:
|
|
122
|
+
continue
|
|
123
|
+
eff = success_effect(runs, FULL, cid, margin=margin, seed=seed + i)
|
|
124
|
+
tok = metric_delta(runs, FULL, cid, "input_tokens", seed=seed + i)
|
|
125
|
+
cost = metric_delta(runs, FULL, cid, "cost_usd", seed=seed + i)
|
|
126
|
+
per_run = rd["tokens"] * mean_turns
|
|
127
|
+
rules.append(
|
|
128
|
+
RuleResult(
|
|
129
|
+
id=rd["id"],
|
|
130
|
+
title=rd["title"],
|
|
131
|
+
text=rd["text"],
|
|
132
|
+
start_line=rd["start_line"],
|
|
133
|
+
end_line=rd["end_line"],
|
|
134
|
+
tokens=rd["tokens"],
|
|
135
|
+
success=eff,
|
|
136
|
+
input_tokens=tok,
|
|
137
|
+
cost=cost,
|
|
138
|
+
tokens_per_run=per_run,
|
|
139
|
+
monthly_usd=per_run * sessions_per_month * price_in / 1_000_000,
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
whole = whole_tok = None
|
|
144
|
+
if NONE in config_ids:
|
|
145
|
+
whole = success_effect(runs, FULL, NONE, margin=margin, seed=seed + 999)
|
|
146
|
+
whole_tok = metric_delta(runs, FULL, NONE, "input_tokens", seed=seed + 999)
|
|
147
|
+
|
|
148
|
+
rates = {cid: success_rate(runs, cid) for cid in config_ids}
|
|
149
|
+
counts = {cid: sum(1 for r in runs if r["config"] == cid) for cid in config_ids}
|
|
150
|
+
return Analysis(
|
|
151
|
+
results=results,
|
|
152
|
+
rules=rules,
|
|
153
|
+
whole_file=whole,
|
|
154
|
+
whole_file_tokens=whole_tok,
|
|
155
|
+
rates=rates,
|
|
156
|
+
mean_turns=mean_turns,
|
|
157
|
+
price_in=price_in,
|
|
158
|
+
price_label=price_label,
|
|
159
|
+
price_label_es=price_label_es,
|
|
160
|
+
sessions_per_month=sessions_per_month,
|
|
161
|
+
margin=margin,
|
|
162
|
+
errors=errors,
|
|
163
|
+
counts=counts,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def file_tokens_per_run(a: Analysis) -> float:
|
|
168
|
+
"""Estimated input tokens the file adds per run if it is resent on every model call."""
|
|
169
|
+
return a.results["file"]["tokens"] * a.mean_turns
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def file_monthly_usd(a: Analysis) -> float:
|
|
173
|
+
tokens = a.results["file"]["tokens"]
|
|
174
|
+
return tokens * a.mean_turns * a.sessions_per_month * a.price_in / 1_000_000
|
rulelab/cli.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Command line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import signal
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import webbrowser
|
|
10
|
+
from importlib import resources
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
from .analysis import DEFAULT_SESSIONS_PER_MONTH, Analysis, analyze, file_tokens_per_run
|
|
20
|
+
from .errors import RulelabError
|
|
21
|
+
from .experiment import BudgetError, check_budget, execute, load, make_plan, new_results
|
|
22
|
+
from .report import VERDICT_LABELS, pp, signed_int, usd, write_report
|
|
23
|
+
from .rules import load_rules
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(
|
|
26
|
+
add_completion=False,
|
|
27
|
+
no_args_is_help=True,
|
|
28
|
+
rich_markup_mode="rich",
|
|
29
|
+
help="Find out which lines of your AGENTS.md earn their tokens.",
|
|
30
|
+
)
|
|
31
|
+
console = Console()
|
|
32
|
+
err = Console(stderr=True)
|
|
33
|
+
|
|
34
|
+
VERDICT_STYLE = {"helps": "green", "hurts": "red", "no_effect": "dim", "inconclusive": "yellow"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _fail(message: str, hint: str | None = None, code: int = 1) -> None:
|
|
38
|
+
err.print(f"[bold red]error:[/] {message}")
|
|
39
|
+
if hint:
|
|
40
|
+
err.print(f"[dim]{hint}[/]")
|
|
41
|
+
raise typer.Exit(code)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _version(value: bool) -> None:
|
|
45
|
+
if value:
|
|
46
|
+
console.print(f"rulelab {__version__}")
|
|
47
|
+
raise typer.Exit()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@app.callback()
|
|
51
|
+
def _root(
|
|
52
|
+
version: bool = typer.Option(False, "--version", callback=_version, is_eager=True, help="Show version."),
|
|
53
|
+
) -> None:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.command()
|
|
58
|
+
def split(
|
|
59
|
+
file: Path = typer.Argument(..., help="AGENTS.md or CLAUDE.md to split.", exists=True, dir_okay=False),
|
|
60
|
+
granularity: str = typer.Option("section", "--granularity", "-g", help="section or bullet."),
|
|
61
|
+
as_json: bool = typer.Option(False, "--json", help="Print rules as JSON."),
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Show how a file is split into rules (no agent runs)."""
|
|
64
|
+
rf = _load(file, granularity)
|
|
65
|
+
if as_json:
|
|
66
|
+
print(json.dumps([r.to_dict() for r in rf.rules], indent=2, ensure_ascii=False))
|
|
67
|
+
return
|
|
68
|
+
table = Table(title=f"{rf.path}: {len(rf.rules)} rules, ≈{rf.tokens} tokens", title_justify="left")
|
|
69
|
+
table.add_column("id", style="bold")
|
|
70
|
+
table.add_column("rule")
|
|
71
|
+
table.add_column("lines", justify="right")
|
|
72
|
+
table.add_column("≈tok", justify="right")
|
|
73
|
+
for r in rf.rules:
|
|
74
|
+
table.add_row(r.id, r.title, f"{r.start_line}-{r.end_line}", str(r.tokens))
|
|
75
|
+
console.print(table)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _load(file: Path, granularity: str):
|
|
79
|
+
if granularity not in ("section", "bullet"):
|
|
80
|
+
_fail("--granularity must be 'section' or 'bullet'")
|
|
81
|
+
try:
|
|
82
|
+
return load_rules(file, granularity) # type: ignore[arg-type]
|
|
83
|
+
except UnicodeDecodeError:
|
|
84
|
+
_fail(f"{file} is not UTF-8 text")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def print_summary(a: Analysis) -> None:
|
|
88
|
+
res = a.results
|
|
89
|
+
full = a.rates.get("full")
|
|
90
|
+
none = a.rates.get("none")
|
|
91
|
+
console.print(
|
|
92
|
+
f"[bold]{res['file']['path']}[/] · model [bold]{res['model']}[/] · "
|
|
93
|
+
f"{len(res['tasks'])} tasks × {res['repeats']} repeats × {len(res['configs'])} configs · "
|
|
94
|
+
f"{len(res['runs'])} runs · {usd(res.get('spent_usd', 0), 4)} USD"
|
|
95
|
+
)
|
|
96
|
+
if full:
|
|
97
|
+
line = f"full file {full[1]}/{full[2]}"
|
|
98
|
+
if none:
|
|
99
|
+
line += f" · no file {none[1]}/{none[2]}"
|
|
100
|
+
if a.whole_file:
|
|
101
|
+
w = a.whole_file
|
|
102
|
+
line += f" · file effect {pp(w.estimate)} pp [{pp(w.low)}, {pp(w.high)}]"
|
|
103
|
+
console.print(line)
|
|
104
|
+
est = f"file tokens per run, estimated if resent on every call: ≈{file_tokens_per_run(a):,.0f}"
|
|
105
|
+
if a.whole_file_tokens and a.whole_file_tokens.n:
|
|
106
|
+
t = a.whole_file_tokens
|
|
107
|
+
est += (
|
|
108
|
+
f" · measured difference vs no file: {signed_int(t.mean)} "
|
|
109
|
+
f"[{signed_int(t.low)}, {signed_int(t.high)}]"
|
|
110
|
+
)
|
|
111
|
+
console.print(est)
|
|
112
|
+
table = Table(show_lines=False, title_justify="left")
|
|
113
|
+
table.add_column("rule", overflow="fold")
|
|
114
|
+
table.add_column("≈tok", justify="right")
|
|
115
|
+
table.add_column("effect (pp, 95% CI)", justify="right")
|
|
116
|
+
table.add_column("verdict")
|
|
117
|
+
table.add_column("USD/month", justify="right")
|
|
118
|
+
for r in a.rules:
|
|
119
|
+
e = r.success
|
|
120
|
+
table.add_row(
|
|
121
|
+
f"[bold]{r.id}[/] {r.title}",
|
|
122
|
+
str(r.tokens),
|
|
123
|
+
f"{pp(e.estimate)} [{pp(e.low)}, {pp(e.high)}]",
|
|
124
|
+
f"[{VERDICT_STYLE[e.verdict]}]{VERDICT_LABELS[e.verdict][0]}[/]",
|
|
125
|
+
usd(r.monthly_usd),
|
|
126
|
+
)
|
|
127
|
+
console.print(table)
|
|
128
|
+
console.print(
|
|
129
|
+
f"[dim]USD/month = rule tokens × {a.mean_turns:.1f} model calls/run × {a.sessions_per_month:,} sessions "
|
|
130
|
+
f"at {a.price_in} USD/1M input tokens ({a.price_label}). Tokens ≈ chars/4.[/]"
|
|
131
|
+
)
|
|
132
|
+
if a.floor_or_ceiling == "floor":
|
|
133
|
+
console.print(
|
|
134
|
+
f"[yellow]Floor effect:[/] only {a.overall_rate:.0%} of all runs passed. This model rarely solves these "
|
|
135
|
+
"tasks with or without the file, so success cannot show what a rule does; only the cost columns are "
|
|
136
|
+
"informative. Use a stronger model, more steps or easier tasks."
|
|
137
|
+
)
|
|
138
|
+
elif a.floor_or_ceiling == "ceiling":
|
|
139
|
+
console.print(
|
|
140
|
+
f"[yellow]Ceiling effect:[/] {a.overall_rate:.0%} of all runs passed. The tasks are too easy for this "
|
|
141
|
+
"model to show what a rule does; only the cost columns are informative. Use harder tasks."
|
|
142
|
+
)
|
|
143
|
+
if a.low_power:
|
|
144
|
+
console.print(
|
|
145
|
+
"[yellow]Small sample:[/] most rules are inconclusive. Only large effects can be told apart "
|
|
146
|
+
"from noise with this many tasks and repeats. Add tasks or repeats before deleting a rule."
|
|
147
|
+
)
|
|
148
|
+
if res.get("status") == "budget_exceeded":
|
|
149
|
+
console.print("[yellow]Stopped at --max-cost: results are partial.[/]")
|
|
150
|
+
elif res.get("status") == "interrupted":
|
|
151
|
+
console.print("[yellow]Interrupted: results are partial. Re-run with --resume to continue.[/]")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _parse_price(price: str | None) -> tuple[float, float] | None:
|
|
155
|
+
if not price:
|
|
156
|
+
return None
|
|
157
|
+
try:
|
|
158
|
+
pin, pout = (float(x) for x in price.split(","))
|
|
159
|
+
except ValueError:
|
|
160
|
+
_fail("--price must look like IN,OUT in USD per million tokens, e.g. 0.30,0.90")
|
|
161
|
+
return (pin, pout)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@app.command()
|
|
165
|
+
def run(
|
|
166
|
+
file: Path = typer.Argument(..., help="AGENTS.md or CLAUDE.md to test.", exists=True, dir_okay=False),
|
|
167
|
+
repo: Optional[str] = typer.Option(None, "--repo", help="Git repo (path or URL) to mine tasks from with commitarena."),
|
|
168
|
+
tasks_file: Optional[Path] = typer.Option(None, "--tasks-file", help="Existing tasks.json from `commitarena mine`.", exists=True),
|
|
169
|
+
only: Optional[str] = typer.Option(None, "--only", help="Comma-separated task ids to use (default: all)."),
|
|
170
|
+
max_tasks: int = typer.Option(6, "--max-tasks", min=1, help="Tasks to mine when using --repo."),
|
|
171
|
+
model: str = typer.Option("ollama/qwen3:4b", "--model", "-m", help="provider/model: ollama/qwen3:4b, mistral/codestral-latest, openrouter/<id>, openai/<id>."),
|
|
172
|
+
granularity: str = typer.Option("section", "--granularity", "-g", help="Split by section or bullet."),
|
|
173
|
+
repeats: int = typer.Option(3, "--repeats", "-n", min=1, help="Runs per task and configuration."),
|
|
174
|
+
max_cost: float = typer.Option(1.0, "--max-cost", min=0.0, help="Hard budget in USD. Local models count as 0."),
|
|
175
|
+
inject: str = typer.Option("both", "--inject", help="Where the file goes: both (repo root + system prompt), repo or prompt."),
|
|
176
|
+
max_steps: int = typer.Option(30, "--max-steps", min=1, help="Agent steps (model calls) per run."),
|
|
177
|
+
concurrency: int = typer.Option(1, "--concurrency", "-j", min=1, max=8, help="Agent runs at the same time (each in its own sandbox)."),
|
|
178
|
+
price: Optional[str] = typer.Option(None, "--price", help="IN,OUT USD per 1M tokens for models without a known price."),
|
|
179
|
+
price_source: Optional[str] = typer.Option(None, "--price-source", help="Where --price comes from (shown in the report)."),
|
|
180
|
+
out: Path = typer.Option(Path("rulelab-results"), "--out", "-o", help="Output directory."),
|
|
181
|
+
resume: bool = typer.Option(False, "--resume", help="Continue an interrupted run in --out."),
|
|
182
|
+
sessions: int = typer.Option(DEFAULT_SESSIONS_PER_MONTH, "--sessions-per-month", help="Agent sessions per month for the cost projection."),
|
|
183
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Print the plan and estimate, run nothing."),
|
|
184
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Do not ask for confirmation."),
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Run the leave-one-out experiment on real tasks from a repository."""
|
|
187
|
+
from . import engine # imported lazily: needs the commitarena engine
|
|
188
|
+
|
|
189
|
+
if inject not in ("both", "repo", "prompt"):
|
|
190
|
+
_fail("--inject must be both, repo or prompt")
|
|
191
|
+
rf = _load(file, granularity)
|
|
192
|
+
if not rf.rules:
|
|
193
|
+
_fail(f"no rules found in {file}")
|
|
194
|
+
try:
|
|
195
|
+
runner = engine.make_runner(
|
|
196
|
+
repo=repo,
|
|
197
|
+
tasks_file=tasks_file,
|
|
198
|
+
model=model,
|
|
199
|
+
cache_dir=out,
|
|
200
|
+
max_tasks=max_tasks,
|
|
201
|
+
context_name=file.name,
|
|
202
|
+
inject=inject,
|
|
203
|
+
max_steps=max_steps,
|
|
204
|
+
price=_parse_price(price),
|
|
205
|
+
price_source=price_source,
|
|
206
|
+
check=not dry_run,
|
|
207
|
+
)
|
|
208
|
+
task_list = [t.strip() for t in only.split(",")] if only else None
|
|
209
|
+
plan = make_plan(rf, runner, repeats, task_list)
|
|
210
|
+
except RulelabError as e:
|
|
211
|
+
_fail(str(e), e.hint)
|
|
212
|
+
except ValueError as e:
|
|
213
|
+
_fail(str(e))
|
|
214
|
+
|
|
215
|
+
results_path = out / "results.json"
|
|
216
|
+
previous = None
|
|
217
|
+
if resume:
|
|
218
|
+
if not results_path.exists():
|
|
219
|
+
_fail(f"nothing to resume: {results_path} does not exist")
|
|
220
|
+
previous = load(results_path)
|
|
221
|
+
if previous["file"]["text"] != rf.text or previous["model"] != runner.model:
|
|
222
|
+
_fail("--resume needs the same file and model as the interrupted run")
|
|
223
|
+
elif results_path.exists() and not dry_run:
|
|
224
|
+
_fail(f"{results_path} already exists", "Use --resume to continue it or --out to write elsewhere.")
|
|
225
|
+
|
|
226
|
+
done = len(previous["runs"]) if previous else 0
|
|
227
|
+
spent = float(previous.get("spent_usd", 0.0)) if previous else 0.0
|
|
228
|
+
console.print(
|
|
229
|
+
f"[bold]{len(rf.rules)} rules[/] → {len(plan.configs)} configurations "
|
|
230
|
+
f"(no file, full file, full minus each rule) × {len(plan.tasks)} tasks × {plan.repeats} repeats "
|
|
231
|
+
f"= [bold]{plan.runs} agent runs[/]" + (f" ({done} already done)" if done else "")
|
|
232
|
+
)
|
|
233
|
+
est = (plan.runs - done) * plan.per_run_usd
|
|
234
|
+
if plan.per_run_usd == 0:
|
|
235
|
+
console.print(f"Estimated cost: [bold]0 USD[/] (local model {runner.model}). Local runs can take a while.")
|
|
236
|
+
else:
|
|
237
|
+
console.print(
|
|
238
|
+
f"Estimated cost: [bold]{est:.2f} USD[/] ({plan.per_run_usd:.4f} USD per run, "
|
|
239
|
+
f"{runner.estimate_note}) · budget --max-cost {max_cost:.2f} USD"
|
|
240
|
+
)
|
|
241
|
+
try:
|
|
242
|
+
check_budget(plan, max_cost, done, spent)
|
|
243
|
+
except BudgetError as e:
|
|
244
|
+
if dry_run:
|
|
245
|
+
console.print(f"[yellow]Over budget:[/] {e}")
|
|
246
|
+
return
|
|
247
|
+
_fail(str(e), "Nothing was run. Lower --repeats, use --only with fewer tasks, pick a cheaper --model, or raise --max-cost.", code=2)
|
|
248
|
+
if dry_run:
|
|
249
|
+
return
|
|
250
|
+
if not yes and plan.per_run_usd > 0 and sys.stdin.isatty():
|
|
251
|
+
typer.confirm(f"Spend up to {min(est, max_cost):.2f} USD?", abort=True)
|
|
252
|
+
|
|
253
|
+
results = previous or new_results(rf, plan, runner, max_cost, meta=runner.meta())
|
|
254
|
+
results["max_cost"] = max_cost
|
|
255
|
+
if hasattr(runner, "set_budget"):
|
|
256
|
+
runner.set_budget(max_cost, spent)
|
|
257
|
+
|
|
258
|
+
def progress(i: int, total: int, rec: dict) -> None:
|
|
259
|
+
mark = {True: "[green]pass[/]", False: "[red]fail[/]", None: "[yellow]error[/]"}[rec["success"]]
|
|
260
|
+
extra = f"{rec['duration_s']:.0f}s · {rec['input_tokens']:,} tok in · {rec['cost_usd']:.4f} USD"
|
|
261
|
+
console.print(f"[dim]{i:>4}/{total}[/] {rec['config']:<12} {rec['task']:<32} #{rec['repeat']} {mark} [dim]{extra}[/]")
|
|
262
|
+
|
|
263
|
+
def _term(signum, frame): # treat SIGTERM like Ctrl-C: stop cleanly, keep what was measured
|
|
264
|
+
raise KeyboardInterrupt
|
|
265
|
+
|
|
266
|
+
signal.signal(signal.SIGTERM, _term)
|
|
267
|
+
try:
|
|
268
|
+
results = execute(plan, runner, results, results_path, max_cost, on_run=progress, concurrency=concurrency)
|
|
269
|
+
except RulelabError as e:
|
|
270
|
+
_fail(str(e), e.hint)
|
|
271
|
+
a = analyze(results, sessions_per_month=sessions)
|
|
272
|
+
html = write_report(a, out / "report.html")
|
|
273
|
+
print_summary(a)
|
|
274
|
+
console.print(f"Results: {results_path}\nReport: {html}")
|
|
275
|
+
if results["status"] == "budget_exceeded":
|
|
276
|
+
raise typer.Exit(2)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@app.command()
|
|
280
|
+
def report(
|
|
281
|
+
results_file: Path = typer.Argument(..., help="results.json written by rulelab run.", exists=True, dir_okay=False),
|
|
282
|
+
out: Path = typer.Option(None, "--out", "-o", help="HTML path (default: next to results.json)."),
|
|
283
|
+
sessions: int = typer.Option(DEFAULT_SESSIONS_PER_MONTH, "--sessions-per-month"),
|
|
284
|
+
price_in: Optional[float] = typer.Option(None, "--price-in", help="USD per 1M input tokens for the projection."),
|
|
285
|
+
margin: float = typer.Option(0.10, "--margin", help="± success difference counted as no effect (0.10 = 10 pp)."),
|
|
286
|
+
open_browser: bool = typer.Option(False, "--open", help="Open the report in a browser."),
|
|
287
|
+
) -> None:
|
|
288
|
+
"""Rebuild the HTML report from a results file."""
|
|
289
|
+
try:
|
|
290
|
+
results = load(results_file)
|
|
291
|
+
except (ValueError, json.JSONDecodeError) as e:
|
|
292
|
+
_fail(str(e))
|
|
293
|
+
a = analyze(results, sessions_per_month=sessions, price_in=price_in, margin=margin)
|
|
294
|
+
html = write_report(a, out or results_file.with_name("report.html"), recorded_note=results.get("recorded"))
|
|
295
|
+
print_summary(a)
|
|
296
|
+
console.print(f"Report: {html}")
|
|
297
|
+
if open_browser:
|
|
298
|
+
webbrowser.open(html.resolve().as_uri())
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def demo_results() -> dict:
|
|
302
|
+
"""The recorded demo run: packaged in the wheel, or read from examples/ in a clone."""
|
|
303
|
+
try:
|
|
304
|
+
packaged = resources.files("rulelab").joinpath("_demo/results.json")
|
|
305
|
+
if packaged.is_file():
|
|
306
|
+
return json.loads(packaged.read_text(encoding="utf-8"))
|
|
307
|
+
except (FileNotFoundError, ModuleNotFoundError):
|
|
308
|
+
pass
|
|
309
|
+
repo_copy = Path(__file__).resolve().parents[2] / "examples" / "demo" / "results.json"
|
|
310
|
+
if repo_copy.is_file():
|
|
311
|
+
return json.loads(repo_copy.read_text(encoding="utf-8"))
|
|
312
|
+
_fail("the recorded demo results are missing from this installation")
|
|
313
|
+
raise AssertionError # unreachable
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@app.command()
|
|
317
|
+
def demo(
|
|
318
|
+
out: Path = typer.Option(None, "--out", "-o", help="Where to write the HTML report (default: a temp dir)."),
|
|
319
|
+
open_browser: bool = typer.Option(False, "--open", help="Open the report in a browser."),
|
|
320
|
+
) -> None:
|
|
321
|
+
"""Show a RECORDED real run on the commitarena demo repo. No keys, no Docker."""
|
|
322
|
+
results = demo_results()
|
|
323
|
+
rec = results.get("recorded", {})
|
|
324
|
+
console.print(
|
|
325
|
+
f"[bold magenta]Recorded run[/] · real results from [bold]rulelab run[/] on {rec.get('date', '?')} · "
|
|
326
|
+
f"rulelab {rec.get('version', '?')} · {rec.get('hardware', '?')}. Nothing is executed now."
|
|
327
|
+
)
|
|
328
|
+
a = analyze(results)
|
|
329
|
+
print_summary(a)
|
|
330
|
+
target = out or Path(tempfile.mkdtemp(prefix="rulelab-demo-")) / "report.html"
|
|
331
|
+
html = write_report(a, target, recorded_note=rec)
|
|
332
|
+
console.print(f"Report: {html}")
|
|
333
|
+
if open_browser:
|
|
334
|
+
webbrowser.open(html.resolve().as_uri())
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def main() -> None:
|
|
338
|
+
try:
|
|
339
|
+
app()
|
|
340
|
+
except RulelabError as e: # pragma: no cover - safety net
|
|
341
|
+
err.print(f"[bold red]error:[/] {e}")
|
|
342
|
+
if e.hint:
|
|
343
|
+
err.print(f"[dim]{e.hint}[/]")
|
|
344
|
+
sys.exit(1)
|
rulelab/configs.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Experiment configurations: no file, full file, and full file minus each rule."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from .rules import RuleFile
|
|
8
|
+
|
|
9
|
+
NONE = "none"
|
|
10
|
+
FULL = "full"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Config:
|
|
15
|
+
id: str
|
|
16
|
+
removed: str | None # rule id removed (leave-one-out), None for "none" / "full"
|
|
17
|
+
context: str | None # file text given to the agent; None means no file at all
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def is_ablation(self) -> bool:
|
|
21
|
+
return self.removed is not None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def minus_id(rule_id: str) -> str:
|
|
25
|
+
return f"minus:{rule_id}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_configs(rule_file: RuleFile, include_none: bool = True) -> list[Config]:
|
|
29
|
+
configs: list[Config] = []
|
|
30
|
+
if include_none:
|
|
31
|
+
configs.append(Config(NONE, None, None))
|
|
32
|
+
configs.append(Config(FULL, None, rule_file.render()))
|
|
33
|
+
for rule in rule_file.rules:
|
|
34
|
+
configs.append(Config(minus_id(rule.id), rule.id, rule_file.render({rule.id})))
|
|
35
|
+
return configs
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def count_runs(n_configs: int, n_tasks: int, repeats: int) -> int:
|
|
39
|
+
return n_configs * n_tasks * repeats
|