Skip to content

Commit 91e64a0

Browse files
committed
feat(eval_optimize_loop): add EvalOptimizePipeline orchestrator
1 parent c3210f5 commit 91e64a0

2 files changed

Lines changed: 477 additions & 0 deletions

File tree

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import time
5+
import tempfile
6+
from datetime import datetime, timezone
7+
from pathlib import Path
8+
from typing import Optional
9+
10+
from trpc_agent_sdk.evaluation import (
11+
AgentEvaluator,
12+
AgentOptimizer,
13+
CallAgent,
14+
EvalCaseResult,
15+
EvalConfig,
16+
EvalStatus,
17+
EvaluateResult,
18+
EvalSetAggregateResult,
19+
TargetPrompt,
20+
)
21+
22+
from .delta import compute_delta
23+
from .failure_attribution import attribute_failures
24+
from .gate import apply_gate
25+
from .models import (
26+
GateDecision,
27+
PerCaseResult,
28+
PipelineConfig,
29+
PipelineResult,
30+
SplitResult,
31+
)
32+
from .reporting import write_reports
33+
34+
35+
class EvalOptimizePipeline:
36+
def __init__(self, config: PipelineConfig) -> None:
37+
self._config = config
38+
self._live_call_agent: Optional[CallAgent] = None
39+
self._live_target_prompt: Optional[TargetPrompt] = None
40+
self._optimizer_call: Optional[callable] = None # type: ignore[valid-type]
41+
42+
@classmethod
43+
def from_config(
44+
cls,
45+
config_path: str,
46+
*,
47+
call_agent: Optional[CallAgent] = None,
48+
target_prompt: Optional[TargetPrompt] = None,
49+
) -> "EvalOptimizePipeline":
50+
with open(config_path, "r", encoding="utf-8") as f:
51+
raw = f.read()
52+
53+
config = PipelineConfig.model_validate_json(raw)
54+
55+
if config.mode == "live" and (call_agent is None or target_prompt is None):
56+
raise ValueError(
57+
"live mode requires call_agent and target_prompt "
58+
"to be passed to from_config()"
59+
)
60+
61+
pipeline = cls(config)
62+
pipeline._live_call_agent = call_agent
63+
pipeline._live_target_prompt = target_prompt
64+
return pipeline
65+
66+
async def run(self) -> PipelineResult:
67+
started_at = datetime.now(timezone.utc).isoformat()
68+
t0 = time.monotonic()
69+
70+
if self._config.mode == "trace":
71+
baseline_train, baseline_val = await self._evaluate_trace_baseline()
72+
fa = attribute_failures(self._extract_case_results(baseline_train))
73+
candidate_train, candidate_val = await self._evaluate_trace_candidate()
74+
elif self._config.mode == "live":
75+
baseline_train, baseline_val = await self._evaluate_live_baseline()
76+
fa = attribute_failures(self._extract_case_results(baseline_train))
77+
await self._run_optimization()
78+
candidate_train, candidate_val = await self._evaluate_live_candidate()
79+
else:
80+
raise ValueError(f"unknown mode: {self._config.mode}")
81+
82+
baseline_split = {
83+
"train": self._build_split_result(baseline_train),
84+
"val": self._build_split_result(baseline_val),
85+
}
86+
candidate_split = {
87+
"train": self._build_split_result(candidate_train),
88+
"val": self._build_split_result(candidate_val),
89+
}
90+
91+
delta = compute_delta(baseline_split, candidate_split)
92+
93+
duration = time.monotonic() - t0
94+
gate = apply_gate(
95+
delta, self._config.gate, cost_usd=0.0, duration_seconds=duration
96+
)
97+
98+
finished_at = datetime.now(timezone.utc).isoformat()
99+
100+
result = PipelineResult(
101+
mode=self._config.mode,
102+
gate_decision=gate.decision,
103+
gate_reasons=gate.reasons,
104+
baseline=baseline_split,
105+
candidate=candidate_split,
106+
delta=delta,
107+
failure_attribution=fa,
108+
overfitting_warning=gate.overfitting_warning,
109+
duration_seconds=duration,
110+
cost_usd=0.0,
111+
seed=self._config.seed,
112+
started_at=started_at,
113+
finished_at=finished_at,
114+
)
115+
116+
write_reports(result, self._config.output_dir)
117+
return result
118+
119+
# ── Trace mode evals ────────────────────────────────────────────
120+
121+
async def _evaluate_trace_baseline(
122+
self,
123+
) -> tuple[EvaluateResult, EvaluateResult]:
124+
train = await self._run_eval(self._config.train_baseline_evalset)
125+
val = await self._run_eval(self._config.val_baseline_evalset)
126+
return train, val
127+
128+
async def _evaluate_trace_candidate(
129+
self,
130+
) -> tuple[EvaluateResult, EvaluateResult]:
131+
train = await self._run_eval(self._config.train_candidate_evalset)
132+
val = await self._run_eval(self._config.val_candidate_evalset)
133+
return train, val
134+
135+
async def _run_eval(self, evalset_path: str) -> EvaluateResult:
136+
eval_config_path = await self._write_eval_config_temp()
137+
try:
138+
executer = AgentEvaluator.get_executer(
139+
evalset_path,
140+
eval_metrics_file_path_or_dir=eval_config_path,
141+
print_detailed_results=False,
142+
print_summary_report=False,
143+
)
144+
await executer.evaluate()
145+
return executer.get_result()
146+
finally:
147+
os.unlink(eval_config_path)
148+
149+
async def _write_eval_config_temp(self) -> str:
150+
fd, path = tempfile.mkstemp(suffix=".json")
151+
os.close(fd)
152+
with open(path, "w", encoding="utf-8") as f:
153+
f.write(
154+
self._config.evaluate.model_dump_json(indent=2, by_alias=True)
155+
)
156+
return path
157+
158+
# ── Live mode evals ─────────────────────────────────────────────
159+
160+
async def _evaluate_live_baseline(
161+
self,
162+
) -> tuple[EvaluateResult, EvaluateResult]:
163+
train = await self._run_eval_with_agent(self._config.live_train_evalset)
164+
val = await self._run_eval_with_agent(self._config.live_val_evalset)
165+
return train, val
166+
167+
async def _evaluate_live_candidate(
168+
self,
169+
) -> tuple[EvaluateResult, EvaluateResult]:
170+
train = await self._run_eval_with_agent(self._config.live_train_evalset)
171+
val = await self._run_eval_with_agent(self._config.live_val_evalset)
172+
return train, val
173+
174+
async def _run_eval_with_agent(self, evalset_path: str) -> EvaluateResult:
175+
eval_config_path = await self._write_eval_config_temp()
176+
try:
177+
executer = AgentEvaluator.get_executer(
178+
evalset_path,
179+
call_agent=self._live_call_agent,
180+
eval_metrics_file_path_or_dir=eval_config_path,
181+
print_detailed_results=False,
182+
print_summary_report=False,
183+
)
184+
await executer.evaluate()
185+
return executer.get_result()
186+
finally:
187+
os.unlink(eval_config_path)
188+
189+
async def _run_optimization(self) -> None:
190+
if self._optimizer_call is not None:
191+
await self._optimizer_call(self)
192+
return
193+
194+
await AgentOptimizer.optimize(
195+
config_path=self._config.optimizer_config_path,
196+
call_agent=self._live_call_agent,
197+
target_prompt=self._live_target_prompt,
198+
train_dataset_path=self._config.live_train_evalset,
199+
validation_dataset_path=self._config.live_val_evalset,
200+
output_dir=os.path.join(self._config.output_dir, "optimizer"),
201+
update_source=True,
202+
verbose=0,
203+
)
204+
205+
# ── Result helpers ──────────────────────────────────────────────
206+
207+
@staticmethod
208+
def _extract_case_results(
209+
eval_result: EvaluateResult,
210+
) -> dict[str, list[EvalCaseResult]]:
211+
cases_by_id: dict[str, list[EvalCaseResult]] = {}
212+
for aggregate in eval_result.results_by_eval_set_id.values():
213+
for case_id, case_results in aggregate.eval_results_by_eval_id.items():
214+
if case_id not in cases_by_id:
215+
cases_by_id[case_id] = []
216+
cases_by_id[case_id].extend(case_results)
217+
return cases_by_id
218+
219+
def _build_split_result(self, eval_result: EvaluateResult) -> SplitResult:
220+
cases_by_id = self._extract_case_results(eval_result)
221+
222+
per_case: dict[str, PerCaseResult] = {}
223+
metric_sums: dict[str, float] = {}
224+
metric_counts: dict[str, int] = {}
225+
passed_count = 0
226+
227+
for case_id, case_results in cases_by_id.items():
228+
all_passed = all(
229+
cr.final_eval_status == EvalStatus.PASSED and not cr.error_message
230+
for cr in case_results
231+
)
232+
if all_passed:
233+
passed_count += 1
234+
235+
run_scores: dict[str, list[float]] = {}
236+
for cr in case_results:
237+
for mr in cr.overall_eval_metric_results:
238+
score = mr.score if mr.score is not None else 0.0
239+
if mr.metric_name not in run_scores:
240+
run_scores[mr.metric_name] = []
241+
run_scores[mr.metric_name].append(score)
242+
243+
avg_scores: dict[str, float] = {}
244+
for name, scores in run_scores.items():
245+
avg = sum(scores) / len(scores)
246+
avg_scores[name] = avg
247+
metric_sums[name] = metric_sums.get(name, 0.0) + avg
248+
metric_counts[name] = metric_counts.get(name, 0) + 1
249+
250+
per_case[case_id] = PerCaseResult(
251+
case_id=case_id,
252+
passed=all_passed,
253+
metric_scores=avg_scores,
254+
)
255+
256+
total = len(per_case)
257+
pass_rate = passed_count / total if total > 0 else 0.0
258+
259+
metric_breakdown: dict[str, float] = {}
260+
for name in metric_sums:
261+
if metric_counts[name] > 0:
262+
metric_breakdown[name] = metric_sums[name] / metric_counts[name]
263+
264+
return SplitResult(
265+
pass_rate=pass_rate,
266+
metric_breakdown=metric_breakdown,
267+
per_case=per_case,
268+
)

0 commit comments

Comments
 (0)