synth-optimizers 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.
gepa/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Drop-in `gepa` module shim provided by prompt-opt."""
2
+
3
+ from prompt_opt.gepa_ai_compat import LocalGEPAAdapterProtocol, optimize
4
+
5
+ __all__ = ["LocalGEPAAdapterProtocol", "optimize"]
prompt_opt/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """synth-optimizers public API exposed through the `prompt_opt` package."""
2
+
3
+ from .gepa_ai_compat import LocalGEPAAdapterProtocol, optimize
4
+ from .mipro import proposer_backends, run_mipro
5
+ from .dspy.miprov2 import MIPROv2
6
+ from .sdk.optimization import PolicyOptimizationOfflineJob, PromptLearningJob
7
+
8
+ __all__ = [
9
+ "LocalGEPAAdapterProtocol",
10
+ "MIPROv2",
11
+ "PolicyOptimizationOfflineJob",
12
+ "PromptLearningJob",
13
+ "optimize",
14
+ "proposer_backends",
15
+ "run_mipro",
16
+ ]
@@ -0,0 +1,18 @@
1
+ """Adapter implementations for prompt-opt."""
2
+
3
+ from .synth_container import (
4
+ ContainerEvaluationBatch,
5
+ SynthContainerLearningAdapter,
6
+ default_rollout_request_builder,
7
+ default_rollout_score_extractor,
8
+ )
9
+ from .synth_offline import LocalEvaluator, SynthOfflineLearningAdapter
10
+
11
+ __all__ = [
12
+ "ContainerEvaluationBatch",
13
+ "LocalEvaluator",
14
+ "SynthContainerLearningAdapter",
15
+ "SynthOfflineLearningAdapter",
16
+ "default_rollout_request_builder",
17
+ "default_rollout_score_extractor",
18
+ ]
@@ -0,0 +1,170 @@
1
+ """Synth Container adapter for local prompt-opt evaluations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import uuid
7
+ from collections.abc import Callable, Mapping, Sequence
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+ from urllib import request
11
+
12
+
13
+ RolloutRequestBuilder = Callable[[Mapping[str, Any], dict[str, str], str], dict[str, Any]]
14
+ RolloutScoreExtractor = Callable[[dict[str, Any]], float]
15
+
16
+
17
+ def default_rollout_request_builder(
18
+ *,
19
+ example: Mapping[str, Any],
20
+ candidate: dict[str, str],
21
+ trace_correlation_id: str,
22
+ env_name: str = "prompt-opt-local",
23
+ seed: int | None = None,
24
+ ) -> dict[str, Any]:
25
+ """Build a minimal Synth rollout request using the canonical container contract."""
26
+ env_seed = seed
27
+ if env_seed is None:
28
+ raw_seed = example.get("seed")
29
+ if isinstance(raw_seed, int):
30
+ env_seed = raw_seed
31
+
32
+ return {
33
+ "trace_correlation_id": trace_correlation_id,
34
+ "env": {
35
+ "env_name": env_name,
36
+ "seed": env_seed,
37
+ "config": {
38
+ "example": dict(example),
39
+ },
40
+ },
41
+ "policy": {
42
+ "policy_name": "prompt-opt",
43
+ "config": {
44
+ "candidate": dict(candidate),
45
+ },
46
+ },
47
+ "on_done": "reset",
48
+ "safety": {"max_time_s": 300},
49
+ }
50
+
51
+
52
+ def default_rollout_score_extractor(response_payload: dict[str, Any]) -> float:
53
+ """Extract the rollout reward from a Synth Container response payload."""
54
+ reward_info = response_payload.get("metrics")
55
+ if not isinstance(reward_info, dict):
56
+ reward_info = response_payload.get("reward_info")
57
+ if not isinstance(reward_info, dict):
58
+ raise ValueError("rollout response is missing metrics/reward_info")
59
+ reward = reward_info.get("outcome_reward")
60
+ if reward is None:
61
+ raise ValueError("rollout response metrics are missing outcome_reward")
62
+ return float(reward)
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class ContainerEvaluationBatch:
67
+ """Batch output shaped to GEPA adapter expectations."""
68
+
69
+ outputs: list[dict[str, Any]]
70
+ scores: list[float]
71
+ trajectories: list[dict[str, Any] | None] | None = None
72
+ objective_scores: list[dict[str, float]] | None = None
73
+
74
+
75
+ class SynthContainerLearningAdapter:
76
+ """Evaluate candidates against a Synth-compatible Container rollout endpoint."""
77
+
78
+ def __init__(
79
+ self,
80
+ *,
81
+ container_url: str,
82
+ request_builder: RolloutRequestBuilder,
83
+ api_key: str | None = None,
84
+ headers: Mapping[str, str] | None = None,
85
+ timeout_seconds: float = 30.0,
86
+ score_extractor: RolloutScoreExtractor = default_rollout_score_extractor,
87
+ ) -> None:
88
+ self._container_url = container_url.rstrip("/")
89
+ self._request_builder = request_builder
90
+ self._api_key = api_key
91
+ self._headers = dict(headers or {})
92
+ self._timeout_seconds = float(timeout_seconds)
93
+ self._score_extractor = score_extractor
94
+
95
+ def _post_rollout(self, payload: dict[str, Any]) -> dict[str, Any]:
96
+ body = json.dumps(payload).encode("utf-8")
97
+ headers = {"content-type": "application/json", **self._headers}
98
+ if self._api_key:
99
+ headers["x-api-key"] = self._api_key
100
+ http_request = request.Request(
101
+ f"{self._container_url}/rollout",
102
+ data=body,
103
+ headers=headers,
104
+ method="POST",
105
+ )
106
+ with request.urlopen(http_request, timeout=self._timeout_seconds) as response:
107
+ return json.loads(response.read().decode("utf-8"))
108
+
109
+ def evaluate(
110
+ self,
111
+ batch: list[Mapping[str, Any]],
112
+ candidate: dict[str, str],
113
+ capture_traces: bool = False,
114
+ ) -> ContainerEvaluationBatch:
115
+ outputs: list[dict[str, Any]] = []
116
+ scores: list[float] = []
117
+ trajectories: list[dict[str, Any] | None] = []
118
+ objective_scores: list[dict[str, float]] = []
119
+
120
+ for index, example in enumerate(batch):
121
+ correlation_id = f"prompt-opt-{index}-{uuid.uuid4().hex}"
122
+ rollout_request = self._request_builder(example, candidate, correlation_id)
123
+ rollout_response = self._post_rollout(rollout_request)
124
+ score = self._score_extractor(rollout_response)
125
+ reward_info = rollout_response.get("metrics") or rollout_response.get("reward_info") or {}
126
+ outcome_objectives = reward_info.get("outcome_objectives")
127
+ outputs.append(
128
+ {
129
+ "request": rollout_request,
130
+ "response": rollout_response,
131
+ }
132
+ )
133
+ scores.append(score)
134
+ trajectories.append(rollout_response.get("trace") if capture_traces else None)
135
+ objective_scores.append(
136
+ dict(outcome_objectives) if isinstance(outcome_objectives, dict) else {"reward": score}
137
+ )
138
+
139
+ return ContainerEvaluationBatch(
140
+ outputs=outputs,
141
+ scores=scores,
142
+ trajectories=trajectories,
143
+ objective_scores=objective_scores,
144
+ )
145
+
146
+ def make_reflective_dataset(
147
+ self,
148
+ candidate: dict[str, str],
149
+ eval_batch: ContainerEvaluationBatch,
150
+ components_to_update: list[str],
151
+ ) -> Mapping[str, Sequence[Mapping[str, Any]]]:
152
+ reflective_rows: list[dict[str, Any]] = []
153
+ for idx, (output, score) in enumerate(zip(eval_batch.outputs, eval_batch.scores)):
154
+ reflective_rows.append(
155
+ {
156
+ "index": idx,
157
+ "candidate": dict(candidate),
158
+ "score": float(score),
159
+ "output": output,
160
+ }
161
+ )
162
+ return {component: tuple(reflective_rows) for component in components_to_update}
163
+
164
+
165
+ __all__ = [
166
+ "ContainerEvaluationBatch",
167
+ "SynthContainerLearningAdapter",
168
+ "default_rollout_request_builder",
169
+ "default_rollout_score_extractor",
170
+ ]
@@ -0,0 +1,81 @@
1
+ """Synth-compatible offline learning adapters for local use."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from dataclasses import dataclass
7
+ from typing import Any, Callable
8
+
9
+
10
+ ScoreFunction = Callable[[Mapping[str, Any], dict[str, str]], float]
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class LocalEvaluator:
15
+ """Evaluates one dataset example against one candidate."""
16
+
17
+ score_fn: ScoreFunction
18
+
19
+ def score_batch(self, batch: Sequence[Mapping[str, Any]], candidate: dict[str, str]) -> list[float]:
20
+ """Return per-example scores for the candidate."""
21
+ return [float(self.score_fn(example, candidate)) for example in batch]
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class LocalEvaluationBatch:
26
+ """Local batch output shaped to GEPA adapter expectations."""
27
+
28
+ outputs: list[dict[str, Any]]
29
+ scores: list[float]
30
+ trajectories: list[dict[str, Any]] | None = None
31
+ objective_scores: list[dict[str, float]] | None = None
32
+
33
+
34
+ class SynthOfflineLearningAdapter:
35
+ """Local adapter with a GEPA-compatible evaluation shape.
36
+
37
+ This adapter is intentionally lightweight so it can run completely offline
38
+ without backend dependencies.
39
+ """
40
+
41
+ def __init__(self, evaluator: LocalEvaluator) -> None:
42
+ self._evaluator = evaluator
43
+
44
+ def evaluate(
45
+ self,
46
+ batch: list[Mapping[str, Any]],
47
+ candidate: dict[str, str],
48
+ capture_traces: bool = False,
49
+ ) -> LocalEvaluationBatch:
50
+ del capture_traces
51
+ scores = self._evaluator.score_batch(batch, candidate)
52
+ outputs = [{"candidate": candidate, "score": score} for score in scores]
53
+ trajectories = [{"kind": "offline_local"} for _ in scores]
54
+ objective_scores = [{"reward": score} for score in scores]
55
+ return LocalEvaluationBatch(
56
+ outputs=outputs,
57
+ scores=scores,
58
+ trajectories=trajectories,
59
+ objective_scores=objective_scores,
60
+ )
61
+
62
+ def make_reflective_dataset(
63
+ self,
64
+ candidate: dict[str, str],
65
+ eval_batch: LocalEvaluationBatch,
66
+ components_to_update: list[str],
67
+ ) -> Mapping[str, Sequence[Mapping[str, Any]]]:
68
+ reflective_rows: list[dict[str, Any]] = []
69
+ for idx, (output, score) in enumerate(zip(eval_batch.outputs, eval_batch.scores)):
70
+ reflective_rows.append(
71
+ {
72
+ "index": idx,
73
+ "candidate": dict(candidate),
74
+ "score": float(score),
75
+ "output": output,
76
+ }
77
+ )
78
+ return {
79
+ component: tuple(reflective_rows)
80
+ for component in components_to_update
81
+ }
@@ -0,0 +1,5 @@
1
+ """DSPy-facing compatibility surface for prompt-opt."""
2
+
3
+ from .miprov2 import MIPROv2
4
+
5
+ __all__ = ["MIPROv2"]
@@ -0,0 +1,5 @@
1
+ """DSPy + GEPA slot-in helpers."""
2
+
3
+ from prompt_opt.gepa_ai_compat import optimize
4
+
5
+ __all__ = ["optimize"]
@@ -0,0 +1,281 @@
1
+ """DSPy-compatible MIPROv2 wrapper backed by the local offline SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import json
7
+ import math
8
+ from typing import Any, Literal
9
+
10
+ from prompt_opt.mipro import proposer_backends
11
+ from prompt_opt.sdk.optimization.internal.prompt_learning import PromptLearningJob
12
+
13
+
14
+ def _extract_text_field(example: Any, keys: tuple[str, ...]) -> str:
15
+ if isinstance(example, dict):
16
+ for key in keys:
17
+ value = example.get(key)
18
+ if value is not None:
19
+ return str(value)
20
+ for key in keys:
21
+ if hasattr(example, key):
22
+ value = getattr(example, key)
23
+ if value is not None:
24
+ return str(value)
25
+ return ""
26
+
27
+
28
+ def _materialize_dataset(dataset: list[Any]) -> list[dict[str, Any]]:
29
+ rows: list[dict[str, Any]] = []
30
+ for item in dataset:
31
+ input_text = _extract_text_field(item, ("input", "question", "query", "text"))
32
+ answer_text = _extract_text_field(item, ("answer", "label", "expected", "output"))
33
+ additional_context: dict[str, Any] = {}
34
+ if isinstance(item, dict):
35
+ raw_context = item.get("additional_context")
36
+ if isinstance(raw_context, dict):
37
+ additional_context = dict(raw_context)
38
+ labels_blob = str(additional_context.get("labels", "")).strip()
39
+ if labels_blob:
40
+ input_text = f"{input_text}\n\nLabels: {labels_blob}"
41
+ rows.append({"input": input_text, "answer": answer_text, "metadata": {}})
42
+ return rows
43
+
44
+
45
+ def _resolve_num_trials(
46
+ *,
47
+ auto: Literal["light", "medium", "heavy"] | None,
48
+ explicit_trials: int | None,
49
+ num_candidates: int | None,
50
+ student: Any,
51
+ ) -> int:
52
+ if explicit_trials is not None:
53
+ return max(1, int(explicit_trials))
54
+ if auto is None and num_candidates is not None:
55
+ predictor_count = 1
56
+ named_predictors = getattr(student, "named_predictors", None)
57
+ if callable(named_predictors):
58
+ try:
59
+ predictor_count = max(1, len(list(named_predictors())))
60
+ except Exception:
61
+ predictor_count = 1
62
+ suggested = int(max(2 * (predictor_count * 2) * math.log2(max(num_candidates, 2)), 1.5 * num_candidates))
63
+ return max(1, suggested)
64
+ if auto == "light":
65
+ return 7
66
+ if auto == "medium":
67
+ return 20
68
+ if auto == "heavy":
69
+ return 50
70
+ raise ValueError("num_trials must be provided when auto is None.")
71
+
72
+
73
+ def _extract_seed_candidate_from_student(student: Any) -> dict[str, str]:
74
+ named_predictors = getattr(student, "named_predictors", None)
75
+ if callable(named_predictors):
76
+ try:
77
+ entries = list(named_predictors())
78
+ candidate: dict[str, str] = {}
79
+ for entry_index, (name, predictor) in enumerate(entries):
80
+ signature = getattr(predictor, "signature", None)
81
+ instructions = getattr(signature, "instructions", None)
82
+ if isinstance(instructions, str) and instructions.strip():
83
+ candidate[str(name or f"predictor_{entry_index}")] = instructions.strip()
84
+ if candidate:
85
+ return candidate
86
+ except Exception:
87
+ pass
88
+ return {"default": "You are a helpful assistant."}
89
+
90
+
91
+ def _apply_prompts_to_student(student: Any, prompts: dict[str, str]) -> Any:
92
+ named_predictors = getattr(student, "named_predictors", None)
93
+ if not callable(named_predictors):
94
+ return student
95
+ try:
96
+ for name, predictor in list(named_predictors()):
97
+ prompt = prompts.get(str(name))
98
+ if not isinstance(prompt, str) or not prompt.strip():
99
+ continue
100
+ signature = getattr(predictor, "signature", None)
101
+ if signature is None:
102
+ continue
103
+ with_instructions = getattr(signature, "with_instructions", None)
104
+ if callable(with_instructions):
105
+ predictor.signature = with_instructions(prompt)
106
+ else:
107
+ signature.instructions = prompt
108
+ except Exception:
109
+ return student
110
+ return student
111
+
112
+
113
+ def _extract_best_prompts(result_payload: dict[str, Any]) -> dict[str, str]:
114
+ best_candidate = result_payload.get("best_candidate")
115
+ if not isinstance(best_candidate, dict):
116
+ return {}
117
+ stage_items = best_candidate.get("stages") or best_candidate.get("candidate", {}).get("stages") or []
118
+ prompts: dict[str, str] = {}
119
+ for stage in stage_items:
120
+ if not isinstance(stage, dict):
121
+ continue
122
+ stage_key = str(stage.get("id") or stage.get("name") or f"stage_{len(prompts)}")
123
+ for message in stage.get("messages", []):
124
+ if isinstance(message, dict) and message.get("role") == "system":
125
+ text = message.get("pattern") or message.get("content")
126
+ if isinstance(text, str) and text.strip():
127
+ prompts[stage_key] = text.strip()
128
+ break
129
+ return prompts
130
+
131
+
132
+ class MIPROv2:
133
+ """Drop-in replacement for `dspy.MIPROv2` running locally only."""
134
+
135
+ def __init__(
136
+ self,
137
+ metric: Any,
138
+ prompt_model: Any | None = None,
139
+ task_model: Any | None = None,
140
+ teacher_settings: dict[str, Any] | None = None,
141
+ max_bootstrapped_demos: int = 4,
142
+ max_labeled_demos: int = 4,
143
+ auto: Literal["light", "medium", "heavy"] | None = "light",
144
+ num_candidates: int | None = None,
145
+ num_threads: int | None = None,
146
+ max_errors: int | None = None,
147
+ seed: int = 9,
148
+ init_temperature: float = 1.0,
149
+ verbose: bool = False,
150
+ track_stats: bool = True,
151
+ log_dir: str | None = None,
152
+ metric_threshold: float | None = None,
153
+ backend_mode: Literal["local"] = "local",
154
+ proposer_backend: Literal["single_prompt", "rlm"] = "single_prompt",
155
+ **kwargs: Any,
156
+ ) -> None:
157
+ del metric, teacher_settings, num_threads, max_errors, init_temperature, log_dir, metric_threshold, kwargs
158
+ if proposer_backend not in proposer_backends():
159
+ raise ValueError(f"Unsupported proposer_backend={proposer_backend!r}")
160
+ self.prompt_model = prompt_model
161
+ self.task_model = task_model
162
+ self.max_bootstrapped_demos = max_bootstrapped_demos
163
+ self.max_labeled_demos = max_labeled_demos
164
+ self.auto = auto
165
+ self.num_candidates = num_candidates
166
+ self.seed = seed
167
+ self.verbose = verbose
168
+ self.track_stats = track_stats
169
+ self.backend_mode = backend_mode
170
+ self.proposer_backend = proposer_backend
171
+
172
+ def compile(
173
+ self,
174
+ student: Any,
175
+ *,
176
+ trainset: list[Any],
177
+ teacher: Any = None,
178
+ valset: list[Any] | None = None,
179
+ num_trials: int | None = None,
180
+ num_candidates: int | None = None,
181
+ max_bootstrapped_demos: int | None = None,
182
+ max_labeled_demos: int | None = None,
183
+ seed: int | None = None,
184
+ minibatch: bool = True,
185
+ minibatch_size: int = 35,
186
+ minibatch_full_eval_steps: int = 5,
187
+ program_aware_proposer: bool = True,
188
+ data_aware_proposer: bool = True,
189
+ view_data_batch_size: int = 10,
190
+ tip_aware_proposer: bool = True,
191
+ fewshot_aware_proposer: bool = True,
192
+ requires_permission_to_run: bool | None = None,
193
+ provide_traceback: bool | None = None,
194
+ **kwargs: Any,
195
+ ) -> Any:
196
+ del (
197
+ teacher,
198
+ max_bootstrapped_demos,
199
+ max_labeled_demos,
200
+ minibatch,
201
+ minibatch_size,
202
+ minibatch_full_eval_steps,
203
+ program_aware_proposer,
204
+ data_aware_proposer,
205
+ view_data_batch_size,
206
+ tip_aware_proposer,
207
+ fewshot_aware_proposer,
208
+ requires_permission_to_run,
209
+ provide_traceback,
210
+ )
211
+ if self.backend_mode != "local":
212
+ raise ValueError("prompt-opt is local-only. Use backend_mode='local'.")
213
+
214
+ if not callable(getattr(self.task_model, "__call__", None)):
215
+ raise ValueError(
216
+ "Local backend_mode requires task_model to be callable(prompt)->str. "
217
+ "Model-id strings are not supported in local-only mode."
218
+ )
219
+
220
+ run_seed = int(seed if seed is not None else self.seed)
221
+ train_records = _materialize_dataset(trainset)
222
+ val_records = _materialize_dataset(valset) if valset is not None else train_records
223
+ effective_num_candidates = int(num_candidates) if num_candidates is not None else self.num_candidates
224
+ trials = _resolve_num_trials(
225
+ auto=self.auto,
226
+ explicit_trials=num_trials,
227
+ num_candidates=effective_num_candidates,
228
+ student=student,
229
+ )
230
+ seed_candidate = _extract_seed_candidate_from_student(student)
231
+ stages = [
232
+ {
233
+ "id": stage_id,
234
+ "name": stage_id,
235
+ "messages": [
236
+ {"role": "system", "pattern": prompt, "order": 0},
237
+ {"role": "user", "pattern": "{input}", "order": 1},
238
+ ],
239
+ "wildcards": {},
240
+ }
241
+ for stage_id, prompt in seed_candidate.items()
242
+ ]
243
+ prompt_learning_config = {
244
+ "prompt_learning": {
245
+ "algorithm": "mipro",
246
+ "execution_mode": "retrieved",
247
+ "task_data": {
248
+ "train_examples": train_records,
249
+ "validation_examples": val_records,
250
+ },
251
+ "mipro": {
252
+ "initial_candidate": {"stages": stages},
253
+ "num_candidates": max(1, int(effective_num_candidates or 8)),
254
+ "max_iterations": max(1, int(trials)),
255
+ "early_stop_rounds": 3,
256
+ "min_improvement": 1e-6,
257
+ "seed": run_seed,
258
+ "proposer_backend": self.proposer_backend,
259
+ "termination_conditions": {
260
+ "total_rollouts": max(1, int(trials)) * max(1, len(val_records)),
261
+ },
262
+ },
263
+ "local_runtime": {
264
+ "task_model": self.task_model,
265
+ },
266
+ }
267
+ }
268
+ job = PromptLearningJob.from_dict(
269
+ prompt_learning_config,
270
+ backend_url="local://prompt-opt",
271
+ api_key="local",
272
+ )
273
+ job.submit()
274
+ result = job.stream_until_complete(timeout=300.0, interval=0.05).to_dict()
275
+ optimized_program = copy.deepcopy(student)
276
+ best_prompts = _extract_best_prompts(result)
277
+ if best_prompts:
278
+ optimized_program = _apply_prompts_to_student(optimized_program, best_prompts)
279
+ if self.track_stats:
280
+ setattr(optimized_program, "prompt_opt_result", json.loads(json.dumps(result)))
281
+ return optimized_program