-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathoptimize.py
More file actions
365 lines (320 loc) · 14.4 KB
/
Copy pathoptimize.py
File metadata and controls
365 lines (320 loc) · 14.4 KB
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
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
"""GEPA optimization of PlanSignature instructions.
Demonstrates the upside of stripping a hand-tuned signature docstring back
to a terse contract and letting GEPA rebuild the elaboration empirically
against a methodology-grounded scoring function.
The production `signatures.PlanSignature` has a multi-paragraph docstring
encoding methodology rules, behavioral defaults, halted-movement handling,
and population-specific guidance. That's hand-engineered prompt — the kind
of work that should belong to an optimizer. This file:
1. Defines `PlanSignatureMinimal` — same I/O contract, terse one-line
docstring (the actual "signature as contract" pattern)
2. Builds 6 hand-picked eval cases drawn from the synthetic athletes
(powerlifter clean progression / plateau / shoulder pain;
runner clean week / ITB warning / returning from skipped week)
3. Scores plans by `methodology.validate_plan` — pass/fail signal that
also respects `halted_movements` constraints
4. Runs GEPA against PlanSignatureMinimal's instructions
5. Prints baseline pass rate → optimized instructions → optimized pass rate
Cost: ~$0.30 per run on gpt-4o-mini (task LM) + gpt-4o (reflection LM).
This file is run independently of the main demo:
uv run python -m fitness_coach.optimize
"""
from __future__ import annotations
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import dspy
import gepa
from gepa import EvaluationBatch, GEPAAdapter
from pydantic_ai import Agent
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from toy import agent_from_signature # noqa: E402, F401 (kept for parity with rest of codebase)
from fitness_coach.methodology import get_methodology # noqa: E402
from fitness_coach.schemas import ( # noqa: E402
AthleteState,
HandoffDoc,
ObservationOutput,
PlanProposal,
)
from fitness_coach.synthetic import ATHLETES # noqa: E402
# ====================================================================
# PlanSignatureMinimal — same I/O contract, no hand-tuned elaboration
# ====================================================================
class PlanSignatureMinimal(dspy.Signature):
"""Propose the next session's prescription for the athlete."""
athlete_state: AthleteState = dspy.InputField(
desc="Current longitudinal state",
)
observation: ObservationOutput = dspy.InputField(
desc="Coded observation from the just-completed session",
)
prior_handoff: HandoffDoc | None = dspy.InputField(
desc="Handoff document from the previous session, or None for the first session",
)
plan: PlanProposal = dspy.OutputField(
desc="Prescription for the next session — activities + rationale",
)
# ====================================================================
# Eval cases — representative situations sampled from synthetic library
# ====================================================================
@dataclass
class PlanEvalCase:
name: str
state: AthleteState
observation: ObservationOutput
prior_handoff: HandoffDoc | None
halted_movements: frozenset[str]
def build_eval_cases() -> list[PlanEvalCase]:
"""Six hand-picked cases covering the architectural surface area."""
pl_001_state, _ = ATHLETES["pl_001"]
pl_002_state, _ = ATHLETES["pl_002"]
rn_001_state, _ = ATHLETES["rn_001"]
rn_002_state, _ = ATHLETES["rn_002"]
return [
PlanEvalCase(
name="pl_001 healthy progression",
state=pl_001_state.model_copy(deep=True),
observation=ObservationOutput(
progression_signals=["all sets at target RPE 7", "bar speed consistent"],
concern_signals=[],
evidence_quality_assessment="High-trust self-report; no objective concerns",
),
prior_handoff=None,
halted_movements=frozenset(),
),
PlanEvalCase(
name="pl_002 bench plateau (3 failures triggers deload rule)",
state=pl_002_state.model_copy(deep=True, update={
"consecutive_failed_sessions": {"bench": 3},
}),
observation=ObservationOutput(
progression_signals=[],
concern_signals=["bench plateau confirmed across 3 sessions", "RPE peaked at 10"],
evidence_quality_assessment="High-trust evidence; technique check confirmed plateau is strength, not form",
),
prior_handoff=None,
halted_movements=frozenset(),
),
PlanEvalCase(
name="pl_002 shoulder pain on bench (movement halted)",
state=pl_002_state.model_copy(deep=True),
observation=ObservationOutput(
progression_signals=[],
concern_signals=["sharp pain in left front delt during bench, ~5/10"],
evidence_quality_assessment="High-trust self-report on pain",
),
prior_handoff=None,
halted_movements=frozenset({"bench"}),
),
PlanEvalCase(
name="rn_001 healthy build week",
state=rn_001_state.model_copy(deep=True),
observation=ObservationOutput(
progression_signals=["all easy paces felt easy", "long run smooth"],
concern_signals=[],
evidence_quality_assessment="Solid week, on-track for half-marathon plan",
),
prior_handoff=None,
halted_movements=frozenset(),
),
PlanEvalCase(
name="rn_002 ITB warning (injury history present)",
state=rn_002_state.model_copy(deep=True, update={
"current_weekly_mileage": 14.0,
"weeks_at_current_mileage": 0,
}),
observation=ObservationOutput(
progression_signals=[],
concern_signals=["ITB tightness returning", "preventive halt of last run"],
evidence_quality_assessment="High-trust self-report; consistent with prior ITB pattern",
),
prior_handoff=None,
halted_movements=frozenset(),
),
PlanEvalCase(
name="rn_001 returning from skipped week (baseline preserved)",
state=rn_001_state.model_copy(deep=True),
observation=ObservationOutput(
progression_signals=["legs felt fresh from unintended rest"],
concern_signals=["only 1 run completed last week — work travel"],
evidence_quality_assessment="Self-report consistent; baseline mileage intact",
),
prior_handoff=None,
halted_movements=frozenset(),
),
]
# ====================================================================
# Prompt rendering (parallel to graph._render_prompt)
# ====================================================================
def _render(sig: type[dspy.Signature], **inputs: Any) -> str:
lines = []
for name, fi in sig.input_fields.items():
val = inputs.get(name)
if val is None:
val = "(none)"
elif hasattr(val, "model_dump_json"):
val = val.model_dump_json(indent=2)
prefix = fi.json_schema_extra.get("prefix", f"{name}:")
desc = fi.json_schema_extra.get("desc", "")
lines.append(f"{prefix} {desc}\n{val}")
return "\n\n".join(lines)
# ====================================================================
# GEPA Adapter — bridges {plan: <instructions>} candidates to the agent
# ====================================================================
class PlanGEPAAdapter(GEPAAdapter):
def __init__(self, model: str = "openai:gpt-4o-mini"):
# Build base agent once; instructions overridden per candidate.
# CRITICAL: temperature=0 makes the eval deterministic. Without it the
# same prompt produces different plans across runs and GEPA's score
# signal is dominated by noise — measured 5/6 in optimization but 1/6
# in re-eval on identical inputs.
output_field = next(iter(PlanSignatureMinimal.output_fields.values()))
self.base_agent = Agent(
model=model,
output_type=output_field.annotation,
instructions=PlanSignatureMinimal.instructions,
output_retries=3,
model_settings={"temperature": 0.0},
)
def evaluate(
self,
batch: list[PlanEvalCase],
candidate: dict[str, str],
capture_traces: bool = False,
) -> EvaluationBatch:
outputs: list[PlanProposal | None] = []
scores: list[float] = []
trajectories: list[dict[str, Any]] = []
for case in batch:
halted_hint = ""
if case.halted_movements:
halted_hint = (
f"\n\nHALTED MOVEMENTS THIS SESSION (do NOT include in the plan): "
f"{sorted(case.halted_movements)}\n"
"These have moderate+ pain signals and must be omitted or substituted."
)
prompt = _render(
PlanSignatureMinimal,
athlete_state=case.state,
observation=case.observation,
prior_handoff=case.prior_handoff,
) + halted_hint
with self.base_agent.override(instructions=candidate["plan"]):
try:
result = self.base_agent.run_sync(prompt)
plan: PlanProposal | None = result.output
except Exception as e: # noqa: BLE001
plan = None
score = 0.0
violations = [f"agent run failed: {type(e).__name__}: {str(e)[:120]}"]
else:
methodology = get_methodology(case.state)
vr = methodology.validate_plan(
case.state, plan, halted_movements=case.halted_movements,
)
score = 1.0 if vr.is_valid else 0.0
violations = vr.violations
outputs.append(plan)
scores.append(score)
if capture_traces:
trajectories.append({
"case_name": case.name,
"score": score,
"violations": violations,
"plan_summary": (
f"{len(plan.activities)} activities" if plan else "FAILED"
),
})
return EvaluationBatch(
outputs=outputs,
scores=scores,
trajectories=trajectories if capture_traces else None,
)
def make_reflective_dataset(
self,
candidate: dict[str, str],
eval_batch: EvaluationBatch,
components_to_update: list[str],
) -> dict[str, list[dict[str, Any]]]:
records = []
for traj in (eval_batch.trajectories or []):
records.append({
"Inputs": {"case": traj["case_name"]},
"Generated Output": {"plan_summary": traj["plan_summary"]},
"Feedback": (
"PASSED methodology validation"
if traj["score"] == 1.0
else f"FAILED. Violations: {traj['violations']}"
),
})
return {comp: records for comp in components_to_update}
# ====================================================================
# Main
# ====================================================================
def main():
print("=" * 72)
print("GEPA optimization of PlanSignature — minimal seed → optimized")
print("=" * 72)
cases = build_eval_cases()
adapter = PlanGEPAAdapter(model="openai:gpt-4o-mini")
seed = {"plan": "Propose the next session's prescription for the athlete."}
print(f"\n--- BASELINE (terse seed: {len(seed['plan'])} chars) ---")
print(f" Seed: {seed['plan']!r}")
baseline = adapter.evaluate(cases, seed, capture_traces=True)
baseline_score = sum(baseline.scores) / len(baseline.scores)
print(f"\n Baseline pass rate: {baseline_score:.0%} "
f"({int(sum(baseline.scores))}/{len(baseline.scores)} cases)")
for traj in baseline.trajectories:
marker = "✓" if traj["score"] == 1.0 else "✗"
print(f" {marker} {traj['case_name']:55s} ({traj['plan_summary']})")
if traj["score"] < 1.0:
for v in traj["violations"][:2]:
print(f" ! {v[:110]}")
print(f"\n{'=' * 72}")
print("GEPA OPTIMIZATION (max 30 metric calls, gpt-4o reflection)")
print(f"{'=' * 72}")
result = gepa.optimize(
seed_candidate=seed,
trainset=cases,
valset=cases,
adapter=adapter,
reflection_lm="openai/gpt-4o",
max_metric_calls=60, # 30 was too few — only 2 candidates explored
reflection_minibatch_size=3,
display_progress_bar=False,
)
# Tie-break on recency: later candidates evolved from prior ones, so
# when aggregate scores tie they are at least as good and often
# broader on per-instance Pareto coverage. Picking the seed on a tie
# would obscure GEPA's actual contribution.
best_idx = max(
range(len(result.candidates)),
key=lambda i: (result.val_aggregate_scores[i], i),
)
best = result.candidates[best_idx]
print(f"\n{'=' * 72}")
print(f"OPTIMIZED instructions ({len(best['plan'])} chars)")
print(f"{'=' * 72}")
# Word-wrap the instructions for readability
import textwrap
print(textwrap.fill(best["plan"], width=72))
optimized = adapter.evaluate(cases, best, capture_traces=True)
optimized_score = sum(optimized.scores) / len(optimized.scores)
print(f"\n--- POST-OPTIMIZATION ---")
print(f" Pass rate: {optimized_score:.0%} "
f"({int(sum(optimized.scores))}/{len(optimized.scores)} cases)")
for traj in optimized.trajectories:
marker = "✓" if traj["score"] == 1.0 else "✗"
print(f" {marker} {traj['case_name']:55s} ({traj['plan_summary']})")
if traj["score"] < 1.0:
for v in traj["violations"][:2]:
print(f" ! {v[:110]}")
print(f"\n{'=' * 72}")
print(f"Lift: baseline {baseline_score:.0%} → optimized {optimized_score:.0%} "
f"({(optimized_score - baseline_score) * 100:+.0f} pp)")
print(f"Candidates explored: {len(result.candidates)}")
print(f"Total metric calls: {result.total_metric_calls}")
print(f"{'=' * 72}")
if __name__ == "__main__":
main()