File size: 13,508 Bytes
3bc7cb3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 | # TODO: SPRT Early Stopping for AgentArena Evaluation
**Origin:** Gemini research suggestion β replace fixed k=3 pass@k with Sequential Probability Ratio Test (SPRT) for cost-aware early stopping.
**Status:** Research/Design β not yet scheduled for implementation.
---
## What is SPRT
SPRT (Sequential Probability Ratio Test) is a statistical decision procedure by Abraham Wald (1945). Instead of collecting a fixed sample size and then testing, SPRT tests after each new observation and makes one of three decisions:
1. **Accept H0** (null hypothesis) β stop, evidence is clear enough that agent fails
2. **Accept H1** (alternative hypothesis) β stop, evidence is clear enough that agent passes
3. **Continue** β not yet enough evidence, run another trial
The test maintains a **likelihood ratio** Ξ that accumulates evidence:
```
Ξ_n = product of [ P(x_i | H1) / P(x_i | H0) ] for i=1..n
```
For binary pass/fail outcomes with pass probability p0 (null, agent is bad) and p1 (alternative, agent is good):
```
# After each run with outcome x_i in {0, 1}:
Ξ *= (p1 ** x_i * (1-p1) ** (1-x_i)) / (p0 ** x_i * (1-p0) ** (1-x_i))
if Ξ >= B: β Accept H1 (agent passes), stop
if Ξ <= A: β Accept H0 (agent fails), stop
else: β Run another trial
```
The decision boundaries A and B are derived from the acceptable error rates:
```
A = beta / (1 - alpha) # lower boundary (accept H0)
B = (1 - beta) / alpha # upper boundary (accept H1)
```
Where alpha = Type I error rate (false positive: calling a bad agent good) and beta = Type II error rate (false negative: calling a good agent bad). Typical values: alpha=0.05, beta=0.10.
Wald proved SPRT is optimal β it reaches a decision with the minimum expected number of samples given the error constraints.
---
## Why It Matters for AgentArena
Current state in `core/runner.py`, `run_pass_at_k`:
```python
async def run_pass_at_k(self, db, task, agent, k=3) -> list[Run]:
group_id = str(uuid.uuid4())[:8]
results = await asyncio.gather(*[_single(i) for i in range(k)])
...
```
All k=3 runs are fired in **parallel** via `asyncio.gather`. There is no mechanism to skip the third run even if the first two are unambiguous. Every agent-task pair always costs exactly 3x whatever a single run costs.
Concrete waste scenarios:
- **Clear failure:** Agent fails runs 1 and 2. If p_fail is very high, run 3 is paying ~$0.30-0.50 (typical Claude Code run) to confirm what is already statistically obvious.
- **Clear pass:** Agent passes runs 1 and 2. Same problem in reverse. pass@k with k=3 needs at least one pass to confirm capability β but if both pass, the marginal value of a third pass is low for ranking purposes.
- **Edge cases (1/2 split):** Here k=3 is most valuable and SPRT would correctly continue.
At 84 runs across 7 experiments (from eval dashboard), we are not yet at a scale where this pain is acute. But once we run comparative benchmarks across 5+ agents on 10+ tasks, the cost compounds fast. 50 tasks x 5 agents x 3 runs = 750 runs. SPRT might cut that to ~600.
---
## How It Works β Decision Boundaries
For AgentArena, sensible defaults:
```
p0 = 0.3 # null: agent solves the task less than 30% of the time β "bad"
p1 = 0.7 # alt: agent solves the task more than 70% of the time β "good"
alpha = 0.05 # tolerate 5% false positives (bad agent called good)
beta = 0.10 # tolerate 10% false negatives (good agent called bad)
A = beta / (1 - alpha) = 0.10 / 0.95 β 0.105 # stop β reject H1
B = (1 - beta) / alpha = 0.90 / 0.05 = 18.0 # stop β accept H1
```
Walk through example β agent that passes 2/3 runs (sequence: pass, fail, pass):
```
Start: Ξ = 1.0
Run 1 β pass (x=1):
Ξ *= (0.7 / 0.3) = 2.333 β Ξ = 2.333 (between A and B β continue)
Run 2 β fail (x=0):
Ξ *= (0.3 / 0.7) = 0.429 β Ξ = 1.000 (between A and B β continue)
Run 3 β pass (x=1):
Ξ *= (0.7 / 0.3) = 2.333 β Ξ = 2.333 (between A and B β continue, but k=3 reached)
```
Here SPRT does not help β mixed results require all 3 runs. That is correct behavior.
Example β clear failure (0/2 runs):
```
Run 1 β fail: Ξ = 0.429 (above A=0.105 β continue)
Run 2 β fail: Ξ = 0.184 (above A=0.105 β continue)
Run 3 β fail: Ξ = 0.079 (below A=0.105 β accept H0, stop)
```
With these thresholds, even 3 consecutive failures barely cross the boundary. To stop after 2 failures we would need tighter p0/p1 or higher beta. This is a parameter tuning problem.
Example with tighter priors (p0=0.2, p1=0.8, alpha=0.1, beta=0.2, Aβ0.22, B=9.0):
```
Run 1 β fail: Ξ = (0.2/0.8) = 0.25 (above A=0.22 β continue)
Run 2 β fail: Ξ = 0.25*0.25 = 0.0625 (below A=0.22 β STOP after 2 runs)
```
Tighter priors = earlier stopping = less data per decision. This is the core tradeoff.
---
## Implementation Sketch
The fundamental obstacle is that the current implementation uses `asyncio.gather` for parallelism. SPRT is inherently sequential β it cannot decide after run 2 if runs 1, 2, 3 all started simultaneously.
Two implementation paths:
### Option A: Sequential SPRT (true early stopping, loses parallelism)
Replace the gather loop with a sequential loop that checks SPRT after each run:
```python
# In runner.py β new method alongside run_pass_at_k
async def run_sprt(
self,
db: Session,
task: Task,
agent: Agent,
k_max: int = 5,
p0: float = 0.3, # null hypothesis pass rate
p1: float = 0.7, # alternative hypothesis pass rate
alpha: float = 0.05,
beta: float = 0.10,
) -> tuple[list[Run], str]:
"""
Run agent sequentially, stopping early when SPRT reaches a decision.
Returns (runs, decision) where decision is 'h0_reject', 'h1_accept', or 'inconclusive'.
"""
A = beta / (1 - alpha) # lower boundary β accept H0
B = (1 - beta) / alpha # upper boundary β accept H1
lambda_ratio = 1.0
runs = []
group_id = str(uuid.uuid4())[:8]
for i in range(k_max):
run = await self._run_single_for_k(db, task, agent, group_id)
runs.append(run)
x = 1 if run.status == RunStatus.success else 0
lambda_ratio *= (p1 ** x * (1 - p1) ** (1 - x)) / \
(p0 ** x * (1 - p0) ** (1 - x))
logger.info(
"SPRT run %d/%d: status=%s, Ξ=%.4f (A=%.4f, B=%.4f)",
i + 1, k_max, run.status.value, lambda_ratio, A, B
)
if lambda_ratio <= A:
return runs, "h0_reject" # agent fails, stop early
if lambda_ratio >= B:
return runs, "h1_accept" # agent passes, stop early
return runs, "inconclusive"
```
Caller in the API endpoint or experiment orchestrator:
```python
runs, decision = await runner.run_sprt(db, task, agent, k_max=5)
# Store decision alongside runs for analysis
```
Tradeoff: loses wall-clock parallelism. If each run takes 90s, 3 sequential runs = 270s vs 90s parallel. For cost-optimized batch evaluation this is acceptable; for live benchmarks it is not.
### Option B: Hybrid β Parallel batch with SPRT gate
Run 2 runs in parallel first. Check SPRT. If inconclusive, run 1 more (or a second batch of 2). This preserves some parallelism while still saving cost on clear cases.
```python
async def run_sprt_batched(
self, db, task, agent,
batch_sizes: list[int] = [2, 1, 1, 1], # run 2 first, then 1 at a time
p0=0.3, p1=0.7, alpha=0.05, beta=0.10
) -> tuple[list[Run], str]:
A = beta / (1 - alpha)
B = (1 - beta) / alpha
lambda_ratio = 1.0
all_runs = []
group_id = str(uuid.uuid4())[:8]
for batch_size in batch_sizes:
# Run batch in parallel
batch = await asyncio.gather(
*[self._run_single_for_k(db, task, agent, group_id)
for _ in range(batch_size)],
return_exceptions=True
)
# Filter exceptions, update lambda
for run in batch:
if isinstance(run, Exception):
continue
all_runs.append(run)
x = 1 if run.status == RunStatus.success else 0
lambda_ratio *= (p1**x * (1-p1)**(1-x)) / (p0**x * (1-p0)**(1-x))
if lambda_ratio <= A:
return all_runs, "h0_reject"
if lambda_ratio >= B:
return all_runs, "h1_accept"
return all_runs, "inconclusive"
```
This is probably the right design for AgentArena: run 2 in parallel (quick), check, run a third if uncertain. Best of both worlds.
### Where to add it
File: `/home/smlflg/Projekte/AgentArena/core/runner.py`
- Add `run_sprt` and/or `run_sprt_batched` as new methods on `Runner`
- Keep `run_pass_at_k` unchanged (backward compat)
- Add a `sprt_decision` column to the `Run` model or store it in `metrics_json`
- The API endpoint that triggers evaluation (wherever `run_pass_at_k` is called) gets a flag: `early_stopping: bool = False`
---
## Expected Cost Savings
Assumptions:
- Average run cost: $0.40 (Claude Code, mid-complexity task)
- k=3 baseline cost per agent-task: $1.20
- Typical outcome distribution at current scale: 40% clearly pass (β₯2/3), 30% clearly fail (β€1/3), 30% mixed
With SPRT batched (2+1 scheme):
- Clear pass after 2 runs (call it 30% of cases): saves 1 run = $0.40
- Clear fail after 2 runs (call it 25% of cases): saves 1 run = $0.40
- Inconclusive (45%): runs all 3, no savings
Expected savings per agent-task pair:
```
0.30 * $0.40 + 0.25 * $0.40 = $0.22 saved per agent-task (out of $1.20)
β 18% cost reduction
```
At scale (50 tasks x 6 agents = 300 agent-task pairs):
```
Baseline: 300 * $1.20 = $360
With SPRT: 300 * $0.98 = $294
Savings: ~$66 per full benchmark round
```
These numbers are rough. The actual savings depend heavily on the real pass-rate distribution. If most agents are mediocre (lots of mixed 1/3, 2/3 results), SPRT saves little. If agents polarize (clearly good or clearly bad), savings are higher.
The 18% figure in the briefing request assumes a generous distribution. A conservative estimate is 10-12%.
---
## Risks
**Reduced statistical power.** SPRT with tight boundaries makes decisions faster but with higher error rates in edge cases. An agent that truly passes 50% of the time (neither clearly good nor clearly bad) might get mislabeled.
**Bias toward early evidence.** If run infrastructure has systematic variance (cold-start latency, model version drift, time-of-day effects), the first 2 runs may not be representative. SPRT assumes i.i.d. observations. AgentArena runs are not perfectly i.i.d. β there can be sandbox setup jitter, API rate limit effects, etc.
**Parameter sensitivity.** The p0/p1 thresholds encode an opinion about what "good" and "bad" means. Wrong priors = wrong decisions. These need to be calibrated empirically from existing run data before deploying.
**Loss of parallelism (Option A only).** Sequential SPRT triples wall-clock time for most cases. For interactive benchmarking this is unusable. Option B (batched) avoids most of this.
**Audit trail complexity.** Experiments with different numbers of runs per agent are harder to compare. Pass@k requires knowing k. If k varies per agent, the formula `pass@k = 1 - C(n-c, k)/C(n,k)` needs adjustment. SPRT outcomes need their own metric column, not overloading pass@k.
**Inconclusive results are a new failure mode.** The current system always produces a definitive pass/fail count. SPRT introduces a third state. The dashboard and ranking logic need to handle this.
---
## Decision: Is It Worth Implementing Now?
**Current scale:** 84 runs across 7 experiments. At $0.40/run that is ~$34 total. SPRT would have saved maybe $6. Not meaningful.
**Recommendation: Defer to milestone.**
Implement SPRT when we hit one of these triggers:
1. A single benchmark round (one full agent x task matrix) costs more than $50
2. We run more than 5 agents against more than 10 tasks
3. We want to add a "fast screening" mode that does a cheap 2-run sanity check before committing to a full k=3 evaluation
Until then, k=3 fixed parallel is simpler, faster wall-clock, easier to reason about statistically, and the cost savings are not worth the added complexity.
**When we do implement:** start with Option B (batched 2+1) as a separate method `run_sprt_batched`. Keep `run_pass_at_k` unchanged. Add a feature flag in the experiment config. Calibrate p0/p1 from existing run data first.
---
## References
- Wald, A. (1945). *Sequential Tests of Statistical Hypotheses.* Annals of Mathematical Statistics, 16(2), 117β186. https://doi.org/10.1214/aoms/1177731118
- Wald, A. (1947). *Sequential Analysis.* Wiley. (The textbook β Chapter 3 has the boundary derivation.)
- Wikipedia overview (good starting point): https://en.wikipedia.org/wiki/Sequential_probability_ratio_test
- MLflow / Statsig implementation references for A/B testing context: https://www.statsig.com/blog/sequential-testing-on-statsig
- Python implementation (scipy-compatible): https://github.com/dmarx/sprt β small, readable, MIT license
- OpenAI Evals discussion on sequential testing: https://github.com/openai/evals/discussions/780 (community thread, not official)
- Chen et al. (2021) HumanEval pass@k formula: https://arxiv.org/abs/2107.03374 β relevant because AgentArena's pass@k is modeled on this; SPRT interacts with how k is defined here
---
*Written: 2026-03-19. Revisit when benchmark scale crosses the thresholds above.*
|