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 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
@@ -0,0 +1,6 @@
1
+ """Library identity. The name lives HERE and nowhere else in code — a rename is
2
+ this constant plus the package directory and the `[project]` table in
3
+ pyproject.toml (one commit)."""
4
+
5
+ LIBRARY_NAME = "melvil"
6
+ __version__ = "0.3.0"
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}"])