pymelvil 0.3.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.
- melvil/__init__.py +52 -0
- melvil/_about.py +6 -0
- melvil/adapter.py +160 -0
- melvil/artifact.py +194 -0
- melvil/config.py +114 -0
- melvil/costs.py +125 -0
- melvil/data.py +139 -0
- melvil/evaluate.py +158 -0
- melvil/features/__init__.py +3 -0
- melvil/features/confusion.py +155 -0
- melvil/features/mining.py +122 -0
- melvil/lmutil.py +60 -0
- melvil/optimize.py +299 -0
- melvil/program.py +93 -0
- melvil/proposer.py +153 -0
- melvil/py.typed +0 -0
- melvil/screen.py +108 -0
- melvil/taskspec.py +174 -0
- melvil/testing.py +174 -0
- pymelvil-0.3.0.dist-info/METADATA +161 -0
- pymelvil-0.3.0.dist-info/RECORD +23 -0
- pymelvil-0.3.0.dist-info/WHEEL +4 -0
- pymelvil-0.3.0.dist-info/licenses/LICENSE +21 -0
melvil/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""melvil: optimized, versioned classifier prompts from labeled examples.
|
|
2
|
+
|
|
3
|
+
Quickstart::
|
|
4
|
+
|
|
5
|
+
import melvil as mv
|
|
6
|
+
|
|
7
|
+
examples = mv.load_csv("tickets.csv") # text,label columns
|
|
8
|
+
train, dev = mv.train_dev_split(examples, dev_size=100, seed=0)
|
|
9
|
+
spec = mv.TaskSpec.from_examples("ticket-intents", examples)
|
|
10
|
+
cfg = mv.Config(task_model="openai/gpt-4.1-mini",
|
|
11
|
+
reflection_model="openai/gpt-4.1", budget="light")
|
|
12
|
+
artifact = mv.optimize(spec, train, dev, cfg)
|
|
13
|
+
print(artifact.render()) # deployable prompt
|
|
14
|
+
artifact.save("ticket_intents.v1.json")
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from melvil._about import LIBRARY_NAME, __version__
|
|
18
|
+
from melvil.adapter import RoundInfo
|
|
19
|
+
from melvil.artifact import PromptArtifact, render_prompt
|
|
20
|
+
from melvil.config import BUDGET_PRESETS, Config, Features
|
|
21
|
+
from melvil.costs import CostEstimate, estimate_evaluate_cost, estimate_optimize_cost
|
|
22
|
+
from melvil.data import Example, load_csv, load_hf, train_dev_split
|
|
23
|
+
from melvil.evaluate import Report, evaluate, report
|
|
24
|
+
from melvil.optimize import optimize
|
|
25
|
+
from melvil.screen import ScreenResult, screen
|
|
26
|
+
from melvil.taskspec import Label, TaskSpec
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"BUDGET_PRESETS",
|
|
30
|
+
"LIBRARY_NAME",
|
|
31
|
+
"Config",
|
|
32
|
+
"CostEstimate",
|
|
33
|
+
"Example",
|
|
34
|
+
"Features",
|
|
35
|
+
"Label",
|
|
36
|
+
"PromptArtifact",
|
|
37
|
+
"Report",
|
|
38
|
+
"RoundInfo",
|
|
39
|
+
"TaskSpec",
|
|
40
|
+
"__version__",
|
|
41
|
+
"estimate_evaluate_cost",
|
|
42
|
+
"estimate_optimize_cost",
|
|
43
|
+
"evaluate",
|
|
44
|
+
"load_csv",
|
|
45
|
+
"load_hf",
|
|
46
|
+
"optimize",
|
|
47
|
+
"render_prompt",
|
|
48
|
+
"report",
|
|
49
|
+
"screen",
|
|
50
|
+
"ScreenResult",
|
|
51
|
+
"train_dev_split",
|
|
52
|
+
]
|
melvil/_about.py
ADDED
melvil/adapter.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""The gepa `GEPAAdapter` for classification: builds the prompt from candidate
|
|
2
|
+
components, evaluates batches with the task LM, maintains confusion state on
|
|
3
|
+
full dev evaluations, and produces reflective datasets per component."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from gepa.core.adapter import EvaluationBatch
|
|
12
|
+
|
|
13
|
+
from melvil.artifact import BLOB_COMPONENT, LABEL_PREFIX, render_prompt
|
|
14
|
+
from melvil.config import Config
|
|
15
|
+
from melvil.data import Example
|
|
16
|
+
from melvil.features.confusion import ConfusionState, render_confusion_block
|
|
17
|
+
from melvil.program import Prediction, classify_batch
|
|
18
|
+
from melvil.proposer import ClassificationProposer
|
|
19
|
+
from melvil.taskspec import TaskSpec
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class RoundInfo:
|
|
24
|
+
"""Passed to the `on_round` callback after every full dev evaluation."""
|
|
25
|
+
|
|
26
|
+
round: int
|
|
27
|
+
dev_score: float
|
|
28
|
+
best_dev_score: float
|
|
29
|
+
metric_calls_spent: int
|
|
30
|
+
cost_usd: float
|
|
31
|
+
top_confusions: list[tuple[str, str, int]] = field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ClassificationAdapter:
|
|
35
|
+
"""One instance per optimization run."""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
spec: TaskSpec,
|
|
40
|
+
task_lm: Any,
|
|
41
|
+
config: Config,
|
|
42
|
+
valset: list[Example],
|
|
43
|
+
trace_path=None,
|
|
44
|
+
on_round: Callable[[RoundInfo], None] | None = None,
|
|
45
|
+
cost_fn: Callable[[], float] | None = None,
|
|
46
|
+
):
|
|
47
|
+
self.spec = spec
|
|
48
|
+
self.task_lm = task_lm
|
|
49
|
+
self.config = config
|
|
50
|
+
self.label_names = spec.label_names
|
|
51
|
+
self.confusion = ConfusionState()
|
|
52
|
+
self.on_round = on_round
|
|
53
|
+
self.cost_fn = cost_fn or (lambda: 0.0)
|
|
54
|
+
self._val_texts = tuple(e.text for e in valset)
|
|
55
|
+
self._metric_calls = 0
|
|
56
|
+
self._best_dev = 0.0
|
|
57
|
+
self._round = 0
|
|
58
|
+
|
|
59
|
+
feats = config.features
|
|
60
|
+
if feats.codebook or feats.confusion_reflection:
|
|
61
|
+
self.proposer: ClassificationProposer | None = ClassificationProposer(
|
|
62
|
+
render_full_prompt=lambda cand: render_prompt(cand, self.label_names),
|
|
63
|
+
confusion_block=(
|
|
64
|
+
(lambda: render_confusion_block(self.confusion))
|
|
65
|
+
if feats.confusion_reflection
|
|
66
|
+
else (lambda: "")
|
|
67
|
+
),
|
|
68
|
+
trace_path=trace_path,
|
|
69
|
+
)
|
|
70
|
+
else:
|
|
71
|
+
self.proposer = None
|
|
72
|
+
|
|
73
|
+
# gepa checks `adapter.propose_new_texts is not None`
|
|
74
|
+
@property
|
|
75
|
+
def propose_new_texts(self):
|
|
76
|
+
return self.proposer
|
|
77
|
+
|
|
78
|
+
# ------------------------------------------------------------- evaluate
|
|
79
|
+
def evaluate(
|
|
80
|
+
self, batch: list[Example], candidate: dict[str, str], capture_traces: bool = False
|
|
81
|
+
) -> EvaluationBatch:
|
|
82
|
+
rendered = render_prompt(candidate, self.label_names)
|
|
83
|
+
preds = classify_batch(
|
|
84
|
+
self.task_lm, rendered, batch, self.label_names, self.config.num_threads
|
|
85
|
+
)
|
|
86
|
+
self._metric_calls += len(batch)
|
|
87
|
+
scores = [1.0 if p.correct else 0.0 for p in preds]
|
|
88
|
+
|
|
89
|
+
if self._is_full_val(batch):
|
|
90
|
+
self.confusion.update(preds)
|
|
91
|
+
self._round += 1
|
|
92
|
+
dev_score = sum(scores) / len(scores)
|
|
93
|
+
self._best_dev = max(self._best_dev, dev_score)
|
|
94
|
+
if self.on_round:
|
|
95
|
+
self.on_round(
|
|
96
|
+
RoundInfo(
|
|
97
|
+
round=self._round,
|
|
98
|
+
dev_score=dev_score,
|
|
99
|
+
best_dev_score=self._best_dev,
|
|
100
|
+
metric_calls_spent=self._metric_calls,
|
|
101
|
+
cost_usd=self.cost_fn(),
|
|
102
|
+
top_confusions=list(self.confusion.last_pairs[:3]),
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
trajectories = None
|
|
107
|
+
if capture_traces:
|
|
108
|
+
trajectories = [
|
|
109
|
+
{"example": ex, "prediction": p} for ex, p in zip(batch, preds, strict=True)
|
|
110
|
+
]
|
|
111
|
+
return EvaluationBatch(
|
|
112
|
+
outputs=[p.predicted for p in preds], scores=scores, trajectories=trajectories
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def _is_full_val(self, batch: list[Example]) -> bool:
|
|
116
|
+
return len(batch) == len(self._val_texts) and all(
|
|
117
|
+
e.text == t for e, t in zip(batch, self._val_texts, strict=True)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# ------------------------------------------------- reflective dataset
|
|
121
|
+
def make_reflective_dataset(
|
|
122
|
+
self,
|
|
123
|
+
candidate: dict[str, str],
|
|
124
|
+
eval_batch: EvaluationBatch,
|
|
125
|
+
components_to_update: list[str],
|
|
126
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
127
|
+
preds: list[Prediction] = [t["prediction"] for t in (eval_batch.trajectories or [])]
|
|
128
|
+
out: dict[str, list[dict[str, Any]]] = {}
|
|
129
|
+
for comp in components_to_update:
|
|
130
|
+
if comp.startswith(LABEL_PREFIX):
|
|
131
|
+
label = comp[len(LABEL_PREFIX):]
|
|
132
|
+
relevant = [p for p in preds if not p.correct and label in (p.gold, p.predicted)]
|
|
133
|
+
# pad with confusion-state examples for this label if the minibatch is clean
|
|
134
|
+
if not relevant:
|
|
135
|
+
relevant = [
|
|
136
|
+
p for p in self.confusion.last_preds
|
|
137
|
+
if not p.correct and label in (p.gold, p.predicted)
|
|
138
|
+
][:3]
|
|
139
|
+
else:
|
|
140
|
+
relevant = [p for p in preds if not p.correct]
|
|
141
|
+
if not relevant: # nothing wrong -> show the minibatch as-is
|
|
142
|
+
relevant = preds
|
|
143
|
+
records = [
|
|
144
|
+
{
|
|
145
|
+
"Inputs": {"text": p.text},
|
|
146
|
+
"Generated Outputs": {"response": p.raw},
|
|
147
|
+
"Feedback": p.feedback,
|
|
148
|
+
}
|
|
149
|
+
for p in relevant[:6]
|
|
150
|
+
]
|
|
151
|
+
if records:
|
|
152
|
+
out[comp] = records
|
|
153
|
+
if not out:
|
|
154
|
+
raise ValueError("no reflective examples available for any component")
|
|
155
|
+
return out
|
|
156
|
+
|
|
157
|
+
# convenience for blob mode default proposer compatibility
|
|
158
|
+
@staticmethod
|
|
159
|
+
def blob_candidate(text: str) -> dict[str, str]:
|
|
160
|
+
return {BLOB_COMPONENT: text}
|
melvil/artifact.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""PromptArtifact: the versioned output of an optimization run.
|
|
2
|
+
|
|
3
|
+
An artifact is a JSON document holding the evolved prompt *components*, the
|
|
4
|
+
models/budget/scores/config that produced them, and lineage. `render()`
|
|
5
|
+
assembles the deployable prompt string — the exact prompt that was executed
|
|
6
|
+
during optimization, not a reconstruction.
|
|
7
|
+
|
|
8
|
+
Component naming convention:
|
|
9
|
+
- codebook mode: ``task_instruction``, ``label::<name>`` (one per label),
|
|
10
|
+
``boundary_rules``
|
|
11
|
+
- blob mode (codebook off): a single ``instruction`` component
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import difflib
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import time
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from melvil._about import LIBRARY_NAME, __version__
|
|
25
|
+
|
|
26
|
+
SCHEMA_VERSION = 1
|
|
27
|
+
|
|
28
|
+
TASK_COMPONENT = "task_instruction"
|
|
29
|
+
BLOB_COMPONENT = "instruction"
|
|
30
|
+
BOUNDARY_COMPONENT = "boundary_rules"
|
|
31
|
+
LABEL_PREFIX = "label::"
|
|
32
|
+
|
|
33
|
+
#: Fixed output contract appended to every rendered prompt. It is NOT an
|
|
34
|
+
#: evolvable component, so the optimizer can never break the output format.
|
|
35
|
+
OUTPUT_CONTRACT = "Respond with exactly one category name from the list above and nothing else."
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def label_component(name: str) -> str:
|
|
39
|
+
return f"{LABEL_PREFIX}{name}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def render_prompt(
|
|
43
|
+
components: dict[str, str],
|
|
44
|
+
label_names: list[str],
|
|
45
|
+
exemplars: list[dict[str, str]] | None = None,
|
|
46
|
+
) -> str:
|
|
47
|
+
"""Assemble the deployable system prompt from components (+ optional mined
|
|
48
|
+
few-shot exemplars). Used identically at optimize time and via
|
|
49
|
+
`PromptArtifact.render()`."""
|
|
50
|
+
parts: list[str] = []
|
|
51
|
+
if BLOB_COMPONENT in components: # blob mode
|
|
52
|
+
parts.append(components[BLOB_COMPONENT].strip())
|
|
53
|
+
else:
|
|
54
|
+
parts.append(components.get(TASK_COMPONENT, "").strip())
|
|
55
|
+
defs = []
|
|
56
|
+
for name in label_names:
|
|
57
|
+
desc = components.get(label_component(name), "").strip()
|
|
58
|
+
defs.append(f"- {name}: {desc}" if desc else f"- {name}")
|
|
59
|
+
parts.append("Categories:\n" + "\n".join(defs))
|
|
60
|
+
boundary = components.get(BOUNDARY_COMPONENT, "").strip()
|
|
61
|
+
if boundary:
|
|
62
|
+
parts.append("Boundary rules:\n" + boundary)
|
|
63
|
+
if exemplars:
|
|
64
|
+
shot = []
|
|
65
|
+
for ex in exemplars:
|
|
66
|
+
shot.append(f"Text: {ex['text']}\nLabel: {ex['label']}")
|
|
67
|
+
parts.append("Examples:\n\n" + "\n\n".join(shot))
|
|
68
|
+
parts.append(OUTPUT_CONTRACT)
|
|
69
|
+
return "\n\n".join(p for p in parts if p)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class PromptArtifact:
|
|
74
|
+
task_name: str
|
|
75
|
+
components: dict[str, str]
|
|
76
|
+
label_names: list[str]
|
|
77
|
+
exemplars: list[dict[str, str]] = field(default_factory=list)
|
|
78
|
+
models: dict[str, str] = field(default_factory=dict)
|
|
79
|
+
budget: dict[str, Any] = field(default_factory=dict) # metric_calls_spent, cost_usd, ...
|
|
80
|
+
scores: dict[str, Any] = field(default_factory=dict) # dev_accuracy, dev_macro_f1, ...
|
|
81
|
+
confusion: dict[str, Any] | None = None # {"labels": [...], "matrix": [[...]]} on dev
|
|
82
|
+
curve: list[dict[str, Any]] = field(default_factory=list) # metric_calls -> best dev score
|
|
83
|
+
config: dict[str, Any] = field(default_factory=dict)
|
|
84
|
+
config_hash: str = ""
|
|
85
|
+
parent_id: str | None = None
|
|
86
|
+
artifact_id: str = ""
|
|
87
|
+
created_at: str = ""
|
|
88
|
+
library: dict[str, str] = field(
|
|
89
|
+
default_factory=lambda: {"name": LIBRARY_NAME, "version": __version__}
|
|
90
|
+
)
|
|
91
|
+
schema_version: int = SCHEMA_VERSION
|
|
92
|
+
notes: str = ""
|
|
93
|
+
|
|
94
|
+
def __post_init__(self) -> None:
|
|
95
|
+
if not self.created_at:
|
|
96
|
+
self.created_at = time.strftime("%Y-%m-%dT%H:%M:%S%z")
|
|
97
|
+
if not self.artifact_id:
|
|
98
|
+
payload = json.dumps(
|
|
99
|
+
[self.task_name, self.components, self.exemplars, self.created_at,
|
|
100
|
+
self.config_hash],
|
|
101
|
+
sort_keys=True,
|
|
102
|
+
)
|
|
103
|
+
self.artifact_id = hashlib.sha256(payload.encode()).hexdigest()[:12]
|
|
104
|
+
|
|
105
|
+
# ------------------------------------------------------------- behavior
|
|
106
|
+
def render(self) -> str:
|
|
107
|
+
"""The deployable prompt string."""
|
|
108
|
+
return render_prompt(self.components, self.label_names, self.exemplars)
|
|
109
|
+
|
|
110
|
+
def diff(self, other: PromptArtifact) -> str:
|
|
111
|
+
"""Human-readable comparison: per-component text diff + score deltas."""
|
|
112
|
+
lines: list[str] = [f"artifact {other.artifact_id} -> {self.artifact_id}"]
|
|
113
|
+
for key in ("dev_accuracy", "dev_macro_f1", "test_accuracy"):
|
|
114
|
+
a, b = other.scores.get(key), self.scores.get(key)
|
|
115
|
+
if a is not None or b is not None:
|
|
116
|
+
def fmt(v):
|
|
117
|
+
return "—" if v is None else f"{v:.3f}"
|
|
118
|
+
|
|
119
|
+
delta = f" ({b - a:+.3f})" if (a is not None and b is not None) else ""
|
|
120
|
+
lines.append(f" {key}: {fmt(a)} -> {fmt(b)}{delta}")
|
|
121
|
+
all_comps = sorted(set(self.components) | set(other.components))
|
|
122
|
+
for comp in all_comps:
|
|
123
|
+
old = other.components.get(comp, "")
|
|
124
|
+
new = self.components.get(comp, "")
|
|
125
|
+
if old == new:
|
|
126
|
+
continue
|
|
127
|
+
lines.append(f"\n## {comp}")
|
|
128
|
+
diff = difflib.unified_diff(
|
|
129
|
+
old.splitlines(), new.splitlines(), lineterm="",
|
|
130
|
+
fromfile=f"{comp}@{other.artifact_id}", tofile=f"{comp}@{self.artifact_id}",
|
|
131
|
+
)
|
|
132
|
+
lines.extend(list(diff)[2:] or ["(whitespace-only change)"])
|
|
133
|
+
if len(all_comps) and len(lines) == 1:
|
|
134
|
+
lines.append(" (no component changes)")
|
|
135
|
+
ex_a, ex_b = len(other.exemplars), len(self.exemplars)
|
|
136
|
+
if ex_a != ex_b:
|
|
137
|
+
lines.append(f"\nexemplars: {ex_a} -> {ex_b}")
|
|
138
|
+
return "\n".join(lines)
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------- persistence
|
|
141
|
+
def to_dict(self) -> dict[str, Any]:
|
|
142
|
+
return {
|
|
143
|
+
"schema_version": self.schema_version,
|
|
144
|
+
"artifact_id": self.artifact_id,
|
|
145
|
+
"parent_id": self.parent_id,
|
|
146
|
+
"task_name": self.task_name,
|
|
147
|
+
"created_at": self.created_at,
|
|
148
|
+
"library": self.library,
|
|
149
|
+
"models": self.models,
|
|
150
|
+
"budget": self.budget,
|
|
151
|
+
"scores": self.scores,
|
|
152
|
+
"config_hash": self.config_hash,
|
|
153
|
+
"config": self.config,
|
|
154
|
+
"label_names": self.label_names,
|
|
155
|
+
"components": self.components,
|
|
156
|
+
"exemplars": self.exemplars,
|
|
157
|
+
"confusion": self.confusion,
|
|
158
|
+
"curve": self.curve,
|
|
159
|
+
"notes": self.notes,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
def save(self, path: str | Path) -> Path:
|
|
163
|
+
path = Path(path)
|
|
164
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
path.write_text(json.dumps(self.to_dict(), indent=1, ensure_ascii=False))
|
|
166
|
+
return path
|
|
167
|
+
|
|
168
|
+
@classmethod
|
|
169
|
+
def load(cls, path: str | Path) -> PromptArtifact:
|
|
170
|
+
d = json.loads(Path(path).read_text())
|
|
171
|
+
if d.get("schema_version", 0) > SCHEMA_VERSION:
|
|
172
|
+
raise ValueError(
|
|
173
|
+
f"artifact schema {d['schema_version']} is newer than this "
|
|
174
|
+
f"{LIBRARY_NAME} ({SCHEMA_VERSION}); please upgrade"
|
|
175
|
+
)
|
|
176
|
+
return cls(
|
|
177
|
+
task_name=d["task_name"],
|
|
178
|
+
components=d["components"],
|
|
179
|
+
label_names=d["label_names"],
|
|
180
|
+
exemplars=d.get("exemplars", []),
|
|
181
|
+
models=d.get("models", {}),
|
|
182
|
+
budget=d.get("budget", {}),
|
|
183
|
+
scores=d.get("scores", {}),
|
|
184
|
+
confusion=d.get("confusion"),
|
|
185
|
+
curve=d.get("curve", []),
|
|
186
|
+
config=d.get("config", {}),
|
|
187
|
+
config_hash=d.get("config_hash", ""),
|
|
188
|
+
parent_id=d.get("parent_id"),
|
|
189
|
+
artifact_id=d["artifact_id"],
|
|
190
|
+
created_at=d.get("created_at", ""),
|
|
191
|
+
library=d.get("library", {}),
|
|
192
|
+
schema_version=d.get("schema_version", SCHEMA_VERSION),
|
|
193
|
+
notes=d.get("notes", ""),
|
|
194
|
+
)
|
melvil/config.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Run configuration: models, budget presets, feature toggles, seeds.
|
|
2
|
+
|
|
3
|
+
Plain dataclasses — no YAML required, but `Config.from_yaml()` exists for teams
|
|
4
|
+
that want files.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import asdict, dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
#: Budget presets in metric calls (one metric call = one task-model
|
|
16
|
+
#: classification). "light" matches the dspy auto="light" budget for a
|
|
17
|
+
#: single-predictor program with a 100-example dev set (measured in the pilot).
|
|
18
|
+
BUDGET_PRESETS: dict[str, int] = {"light": 800, "medium": 2000, "heavy": 5000}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Features:
|
|
23
|
+
"""The classification layer. Each feature is independently toggleable
|
|
24
|
+
(benchmark ablations rely on this).
|
|
25
|
+
|
|
26
|
+
- codebook: structure the prompt as named components (task instruction,
|
|
27
|
+
one definition per label, boundary rules) and let GEPA evolve them
|
|
28
|
+
per-component. Off = single free-text instruction blob (vanilla GEPA).
|
|
29
|
+
- confusion_reflection: compute the dev confusion matrix as the run
|
|
30
|
+
progresses, target the most-confused labels' components for update, and
|
|
31
|
+
show the reflection LM the top confused pairs with concrete misclassified
|
|
32
|
+
examples.
|
|
33
|
+
- hard_example_mining: persistently misclassified dev examples become
|
|
34
|
+
candidate few-shot exemplars, selected to cover the top confused
|
|
35
|
+
boundaries; kept only if they don't hurt dev accuracy (one extra dev
|
|
36
|
+
eval, reserved out of the budget).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
codebook: bool = False
|
|
40
|
+
confusion_reflection: bool = False
|
|
41
|
+
hard_example_mining: bool = False
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def none(cls) -> Features:
|
|
45
|
+
"""Vanilla GEPA: all classification features off. This is the default —
|
|
46
|
+
the strongest configuration in our pre-registered confirmation benchmarks
|
|
47
|
+
at light budgets (see benchmarks/RESULTS.md)."""
|
|
48
|
+
return cls(codebook=False, confusion_reflection=False, hard_example_mining=False)
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def all(cls) -> Features:
|
|
52
|
+
"""The full classification layer. In confirmation benchmarks it trailed
|
|
53
|
+
the default at light budgets; consider it for large taxonomies (where the
|
|
54
|
+
fixed confusion selector was directionally positive) or higher budgets
|
|
55
|
+
(untested — see benchmarks/RESULTS.md)."""
|
|
56
|
+
return cls(codebook=True, confusion_reflection=True, hard_example_mining=True)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class Config:
|
|
61
|
+
"""Everything `optimize()` needs. `budget` is a preset name or an explicit
|
|
62
|
+
metric-call cap."""
|
|
63
|
+
|
|
64
|
+
task_model: str
|
|
65
|
+
reflection_model: str
|
|
66
|
+
budget: int | str = "light"
|
|
67
|
+
seed: int = 0
|
|
68
|
+
features: Features = field(default_factory=Features)
|
|
69
|
+
num_threads: int = 8
|
|
70
|
+
run_dir: str | None = None
|
|
71
|
+
task_temperature: float = 0.0
|
|
72
|
+
task_max_tokens: int = 40
|
|
73
|
+
reflection_temperature: float = 1.0
|
|
74
|
+
reflection_max_tokens: int = 8000
|
|
75
|
+
reflection_minibatch_size: int = 3
|
|
76
|
+
max_exemplars: int = 6
|
|
77
|
+
mining_llm_screen: bool = True
|
|
78
|
+
prices: dict[str, tuple[float, float]] = field(default_factory=dict) # $/M in, $/M out
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def metric_calls(self) -> int:
|
|
82
|
+
if isinstance(self.budget, int):
|
|
83
|
+
return self.budget
|
|
84
|
+
try:
|
|
85
|
+
return BUDGET_PRESETS[self.budget]
|
|
86
|
+
except KeyError:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"budget must be an int or one of {sorted(BUDGET_PRESETS)}; got {self.budget!r}"
|
|
89
|
+
) from None
|
|
90
|
+
|
|
91
|
+
def to_dict(self) -> dict[str, Any]:
|
|
92
|
+
d = asdict(self)
|
|
93
|
+
d["metric_calls"] = self.metric_calls
|
|
94
|
+
return d
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def config_hash(self) -> str:
|
|
98
|
+
"""Stable hash of everything that affects the optimization result
|
|
99
|
+
(run_dir and thread count excluded)."""
|
|
100
|
+
d = self.to_dict()
|
|
101
|
+
d.pop("run_dir", None)
|
|
102
|
+
d.pop("num_threads", None)
|
|
103
|
+
return hashlib.sha256(json.dumps(d, sort_keys=True).encode()).hexdigest()[:12]
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def from_yaml(cls, path: str | Path) -> Config:
|
|
107
|
+
import yaml
|
|
108
|
+
|
|
109
|
+
d = yaml.safe_load(Path(path).read_text())
|
|
110
|
+
if "features" in d and isinstance(d["features"], dict):
|
|
111
|
+
d["features"] = Features(**d["features"])
|
|
112
|
+
if "prices" in d:
|
|
113
|
+
d["prices"] = {k: tuple(v) for k, v in d["prices"].items()}
|
|
114
|
+
return cls(**d)
|
melvil/costs.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Cost accounting (measured, from lm.history — pilot-proven) and dry-run
|
|
2
|
+
estimation (upper bound, printed before any paid run)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
#: USD per 1M tokens: (input, output). Extend/override via Config.prices.
|
|
10
|
+
DEFAULT_PRICES: dict[str, tuple[float, float]] = {
|
|
11
|
+
"openai/gpt-4.1-mini": (0.40, 1.60),
|
|
12
|
+
"openai/gpt-4.1": (2.00, 8.00),
|
|
13
|
+
"openai/gpt-4.1-nano": (0.10, 0.40),
|
|
14
|
+
"openai/gpt-4o-mini": (0.15, 0.60),
|
|
15
|
+
"anthropic/claude-haiku-4-5-20251001": (1.00, 5.00),
|
|
16
|
+
"anthropic/claude-sonnet-4-5-20250929": (3.00, 15.00),
|
|
17
|
+
"meta-llama/llama-3.1-8b-instruct": (0.02, 0.03),
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def price_for(
|
|
22
|
+
model: str, prices: dict[str, tuple[float, float]] | None = None
|
|
23
|
+
) -> tuple[float, float]:
|
|
24
|
+
table = {**DEFAULT_PRICES, **(prices or {})}
|
|
25
|
+
if model in table:
|
|
26
|
+
return table[model]
|
|
27
|
+
# tolerate provider prefixes, e.g. "openrouter/openai/gpt-4.1"
|
|
28
|
+
for key, val in table.items():
|
|
29
|
+
if model.endswith(key) or key.endswith(model):
|
|
30
|
+
return val
|
|
31
|
+
raise KeyError(
|
|
32
|
+
f"no price known for model {model!r}; "
|
|
33
|
+
f"pass Config(prices={{{model!r}: (in_per_M, out_per_M)}})"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def lm_usage(lm: Any, since: int = 0) -> dict[str, Any]:
|
|
38
|
+
"""Sum real (non-cache-hit) usage from lm.history[since:]. Thread-safe by
|
|
39
|
+
construction (history is per-instance and append-only); includes provider-
|
|
40
|
+
reported per-call cost when available (OpenRouter reports it)."""
|
|
41
|
+
pt = ct = calls = hits = 0
|
|
42
|
+
cost = 0.0
|
|
43
|
+
for e in lm.history[since:]:
|
|
44
|
+
u = e.get("usage") or {}
|
|
45
|
+
if not u.get("total_tokens"):
|
|
46
|
+
hits += 1
|
|
47
|
+
continue
|
|
48
|
+
calls += 1
|
|
49
|
+
pt += u.get("prompt_tokens") or 0
|
|
50
|
+
ct += u.get("completion_tokens") or 0
|
|
51
|
+
cost += u.get("cost") or e.get("cost") or 0.0
|
|
52
|
+
return {
|
|
53
|
+
"model": getattr(lm, "model", "?"),
|
|
54
|
+
"prompt_tokens": pt,
|
|
55
|
+
"completion_tokens": ct,
|
|
56
|
+
"calls": calls,
|
|
57
|
+
"cache_hits": hits,
|
|
58
|
+
"cost_usd": round(float(cost), 6),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def usage_cost_usd(
|
|
63
|
+
usage: dict[str, Any], prices: dict[str, tuple[float, float]] | None = None
|
|
64
|
+
) -> float:
|
|
65
|
+
"""Cost of a usage record; prefers provider-reported cost, falls back to the
|
|
66
|
+
price table."""
|
|
67
|
+
if usage.get("cost_usd"):
|
|
68
|
+
return float(usage["cost_usd"])
|
|
69
|
+
try:
|
|
70
|
+
pin, pout = price_for(usage.get("model", ""), prices)
|
|
71
|
+
except KeyError:
|
|
72
|
+
return 0.0
|
|
73
|
+
return (usage.get("prompt_tokens", 0) * pin + usage.get("completion_tokens", 0) * pout) / 1e6
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _tokens(text: str) -> int:
|
|
77
|
+
return max(1, len(text) // 4) # chars/4 heuristic
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class CostEstimate:
|
|
82
|
+
total_usd: float
|
|
83
|
+
lines: list[str] = field(default_factory=list)
|
|
84
|
+
|
|
85
|
+
def __str__(self) -> str:
|
|
86
|
+
body = "\n".join(f" {ln}" for ln in self.lines)
|
|
87
|
+
return f"{body}\n ESTIMATED TOTAL (upper bound): ${self.total_usd:.2f}"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def estimate_optimize_cost(spec, train, dev, config) -> CostEstimate:
|
|
91
|
+
"""Upper-bound dry-run estimate for one `optimize()` call. Assumptions
|
|
92
|
+
(calibrated on the pilot): the evolved prompt is ~3x the seed prompt;
|
|
93
|
+
~1 reflection per 60 metric calls with ~3.5k in / 700 out tokens; caching
|
|
94
|
+
is ignored (it only lowers real spend)."""
|
|
95
|
+
from melvil.artifact import render_prompt
|
|
96
|
+
from melvil.optimize import seed_candidate_for
|
|
97
|
+
|
|
98
|
+
candidate = seed_candidate_for(spec)
|
|
99
|
+
seed_prompt = render_prompt(candidate, spec.label_names)
|
|
100
|
+
avg_text = sum(_tokens(e.text) for e in (train + dev)[:200]) / max(1, len((train + dev)[:200]))
|
|
101
|
+
per_call_in = 3 * _tokens(seed_prompt) + avg_text + 20
|
|
102
|
+
per_call_out = 8
|
|
103
|
+
n_calls = config.metric_calls
|
|
104
|
+
tin, tout = price_for(config.task_model, config.prices)
|
|
105
|
+
rin, rout = price_for(config.reflection_model, config.prices)
|
|
106
|
+
task_usd = n_calls * (per_call_in * tin + per_call_out * tout) / 1e6
|
|
107
|
+
n_refl = max(4, n_calls // 60)
|
|
108
|
+
refl_usd = n_refl * (3500 * rin + 700 * rout) / 1e6
|
|
109
|
+
total = task_usd + refl_usd
|
|
110
|
+
return CostEstimate(
|
|
111
|
+
total_usd=total,
|
|
112
|
+
lines=[
|
|
113
|
+
f"task model {config.task_model}: {n_calls} calls x "
|
|
114
|
+
f"~{per_call_in:.0f} in / {per_call_out} out tok = ${task_usd:.2f}",
|
|
115
|
+
f"reflection {config.reflection_model}: ~{n_refl} calls x "
|
|
116
|
+
f"~3.5k/700 tok = ${refl_usd:.2f}",
|
|
117
|
+
],
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def estimate_evaluate_cost(artifact, data, model: str, prices=None) -> CostEstimate:
|
|
122
|
+
per_call_in = _tokens(artifact.render()) + 40
|
|
123
|
+
pin, pout = price_for(model, prices)
|
|
124
|
+
usd = len(data) * (per_call_in * pin + 8 * pout) / 1e6
|
|
125
|
+
return CostEstimate(usd, [f"{model}: {len(data)} calls x ~{per_call_in} in tok = ${usd:.2f}"])
|