acumen 0.0.1.dev0__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.
acumen/bench.py ADDED
@@ -0,0 +1,202 @@
1
+ """Benchmark orchestration: build the matrix, run it concurrently, resume what's done."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Callable, Iterable, Sequence
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from acumen.config import Config
11
+ from acumen.env import AuthMode, Target
12
+ from acumen.paths import SPLITS, RunKey, Split, arm_name, is_complete, run_dir
13
+ from acumen.runner import RunOutcome, run_once
14
+ from acumen.skills import Skill
15
+ from acumen.tasks import Task
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class PlannedRun:
20
+ """One cell of the benchmark matrix, with its caps already resolved."""
21
+
22
+ key: RunKey
23
+ task: Task
24
+ model: str
25
+ max_turns: int
26
+ max_usd: float
27
+
28
+
29
+ def models_for(cfg: Config, task: Task) -> list[str]:
30
+ """Return the models a task runs on — its own override, else the config's list."""
31
+ return [task.model] if task.model else list(cfg.models)
32
+
33
+
34
+ def build_matrix(
35
+ cfg: Config,
36
+ tasks: Sequence[Task],
37
+ *,
38
+ skill: str | None = None,
39
+ splits: Iterable[Split] = SPLITS,
40
+ task_ids: Sequence[str] | None = None,
41
+ ) -> list[PlannedRun]:
42
+ """Expand config and tasks into the full list of runs for one arm.
43
+
44
+ A pass is models x tasks x replicates x splits. Both splits always run; only
45
+ train results are ever shown to the improver, and that is enforced downstream.
46
+
47
+ Parameters
48
+ ----------
49
+ cfg
50
+ The pass config.
51
+ tasks
52
+ The tasks to run.
53
+ skill
54
+ Skill version for this arm, or ``None`` for the baseline.
55
+ splits
56
+ Which splits to run. Defaults to both.
57
+ task_ids
58
+ Restrict to these task ids; ``None`` runs all of them.
59
+
60
+ Returns
61
+ -------
62
+ The planned runs, in a stable order.
63
+ """
64
+ splits = list(splits)
65
+ for split in splits:
66
+ if split not in SPLITS:
67
+ raise ValueError(f"split must be one of {SPLITS}, got {split!r}")
68
+ wanted = set(task_ids) if task_ids else None
69
+ if wanted:
70
+ known = {t.id for t in tasks}
71
+ missing = wanted - known
72
+ if missing:
73
+ raise ValueError(f"unknown task ids: {sorted(missing)} (known: {sorted(known)})")
74
+
75
+ arm = arm_name(skill)
76
+ planned: list[PlannedRun] = []
77
+ for task in tasks:
78
+ if wanted and task.id not in wanted:
79
+ continue
80
+ for split in splits:
81
+ for model in models_for(cfg, task):
82
+ for rep in range(1, cfg.n_replicates + 1):
83
+ planned.append(
84
+ PlannedRun(
85
+ key=RunKey(arm=arm, split=split, model=model, task_id=task.id, rep=rep),
86
+ task=task,
87
+ model=model,
88
+ max_turns=task.max_turns or cfg.max_turns,
89
+ max_usd=task.max_usd or cfg.max_usd,
90
+ )
91
+ )
92
+ return planned
93
+
94
+
95
+ def pending(planned: Sequence[PlannedRun], runs_root: Path, *, resume: bool = True) -> list[PlannedRun]:
96
+ """Drop runs that already have a complete ``result.json``."""
97
+ if not resume:
98
+ return list(planned)
99
+ return [p for p in planned if not is_complete(run_dir(runs_root, p.key))]
100
+
101
+
102
+ async def run_matrix(
103
+ planned: Sequence[PlannedRun],
104
+ *,
105
+ target: Target,
106
+ runs_root: Path,
107
+ max_concurrency: int,
108
+ auth_mode: AuthMode = "api",
109
+ skill: Skill | None = None,
110
+ skill_name: str | None = None,
111
+ sandbox_base: Path | None = None,
112
+ keep_sandbox: bool = False,
113
+ stderr: Callable[[str], None] | None = None,
114
+ on_start: Callable[[PlannedRun], None] | None = None,
115
+ on_done: Callable[[RunOutcome], None] | None = None,
116
+ env_passthrough: Sequence[str] | None = None,
117
+ ) -> list[RunOutcome]:
118
+ """Run planned runs concurrently, bounded by ``max_concurrency``.
119
+
120
+ One failing run never takes down the pass — :func:`acumen.runner.run_once` records
121
+ agent errors as failed runs rather than raising.
122
+
123
+ Parameters
124
+ ----------
125
+ planned
126
+ The runs to execute; already filtered for resume by :func:`pending`.
127
+ target
128
+ The prepared target.
129
+ runs_root
130
+ The ``runs/`` root.
131
+ max_concurrency
132
+ Ceiling on simultaneous agents.
133
+ auth_mode
134
+ Which credential every run authenticates with; benchmark passes always use ``"api"``
135
+ so the recorded ``cost_usd`` reflects real metered spend.
136
+ skill
137
+ The skill every run in this matrix installs, or ``None`` for the baseline. One
138
+ matrix is one arm, so this is a property of the pass rather than of a run.
139
+ skill_name
140
+ Name of the skill under test (``config.skill_name``), used to recognise it in each
141
+ transcript. Pass it in the baseline arm too — nothing is installed there, but a
142
+ baseline run that loads it anyway is the contamination worth catching.
143
+ sandbox_base
144
+ Parent directory for sandboxes.
145
+ keep_sandbox
146
+ Leave sandboxes on disk for inspection.
147
+ stderr
148
+ Callback for each run's CLI subprocess stderr. Pass one shared
149
+ :class:`~acumen.runner.StderrFilter` so the per-spawn startup warnings are
150
+ printed once for the whole pass instead of once per run.
151
+ on_start
152
+ Optional callable invoked with each :class:`PlannedRun` as it is admitted through
153
+ the concurrency gate and begins, for progress output.
154
+ on_done
155
+ Optional callable invoked with each :class:`~acumen.runner.RunOutcome` as it
156
+ lands, for progress output.
157
+ env_passthrough
158
+ Extra environment variable names each run carries into its sandbox on top of the
159
+ built-in allowlist (the operator's ``config.env_passthrough``).
160
+
161
+ Returns
162
+ -------
163
+ The outcomes, in completion order.
164
+ """
165
+ semaphore = asyncio.Semaphore(max_concurrency)
166
+ outcomes: list[RunOutcome] = []
167
+
168
+ async def one(item: PlannedRun) -> RunOutcome:
169
+ async with semaphore:
170
+ if on_start is not None:
171
+ on_start(item)
172
+ outcome = await run_once(
173
+ key=item.key,
174
+ task=item.task,
175
+ target=target,
176
+ run_dir=run_dir(runs_root, item.key),
177
+ model=item.model,
178
+ max_turns=item.max_turns,
179
+ max_usd=item.max_usd,
180
+ auth_mode=auth_mode,
181
+ skill=skill,
182
+ skill_name=skill_name,
183
+ sandbox_base=sandbox_base,
184
+ keep_sandbox=keep_sandbox,
185
+ stderr=stderr,
186
+ env_passthrough=env_passthrough,
187
+ )
188
+ if on_done is not None:
189
+ on_done(outcome)
190
+ return outcome
191
+
192
+ for coro in asyncio.as_completed([one(item) for item in planned]):
193
+ outcomes.append(await coro)
194
+ return outcomes
195
+
196
+
197
+ def summarize(outcomes: Sequence[RunOutcome]) -> dict[str, int]:
198
+ """Count outcomes by reason, for a one-line pass summary."""
199
+ counts: dict[str, int] = {}
200
+ for outcome in outcomes:
201
+ counts[outcome.reason] = counts.get(outcome.reason, 0) + 1
202
+ return counts