harness-evolve 0.1.0__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.
- harness_evolve/__init__.py +20 -0
- harness_evolve/acceptance.py +237 -0
- harness_evolve/bench.py +137 -0
- harness_evolve/budget.py +57 -0
- harness_evolve/candidates.py +112 -0
- harness_evolve/cli.py +120 -0
- harness_evolve/critic.py +92 -0
- harness_evolve/evaluator.py +48 -0
- harness_evolve/evidence.py +93 -0
- harness_evolve/grader.py +71 -0
- harness_evolve/harness.py +62 -0
- harness_evolve/health.py +121 -0
- harness_evolve/history.py +91 -0
- harness_evolve/llm.py +56 -0
- harness_evolve/loop.py +857 -0
- harness_evolve/pass_k.py +57 -0
- harness_evolve/profiles.py +177 -0
- harness_evolve/proposer.py +192 -0
- harness_evolve/report.py +99 -0
- harness_evolve/state.py +116 -0
- harness_evolve-0.1.0.dist-info/METADATA +207 -0
- harness_evolve-0.1.0.dist-info/RECORD +25 -0
- harness_evolve-0.1.0.dist-info/WHEEL +4 -0
- harness_evolve-0.1.0.dist-info/entry_points.txt +2 -0
- harness_evolve-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""
|
|
2
|
+
harness-evolve: improve an agent's harness from its own failure traces.
|
|
3
|
+
|
|
4
|
+
from harness_evolve import AgentProfile, Optimizer, OptimizerConfig, profiles
|
|
5
|
+
profiles.use(AgentProfile.build(name=..., harness_root=..., agent_description=...))
|
|
6
|
+
opt = Optimizer(run_dir, base_harness, OptimizerConfig(evolve_cases=[...]),
|
|
7
|
+
evaluator=MyEvaluator(), proposer_llm=llm, critic_llm=llm, critic_patterns=[])
|
|
8
|
+
state = asyncio.run(opt.run_until_stopped())
|
|
9
|
+
"""
|
|
10
|
+
from harness_evolve.acceptance import CaseResult, EvalResult
|
|
11
|
+
from harness_evolve.evaluator import EvalOutcome, Evaluator, ProviderFailure, UnpricedModel
|
|
12
|
+
from harness_evolve.harness import Harness, load_harness
|
|
13
|
+
from harness_evolve.llm import LLMReply, litellm_llm
|
|
14
|
+
from harness_evolve.loop import LocalPublisher, Optimizer, OptimizerConfig
|
|
15
|
+
from harness_evolve.profiles import AgentProfile
|
|
16
|
+
|
|
17
|
+
__all__ = ["AgentProfile", "CaseResult", "EvalOutcome", "EvalResult", "Evaluator", "Harness", "LLMReply",
|
|
18
|
+
"LocalPublisher", "Optimizer", "OptimizerConfig", "ProviderFailure", "UnpricedModel",
|
|
19
|
+
"litellm_llm", "load_harness"]
|
|
20
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Acceptance rules: when does a candidate harness replace the incumbent?
|
|
3
|
+
|
|
4
|
+
The rules follow RRSI (Xia et al., "RRSI: Regularized Recursive
|
|
5
|
+
Self-Improvement of Agent Harnesses", arXiv 2609.24972; reference
|
|
6
|
+
implementation github.com/google-research/rrsi, Apache-2.0,
|
|
7
|
+
rrsi/selection.py), reimplemented here for our setting:
|
|
8
|
+
|
|
9
|
+
floor S' >= S* - delta (RRSI Eq. floor)
|
|
10
|
+
clear gain if dS > delta: dC <= beta0 + beta1 * dS (RRSI Eq. tokenbudget)
|
|
11
|
+
in band otherwise: w_s*dS - w_c*dC > 0 (RRSI Alg. 2 l.5)
|
|
12
|
+
guards domain vetoes (RRSI's eng domain vetoes a rising no-submission
|
|
13
|
+
rate; ours is the escalation rate, the same failure shape)
|
|
14
|
+
|
|
15
|
+
dC is RELATIVE (C'/C - 1), as in RRSI. Defaults mirror RRSI's coding config,
|
|
16
|
+
where w_s = 0: inside the noise band a candidate can only win by being
|
|
17
|
+
cheaper, never by a within-noise score bump. Our noise is far larger than
|
|
18
|
+
theirs (delta ~0.2-0.3 on ~17 cases vs their 0.017 on 178 trials), so in
|
|
19
|
+
practice most gains are "in band" and the rule reduces to "no worse, and
|
|
20
|
+
cheaper".
|
|
21
|
+
|
|
22
|
+
What differs from RRSI, and why:
|
|
23
|
+
|
|
24
|
+
- **Paired, per-case comparison instead of one aggregate score each.** The
|
|
25
|
+
candidate and incumbent run on the same cases, so S and C are compared
|
|
26
|
+
case by case, and each decision records which cases improved or
|
|
27
|
+
regressed. With ~20% of cases flipping between identical runs, knowing
|
|
28
|
+
*which* cases moved matters as much as the mean.
|
|
29
|
+
- **Calibration from trial pairs.** RRSI estimates delta by re-running the
|
|
30
|
+
unchanged base harness. We get the same null distribution from round 0's
|
|
31
|
+
k=2 trials (trial 1 vs trial 2 of the same harness), bootstrapped over
|
|
32
|
+
cases: no extra run.
|
|
33
|
+
- **Guard cases.** The evolve set's always-pass regression guards must keep
|
|
34
|
+
passing: a candidate that fails a guard on every trial (where the
|
|
35
|
+
incumbent passed it) is vetoed outright.
|
|
36
|
+
- **No novelty term, pruning window or annealing.** Those need many rounds;
|
|
37
|
+
we can afford a handful. See docs/blog-drafts/harness-evolution-v2.md §0.4.
|
|
38
|
+
"""
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import random
|
|
42
|
+
import statistics
|
|
43
|
+
from dataclasses import asdict, dataclass, field
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class CaseResult:
|
|
48
|
+
"""One case's outcome across its trials under one harness."""
|
|
49
|
+
passes: int
|
|
50
|
+
trials: int
|
|
51
|
+
escalations: int = 0 # attempts that never got an accepted submission
|
|
52
|
+
cost_usd: float = 0.0 # summed over this case's trials
|
|
53
|
+
verdicts: list[str] = field(default_factory=list)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def score(self) -> float:
|
|
57
|
+
return self.passes / self.trials if self.trials else 0.0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class EvalResult:
|
|
62
|
+
"""A harness evaluated on a set of cases. S and C are means over cases."""
|
|
63
|
+
per_case: dict[str, CaseResult]
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def S(self) -> float:
|
|
67
|
+
return statistics.fmean(c.score for c in self.per_case.values()) if self.per_case else 0.0
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def C(self) -> float:
|
|
71
|
+
"""Mean cost per trial."""
|
|
72
|
+
trials = sum(c.trials for c in self.per_case.values())
|
|
73
|
+
return sum(c.cost_usd for c in self.per_case.values()) / trials if trials else 0.0
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def escalation_rate(self) -> float:
|
|
77
|
+
trials = sum(c.trials for c in self.per_case.values())
|
|
78
|
+
return sum(c.escalations for c in self.per_case.values()) / trials if trials else 0.0
|
|
79
|
+
|
|
80
|
+
def to_json(self) -> dict:
|
|
81
|
+
return {"S": self.S, "C": self.C, "escalation_rate": self.escalation_rate,
|
|
82
|
+
"per_case": {k: asdict(v) for k, v in self.per_case.items()}}
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def from_json(cls, data: dict) -> EvalResult:
|
|
86
|
+
return cls({k: CaseResult(**v) for k, v in data["per_case"].items()})
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class AcceptanceConfig:
|
|
91
|
+
delta: float = 0.25 # noise band; replaced by calibrate() after round 0
|
|
92
|
+
beta0: float = 0.10 # RRSI coding: 10% more cost for free on a clear gain
|
|
93
|
+
beta1: float = 44.5 # RRSI coding: each +1pp of S buys +44.5% cost
|
|
94
|
+
w_s: float = 0.0 # RRSI coding: no credit for within-band score change
|
|
95
|
+
w_c: float = 15.0 # RRSI coding
|
|
96
|
+
max_escalation_rise: float = 0.05
|
|
97
|
+
guard_cases: tuple[str, ...] = ()
|
|
98
|
+
# Noise band on the relative cost change, calibrated like delta from round
|
|
99
|
+
# 0's trial pairs. 0 = the original rule (in band, any saving counts).
|
|
100
|
+
cost_delta: float = 0.0
|
|
101
|
+
# Compare cost only on cases both harnesses resolved on every trial, so a
|
|
102
|
+
# cheaper failure never counts as a saving (multi-harness RL article: reward
|
|
103
|
+
# efficiency only on correct answers). Off = cost over all shared cases.
|
|
104
|
+
cost_on_shared_successes: bool = False
|
|
105
|
+
min_shared_successes: int = 5
|
|
106
|
+
# A rejected in-band candidate with dS >= this fraction of delta is kept as a
|
|
107
|
+
# near miss the proposer can build on (DGM/HGM archives). 0 = off.
|
|
108
|
+
near_miss_fraction: float = 0.0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class Decision:
|
|
113
|
+
accept: bool
|
|
114
|
+
reason: str
|
|
115
|
+
delta_S: float
|
|
116
|
+
delta_C: float # relative
|
|
117
|
+
improved: list[str] = field(default_factory=list)
|
|
118
|
+
regressed: list[str] = field(default_factory=list)
|
|
119
|
+
vetoes: list[str] = field(default_factory=list)
|
|
120
|
+
near_miss: bool = False
|
|
121
|
+
|
|
122
|
+
def to_json(self) -> dict:
|
|
123
|
+
return asdict(self)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def paired_deltas(incumbent: EvalResult, candidate: EvalResult,
|
|
127
|
+
cfg: AcceptanceConfig | None = None) -> tuple[float, float, list, list]:
|
|
128
|
+
"""dS over the cases both evaluated, relative dC, and which cases moved.
|
|
129
|
+
With cfg.cost_on_shared_successes, dC is over the cases both resolved on
|
|
130
|
+
every trial (0 if fewer than cfg.min_shared_successes)."""
|
|
131
|
+
shared = sorted(set(incumbent.per_case) & set(candidate.per_case))
|
|
132
|
+
if not shared:
|
|
133
|
+
raise ValueError("candidate and incumbent share no evaluated cases")
|
|
134
|
+
inc = EvalResult({c: incumbent.per_case[c] for c in shared})
|
|
135
|
+
cand = EvalResult({c: candidate.per_case[c] for c in shared})
|
|
136
|
+
dS = cand.S - inc.S
|
|
137
|
+
dC = (cand.C / inc.C - 1.0) if inc.C > 0 else 0.0
|
|
138
|
+
if cfg is not None and cfg.cost_on_shared_successes:
|
|
139
|
+
both = [c for c in shared if inc.per_case[c].score == 1.0 and cand.per_case[c].score == 1.0]
|
|
140
|
+
if len(both) < cfg.min_shared_successes:
|
|
141
|
+
dC = 0.0
|
|
142
|
+
else:
|
|
143
|
+
inc_s = EvalResult({c: inc.per_case[c] for c in both})
|
|
144
|
+
cand_s = EvalResult({c: cand.per_case[c] for c in both})
|
|
145
|
+
dC = (cand_s.C / inc_s.C - 1.0) if inc_s.C > 0 else 0.0
|
|
146
|
+
improved = [c for c in shared if cand.per_case[c].score > inc.per_case[c].score]
|
|
147
|
+
regressed = [c for c in shared if cand.per_case[c].score < inc.per_case[c].score]
|
|
148
|
+
return dS, dC, improved, regressed
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def decide(incumbent: EvalResult, candidate: EvalResult, S_star: float,
|
|
152
|
+
cfg: AcceptanceConfig) -> Decision:
|
|
153
|
+
dS, dC, improved, regressed = paired_deltas(incumbent, candidate, cfg)
|
|
154
|
+
d = Decision(False, "", dS, dC, improved, regressed)
|
|
155
|
+
|
|
156
|
+
# A guard vetoes only when it fails EVERY trial of the candidate and the
|
|
157
|
+
# incumbent passed it at least once. The first rule (any failed trial)
|
|
158
|
+
# was noise at 2 trials a case: a guard that passes 80-95% of the time
|
|
159
|
+
# (astropy-14309 went PASS/FAIL on the unchanged harness) would have
|
|
160
|
+
# vetoed about a third of candidates by chance across 4 guards. Breaking
|
|
161
|
+
# the easy path shows up as all-fail, not one-in-two.
|
|
162
|
+
for g in cfg.guard_cases:
|
|
163
|
+
cr = candidate.per_case.get(g)
|
|
164
|
+
inc = incumbent.per_case.get(g)
|
|
165
|
+
if cr is not None and cr.trials and cr.passes == 0 and (inc is None or inc.passes > 0):
|
|
166
|
+
d.vetoes.append(f"guard case {g} failed {cr.trials}/{cr.trials}")
|
|
167
|
+
rise = candidate.escalation_rate - incumbent.escalation_rate
|
|
168
|
+
if rise > cfg.max_escalation_rise:
|
|
169
|
+
d.vetoes.append(f"escalation rate rose {rise:+.3f} (limit {cfg.max_escalation_rise})")
|
|
170
|
+
if d.vetoes:
|
|
171
|
+
d.reason = "vetoed: " + "; ".join(d.vetoes)
|
|
172
|
+
return d
|
|
173
|
+
|
|
174
|
+
floor = S_star - cfg.delta
|
|
175
|
+
if candidate.S < floor:
|
|
176
|
+
d.reason = f"below noise floor: S' {candidate.S:.3f} < S* {S_star:.3f} - delta {cfg.delta:.3f}"
|
|
177
|
+
return d
|
|
178
|
+
|
|
179
|
+
if dS > cfg.delta:
|
|
180
|
+
budget = cfg.beta0 + cfg.beta1 * dS
|
|
181
|
+
d.accept = dC <= budget
|
|
182
|
+
d.reason = (f"clear gain dS {dS:+.3f} > delta {cfg.delta:.3f}; relative cost "
|
|
183
|
+
f"{dC:+.3f} {'<=' if d.accept else '>'} budget {budget:.3f}")
|
|
184
|
+
return d
|
|
185
|
+
|
|
186
|
+
# In band a candidate must be cheaper, and with a cost band, cheaper by
|
|
187
|
+
# more than cost noise: the same "submit sooner" edit measured dC anywhere
|
|
188
|
+
# from -1.8% to +4.8% across evaluations, so comparing with 0 let noise decide.
|
|
189
|
+
shaped = cfg.w_s * dS - cfg.w_c * (dC + cfg.cost_delta)
|
|
190
|
+
d.accept = shaped > 0
|
|
191
|
+
d.near_miss = (not d.accept and cfg.near_miss_fraction > 0 and dS > 0
|
|
192
|
+
and dS >= cfg.near_miss_fraction * cfg.delta)
|
|
193
|
+
band = f", cost band {cfg.cost_delta:.3f}" if cfg.cost_delta else ""
|
|
194
|
+
d.reason = (f"within noise band (dS {dS:+.3f}, delta {cfg.delta:.3f}{band}); "
|
|
195
|
+
f"{cfg.w_s}*dS - {cfg.w_c}*(dC{' + band' if cfg.cost_delta else ''}) = {shaped:+.3f} "
|
|
196
|
+
f"{'>' if d.accept else '<='} 0"
|
|
197
|
+
+ ("" if d.accept else " (in band, a candidate must be cheaper"
|
|
198
|
+
+ (" by more than cost noise)" if cfg.cost_delta else ")")))
|
|
199
|
+
return d
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def calibrate_cost_delta(trial_costs: dict[str, tuple[float, float]], z: float = 2.0,
|
|
203
|
+
trials_per_eval: int = 1, reps: int = 2000, seed: int = 7) -> float:
|
|
204
|
+
"""Noise band on the RELATIVE cost change, from two trials of the same
|
|
205
|
+
harness per case: the null dC is (sum trial1 - sum trial2) / mean total,
|
|
206
|
+
bootstrapped over cases, times z, shrunk by sqrt(trials_per_eval)."""
|
|
207
|
+
pairs = list(trial_costs.values())
|
|
208
|
+
if len(pairs) < 2:
|
|
209
|
+
raise ValueError("need at least 2 cases with two trials each")
|
|
210
|
+
rng = random.Random(seed)
|
|
211
|
+
boot = []
|
|
212
|
+
for _ in range(reps):
|
|
213
|
+
sample = [rng.choice(pairs) for _ in pairs]
|
|
214
|
+
a, b = sum(x for x, _ in sample), sum(y for _, y in sample)
|
|
215
|
+
boot.append((a - b) / ((a + b) / 2) if a + b else 0.0)
|
|
216
|
+
return z * statistics.pstdev(boot) / trials_per_eval ** 0.5
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def calibrate_delta(trial_pairs: dict[str, tuple[bool, bool]], z: float = 2.0,
|
|
220
|
+
trials_per_eval: int = 1, reps: int = 2000, seed: int = 7) -> float:
|
|
221
|
+
"""Noise band from two trials of the SAME harness on each case.
|
|
222
|
+
|
|
223
|
+
The null difference of two independent evaluations is observed directly:
|
|
224
|
+
dS_null = mean over cases of (trial1 - trial2). Its standard deviation is
|
|
225
|
+
bootstrapped over cases, and delta = z * sd, so an unchanged harness
|
|
226
|
+
clears the floor about 97.5% of the time at z = 2 (as in rrsi/calibrate.py).
|
|
227
|
+
|
|
228
|
+
The pairs measure single-trial evaluations. If each later evaluation
|
|
229
|
+
averages `trials_per_eval` trials per case, its noise shrinks by
|
|
230
|
+
sqrt(trials_per_eval), and so does delta.
|
|
231
|
+
"""
|
|
232
|
+
diffs = [float(a) - float(b) for a, b in trial_pairs.values()]
|
|
233
|
+
if len(diffs) < 2:
|
|
234
|
+
raise ValueError("need at least 2 cases with two trials each to calibrate")
|
|
235
|
+
rng = random.Random(seed)
|
|
236
|
+
boot = [statistics.fmean(rng.choice(diffs) for _ in diffs) for _ in range(reps)]
|
|
237
|
+
return z * statistics.pstdev(boot) / trials_per_eval ** 0.5
|
harness_evolve/bench.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Benchmark the optimizer itself, so "this change makes harness-evolve better" is a measured claim.
|
|
3
|
+
|
|
4
|
+
A benchmark is a list of scenarios. Each builds an Optimizer in its own run directory and may state
|
|
5
|
+
what must happen:
|
|
6
|
+
|
|
7
|
+
- Known-answer scenarios: a scripted proposer (and critic) puts one known edit in front of the
|
|
8
|
+
acceptance rules, so there is a right answer. A real fix to a planted flaw must be accepted (the
|
|
9
|
+
rules can see a real gain); a harmful edit must be rejected; a neutral rewording must not be
|
|
10
|
+
accepted (false accepts).
|
|
11
|
+
- Open scenarios: the real proposer, reported but not pass/fail: does it find a planted flaw, and
|
|
12
|
+
what does a standard run gain on held-out cases, per dollar?
|
|
13
|
+
|
|
14
|
+
harness-evolve bench examples/math_agent/bench.py --out runs/bench-1
|
|
15
|
+
|
|
16
|
+
Run directories persist, so a stopped benchmark resumes like any run. Results go to
|
|
17
|
+
<out>/bench.json and <out>/bench.md.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from harness_evolve.llm import LLM, LLMReply
|
|
27
|
+
|
|
28
|
+
ACCEPTED, NOT_ACCEPTED = "accepted", "not_accepted"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Scenario:
|
|
33
|
+
name: str
|
|
34
|
+
description: str
|
|
35
|
+
build: Callable[[Path], object] # run_dir -> Optimizer
|
|
36
|
+
expect: str | None = None # ACCEPTED, NOT_ACCEPTED, or None (report only)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def scripted_llm(edit: dict, critic_accepts: bool = True) -> LLM:
|
|
40
|
+
"""A stand-in for the proposer and critic: the proposer always proposes `edit`
|
|
41
|
+
({"component", "hypothesis", "edits": [...]}); the critic always accepts (or rejects).
|
|
42
|
+
With the critic scripted too, the scenario tests the acceptance rules alone."""
|
|
43
|
+
async def call(system: str, prompt: str) -> LLMReply:
|
|
44
|
+
if "strict reviewer" in system:
|
|
45
|
+
verdict = "accept" if critic_accepts else "reject"
|
|
46
|
+
return LLMReply(json.dumps({"verdict": verdict, "reasons": ["scripted"]}), 0.0)
|
|
47
|
+
return LLMReply(json.dumps(edit), 0.0)
|
|
48
|
+
return call
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def outcome(run_dir: Path) -> dict:
|
|
52
|
+
"""What a finished scenario run did: rounds, acceptances, held-out numbers, spend."""
|
|
53
|
+
state = json.loads((run_dir / "state.json").read_text())
|
|
54
|
+
rounds = []
|
|
55
|
+
hist = run_dir / "history.jsonl"
|
|
56
|
+
if hist.exists():
|
|
57
|
+
for line in hist.read_text().splitlines():
|
|
58
|
+
e = json.loads(line)
|
|
59
|
+
if not str(e.get("outcome", "")).startswith("pilot_"):
|
|
60
|
+
rounds.append({k: e.get(k) for k in ("round", "component", "outcome", "delta_S", "delta_C",
|
|
61
|
+
"reason", "hypothesis")})
|
|
62
|
+
out = {"phase": state.get("phase"), "stop_reason": state.get("stop_reason"),
|
|
63
|
+
"accepted": state.get("accepted") or [], "spent_usd": round(state.get("spent_usd") or 0.0, 3),
|
|
64
|
+
"S_star": state.get("S_star"), "delta": state.get("delta"), "rounds": rounds}
|
|
65
|
+
rep = run_dir / "report" / "report.json"
|
|
66
|
+
if rep.exists():
|
|
67
|
+
r = json.loads(rep.read_text())
|
|
68
|
+
out["heldout"] = {
|
|
69
|
+
"original_pass@1": (r.get("original") or {}).get("pass@1"),
|
|
70
|
+
"evolved_pass@1": (r.get("evolved") or {}).get("pass@1"),
|
|
71
|
+
"vs_original_single_run": (r.get("comparisons") or {}).get("vs_original_single_run"),
|
|
72
|
+
"vs_rerun_baseline_best_of_2": (r.get("comparisons") or {}).get("vs_rerun_baseline_best_of_2"),
|
|
73
|
+
}
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def judge(expect: str | None, result: dict) -> bool | None:
|
|
78
|
+
if result.get("phase") != "done":
|
|
79
|
+
return False
|
|
80
|
+
if expect == ACCEPTED:
|
|
81
|
+
return bool(result["accepted"])
|
|
82
|
+
if expect == NOT_ACCEPTED:
|
|
83
|
+
return not result["accepted"]
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def run(scenarios: list[Scenario], out_dir: Path, only: list[str] | None = None) -> list[dict]:
|
|
88
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
results = []
|
|
90
|
+
for sc in scenarios:
|
|
91
|
+
if only and sc.name not in only:
|
|
92
|
+
continue
|
|
93
|
+
run_dir = out_dir / sc.name
|
|
94
|
+
opt = sc.build(run_dir)
|
|
95
|
+
try:
|
|
96
|
+
await opt.run_until_stopped()
|
|
97
|
+
except Exception as exc: # one broken scenario shouldn't hide the rest
|
|
98
|
+
results.append({"name": sc.name, "description": sc.description, "expect": sc.expect,
|
|
99
|
+
"passed": False, "error": f"{type(exc).__name__}: {exc}"})
|
|
100
|
+
continue
|
|
101
|
+
res = outcome(run_dir)
|
|
102
|
+
results.append({"name": sc.name, "description": sc.description, "expect": sc.expect,
|
|
103
|
+
"passed": judge(sc.expect, res), **res})
|
|
104
|
+
if only and (out_dir / "bench.json").exists():
|
|
105
|
+
# A partial re-run updates its scenarios in the existing results instead of replacing them.
|
|
106
|
+
previous = json.loads((out_dir / "bench.json").read_text())
|
|
107
|
+
fresh = {r["name"]: r for r in results}
|
|
108
|
+
names = [r["name"] for r in previous] + [n for n in fresh if n not in {r["name"] for r in previous}]
|
|
109
|
+
old = {r["name"]: r for r in previous}
|
|
110
|
+
results = [fresh.get(n, old.get(n)) for n in names]
|
|
111
|
+
(out_dir / "bench.json").write_text(json.dumps(results, indent=1))
|
|
112
|
+
(out_dir / "bench.md").write_text(render(results))
|
|
113
|
+
return results
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def render(results: list[dict]) -> str:
|
|
117
|
+
def pct(x):
|
|
118
|
+
return "-" if x is None else f"{x:.3f}"
|
|
119
|
+
lines = ["# harness-evolve benchmark", "",
|
|
120
|
+
"| Scenario | Expect | Result | Accepted | First round (dS, dC) "
|
|
121
|
+
"| Held-out pass@1 (orig → evolved) | Spent |",
|
|
122
|
+
"|---|---|---|---|---|---|---|"]
|
|
123
|
+
for r in results:
|
|
124
|
+
verdict = {True: "PASS", False: "FAIL", None: "report"}[r.get("passed")]
|
|
125
|
+
if r.get("error"):
|
|
126
|
+
lines.append(f"| {r['name']} | {r.get('expect') or '-'} | ERROR | - | {r['error'][:80]} | - | - |")
|
|
127
|
+
continue
|
|
128
|
+
first = next(iter(r.get("rounds") or []), None)
|
|
129
|
+
fr = (f"{first['outcome']} ({first['delta_S']:+.3f}, {first['delta_C']:+.0%})"
|
|
130
|
+
if first and first.get("delta_S") is not None and first.get("delta_C") is not None
|
|
131
|
+
else (first or {}).get("outcome", "-"))
|
|
132
|
+
h = r.get("heldout") or {}
|
|
133
|
+
ho = (f"{pct(h.get('original_pass@1'))} → {pct(h.get('evolved_pass@1'))}" if h else "-")
|
|
134
|
+
lines.append(f"| {r['name']} | {r.get('expect') or '-'} | {verdict} | {r.get('accepted') or 'none'} | "
|
|
135
|
+
f"{fr} | {ho} | ${r.get('spent_usd', 0):.2f} |")
|
|
136
|
+
lines += ["", *[f"- **{r['name']}**: {r['description']}" for r in results]]
|
|
137
|
+
return "\n".join(lines) + "\n"
|
harness_evolve/budget.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hard spending cap, enforced in code, never left to the model.
|
|
3
|
+
|
|
4
|
+
Before an evaluation starts, the optimizer asks for a reservation sized by
|
|
5
|
+
the expected cost (per-case cost measured so far x cases x trials, with a
|
|
6
|
+
safety margin). If the reservation would take total spend past the cap, the
|
|
7
|
+
evaluation doesn't start and the run stops with a clear reason, rather than
|
|
8
|
+
discovering the overrun (or an empty credit balance, twice this week) halfway
|
|
9
|
+
through. Actual spend comes from the cost meter and is written into the run
|
|
10
|
+
state, so a resumed run knows what it has already spent.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BudgetExceeded(RuntimeError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Budget:
|
|
23
|
+
cap_usd: float
|
|
24
|
+
spent_usd: float = 0.0
|
|
25
|
+
margin: float = 1.25 # reserve 25% above the estimate
|
|
26
|
+
# Reservations held by evaluations still running. With several running in
|
|
27
|
+
# parallel, each checking only `spent` could pass the cap together and
|
|
28
|
+
# overshoot it; counting in-flight reservations prevents that.
|
|
29
|
+
reserved_usd: float = 0.0
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def remaining_usd(self) -> float:
|
|
33
|
+
return max(0.0, self.cap_usd - self.spent_usd - self.reserved_usd)
|
|
34
|
+
|
|
35
|
+
def reserve(self, estimate_usd: float, what: str) -> float:
|
|
36
|
+
"""Hold `estimate x margin` until release(); returns the amount held."""
|
|
37
|
+
need = estimate_usd * self.margin
|
|
38
|
+
if self.spent_usd + self.reserved_usd + need > self.cap_usd:
|
|
39
|
+
raise BudgetExceeded(
|
|
40
|
+
f"{what}: needs ~${need:.2f} (estimate ${estimate_usd:.2f} x {self.margin} margin), "
|
|
41
|
+
f"but only ${self.remaining_usd:.2f} of the ${self.cap_usd:.2f} cap remains"
|
|
42
|
+
+ (f" (${self.reserved_usd:.2f} held by evaluations in flight)" if self.reserved_usd else "")
|
|
43
|
+
)
|
|
44
|
+
self.reserved_usd += need
|
|
45
|
+
return need
|
|
46
|
+
|
|
47
|
+
def release(self, held_usd: float, actual_usd: float) -> None:
|
|
48
|
+
"""Swap a reservation for what the evaluation actually cost."""
|
|
49
|
+
self.reserved_usd = max(0.0, self.reserved_usd - held_usd)
|
|
50
|
+
self.spent_usd += actual_usd
|
|
51
|
+
|
|
52
|
+
def record(self, actual_usd: float) -> None:
|
|
53
|
+
self.spent_usd += actual_usd
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def estimate_eval_cost(n_cases: int, trials: int, per_case_usd: float) -> float:
|
|
57
|
+
return n_cases * trials * per_case_usd
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Candidate harnesses: copies of a harness directory with one edit applied.
|
|
3
|
+
|
|
4
|
+
The edit surface is bounded to the files that already exist in the harness
|
|
5
|
+
directory: an edit may rewrite them, never add or delete files. Before anything is spent
|
|
6
|
+
evaluating a candidate, it must pass
|
|
7
|
+
structural validation, so a candidate that can't even load (bad JSON, a
|
|
8
|
+
template placeholder the code never fills, a removed tool description, a
|
|
9
|
+
turn budget of 500) is rejected for free. RRSI does the same with its
|
|
10
|
+
"deterministic compile/constructor/smoke checks" after the critic.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import difflib
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import shutil
|
|
18
|
+
import string
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
IGNORED = {"README.md"} # documentation, never loaded, never edited
|
|
22
|
+
|
|
23
|
+
class InvalidCandidate(ValueError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def harness_files(directory: Path) -> dict[str, str]:
|
|
28
|
+
return {p.name: p.read_text() for p in sorted(Path(directory).iterdir())
|
|
29
|
+
if p.is_file() and p.name not in IGNORED}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def content_hash(directory: Path) -> str:
|
|
33
|
+
h = hashlib.sha256()
|
|
34
|
+
for name, text in harness_files(directory).items():
|
|
35
|
+
h.update(name.encode() + b"\0" + text.encode() + b"\0")
|
|
36
|
+
return h.hexdigest()[:16]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _placeholders(template: str) -> set[str]:
|
|
40
|
+
return {f for _, f, _, _ in string.Formatter().parse(template) if f}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def apply_edit(base: Path, dest: Path, files: dict[str, str]) -> None:
|
|
44
|
+
"""dest = copy of base, with `files` (name -> full new content) overwritten."""
|
|
45
|
+
base_files = harness_files(base)
|
|
46
|
+
unknown = sorted(set(files) - set(base_files))
|
|
47
|
+
if unknown:
|
|
48
|
+
raise InvalidCandidate(f"edit touches files outside the harness surface: {unknown}")
|
|
49
|
+
if not any(files[n] != base_files[n] for n in files):
|
|
50
|
+
raise InvalidCandidate("edit changes nothing")
|
|
51
|
+
if dest.exists():
|
|
52
|
+
shutil.rmtree(dest)
|
|
53
|
+
shutil.copytree(base, dest)
|
|
54
|
+
for name, text in files.items():
|
|
55
|
+
(dest / name).write_text(text)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def validate(base: Path, candidate: Path) -> None:
|
|
59
|
+
"""Raise InvalidCandidate unless `candidate` is loadable and in bounds."""
|
|
60
|
+
b, c = harness_files(base), harness_files(candidate)
|
|
61
|
+
if set(b) != set(c):
|
|
62
|
+
raise InvalidCandidate(f"file set changed: {sorted(set(b) ^ set(c))}")
|
|
63
|
+
try:
|
|
64
|
+
b_set = json.loads(b["settings.json"]) if "settings.json" in b else {}
|
|
65
|
+
c_set = json.loads(c["settings.json"]) if "settings.json" in c else {}
|
|
66
|
+
b_tools = json.loads(b["tool_descriptions.json"]) if "tool_descriptions.json" in b else {}
|
|
67
|
+
c_tools = json.loads(c["tool_descriptions.json"]) if "tool_descriptions.json" in c else {}
|
|
68
|
+
except json.JSONDecodeError as exc:
|
|
69
|
+
raise InvalidCandidate(f"invalid JSON: {exc}") from exc
|
|
70
|
+
if set(c_set) != set(b_set):
|
|
71
|
+
raise InvalidCandidate(f"settings keys changed: {sorted(set(b_set) ^ set(c_set))}")
|
|
72
|
+
for key, value in c_set.items():
|
|
73
|
+
if key.startswith("_"):
|
|
74
|
+
continue
|
|
75
|
+
if type(value) is not type(b_set[key]):
|
|
76
|
+
raise InvalidCandidate(f"setting {key} changed type")
|
|
77
|
+
from harness_evolve import profiles
|
|
78
|
+
lo_hi = profiles.active().setting_bounds.get(key)
|
|
79
|
+
if isinstance(lo_hi, frozenset):
|
|
80
|
+
if value not in lo_hi:
|
|
81
|
+
raise InvalidCandidate(f"setting {key}={value!r} not one of {sorted(lo_hi)}")
|
|
82
|
+
elif lo_hi and not lo_hi[0] <= value <= lo_hi[1]:
|
|
83
|
+
raise InvalidCandidate(f"setting {key}={value} outside {lo_hi}")
|
|
84
|
+
if set(c_tools) != set(b_tools):
|
|
85
|
+
raise InvalidCandidate(f"tool set changed: {sorted(set(b_tools) ^ set(c_tools))}")
|
|
86
|
+
if any(not str(v).strip() for v in c_tools.values()):
|
|
87
|
+
raise InvalidCandidate("a tool description is empty")
|
|
88
|
+
for name in b:
|
|
89
|
+
if not name.endswith(".prompt"):
|
|
90
|
+
continue
|
|
91
|
+
try:
|
|
92
|
+
extra = _placeholders(c[name]) - _placeholders(b[name])
|
|
93
|
+
except ValueError as exc: # unbalanced braces
|
|
94
|
+
raise InvalidCandidate(f"{name}: malformed template: {exc}") from exc
|
|
95
|
+
if extra:
|
|
96
|
+
raise InvalidCandidate(f"{name}: uses placeholders the code never fills: {sorted(extra)}")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def diff(base: Path, candidate: Path) -> str:
|
|
100
|
+
b, c = harness_files(base), harness_files(candidate)
|
|
101
|
+
out = []
|
|
102
|
+
for name in sorted(b):
|
|
103
|
+
if b[name] != c.get(name):
|
|
104
|
+
out.extend(difflib.unified_diff(b[name].splitlines(keepends=True),
|
|
105
|
+
c.get(name, "").splitlines(keepends=True),
|
|
106
|
+
f"a/{name}", f"b/{name}"))
|
|
107
|
+
return "".join(out)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def changed_files(base: Path, candidate: Path) -> list[str]:
|
|
111
|
+
b, c = harness_files(base), harness_files(candidate)
|
|
112
|
+
return sorted(n for n in b if b[n] != c.get(n))
|