Repository navigation
feat(dspy): support gepa 0.1.4 β parallel proposals, reflection-cost budget, batch evaluation, checkpoint-safe adapter state #10161
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 7 commits
e7aef27
25c8686
3206671
6bd418b
7437166
f9ece3c
d08ca7d
76216b2
5f87079
f374913
aee1878
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,7 @@ | ||
| import logging | ||
| import random | ||
| from concurrent.futures import ThreadPoolExecutor | ||
| from copy import deepcopy | ||
| from typing import Any, Callable, Protocol, TypedDict | ||
|
|
||
| from gepa import EvaluationBatch, GEPAAdapter | ||
|
|
@@ -10,6 +12,7 @@ | |
| from dspy.adapters.chat_adapter import ChatAdapter | ||
| from dspy.adapters.types import History | ||
| from dspy.adapters.types.base_type import Type | ||
| from dspy.dsp.utils.settings import thread_local_overrides | ||
| from dspy.evaluate import Evaluate | ||
| from dspy.primitives import Example, Prediction | ||
| from dspy.primitives.code_interpreter import CodeInterpreterError | ||
|
|
@@ -83,6 +86,73 @@ def __call__( | |
| ... | ||
|
|
||
|
|
||
| def _stripped_lm_outputs(lm, x: str) -> list[str]: | ||
| raw_outputs = lm(x) | ||
| outputs = [] | ||
| for raw_output in raw_outputs: | ||
| if type(raw_output) == str: | ||
| outputs.append(raw_output) | ||
| elif type(raw_output) == dict: | ||
| if "text" not in raw_output: | ||
| raise KeyError("Missing 'text' field in the output from the base LM!") | ||
| outputs.append(raw_output["text"]) | ||
| else: | ||
| raise TypeError("Unexpected output type from the base LM! Expected str or dict") | ||
| return outputs | ||
|
|
||
|
|
||
| class TrackedReflectionLM: | ||
| """Reflection callable handed to `gepa.optimize` that exposes real cost totals. | ||
|
|
||
| gepa's `max_reflection_cost` stopper reads `total_cost` from the reflection LM; | ||
| plain callables get wrapped in a gepa `TrackingLM` that always reports 0.0, so | ||
| the stopper would never fire. Totals here are derived from the underlying | ||
| `dspy.LM.history`, which also captures reflection calls made by the instruction | ||
| and code proposers that never go through this callable. Requires LM history to | ||
| be enabled (it is unless `dspy.settings.disable_history` is set). | ||
|
|
||
| Totals are scoped to this instance's lifetime: the sums present in `lm.history` | ||
| at construction time are snapshotted as a baseline and subtracted back out, so | ||
| reusing a `reflection_lm` across multiple `compile()` calls doesn't carry over | ||
| spend from a previous run (`DspyAdapter.__init__` constructs a fresh | ||
| `TrackedReflectionLM` per compile). This does NOT protect against sharing the | ||
| reflection LM instance as both the task LM and the reflection LM within the | ||
| same run: concurrent task-LM calls append to the same `history` list, so their | ||
| cost/usage would still be counted toward the reflection budget. | ||
| """ | ||
|
|
||
| def __init__(self, lm): | ||
| self.lm = lm | ||
| self._baseline_cost = self._sum_cost(lm.history) | ||
| self._baseline_tokens_in = self._sum_usage(lm.history, "prompt_tokens") | ||
| self._baseline_tokens_out = self._sum_usage(lm.history, "completion_tokens") | ||
|
|
||
| def __call__(self, x: str) -> str: | ||
| return _stripped_lm_outputs(self.lm, x)[0] | ||
|
|
||
| @staticmethod | ||
| def _sum_cost(history) -> float: | ||
| return sum(entry.get("cost") or 0.0 for entry in history if isinstance(entry, dict)) | ||
|
|
||
| @staticmethod | ||
| def _sum_usage(history, key: str) -> int: | ||
| return sum((entry.get("usage") or {}).get(key) or 0 for entry in history if isinstance(entry, dict)) | ||
|
|
||
| @property | ||
| def total_cost(self) -> float: | ||
| # max(0, ...) also tolerates history eviction at settings.max_history_size: if entries this | ||
| # instance counted toward the baseline get evicted, the raw sum can dip below the baseline. | ||
| return max(0.0, self._sum_cost(self.lm.history) - self._baseline_cost) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a run makes more calls than Useful? React with πΒ / π.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 76216b2 β
greptile-apps[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
| @property | ||
| def total_tokens_in(self) -> int: | ||
| return max(0, self._sum_usage(self.lm.history, "prompt_tokens") - self._baseline_tokens_in) | ||
|
|
||
| @property | ||
| def total_tokens_out(self) -> int: | ||
| return max(0, self._sum_usage(self.lm.history, "completion_tokens") - self._baseline_tokens_out) | ||
|
|
||
|
|
||
| class DspyAdapter(GEPAAdapter[Example, TraceData, Prediction]): | ||
| def __init__( | ||
| self, | ||
|
|
@@ -106,6 +176,7 @@ def __init__( | |
| self.add_format_failure_as_feedback = add_format_failure_as_feedback | ||
| self.rng = rng or random.Random(0) | ||
| self.reflection_lm = reflection_lm | ||
| self.tracked_reflection_lm = TrackedReflectionLM(reflection_lm) if reflection_lm is not None else None | ||
| self.custom_instruction_proposer = custom_instruction_proposer | ||
| self.warn_on_score_mismatch = warn_on_score_mismatch | ||
| self.reflection_minibatch_size = reflection_minibatch_size | ||
|
|
@@ -268,6 +339,46 @@ def evaluate(self, batch, candidate, capture_traces=False): | |
| scores = [s["score"] if hasattr(s, "score") else s for s in scores] | ||
| return EvaluationBatch(outputs=outputs, scores=scores, trajectories=None) | ||
|
|
||
| def batch_evaluate(self, items): | ||
| """Evaluate multiple (candidate, batch) pairs concurrently. | ||
|
|
||
| gepa's multi-proposal iterations (`sampling_strategy`) evaluate several | ||
| candidates per step; its fallback runs them one at a time, and each | ||
| `evaluate` call only parallelizes across the (small) minibatch. Candidates | ||
| are independent, so run them on worker threads. dspy settings overrides are | ||
| thread-local and must be copied into each worker. | ||
| """ | ||
| if len(items) <= 1: | ||
| return [self.evaluate(batch, candidate, capture_traces=True) for candidate, batch in items] | ||
|
|
||
| parent_overrides = thread_local_overrides.get().copy() | ||
|
|
||
| def _evaluate_pair(pair): | ||
| candidate, batch = pair | ||
| new_overrides = {**thread_local_overrides.get(), **parent_overrides} | ||
| if new_overrides.get("usage_tracker"): | ||
| # Mirror dspy's ParallelExecutor: deep-copy the usage tracker per worker so each | ||
| # thread tracks its own usage instead of contending over one shared tracker. | ||
| new_overrides["usage_tracker"] = deepcopy(new_overrides["usage_tracker"]) | ||
| token = thread_local_overrides.set(new_overrides) | ||
| try: | ||
| return self.evaluate(batch, candidate, capture_traces=True) | ||
| finally: | ||
| thread_local_overrides.reset(token) | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
|
|
||
| with ThreadPoolExecutor(max_workers=min(len(items), 8)) as executor: | ||
| return list(executor.map(_evaluate_pair, items)) | ||
|
|
||
| def get_adapter_state(self) -> dict[str, Any]: | ||
| """Snapshot adapter state into gepa's checkpoint (gepa persists it via pickle).""" | ||
| return {"rng_state": self.rng.getstate()} | ||
|
|
||
| def set_adapter_state(self, state: dict[str, Any]) -> None: | ||
| """Restore adapter state on checkpoint resume.""" | ||
| rng_state = state.get("rng_state") | ||
| if rng_state is not None: | ||
| self.rng.setstate(rng_state) | ||
|
|
||
| def make_reflective_dataset( | ||
| self, candidate, eval_batch, components_to_update | ||
| ) -> dict[str, list[ReflectiveExample]]: | ||
|
|
@@ -402,17 +513,5 @@ def make_reflective_dataset( | |
| # Always return strings from the LM outputs | ||
| # Even when it returns a dict with e.g., "text" and "reasoning" fields | ||
| def stripped_lm_call(self, x: str) -> list[str]: | ||
| raw_outputs = self.reflection_lm(x) | ||
| outputs = [] | ||
| for raw_output in raw_outputs: | ||
| if type(raw_output) == str: | ||
| outputs.append(raw_output) | ||
| elif type(raw_output) == dict: | ||
| if "text" not in raw_output: | ||
| raise KeyError("Missing 'text' field in the output from the base LM!") | ||
| outputs.append(raw_output["text"]) | ||
| else: | ||
| raise TypeError("Unexpected output type from the base LM! Expected str or dict") | ||
|
|
||
| return outputs | ||
| return _stripped_lm_outputs(self.reflection_lm, x) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
reflection_lmuses DSPy's typed LM contract or runs withexperimental=True, its history containsLMHistoryEntryobjects, which implementMappingbut are notdictinstances. This filter therefore ignores every reflection call and leavestotal_costat zero, so GEPA'smax_reflection_coststopper never fires and can allow unbounded spend. Accept mapping-like entries rather than only concrete dictionaries.Useful? React with πΒ / π.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 76216b2 β the filter is now
isinstance(entry, Mapping), so typedLMHistoryEntryobjects count toward the budget. Regression test:test_tracked_reflection_lm_counts_mapping_history_entries.