Skip to content

Commit 680369c

Browse files
committed
Map SDK optimize aggregates into eval report
1 parent 843de99 commit 680369c

7 files changed

Lines changed: 298 additions & 50 deletions

File tree

examples/optimization/eval_optimize_loop/README.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,14 @@ python examples/optimization/eval_optimize_loop/run_pipeline.py \
6969
`--sdk-call-agent` must point to an async callable compatible with
7070
`AgentOptimizer.optimize(call_agent=...)`. Configure any real model credentials
7171
needed by that callable. SDK mode never silently falls back to fake mode. When
72-
the SDK optimizer returns a best prompt but does not expose per-case validation
73-
results, this example still writes JSON/Markdown reports and audit artifacts
74-
with `gate_status: not_applicable`, records the SDK result summary, and selects
75-
`sdk_best` as the SDK optimizer's chosen prompt.
72+
the SDK optimizer returns a best prompt, this wrapper maps `OptimizeResult`
73+
aggregate fields into the JSON/Markdown report: baseline/best pass rate,
74+
pass-rate improvement, metric breakdowns, token usage, duration, LLM cost, and
75+
round summaries. SDK mode applies an aggregate gate with
76+
`gate_status: partial_applied`: it checks `status == SUCCEEDED`, validation
77+
improvement against `gate.min_val_score_improvement`, and total LLM cost against
78+
`gate.max_total_cost`. Per-case checks that require full validation case scores
79+
are listed in `not_applied_checks` instead of being silently treated as passed.
7680

7781
## Source Prompt Writes
7882

@@ -110,7 +114,8 @@ optional `simulated_outputs`; it does not depend on sample `case_id` names.
110114
- failure attribution summary and attribution accuracy when expected labels are
111115
present;
112116
- gate decisions with overfit detection, protected regressions, new hard
113-
failures, excessive drops, and cost fields;
117+
failures, excessive drops, cost fields, and SDK `not_applied_checks` when
118+
per-case validation details are not exposed;
114119
- audit data: seed, duration, config hash, input hashes, candidate prompt hashes,
115120
cost, prompt diffs, and reproducibility command.
116121

examples/optimization/eval_optimize_loop/eval_loop/backends.py

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ class SDKBackend:
5454
prompt_path: str | Path
5555
call_agent_path: str | None = None
5656
update_source: bool = False
57+
last_result: Any | None = None
5758
last_result_summary: dict[str, Any] | None = None
5859
last_artifact_dir: str | None = None
5960

@@ -117,6 +118,7 @@ async def optimize_async(
117118
best_prompt = getattr(result, "best_prompts", {}).get("system_prompt")
118119
if not best_prompt:
119120
raise ValueError("sdk mode completed but OptimizeResult.best_prompts['system_prompt'] was missing")
121+
self.last_result = result
120122
self.last_result_summary = _summarize_sdk_result(result)
121123
self.last_artifact_dir = str(output_dir)
122124
return [
@@ -157,16 +159,37 @@ def _has_running_loop() -> bool:
157159

158160

159161
def _summarize_sdk_result(result: Any) -> dict[str, Any]:
160-
if hasattr(result, "model_dump"):
161-
payload = result.model_dump(mode="json")
162-
elif hasattr(result, "__dict__"):
163-
payload = dict(result.__dict__)
164-
else:
165-
payload = {"repr": repr(result)}
166-
return _safe_jsonable(payload)
162+
return {
163+
"status": _safe_jsonable(getattr(result, "status", None)),
164+
"baseline_pass_rate": _safe_jsonable(getattr(result, "baseline_pass_rate", None)),
165+
"best_pass_rate": _safe_jsonable(getattr(result, "best_pass_rate", None)),
166+
"pass_rate_improvement": _safe_jsonable(getattr(result, "pass_rate_improvement", None)),
167+
"baseline_metric_breakdown": _safe_jsonable(getattr(result, "baseline_metric_breakdown", {})),
168+
"best_metric_breakdown": _safe_jsonable(getattr(result, "best_metric_breakdown", {})),
169+
"metric_thresholds": _safe_jsonable(getattr(result, "metric_thresholds", {})),
170+
"total_llm_cost": _safe_jsonable(getattr(result, "total_llm_cost", 0.0)),
171+
"total_token_usage": _safe_jsonable(getattr(result, "total_token_usage", {})),
172+
"duration_seconds": _safe_jsonable(getattr(result, "duration_seconds", 0.0)),
173+
"total_rounds": _safe_jsonable(getattr(result, "total_rounds", 0)),
174+
"rounds": [
175+
{
176+
"validation_pass_rate": _safe_jsonable(getattr(round_record, "validation_pass_rate", None)),
177+
"accepted": _safe_jsonable(getattr(round_record, "accepted", None)),
178+
"failed_case_ids": _safe_jsonable(getattr(round_record, "failed_case_ids", [])),
179+
"round_llm_cost": _safe_jsonable(getattr(round_record, "round_llm_cost", 0.0)),
180+
"budget_used": _safe_jsonable(getattr(round_record, "budget_used", None)),
181+
"budget_total": _safe_jsonable(getattr(round_record, "budget_total", None)),
182+
}
183+
for round_record in getattr(result, "rounds", []) or []
184+
],
185+
}
167186

168187

169188
def _safe_jsonable(value: Any) -> Any:
189+
if hasattr(value, "model_dump"):
190+
return _safe_jsonable(value.model_dump(mode="json"))
191+
if hasattr(value, "__dict__") and not isinstance(value, type):
192+
return _safe_jsonable(dict(value.__dict__))
170193
if isinstance(value, dict):
171194
return {str(key): _safe_jsonable(item) for key, item in value.items()}
172195
if isinstance(value, (list, tuple)):

examples/optimization/eval_optimize_loop/eval_loop/report.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,8 @@ def render_markdown(report: OptimizationReport) -> str:
140140
lines.append(f"### {decision.candidate_id} ({verdict})")
141141
for reason in decision.reasons:
142142
lines.append(f"- {reason}")
143+
if decision.not_applied_checks:
144+
lines.append(f"- not applied checks: {', '.join(decision.not_applied_checks)}")
143145
lines.append("")
144146

145147
lines.extend([

examples/optimization/eval_optimize_loop/eval_loop/schemas.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ class GateDecision:
122122
cost: float
123123
gate_status: str = "applied"
124124
gate_not_applied_reason: str | None = None
125+
not_applied_checks: list[str] = field(default_factory=list)
125126

126127

127128
@dataclass(frozen=True)

examples/optimization/eval_optimize_loop/outputs/optimization_report.example.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1060,6 +1060,7 @@
10601060
"val_explain_cache",
10611061
"val_protected_yes_no"
10621062
],
1063+
"not_applied_checks": [],
10631064
"overfit_detected": true,
10641065
"protected_regressions": [
10651066
"val_protected_yes_no"
@@ -1089,6 +1090,7 @@
10891090
"gate_not_applied_reason": null,
10901091
"gate_status": "applied",
10911092
"new_hard_failures": [],
1093+
"not_applied_checks": [],
10921094
"overfit_detected": false,
10931095
"protected_regressions": [],
10941096
"reasons": [

examples/optimization/eval_optimize_loop/run_pipeline.py

Lines changed: 133 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ def run_pipeline(
6767
if mode == "sdk":
6868
optimizer_config_dict = _read_json_object_for_audit(optimizer_config_path)
6969
baseline_prompt = load_prompt(prompt_path)
70+
sdk_artifact_dir = Path(output_dir) / "sdk_optimizer"
7071
sdk_backend = SDKBackend(
7172
prompt_path=prompt_path,
7273
call_agent_path=sdk_call_agent,
@@ -77,7 +78,7 @@ def run_pipeline(
7778
train_path=train_path,
7879
val_path=val_path,
7980
optimizer_config_path=optimizer_config_path,
80-
output_dir=output_dir,
81+
output_dir=sdk_artifact_dir,
8182
)
8283
report = _build_sdk_report(
8384
candidates=candidates,
@@ -329,12 +330,24 @@ def _build_sdk_report(
329330
optimizer_config_path=optimizer_config_path,
330331
prompt_path=prompt_path,
331332
)
333+
sdk_summary = sdk_backend.last_result_summary or {}
334+
baseline_pass_rate = _summary_float(sdk_summary, "baseline_pass_rate", 0.0)
335+
best_pass_rate = _summary_float(sdk_summary, "best_pass_rate", baseline_pass_rate)
336+
pass_rate_improvement = _summary_float(
337+
sdk_summary,
338+
"pass_rate_improvement",
339+
best_pass_rate - baseline_pass_rate,
340+
)
341+
total_llm_cost = _summary_float(sdk_summary, "total_llm_cost", 0.0)
342+
duration_seconds = _summary_float(sdk_summary, "duration_seconds", 0.0)
343+
gate_config = _sdk_gate_config(optimizer_config_dict)
344+
332345
baseline_train = EvalResult(prompt_id="baseline", split="train", score=0.0, passed=False, cost=0.0, cases=[])
333346
baseline_validation = EvalResult(
334347
prompt_id="baseline",
335348
split="validation",
336-
score=0.0,
337-
passed=False,
349+
score=baseline_pass_rate,
350+
passed=baseline_pass_rate >= 1.0,
338351
cost=0.0,
339352
cases=[],
340353
)
@@ -352,14 +365,14 @@ def _build_sdk_report(
352365
"validation_result": EvalResult(
353366
prompt_id=candidate.candidate_id,
354367
split="validation",
355-
score=0.0,
356-
passed=False,
357-
cost=0.0,
368+
score=best_pass_rate,
369+
passed=best_pass_rate >= baseline_pass_rate,
370+
cost=total_llm_cost,
358371
cases=[],
359372
),
360-
"gate_status": "not_applicable",
361-
"gate_not_applied_reason": "SDK optimizer result does not expose per-case validation results",
362-
"sdk_result_summary": sdk_backend.last_result_summary or {},
373+
"gate_status": "partial_applied",
374+
"gate_not_applied_reason": "SDK OptimizeResult exposes aggregate scores but not full per-case deltas",
375+
"sdk_result_summary": sdk_summary,
363376
}
364377
for candidate in candidates
365378
]
@@ -369,7 +382,7 @@ def _build_sdk_report(
369382
}
370383
audit = {
371384
"seed": None,
372-
"duration_seconds": 0.0,
385+
"duration_seconds": duration_seconds,
373386
"config_hash": stable_config_hash(optimizer_config_dict),
374387
"input_hashes": input_hashes,
375388
"input_paths": {
@@ -380,8 +393,12 @@ def _build_sdk_report(
380393
},
381394
"prompt_hash": input_hashes["prompt"],
382395
"candidate_prompt_hashes": prompt_hashes,
383-
"total_run_cost": 0.0,
384-
"cost": {"baseline": 0.0, "candidates": {candidate.candidate_id: 0.0 for candidate in candidates}, "total": 0.0},
396+
"total_run_cost": total_llm_cost,
397+
"cost": {
398+
"baseline": 0.0,
399+
"candidates": {candidate.candidate_id: total_llm_cost for candidate in candidates},
400+
"total": total_llm_cost,
401+
},
385402
"candidate_prompts": {
386403
candidate.candidate_id: {
387404
"rationale": candidate.rationale,
@@ -391,8 +408,8 @@ def _build_sdk_report(
391408
for candidate in candidates
392409
},
393410
"prompt_diffs": {candidate.candidate_id: candidate.prompt_diff for candidate in candidates},
394-
"sdk_artifact_dir": str(output_dir),
395-
"sdk_result_summary": sdk_backend.last_result_summary or {},
411+
"sdk_artifact_dir": sdk_backend.last_artifact_dir or str(Path(output_dir) / "sdk_optimizer"),
412+
"sdk_result_summary": sdk_summary,
396413
"reproducibility_command": _sdk_reproducibility_command(
397414
train_path=train_path,
398415
val_path=val_path,
@@ -411,7 +428,7 @@ def _build_sdk_report(
411428
"train_cases": train_case_count,
412429
"validation_cases": validation_case_count,
413430
"update_source": update_source,
414-
"sdk_artifact_dir": str(output_dir),
431+
"sdk_artifact_dir": audit["sdk_artifact_dir"],
415432
"reproducibility_command": audit["reproducibility_command"],
416433
"paths": {
417434
"train": str(train_path),
@@ -422,34 +439,24 @@ def _build_sdk_report(
422439
"prompt_source": str(prompt_path),
423440
}
424441
gate_decisions = [
425-
GateDecision(
442+
_sdk_gate_decision(
426443
candidate_id=candidate.candidate_id,
427-
accepted=True,
428-
reasons=["gate not applied: SDK optimizer result does not expose per-case validation results"],
429-
train_score_delta=0.0,
430-
validation_score_delta=0.0,
431-
new_hard_failures=[],
432-
protected_regressions=[],
433-
validation_new_failures=[],
434-
excessive_score_drops=[],
435-
overfit_detected=False,
436-
candidate_cost=0.0,
437-
cumulative_cost=0.0,
438-
total_run_cost=0.0,
439-
cost=0.0,
440-
gate_status="not_applicable",
441-
gate_not_applied_reason="SDK optimizer result does not expose per-case validation results",
444+
sdk_summary=sdk_summary,
445+
gate_config=gate_config,
442446
)
443447
for candidate in candidates
444448
]
449+
selected_candidate = None
450+
if candidates and gate_decisions and gate_decisions[0].accepted:
451+
selected_candidate = candidates[0].candidate_id
445452
return build_report(
446453
run=run,
447454
baseline_train=baseline_train,
448455
baseline_validation=baseline_validation,
449456
candidate_records=candidate_records,
450457
per_case_deltas=[],
451458
gate_decisions=gate_decisions,
452-
selected_candidate=candidates[0].candidate_id if candidates else None,
459+
selected_candidate=selected_candidate,
453460
audit=audit,
454461
)
455462

@@ -474,6 +481,100 @@ def _sdk_reproducibility_command(
474481
return command
475482

476483

484+
def _sdk_gate_decision(
485+
*,
486+
candidate_id: str,
487+
sdk_summary: dict[str, Any],
488+
gate_config: dict[str, float],
489+
) -> GateDecision:
490+
status = str(sdk_summary.get("status") or "UNKNOWN")
491+
improvement = _summary_float(sdk_summary, "pass_rate_improvement", 0.0)
492+
total_cost = _summary_float(sdk_summary, "total_llm_cost", 0.0)
493+
min_improvement = gate_config["min_val_score_improvement"]
494+
max_cost = gate_config["max_total_cost"]
495+
496+
reasons: list[str] = []
497+
accepted = True
498+
if status != "SUCCEEDED":
499+
accepted = False
500+
reasons.append(f"reject: SDK optimizer status {status} is not SUCCEEDED")
501+
else:
502+
reasons.append("accept: SDK optimizer status is SUCCEEDED")
503+
504+
if improvement < min_improvement:
505+
accepted = False
506+
reasons.append(
507+
f"reject: validation improvement {improvement:.3f} is below threshold {min_improvement:.3f}"
508+
)
509+
else:
510+
reasons.append(
511+
f"accept: validation improvement {improvement:.3f} meets threshold {min_improvement:.3f}"
512+
)
513+
514+
if total_cost > max_cost:
515+
accepted = False
516+
reasons.append(f"reject: total SDK cost {total_cost:.3f} exceeds budget {max_cost:.3f}")
517+
else:
518+
reasons.append(f"accept: total SDK cost {total_cost:.3f} is within budget {max_cost:.3f}")
519+
520+
if accepted:
521+
reasons.append("accept: SDK aggregate gate passed")
522+
523+
return GateDecision(
524+
candidate_id=candidate_id,
525+
accepted=accepted,
526+
reasons=reasons,
527+
train_score_delta=0.0,
528+
validation_score_delta=improvement,
529+
new_hard_failures=[],
530+
protected_regressions=[],
531+
validation_new_failures=[],
532+
excessive_score_drops=[],
533+
overfit_detected=False,
534+
candidate_cost=total_cost,
535+
cumulative_cost=0.0,
536+
total_run_cost=total_cost,
537+
cost=total_cost,
538+
gate_status="partial_applied",
539+
gate_not_applied_reason="SDK OptimizeResult exposes aggregate scores but not full per-case deltas",
540+
not_applied_checks=[
541+
"per_case_delta",
542+
"protected_regression",
543+
"new_hard_failure",
544+
"max_score_drop_per_case",
545+
],
546+
)
547+
548+
549+
def _sdk_gate_config(optimizer_config_dict: dict[str, Any]) -> dict[str, float]:
550+
gate_payload = optimizer_config_dict.get("gate")
551+
if gate_payload is None:
552+
gate_payload = {}
553+
if not isinstance(gate_payload, dict):
554+
raise ValueError("optimizer config field 'gate' must be an object when present")
555+
556+
min_improvement = gate_payload.get("min_val_score_improvement", 0.01)
557+
max_cost = gate_payload.get("max_total_cost", 1.0)
558+
if not isinstance(min_improvement, (int, float)) or min_improvement < 0:
559+
raise ValueError("optimizer config field 'gate.min_val_score_improvement' must be a non-negative number")
560+
if not isinstance(max_cost, (int, float)) or max_cost < 0:
561+
raise ValueError("optimizer config field 'gate.max_total_cost' must be a non-negative number")
562+
return {
563+
"min_val_score_improvement": float(min_improvement),
564+
"max_total_cost": float(max_cost),
565+
}
566+
567+
568+
def _summary_float(summary: dict[str, Any], key: str, default: float) -> float:
569+
value = summary.get(key, default)
570+
if value is None:
571+
return default
572+
try:
573+
return float(value)
574+
except (TypeError, ValueError):
575+
return default
576+
577+
477578
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
478579
parser = argparse.ArgumentParser(description=__doc__)
479580
parser.add_argument("--train", default=str(DEFAULT_TRAIN), help="Path to train.evalset.json")

0 commit comments

Comments
 (0)