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.
@@ -0,0 +1,225 @@
1
+ """GEPA-compatible local optimizer facade backed by the local offline SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from dataclasses import dataclass
7
+ from typing import Any, Protocol
8
+
9
+ from prompt_opt.adapters.synth_offline import SynthOfflineLearningAdapter
10
+ from prompt_opt.sdk.optimization.internal.prompt_learning import PromptLearningJob
11
+
12
+ try:
13
+ from synth_ai.gepa.core.result import GEPAResult as SynthGEPAResult
14
+ except Exception: # pragma: no cover - allows standalone use
15
+ SynthGEPAResult = None
16
+
17
+
18
+ class LocalGEPAAdapterProtocol(Protocol):
19
+ """Protocol compatible with GEPA adapter-style calls."""
20
+
21
+ def evaluate(
22
+ self,
23
+ batch: list[Mapping[str, Any]],
24
+ candidate: dict[str, str],
25
+ capture_traces: bool = False,
26
+ ) -> Any:
27
+ ...
28
+
29
+ def make_reflective_dataset(
30
+ self,
31
+ candidate: dict[str, str],
32
+ eval_batch: Any,
33
+ components_to_update: list[str],
34
+ ) -> Mapping[str, Sequence[Mapping[str, Any]]]:
35
+ ...
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class LocalGEPAResult:
40
+ """Minimal GEPA-style result when synth_ai is unavailable."""
41
+
42
+ candidates: list[dict[str, str]]
43
+ parents: list[list[int | None]]
44
+ val_aggregate_scores: list[float]
45
+ val_subscores: list[dict[int, float]]
46
+ per_val_instance_best_candidates: dict[int, set[int]]
47
+ discovery_eval_counts: list[int]
48
+ total_metric_calls: int
49
+
50
+ @property
51
+ def best_idx(self) -> int:
52
+ return max(range(len(self.val_aggregate_scores)), key=self.val_aggregate_scores.__getitem__)
53
+
54
+ @property
55
+ def best_candidate(self) -> dict[str, str]:
56
+ return self.candidates[self.best_idx]
57
+
58
+
59
+ def optimize(
60
+ seed_candidate: dict[str, str],
61
+ trainset: list[Mapping[str, Any]],
62
+ valset: list[Mapping[str, Any]] | None = None,
63
+ adapter: LocalGEPAAdapterProtocol | None = None,
64
+ task_lm: str | Any | None = None,
65
+ evaluator: Any | None = None,
66
+ reflection_lm: str | Any | None = None,
67
+ max_metric_calls: int | None = None,
68
+ stop_callbacks: Any | None = None,
69
+ **_: Any,
70
+ ) -> Any:
71
+ """Local GEPA-compatible optimize function.
72
+
73
+ This function is shaped for `gepa-ai` compatibility and can be used as a
74
+ drop-in local replacement where a simple adapter-based optimization loop is
75
+ sufficient.
76
+ """
77
+ del task_lm, evaluator, reflection_lm, stop_callbacks
78
+ if not seed_candidate:
79
+ raise ValueError("seed_candidate must contain at least one entry.")
80
+ if not trainset:
81
+ raise ValueError("trainset must contain at least one item.")
82
+ if adapter is None:
83
+ raise ValueError("adapter is required for local/offline mode.")
84
+
85
+ eval_set: list[Mapping[str, Any]] = list(valset if valset is not None else trainset)
86
+ budget = max(1, int(max_metric_calls or 8))
87
+ stages = [
88
+ {
89
+ "id": key,
90
+ "name": key,
91
+ "messages": [{"role": "system", "pattern": value, "order": 0}],
92
+ "wildcards": {},
93
+ }
94
+ for key, value in seed_candidate.items()
95
+ ]
96
+ if not stages:
97
+ stages = [
98
+ {
99
+ "id": "default",
100
+ "name": "default",
101
+ "messages": [{"role": "system", "pattern": "You are a helpful assistant.", "order": 0}],
102
+ "wildcards": {},
103
+ }
104
+ ]
105
+
106
+ prompt_learning_config = {
107
+ "prompt_learning": {
108
+ "algorithm": "gepa",
109
+ "execution_mode": "retrieved",
110
+ "task_data": {
111
+ "train_examples": [dict(item) for item in trainset],
112
+ "validation_examples": [dict(item) for item in eval_set],
113
+ },
114
+ "gepa": {
115
+ "initial_candidate": {"stages": stages},
116
+ "population": {
117
+ "initial_size": 1,
118
+ "num_generations": 1,
119
+ "children_per_generation": max(1, budget - 1),
120
+ },
121
+ "termination_conditions": {
122
+ "total_rollouts": budget * max(1, len(eval_set)),
123
+ },
124
+ },
125
+ "local_runtime": {
126
+ "adapter": adapter,
127
+ },
128
+ }
129
+ }
130
+ job = PromptLearningJob.from_dict(
131
+ prompt_learning_config,
132
+ backend_url="local://prompt-opt",
133
+ api_key="local",
134
+ )
135
+ job.submit()
136
+ job.stream_until_complete(timeout=300.0, interval=0.05).to_dict()
137
+ candidates_page = job.list_candidates(limit=budget + 1)
138
+ raw_candidates = candidates_page.get("items", [])
139
+
140
+ candidates: list[dict[str, str]] = []
141
+ parents: list[list[int | None]] = []
142
+ scores: list[float] = []
143
+ subscores: list[dict[int, float]] = []
144
+ eval_counts: list[int] = []
145
+ metric_calls_total = 0
146
+ candidate_index_by_id: dict[str, int] = {}
147
+
148
+ seed_eval_items = job.get_state_envelope().get("state", {}).get("seed_evals", [])
149
+ per_candidate_per_seed: dict[str, dict[int, float]] = {}
150
+ for seed_eval in seed_eval_items:
151
+ if not isinstance(seed_eval, dict):
152
+ continue
153
+ candidate_id = str(seed_eval.get("candidate_id", ""))
154
+ seed = int(seed_eval.get("seed", 0))
155
+ reward = float(seed_eval.get("reward", 0.0))
156
+ per_candidate_per_seed.setdefault(candidate_id, {})[seed] = reward
157
+
158
+ for raw_candidate in raw_candidates:
159
+ if not isinstance(raw_candidate, dict):
160
+ continue
161
+ stage_items = raw_candidate.get("stages") or raw_candidate.get("candidate", {}).get("stages") or []
162
+ candidate_map: dict[str, str] = {}
163
+ for stage in stage_items:
164
+ if not isinstance(stage, dict):
165
+ continue
166
+ stage_key = str(stage.get("id") or stage.get("name") or f"stage_{len(candidate_map)}")
167
+ for message in stage.get("messages", []):
168
+ if isinstance(message, dict) and message.get("role") == "system":
169
+ text = message.get("pattern") or message.get("content")
170
+ if isinstance(text, str):
171
+ candidate_map[stage_key] = text
172
+ break
173
+ if not candidate_map:
174
+ candidate_map = {
175
+ "default": str(raw_candidate.get("candidate_content", "")),
176
+ }
177
+ candidate_id = str(raw_candidate.get("candidate_id", ""))
178
+ candidate_index_by_id[candidate_id] = len(candidates)
179
+ candidates.append(candidate_map)
180
+ parent_id = raw_candidate.get("parent_id")
181
+ parents.append([candidate_index_by_id[parent_id]] if isinstance(parent_id, str) and parent_id in candidate_index_by_id else [None])
182
+ candidate_subscores = per_candidate_per_seed.get(candidate_id, {})
183
+ subscores.append(dict(candidate_subscores))
184
+ score = float(raw_candidate.get("avg_reward", 0.0) or 0.0)
185
+ scores.append(score)
186
+ eval_counts.append(len(candidate_subscores))
187
+ metric_calls_total += len(candidate_subscores)
188
+
189
+ per_instance_best: dict[int, set[int]] = {}
190
+ for example_idx in range(len(eval_set)):
191
+ best_score = max((item.get(example_idx, float("-inf")) for item in subscores), default=float("-inf"))
192
+ per_instance_best[example_idx] = {
193
+ candidate_idx
194
+ for candidate_idx, item in enumerate(subscores)
195
+ if item.get(example_idx, float("-inf")) == best_score
196
+ }
197
+
198
+ if SynthGEPAResult is not None:
199
+ return SynthGEPAResult(
200
+ candidates=candidates,
201
+ parents=parents,
202
+ val_aggregate_scores=scores,
203
+ val_subscores=subscores,
204
+ per_val_instance_best_candidates=per_instance_best,
205
+ discovery_eval_counts=eval_counts,
206
+ total_metric_calls=metric_calls_total,
207
+ )
208
+
209
+ return LocalGEPAResult(
210
+ candidates=candidates,
211
+ parents=parents,
212
+ val_aggregate_scores=scores,
213
+ val_subscores=subscores,
214
+ per_val_instance_best_candidates=per_instance_best,
215
+ discovery_eval_counts=eval_counts,
216
+ total_metric_calls=metric_calls_total,
217
+ )
218
+
219
+
220
+ __all__ = [
221
+ "LocalGEPAAdapterProtocol",
222
+ "LocalGEPAResult",
223
+ "SynthOfflineLearningAdapter",
224
+ "optimize",
225
+ ]
prompt_opt/mipro.py ADDED
@@ -0,0 +1,114 @@
1
+ """Local MIPRO entrypoint backed by the mirrored offline SDK runtime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Callable
6
+
7
+ from prompt_opt.sdk.optimization.internal.prompt_learning import PromptLearningJob
8
+
9
+
10
+ _PROPOSER_BACKENDS = ["single_prompt", "rlm"]
11
+
12
+
13
+ def proposer_backends() -> list[str]:
14
+ """Return the supported public proposer backend tokens."""
15
+ return list(_PROPOSER_BACKENDS)
16
+
17
+
18
+ def _policy_to_initial_candidate(initial_policy: dict[str, Any]) -> dict[str, Any]:
19
+ template = str(initial_policy.get("template", "")).strip() or "You are a helpful assistant."
20
+ return {
21
+ "stages": [
22
+ {
23
+ "id": "default",
24
+ "name": "Default",
25
+ "messages": [
26
+ {"role": "system", "pattern": template, "order": 0},
27
+ {"role": "user", "pattern": "{input}", "order": 1},
28
+ ],
29
+ "wildcards": {},
30
+ }
31
+ ]
32
+ }
33
+
34
+
35
+ def _extract_best_policy(result_payload: dict[str, Any]) -> dict[str, Any]:
36
+ best_candidate = result_payload.get("best_candidate")
37
+ if not isinstance(best_candidate, dict):
38
+ return {"template": ""}
39
+ stages = best_candidate.get("stages") or best_candidate.get("candidate", {}).get("stages") or []
40
+ for stage in stages:
41
+ if not isinstance(stage, dict):
42
+ continue
43
+ for message in stage.get("messages", []):
44
+ if not isinstance(message, dict):
45
+ continue
46
+ if message.get("role") == "system":
47
+ text = message.get("pattern") or message.get("content")
48
+ if isinstance(text, str):
49
+ return {"template": text}
50
+ content = best_candidate.get("candidate_content")
51
+ if isinstance(content, str):
52
+ return {"template": content}
53
+ return {"template": ""}
54
+
55
+
56
+ def run_mipro(
57
+ *,
58
+ config: dict[str, Any],
59
+ initial_policy: dict[str, Any],
60
+ dataset: dict[str, Any],
61
+ task_llm: Callable[[str], str],
62
+ ) -> dict[str, Any]:
63
+ """Run local offline MIPRO and return a JSON-compatible result payload."""
64
+ examples = []
65
+ for item in dataset.get("examples", []):
66
+ if isinstance(item, dict):
67
+ examples.append(
68
+ {
69
+ "input": item.get("input"),
70
+ "answer": item.get("expected"),
71
+ "expected": item.get("expected"),
72
+ "metadata": dict(item.get("metadata") or {}),
73
+ }
74
+ )
75
+ prompt_learning_config = {
76
+ "prompt_learning": {
77
+ "algorithm": "mipro",
78
+ "execution_mode": "retrieved",
79
+ "task_data": {
80
+ "examples": examples,
81
+ },
82
+ "mipro": {
83
+ "initial_candidate": _policy_to_initial_candidate(initial_policy),
84
+ "num_candidates": int(config.get("num_candidates", 8)),
85
+ "max_iterations": int(config.get("max_iterations", 8)),
86
+ "early_stop_rounds": int(config.get("early_stop_rounds", 3)),
87
+ "min_improvement": float(config.get("min_improvement", 1e-6)),
88
+ "seed": int(config.get("seed", 0)),
89
+ "proposer_backend": str(config.get("proposer_backend", "single_prompt")),
90
+ "termination_conditions": {
91
+ "total_rollouts": int(config.get("max_iterations", 8)) * max(1, len(examples)),
92
+ },
93
+ },
94
+ "local_runtime": {
95
+ "task_model": task_llm,
96
+ },
97
+ }
98
+ }
99
+
100
+ job = PromptLearningJob.from_dict(
101
+ prompt_learning_config,
102
+ backend_url="local://prompt-opt",
103
+ api_key="local",
104
+ )
105
+ job_id = job.submit()
106
+ result = job.stream_until_complete(timeout=300.0, interval=0.05).to_dict()
107
+ return {
108
+ "run_id": job_id,
109
+ "initial_policy": dict(initial_policy),
110
+ "best_policy": _extract_best_policy(result),
111
+ "best_score": result.get("best_reward"),
112
+ "history": job.get_results().get("result", {}).get("history", []),
113
+ "job_result": result,
114
+ }
@@ -0,0 +1,2 @@
1
+ """Mirrored Synth-style SDK namespace for local prompt optimization."""
2
+
@@ -0,0 +1,14 @@
1
+ """Local offline prompt optimization SDK."""
2
+
3
+ from .models import PolicyCandidate, PolicyCandidatePage, PromptLearningResult
4
+ from .policy.v1 import PolicyOptimizationOfflineJob
5
+ from .internal.prompt_learning import PromptLearningJob, PromptLearningJobConfig
6
+
7
+ __all__ = [
8
+ "PolicyCandidate",
9
+ "PolicyCandidatePage",
10
+ "PolicyOptimizationOfflineJob",
11
+ "PromptLearningJob",
12
+ "PromptLearningJobConfig",
13
+ "PromptLearningResult",
14
+ ]
@@ -0,0 +1,2 @@
1
+ """Internal mirrored SDK helpers."""
2
+
@@ -0,0 +1,16 @@
1
+ """Mirrored prompt-learning config models."""
2
+
3
+ from .prompt_learning import (
4
+ GEPAConfig,
5
+ MIPROAlgorithmConfig,
6
+ PromptLearningConfig,
7
+ PromptLearningPolicyConfig,
8
+ )
9
+
10
+ __all__ = [
11
+ "GEPAConfig",
12
+ "MIPROAlgorithmConfig",
13
+ "PromptLearningConfig",
14
+ "PromptLearningPolicyConfig",
15
+ ]
16
+
@@ -0,0 +1,230 @@
1
+ """Canonical prompt-learning config models for local offline prompt-opt."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from pydantic import Field, field_validator, model_validator
10
+
11
+ from ..utils import load_toml
12
+ from .shared import ExtraModel
13
+
14
+
15
+ class PromptLearningPolicyConfig(ExtraModel):
16
+ provider: str | None = None
17
+ inference_url: str | None = None
18
+ inference_mode: str | None = None
19
+ temperature: float = 0.0
20
+ max_completion_tokens: int = 512
21
+ policy_name: str | None = None
22
+ config: dict[str, Any] = Field(default_factory=dict)
23
+ context_override: dict[str, Any] | None = None
24
+
25
+
26
+ class MessagePatternConfig(ExtraModel):
27
+ role: str
28
+ pattern: str
29
+ order: int = 0
30
+
31
+
32
+ class PromptStageConfig(ExtraModel):
33
+ id: str | None = None
34
+ name: str | None = None
35
+ messages: list[MessagePatternConfig] = Field(default_factory=list)
36
+ wildcards: dict[str, str] = Field(default_factory=dict)
37
+ metadata: dict[str, Any] = Field(default_factory=dict)
38
+
39
+
40
+ class PromptCandidateConfig(ExtraModel):
41
+ stages: list[PromptStageConfig] = Field(default_factory=list)
42
+
43
+
44
+ class TrainPoolsConfig(ExtraModel):
45
+ reflection_seeds: list[int] = Field(default_factory=list)
46
+ pareto_seeds: list[int] = Field(default_factory=list)
47
+ seeds: list[int] = Field(default_factory=list)
48
+
49
+
50
+ class TaskDataConfig(ExtraModel):
51
+ split: str | None = None
52
+ train_pools: TrainPoolsConfig | dict[str, Any] | None = None
53
+ validation_seeds: list[int] = Field(default_factory=list)
54
+ validation_examples: list[dict[str, Any]] = Field(default_factory=list)
55
+ train_examples: list[dict[str, Any]] = Field(default_factory=list)
56
+ examples: list[dict[str, Any]] = Field(default_factory=list)
57
+ examples_by_seed: dict[str, dict[str, Any]] = Field(default_factory=dict)
58
+
59
+ @field_validator("train_pools", mode="before")
60
+ @classmethod
61
+ def _coerce_train_pools(cls, value: Any) -> Any:
62
+ if value is None or isinstance(value, TrainPoolsConfig):
63
+ return value
64
+ if isinstance(value, Mapping):
65
+ return TrainPoolsConfig.model_validate(dict(value))
66
+ raise ValueError("train_pools must be a mapping when provided")
67
+
68
+
69
+ class PopulationConfig(ExtraModel):
70
+ initial_size: int = 1
71
+ num_generations: int = 1
72
+ children_per_generation: int = 4
73
+
74
+
75
+ class TerminationConditionsConfig(ExtraModel):
76
+ total_rollouts: int | None = None
77
+ max_iterations: int | None = None
78
+ patience: int | None = None
79
+
80
+
81
+ class GEPAConfig(ExtraModel):
82
+ env_name: str | None = None
83
+ initial_candidate: PromptCandidateConfig | dict[str, Any] | None = None
84
+ termination_conditions: TerminationConditionsConfig | dict[str, Any] | None = None
85
+ mode: str | None = None
86
+ execution_mode: str | None = None
87
+ proposer_backend: str | None = None
88
+ population: PopulationConfig | dict[str, Any] | None = None
89
+ modules: list[dict[str, Any]] | None = None
90
+
91
+ @field_validator("initial_candidate", mode="before")
92
+ @classmethod
93
+ def _coerce_initial_candidate(cls, value: Any) -> Any:
94
+ if value is None or isinstance(value, PromptCandidateConfig):
95
+ return value
96
+ if isinstance(value, Mapping):
97
+ return PromptCandidateConfig.model_validate(dict(value))
98
+ raise ValueError("initial_candidate must be a mapping when provided")
99
+
100
+ @field_validator("termination_conditions", mode="before")
101
+ @classmethod
102
+ def _coerce_termination(cls, value: Any) -> Any:
103
+ if value is None or isinstance(value, TerminationConditionsConfig):
104
+ return value
105
+ if isinstance(value, Mapping):
106
+ return TerminationConditionsConfig.model_validate(dict(value))
107
+ raise ValueError("termination_conditions must be a mapping when provided")
108
+
109
+ @field_validator("population", mode="before")
110
+ @classmethod
111
+ def _coerce_population(cls, value: Any) -> Any:
112
+ if value is None or isinstance(value, PopulationConfig):
113
+ return value
114
+ if isinstance(value, Mapping):
115
+ return PopulationConfig.model_validate(dict(value))
116
+ raise ValueError("population must be a mapping when provided")
117
+
118
+
119
+ class MIPROAlgorithmConfig(ExtraModel):
120
+ initial_candidate: PromptCandidateConfig | dict[str, Any] | None = None
121
+ execution_mode: str | None = None
122
+ mode: str | None = None
123
+ num_candidates: int = 8
124
+ max_iterations: int = 8
125
+ early_stop_rounds: int = 3
126
+ min_improvement: float = 1e-6
127
+ proposer_backend: str = "single_prompt"
128
+ seed: int = 0
129
+ termination_conditions: TerminationConditionsConfig | dict[str, Any] | None = None
130
+
131
+ @field_validator("initial_candidate", mode="before")
132
+ @classmethod
133
+ def _coerce_initial_candidate(cls, value: Any) -> Any:
134
+ if value is None or isinstance(value, PromptCandidateConfig):
135
+ return value
136
+ if isinstance(value, Mapping):
137
+ return PromptCandidateConfig.model_validate(dict(value))
138
+ raise ValueError("initial_candidate must be a mapping when provided")
139
+
140
+ @field_validator("termination_conditions", mode="before")
141
+ @classmethod
142
+ def _coerce_termination(cls, value: Any) -> Any:
143
+ if value is None or isinstance(value, TerminationConditionsConfig):
144
+ return value
145
+ if isinstance(value, Mapping):
146
+ return TerminationConditionsConfig.model_validate(dict(value))
147
+ raise ValueError("termination_conditions must be a mapping when provided")
148
+
149
+
150
+ class PromptLearningConfig(ExtraModel):
151
+ algorithm: str
152
+ job_kind: str | None = None
153
+ algorithm_name: str | None = None
154
+ execution_mode: str | None = None
155
+ config_schema_version: str | None = None
156
+ container_url: str | None = None
157
+ container_id: str | None = None
158
+ task_data: TaskDataConfig | dict[str, Any] | None = None
159
+ policy: PromptLearningPolicyConfig | dict[str, Any] | None = None
160
+ gepa: GEPAConfig | None = None
161
+ mipro: MIPROAlgorithmConfig | dict[str, Any] | None = None
162
+ verifier: dict[str, Any] | None = None
163
+ proxy_models: dict[str, Any] | None = None
164
+ env_config: dict[str, Any] | None = None
165
+ use_byok: bool | None = None
166
+
167
+ @field_validator("task_data", mode="before")
168
+ @classmethod
169
+ def _coerce_task_data(cls, value: Any) -> Any:
170
+ if value is None or isinstance(value, TaskDataConfig):
171
+ return value
172
+ if isinstance(value, Mapping):
173
+ return TaskDataConfig.model_validate(dict(value))
174
+ raise ValueError("task_data must be a mapping when provided")
175
+
176
+ @field_validator("policy", mode="before")
177
+ @classmethod
178
+ def _coerce_policy(cls, value: Any) -> Any:
179
+ if value is None or isinstance(value, PromptLearningPolicyConfig):
180
+ return value
181
+ if isinstance(value, Mapping):
182
+ return PromptLearningPolicyConfig.model_validate(dict(value))
183
+ raise ValueError("policy must be a mapping when provided")
184
+
185
+ @field_validator("mipro", mode="before")
186
+ @classmethod
187
+ def _coerce_mipro(cls, value: Any) -> Any:
188
+ if value is None or isinstance(value, MIPROAlgorithmConfig):
189
+ return value
190
+ if isinstance(value, Mapping):
191
+ return MIPROAlgorithmConfig.model_validate(dict(value))
192
+ raise ValueError("mipro must be a mapping when provided")
193
+
194
+ @model_validator(mode="after")
195
+ def _normalize_execution_mode(self) -> "PromptLearningConfig":
196
+ if self.algorithm_name is None:
197
+ self.algorithm_name = self.algorithm
198
+ if self.job_kind is None:
199
+ self.job_kind = "optimization"
200
+
201
+ algorithm_mode = None
202
+ if self.algorithm == "gepa" and self.gepa is not None:
203
+ algorithm_mode = self.gepa.execution_mode or self.gepa.mode
204
+ elif self.algorithm == "mipro" and self.mipro is not None:
205
+ algorithm_mode = self.mipro.execution_mode or self.mipro.mode
206
+
207
+ effective = self.execution_mode or algorithm_mode or "offline"
208
+ if effective == "proxied":
209
+ effective = "retrieved"
210
+ self.execution_mode = effective
211
+
212
+ if self.algorithm == "gepa" and self.gepa is not None:
213
+ self.gepa.execution_mode = effective
214
+ if self.algorithm == "mipro" and self.mipro is not None:
215
+ self.mipro.execution_mode = effective
216
+ return self
217
+
218
+ @classmethod
219
+ def from_mapping(cls, data: Mapping[str, Any]) -> "PromptLearningConfig":
220
+ payload = dict(data)
221
+ if "prompt_learning" in payload and isinstance(payload["prompt_learning"], Mapping):
222
+ payload = dict(payload["prompt_learning"])
223
+ return cls.model_validate(payload)
224
+
225
+ @classmethod
226
+ def from_path(cls, path: str | Path) -> "PromptLearningConfig":
227
+ return cls.from_mapping(load_toml(path))
228
+
229
+ def to_dict(self) -> dict[str, Any]:
230
+ return self.model_dump(mode="python", exclude_none=True)
@@ -0,0 +1,12 @@
1
+ """Shared config base models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+
8
+ class ExtraModel(BaseModel):
9
+ """Base model that preserves unknown keys for forward compatibility."""
10
+
11
+ model_config = ConfigDict(extra="allow")
12
+