diff --git a/dspy/teleprompt/gepa/gepa.py b/dspy/teleprompt/gepa/gepa.py index f774a5f5ad..a9f99d102e 100644 --- a/dspy/teleprompt/gepa/gepa.py +++ b/dspy/teleprompt/gepa/gepa.py @@ -3,12 +3,18 @@ import math import random from dataclasses import dataclass -from typing import Any, Literal, Optional, Protocol, Union +from typing import TYPE_CHECKING, Any, Literal, Optional, Protocol, Union from gepa import GEPAResult from gepa.core.adapter import ProposalFn from gepa.proposer.reflective_mutation.base import ReflectionComponentSelector +if TYPE_CHECKING: + from gepa.strategies.acceptance import AcceptanceCriterion + from gepa.strategies.proposal_sampling import SamplingStrategy + from gepa.strategies.proposal_selection import SelectionStrategy + +import dspy from dspy.clients.lm import LM from dspy.primitives import Example, Module, Prediction from dspy.teleprompt.gepa.gepa_utils import DspyAdapter, DSPyTrace, PredictorFeedbackFn, ScoreWithFeedback @@ -276,6 +282,30 @@ def metric( using LLM-driven selection logic based on optimization state and trajectories. See [gepa component selectors](https://github.com/gepa-ai/gepa/blob/main/src/gepa/strategies/component_selector.py) for available built-in selectors and the ReflectionComponentSelector protocol for implementing custom selectors. + sampling_strategy: Controls how many (parent, minibatch) proposal tasks GEPA samples per iteration. + Accepts a gepa `SamplingStrategy` such as `SameParentSampling(n)`, `IndependentSampling(n)`, or + `PxNSampling(p, n)`. Defaults to classic single-mutation GEPA. Multi-proposal iterations + evaluate up to 8 candidates concurrently (see `DspyAdapter.batch_evaluate`), each using up to + `num_threads` threads, so effective LM concurrency is multiplied accordingly — size + `num_threads` and provider rate limits with this in mind. + selection_strategy: Decides which of an iteration's improving proposals enter the candidate pool. + Accepts a gepa `SelectionStrategy` such as `BestImprovement` or `TopKImprovements(k)`. Defaults + to accepting all improvements. + acceptance_criterion: 'strict_improvement' (default), 'improvement_or_equal', or a custom gepa + `AcceptanceCriterion` instance controlling when a mutated candidate is considered an improvement. + max_reflection_cost: Maximum cumulative reflection-LM spend in USD before GEPA stops. Requires + `reflection_lm`. Cost is read from the reflection LM's call history, so it reflects actual + litellm-reported costs. Requires LM history to be enabled (`dspy.settings.disable_history` + must be False and `dspy.settings.max_history_size` must be nonzero); `compile()` raises if + history is disabled. Spend is tallied cumulatively as calls happen, so a small + `max_history_size` is fine — except that a custom `instruction_proposer` making more direct + LM calls than the history window holds within a single proposal step can have the excess + evicted before it is tallied. This complements, but does not replace, the metric-call budget: + one of `auto`, `max_full_evals`, or `max_metric_calls` is still required. + wandb_attach_existing: Log into the already-active wandb run instead of creating one. Use when GEPA + is embedded in a training loop that owns the run. + mlflow_attach_existing: Log into the already-active MLflow run instead of creating one. + tracking_key_prefix: Prefix applied to metric keys logged to wandb/MLflow, useful when sharing a run. add_format_failure_as_feedback: Whether to add format failures as feedback. Default is False. use_merge: Whether to use merge-based optimization. Default is True. max_merge_invocations: The maximum number of merge invocations to perform. Default is 5. @@ -366,6 +396,12 @@ def __init__( add_format_failure_as_feedback: bool = False, instruction_proposer: "ProposalFn | None" = None, component_selector: "ReflectionComponentSelector | str" = "round_robin", + # Proposal strategy configuration (gepa >= 0.1.4) + sampling_strategy: "SamplingStrategy | None" = None, + selection_strategy: "SelectionStrategy | None" = None, + acceptance_criterion: "AcceptanceCriterion | Literal['strict_improvement', 'improvement_or_equal']" = "strict_improvement", + # Budget + max_reflection_cost: float | None = None, # Merge-based configuration use_merge: bool = True, max_merge_invocations: int | None = 5, @@ -382,6 +418,9 @@ def __init__( track_best_outputs: bool = False, warn_on_score_mismatch: bool = True, use_mlflow: bool = False, + wandb_attach_existing: bool = False, + mlflow_attach_existing: bool = False, + tracking_key_prefix: str = "", # Reproducibility seed: int | None = 0, # GEPA passthrough kwargs @@ -418,6 +457,16 @@ def __init__( "Reflection LM is used by GEPA to reflect on the behavior of the program and propose new instructions, and will benefit from a strong model. " ) + if max_reflection_cost is not None and reflection_lm is None: + raise ValueError("max_reflection_cost requires reflection_lm to be set, so GEPA can track reflection spend.") + self.sampling_strategy = sampling_strategy + self.selection_strategy = selection_strategy + self.acceptance_criterion = acceptance_criterion + self.max_reflection_cost = max_reflection_cost + self.wandb_attach_existing = wandb_attach_existing + self.mlflow_attach_existing = mlflow_attach_existing + self.tracking_key_prefix = tracking_key_prefix + self.reflection_lm = reflection_lm self.skip_perfect_score = skip_perfect_score self.add_format_failure_as_feedback = add_format_failure_as_feedback @@ -600,23 +649,42 @@ def feedback_fn( for path, flex in flex_submodules.items(): seed_candidate[path] = flex.module_src + # TrackedReflectionLM derives max_reflection_cost's spend totals from the reflection LM's + # call history. If history is disabled (or capped at 0 entries), those totals stay 0.0 + # forever and the stopper never fires, silently allowing unbounded spend. Checked here + # (not __init__) because settings can change between construction and compile. + if self.max_reflection_cost is not None and ( + dspy.settings.disable_history or not dspy.settings.max_history_size + ): + raise ValueError( + "max_reflection_cost requires LM call history to be enabled, since reflection spend is " + "computed from the reflection LM's history. Currently " + f"dspy.settings.disable_history={dspy.settings.disable_history} and " + f"dspy.settings.max_history_size={dspy.settings.max_history_size}. Either unset " + "max_reflection_cost, or ensure disable_history is False and max_history_size is nonzero." + ) + gepa_result: GEPAResult = optimize( seed_candidate=seed_candidate, trainset=trainset, valset=valset, adapter=adapter, # Reflection-based configuration - reflection_lm=(lambda x: adapter.stripped_lm_call(x)[0]) if self.reflection_lm is not None else None, + reflection_lm=adapter.tracked_reflection_lm, candidate_selection_strategy=self.candidate_selection_strategy, skip_perfect_score=self.skip_perfect_score, reflection_minibatch_size=self.reflection_minibatch_size, module_selector=self.component_selector, perfect_score=self.perfect_score, + sampling_strategy=self.sampling_strategy, + selection_strategy=self.selection_strategy, + acceptance_criterion=self.acceptance_criterion, # Merge-based configuration use_merge=self.use_merge, max_merge_invocations=self.max_merge_invocations, # Budget max_metric_calls=self.max_metric_calls, + max_reflection_cost=self.max_reflection_cost, # Logging logger=LoggerAdapter(logger), run_dir=self.log_dir, @@ -625,6 +693,9 @@ def feedback_fn( wandb_init_kwargs=self.wandb_init_kwargs, use_mlflow=self.use_mlflow, track_best_outputs=self.track_best_outputs, + wandb_attach_existing=self.wandb_attach_existing, + mlflow_attach_existing=self.mlflow_attach_existing, + tracking_key_prefix=self.tracking_key_prefix, display_progress_bar=True, raise_on_exception=True, # Reproducibility diff --git a/dspy/teleprompt/gepa/gepa_utils.py b/dspy/teleprompt/gepa/gepa_utils.py index e88f969ef8..c59efb7779 100644 --- a/dspy/teleprompt/gepa/gepa_utils.py +++ b/dspy/teleprompt/gepa/gepa_utils.py @@ -1,5 +1,9 @@ import logging import random +import threading +from collections.abc import Mapping +from concurrent.futures import ThreadPoolExecutor +from copy import deepcopy from typing import Any, Callable, Protocol, TypedDict from gepa import EvaluationBatch, GEPAAdapter @@ -10,6 +14,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 @@ -22,6 +27,7 @@ propose_code, rebind_flex_code, ) +from dspy.utils.callback_context import _bind_active_call_id logger = logging.getLogger(__name__) @@ -83,6 +89,106 @@ 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 accumulate from the underlying + `dspy.LM.history` — entries are tallied once (keyed by their `uuid`) on every + call through this wrapper or `DspyAdapter.stripped_lm_call`, and on every + property read — so spend survives history eviction at + `settings.max_history_size`, and reflection calls made by the instruction and + code proposers that never go through this callable are still counted. Requires + LM history to be enabled (it is unless `dspy.settings.disable_history` is set); + entries without a `uuid` (real dspy history entries always have one) are + ignored, as they cannot be safely de-duplicated. + + Totals are scoped to this instance's lifetime: entries already in `lm.history` + at construction are marked as counted without contributing, 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). Two residual caveats: sharing the reflection LM instance as both + the task LM and the reflection LM counts task spend toward the reflection + budget, and a custom proposer that makes more direct LM calls than + `settings.max_history_size` between two syncs can have the excess evicted + before it is tallied. + """ + + def __init__(self, lm): + self.lm = lm + self._counted_uuids: set[str] = set() + self._total_cost = 0.0 + self._total_tokens_in = 0 + self._total_tokens_out = 0 + self._lock = threading.Lock() + with self._lock: + for entry in self._entries(): + uid = entry.get("uuid") + if uid is not None: + self._counted_uuids.add(uid) + + def __call__(self, x: str) -> str: + outputs = _stripped_lm_outputs(self.lm, x) + self._sync() + return outputs[0] + + def _entries(self) -> list: + return [entry for entry in self.lm.history if isinstance(entry, Mapping)] + + def _sync(self) -> None: + """Tally history entries not yet counted, then prune uuids evicted from the window. + + Evicted uuids can never reappear (uuid4), so the set stays bounded by + `settings.max_history_size` without ever double-counting. + """ + with self._lock: + window_uuids = set() + for entry in self._entries(): + uid = entry.get("uuid") + if uid is None: + continue + window_uuids.add(uid) + if uid in self._counted_uuids: + continue + self._counted_uuids.add(uid) + self._total_cost += entry.get("cost") or 0.0 + usage = entry.get("usage") or {} + self._total_tokens_in += usage.get("prompt_tokens") or 0 + self._total_tokens_out += usage.get("completion_tokens") or 0 + self._counted_uuids &= window_uuids + + @property + def total_cost(self) -> float: + self._sync() + return self._total_cost + + @property + def total_tokens_in(self) -> int: + self._sync() + return self._total_tokens_in + + @property + def total_tokens_out(self) -> int: + self._sync() + return self._total_tokens_out + + class DspyAdapter(GEPAAdapter[Example, TraceData, Prediction]): def __init__( self, @@ -106,6 +212,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 +375,47 @@ 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 and + the active callback call ID are thread-local and must be copied into each + worker, or callbacks emitted there would start new root traces. + """ + 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) + + with ThreadPoolExecutor(max_workers=min(len(items), 8)) as executor: + return list(executor.map(_bind_active_call_id(_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 +550,10 @@ 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") - + outputs = _stripped_lm_outputs(self.reflection_lm, x) + # Tally the call into the reflection budget while its history entry is + # guaranteed to still be inside the (possibly small) retention window. + if self.tracked_reflection_lm is not None: + self.tracked_reflection_lm._sync() return outputs diff --git a/pyproject.toml b/pyproject.toml index 96e0f76901..ced5b8f065 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ dependencies = [ "anyio", "cachetools>=5.5.0", "cloudpickle>=3.1.2", - "gepa[dspy]==0.1.1", + "gepa[dspy]==0.1.4", ] [project.optional-dependencies] diff --git a/tests/teleprompt/test_gepa.py b/tests/teleprompt/test_gepa.py index 64c3a274fd..5e6174ef99 100644 --- a/tests/teleprompt/test_gepa.py +++ b/tests/teleprompt/test_gepa.py @@ -1,5 +1,7 @@ import json +import random import threading +from collections.abc import Mapping from typing import Any from unittest import mock @@ -632,3 +634,337 @@ def test_track_best_outputs_result_structure(): assert isinstance(entries, list) for cand_idx, output in entries: assert isinstance(cand_idx, int) + + +class _StubReflectionLM: + """Stub with the two things TrackedReflectionLM reads: history and __call__. + + Entries carry a uuid like real dspy history entries (both the dict path and the + typed LMHistoryEntry path always populate one). + """ + + def __init__(self): + self.history = [] + self._n = 0 + + def __call__(self, x): + self._n += 1 + self.history.append( + {"cost": 0.25, "usage": {"prompt_tokens": 10, "completion_tokens": 5}, "uuid": f"stub-{self._n}"} + ) + return ["reflection output"] + + +def test_tracked_reflection_lm_exposes_cost_totals(): + from dspy.teleprompt.gepa.gepa_utils import TrackedReflectionLM + + tracked = TrackedReflectionLM(_StubReflectionLM()) + assert tracked.total_cost == 0.0 + + out = tracked("some prompt") + assert out == "reflection output" + assert tracked.total_cost == 0.25 + assert tracked.total_tokens_in == 10 + assert tracked.total_tokens_out == 5 + + # Cache hits record cost=None; they must not break the sum. + tracked.lm.history.append({"cost": None, "usage": {}, "uuid": "cache-1"}) + assert tracked.total_cost == 0.25 + + +def test_tracked_reflection_lm_scopes_baseline_to_construction(): + """A reflection_lm reused across compile() calls (or shared with the task LM) must not have + its prior history counted toward this run's budget: totals are scoped to entries appended + after construction.""" + from dspy.teleprompt.gepa.gepa_utils import TrackedReflectionLM + + stub = _StubReflectionLM() + # Pre-populate history as if this LM was already used (e.g. a prior compile() call, or as + # the task LM) before TrackedReflectionLM wraps it. + stub.history.append({"cost": 1.0, "usage": {"prompt_tokens": 100, "completion_tokens": 50}, "uuid": "pre-1"}) + stub.history.append({"cost": 2.0, "usage": {"prompt_tokens": 200, "completion_tokens": 75}, "uuid": "pre-2"}) + + tracked = TrackedReflectionLM(stub) + assert tracked.total_cost == 0.0 + assert tracked.total_tokens_in == 0 + assert tracked.total_tokens_out == 0 + + out = tracked("some prompt") + assert out == "reflection output" + # Only the new entry (appended after construction) counts. + assert tracked.total_cost == 0.25 + assert tracked.total_tokens_in == 10 + assert tracked.total_tokens_out == 5 + + +class _MappingHistoryEntry(Mapping): + """Mimics dspy's typed LMHistoryEntry: a Mapping that is not a dict.""" + + def __init__(self, uid, cost, tokens_in, tokens_out): + self._data = { + "uuid": uid, + "cost": cost, + "usage": {"prompt_tokens": tokens_in, "completion_tokens": tokens_out}, + } + + def __getitem__(self, key): + return self._data[key] + + def __iter__(self): + return iter(self._data) + + def __len__(self): + return len(self._data) + + +def test_tracked_reflection_lm_counts_mapping_history_entries(): + """dspy's typed LM path appends LMHistoryEntry objects (Mapping, not dict); they must + count toward the reflection budget.""" + from dspy.teleprompt.gepa.gepa_utils import TrackedReflectionLM + + stub = _StubReflectionLM() + tracked = TrackedReflectionLM(stub) + stub.history.append(_MappingHistoryEntry("typed-1", 0.5, 20, 10)) + stub.history.append(_MappingHistoryEntry("typed-2", 0.25, 5, 5)) + + assert tracked.total_cost == 0.75 + assert tracked.total_tokens_in == 25 + assert tracked.total_tokens_out == 15 + + +def test_tracked_reflection_lm_survives_history_eviction(): + """With a small settings.max_history_size, update_history evicts old entries; the budget + must accumulate spend cumulatively, not recompute it from the surviving window.""" + from dspy.teleprompt.gepa.gepa_utils import TrackedReflectionLM + + class _EvictingStubLM: + def __init__(self, window): + self.history = [] + self._window = window + self._n = 0 + + def __call__(self, x): + self._n += 1 + if len(self.history) >= self._window: + self.history.pop(0) + self.history.append( + {"cost": 0.6, "usage": {"prompt_tokens": 10, "completion_tokens": 5}, "uuid": f"e{self._n}"} + ) + return ["out"] + + tracked = TrackedReflectionLM(_EvictingStubLM(window=1)) + for _ in range(3): + tracked("prompt") + + assert tracked.total_cost == pytest.approx(1.8) + assert tracked.total_tokens_in == 30 + assert tracked.total_tokens_out == 15 + + +def test_batch_evaluate_preserves_active_call_id(): + """Callbacks emitted inside batch_evaluate's worker threads must be children of the + enclosing trace, not new roots: the submitting thread's ACTIVE_CALL_ID (a ContextVar, + not part of thread_local_overrides) has to be bound into each worker.""" + from gepa import EvaluationBatch + + from dspy.teleprompt.gepa.gepa_utils import DspyAdapter + from dspy.utils.callback_context import ACTIVE_CALL_ID + + adapter = DspyAdapter( + student_module=SimpleModule("input -> output"), + metric_fn=simple_metric, + feedback_map={}, + failure_score=0.0, + ) + + seen = [] + + def fake_evaluate(batch, candidate, capture_traces=False): + seen.append(ACTIVE_CALL_ID.get()) + return EvaluationBatch(outputs=[], scores=[], trajectories=[]) + + adapter.evaluate = fake_evaluate + batch = [Example(input="a", output="b").with_inputs("input")] + + token = ACTIVE_CALL_ID.set("parent-call-id") + try: + adapter.batch_evaluate([({"predictor": "x"}, batch), ({"predictor": "y"}, batch)]) + finally: + ACTIVE_CALL_ID.reset(token) + + assert seen == ["parent-call-id", "parent-call-id"] + + +def test_gepa_forwards_v014_args_to_gepa_optimize(monkeypatch): + from types import SimpleNamespace + + import gepa as gepa_pkg + from gepa.strategies.proposal_sampling import SameParentSampling + from gepa.strategies.proposal_selection import BestImprovement + + captured = {} + + def fake_optimize(seed_candidate=None, **kwargs): + captured.update(kwargs) + return SimpleNamespace(best_candidate=seed_candidate) + + monkeypatch.setattr(gepa_pkg, "optimize", fake_optimize) + + lm = DummyLM([{"output": "b"}] * 10) + dspy.settings.configure(lm=lm) + + sampling = SameParentSampling(2) + selection = BestImprovement() + optimizer = dspy.GEPA( + metric=simple_metric, + max_metric_calls=5, + reflection_lm=DummyLM([{"new_instruction": "x"}] * 10), + sampling_strategy=sampling, + selection_strategy=selection, + acceptance_criterion="improvement_or_equal", + max_reflection_cost=12.5, + wandb_attach_existing=True, + mlflow_attach_existing=True, + tracking_key_prefix="gepa/", + ) + student = SimpleModule("input -> output") + trainset = [Example(input="a", output="b").with_inputs("input")] + optimizer.compile(student, trainset=trainset) + + assert captured["sampling_strategy"] is sampling + assert captured["selection_strategy"] is selection + assert captured["acceptance_criterion"] == "improvement_or_equal" + assert captured["max_reflection_cost"] == 12.5 + assert captured["wandb_attach_existing"] is True + assert captured["mlflow_attach_existing"] is True + assert captured["tracking_key_prefix"] == "gepa/" + assert hasattr(captured["reflection_lm"], "total_cost") + + +def test_gepa_max_reflection_cost_requires_reflection_lm(): + with pytest.raises(ValueError, match="max_reflection_cost"): + dspy.GEPA( + metric=simple_metric, + max_metric_calls=5, + instruction_proposer=lambda candidate, reflective_dataset, components_to_update: {}, + max_reflection_cost=1.0, + ) + + +def test_gepa_max_reflection_cost_requires_history_enabled(monkeypatch): + """max_reflection_cost derives spend totals from LM history; if history is disabled the + stopper can never fire, silently allowing unbounded spend. compile() must raise instead.""" + import gepa as gepa_pkg + + def fake_optimize(seed_candidate=None, **kwargs): + pytest.fail("gepa.optimize should not be called when the history guard should have raised") + + monkeypatch.setattr(gepa_pkg, "optimize", fake_optimize) + + task_lm = DummyLM([{"output": "b"}] * 10) + dspy.settings.configure(lm=task_lm) + + optimizer = dspy.GEPA( + metric=simple_metric, + reflection_lm=DummyLM([{"new_instruction": "x"}] * 10), + max_reflection_cost=1.0, + max_metric_calls=5, + ) + student = SimpleModule("input -> output") + trainset = [Example(input="a", output="b").with_inputs("input")] + + with dspy.context(disable_history=True): + with pytest.raises(ValueError, match="history"): + optimizer.compile(student, trainset=trainset) + + +def test_batch_evaluate_matches_sequential_evaluate(): + from dspy.teleprompt.gepa.gepa_utils import DspyAdapter + + lm = DummyLM([{"output": "blue"}] * 20) + dspy.settings.configure(lm=lm) + + batch = [ + Example(input="What color is the sky?", output="blue").with_inputs("input"), + Example(input="What color is grass?", output="green").with_inputs("input"), + ] + adapter = DspyAdapter( + student_module=SimpleModule("input -> output"), + metric_fn=simple_metric, + feedback_map={}, + failure_score=0.0, + num_threads=2, + ) + cand_a = {"predictor": "Answer the question."} + cand_b = {"predictor": "Answer concisely."} + + sequential = [adapter.evaluate(batch, c, capture_traces=True) for c in (cand_a, cand_b)] + batched = adapter.batch_evaluate([(cand_a, batch), (cand_b, batch)]) + + assert len(batched) == 2 + for seq, bat in zip(sequential, batched): + assert bat.scores == seq.scores + assert bat.trajectories is not None + + +def test_batch_evaluate_single_item_stays_sequential(): + from dspy.teleprompt.gepa.gepa_utils import DspyAdapter + + lm = DummyLM([{"output": "blue"}] * 10) + dspy.settings.configure(lm=lm) + + adapter = DspyAdapter( + student_module=SimpleModule("input -> output"), + metric_fn=simple_metric, + feedback_map={}, + failure_score=0.0, + ) + batch = [Example(input="What color is the sky?", output="blue").with_inputs("input")] + results = adapter.batch_evaluate([({"predictor": "Answer."}, batch)]) + assert len(results) == 1 + assert results[0].trajectories is not None + + +def test_batch_evaluate_propagates_thread_local_overrides(): + from dspy.teleprompt.gepa.gepa_utils import DspyAdapter + + global_lm = DummyLM([{"output": "blue"}] * 10) + override_lm = DummyLM([{"output": "red"}] * 10) + dspy.settings.configure(lm=global_lm) + + batch = [Example(input="What color?", output="red").with_inputs("input")] + adapter = DspyAdapter( + student_module=SimpleModule("input -> output"), + metric_fn=simple_metric, + feedback_map={}, + failure_score=0.0, + ) + cand_a = {"predictor": "Answer the question."} + cand_b = {"predictor": "Answer concisely."} + + # The override is only visible inside the worker threads if batch_evaluate + # copies the caller's thread-local overrides across the thread hop. + with dspy.context(lm=override_lm): + results = adapter.batch_evaluate([(cand_a, batch), (cand_b, batch)]) + + assert len(results) == 2 + for res in results: + assert all(res.scores), f"expected override LM answers to score truthy, got {res.scores}" + + +def test_adapter_state_round_trips_rng(): + from dspy.teleprompt.gepa.gepa_utils import DspyAdapter + + adapter = DspyAdapter( + student_module=SimpleModule("input -> output"), + metric_fn=simple_metric, + feedback_map={}, + failure_score=0.0, + rng=random.Random(42), + ) + saved = adapter.get_adapter_state() + first_draw = adapter.rng.random() + + adapter.rng.random() # advance further so restore actually rewinds + adapter.set_adapter_state(saved) + assert adapter.rng.random() == first_draw diff --git a/uv.lock b/uv.lock index 4b8996b523..584458a052 100644 --- a/uv.lock +++ b/uv.lock @@ -955,7 +955,7 @@ requires-dist = [ { name = "deno", marker = "extra == 'deno'", specifier = ">=2.4.5,<3.0.0" }, { name = "diskcache", specifier = ">=5.6.0" }, { name = "fastapi", marker = "python_full_version < '3.14' and sys_platform != 'win32' and extra == 'dev'", specifier = "<0.140.7" }, - { name = "gepa", extras = ["dspy"], specifier = "==0.1.1" }, + { name = "gepa", extras = ["dspy"], specifier = "==0.1.4" }, { name = "json-repair", specifier = ">=0.54.2" }, { name = "langchain-core", marker = "extra == 'langchain'", specifier = ">=0.3.0" }, { name = "langchain-core", marker = "extra == 'test-extras'", specifier = ">=0.3.0" }, @@ -1285,11 +1285,11 @@ wheels = [ [[package]] name = "gepa" -version = "0.1.1" +version = "0.1.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/62/10f5a8f24c075e3b64f952be73ba8e15f0055584bbcdf9ce48d754a36679/gepa-0.1.1.tar.gz", hash = "sha256:643fda01c23de4c9f01306e01305dd69facc29bcb34ad59e4cd07e6621d34aa1", size = 272251, upload-time = "2026-03-16T10:17:53.131Z" } +sdist = { url = "https://files.pythonhosted.org/packages/95/56/925779e5690971f1b022f7d107caf015c33ec09560261273ec137e23a8f2/gepa-0.1.4.tar.gz", hash = "sha256:6dd153a676ae5481764860d19286a9c0e8ddb5ef70d7f13044faf24978bdb6b8", size = 351343, upload-time = "2026-07-15T14:53:59.929Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/b7/8c72dedbb950d88a6f64588fcbc590d2a21e2b9f19b36aa6c5016c54ec75/gepa-0.1.1-py3-none-any.whl", hash = "sha256:71ead7c591eafcc727b83509cdc4182f20264800a6ddf8520d61419daeb47466", size = 244246, upload-time = "2026-03-16T10:17:51.922Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/5b3a281cfd9caaa9e68349b434cf27f1ca448003ee0067a1ae2184dc52d1/gepa-0.1.4-py3-none-any.whl", hash = "sha256:12b971039599625c156d2231f6d72a29c31a22e9c237689459b5f1a3c353f532", size = 290167, upload-time = "2026-07-15T14:53:58.422Z" }, ] [[package]] @@ -1373,14 +1373,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/67/07ecd6d85c0f253363cbc4ac9e7dd048ca571a267fbccfea084d6009ac3b/greenlet-3.5.5-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:816230f469381ad0a43abc9fa8dda5a699e32fb78958dde32ded93213b70a667", size = 292971, upload-time = "2026-08-10T13:28:09.837Z" }, { url = "https://files.pythonhosted.org/packages/24/35/426733bc24247ee17bb76df90f550c299ff5f8572b2bdd7cacb5c53fd994/greenlet-3.5.5-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5433cf291e0ef9114bd14d0d824db6e5e4a43033234bca48181a9597acca07b", size = 609290, upload-time = "2026-08-10T14:14:32.273Z" }, { url = "https://files.pythonhosted.org/packages/9c/dc/17d3a5acceb2fd0bbc9682a228117b719cdfb68085e6dbb33fa325755f27/greenlet-3.5.5-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:19d59f068887d8c5907fc177f27683413ace3011b6ed646c0b309266e74a6502", size = 622650, upload-time = "2026-08-10T14:27:22.004Z" }, + { url = "https://files.pythonhosted.org/packages/74/3f/b31e6bd6adb8f80492efb6a47e8f9cd0e7335db5aac2795b1876d5afcfee/greenlet-3.5.5-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:86c5113d698cb8d927b2750bb1f1d59eefe3a37e0e0217491aee29a7f84ef52c", size = 629560, upload-time = "2026-08-10T14:30:04.733Z" }, { url = "https://files.pythonhosted.org/packages/51/91/00b3c0566316c6f383ea16ee05388ec4f57f6e0afa49e72ae471eda8c47f/greenlet-3.5.5-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff00e12102358292087274dfb1669132387ff6e7920ebf9d85f4826ce0d3a56", size = 622819, upload-time = "2026-08-10T13:40:46.562Z" }, + { url = "https://files.pythonhosted.org/packages/18/5e/58c561efde575c4ca070030e2e0513e8d1b07b76d8a2d84a9bcef1becd42/greenlet-3.5.5-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:c69bed34470abfcd456984fdadaa18e62169af4480335c45f3c32d1d9c12e638", size = 425489, upload-time = "2026-08-10T14:29:59.768Z" }, { url = "https://files.pythonhosted.org/packages/3c/78/52de4f7ac9152ad1dbc8f437895c1e573d30ee1e1427e0f91a280e2417b6/greenlet-3.5.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:523bb8e27614d77101ea7a8cf59f8d91219b72d5c29f6a038c92b50828bfa8d0", size = 1582164, upload-time = "2026-08-10T14:15:02.645Z" }, { url = "https://files.pythonhosted.org/packages/f6/ff/c3855a00c2417e8f61c7b9f0bc4f8f599c6928f5c15681ad251e78a91e05/greenlet-3.5.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f1e2db190db51c17433eee424803818cf0670bf049d9cfe0dd07be111d1aa7c4", size = 1648807, upload-time = "2026-08-10T13:40:27.471Z" }, { url = "https://files.pythonhosted.org/packages/70/21/d29ff6a79dbd1ec1fe74c155d25d4c5a85e221d6b9ffc2cc7a709e7c8c39/greenlet-3.5.5-cp310-cp310-win_amd64.whl", hash = "sha256:740e544169527b82695ce76af2f7ad6f030904658f2f3921a1d245771fb88cfc", size = 322832, upload-time = "2026-08-10T13:28:10.85Z" }, { url = "https://files.pythonhosted.org/packages/4e/a3/07297917485ee2ca85bc3c8dc6ed85ad3fffcf424047fba62671dba68e97/greenlet-3.5.5-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:be63afcbbccfad3dd95a1ba12ada84dab2ef32031973d80b5b92df67fa763a61", size = 294165, upload-time = "2026-08-10T13:25:17.987Z" }, { url = "https://files.pythonhosted.org/packages/db/51/6f732f9314cda54c5fd48a7620c7160f4f286967e8045ad94b9d66ce80b7/greenlet-3.5.5-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a268024ce2d7d2b04694bf1594058981a9fa663d1df4b762dee499211ed7c1c", size = 613610, upload-time = "2026-08-10T14:14:33.829Z" }, { url = "https://files.pythonhosted.org/packages/d8/c0/b27589e25d220289edcd4d582b2b17b83058d1a56d53d971b6ea1a34f10d/greenlet-3.5.5-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35cbb8bf55ace57fbccb4fb8622c4521713acd8691e77f4696d416ea7ca527da", size = 625481, upload-time = "2026-08-10T14:27:23.647Z" }, + { url = "https://files.pythonhosted.org/packages/39/82/5c873dbb4fb001d22fbbd50e80d4c1b0181ddae106856132160f84b94e88/greenlet-3.5.5-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:abc8bc8d9f935cd685457545b6a53863a877fdc12c2c0f5ee9beee18d9db139c", size = 633329, upload-time = "2026-08-10T14:30:06.062Z" }, { url = "https://files.pythonhosted.org/packages/51/2d/f2c928218ac52f26d7a2c188c171d1b7e728b23782cb3347e7b4fce1493a/greenlet-3.5.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cc6df89ec5302337adc9cf096221cbed2510fd444b0e0f1586cf0470740864", size = 624562, upload-time = "2026-08-10T13:40:48.064Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/f7df98e72a8eb4a7edfec5a08d6d1a4ab53a52c95ba0b2ea6c10b8dd9bd0/greenlet-3.5.5-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:3134291427bb0f3526e9d90311988caf336eb43730e95244997a4fb15f45144f", size = 428145, upload-time = "2026-08-10T14:30:01.071Z" }, { url = "https://files.pythonhosted.org/packages/3e/4a/92fc51d5d35912f4f06eec037ba347985defd0be47463a010a325634d9d2/greenlet-3.5.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d9b454c5fc48aeaa7c4337813dbf513a6870468e426438a04d922c6d0fe63db", size = 1584909, upload-time = "2026-08-10T14:15:04.343Z" }, { url = "https://files.pythonhosted.org/packages/ac/58/ed98b80ac5738c149a5258544843c45601ade1fd70f61740cdaead6351b3/greenlet-3.5.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03551ed792cb1b4fc0277a0c60dfd8c343894a0ba06fe60dcd22f568b433da39", size = 1651184, upload-time = "2026-08-10T13:40:28.879Z" }, { url = "https://files.pythonhosted.org/packages/d8/be/b582ceb80cefdf9d8da34078714e4b12b3d16f509dee0f65e40a5cc8fc7d/greenlet-3.5.5-cp311-cp311-win_amd64.whl", hash = "sha256:ab3df3dffb58bf70564e93a5cec7941e4d9faa5a36cc4234a10d3131afe04f53", size = 323280, upload-time = "2026-08-10T13:26:07.495Z" }, @@ -1388,7 +1392,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2e/7e/9ecd0285e3153532ae07aeb88063c43c72b4221cf0d4d123b02f3682e3ff/greenlet-3.5.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:49520f0c95a48b42cf55414b8e8479beb274ea70431afc33e3f79903c71f4380", size = 295809, upload-time = "2026-08-10T13:25:34.023Z" }, { url = "https://files.pythonhosted.org/packages/35/73/60e4bbcc89252037b18087f2ec16405d5b2d5be42dde191bbf3667e96102/greenlet-3.5.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55272212cbc5f43d1d723725ab931f1939969b7e9523882ca58b55061769d053", size = 611910, upload-time = "2026-08-10T14:14:35.18Z" }, { url = "https://files.pythonhosted.org/packages/a4/17/cd5134be659cd4a443e7a61ae670dabec165a814c51162916d637b6dd38e/greenlet-3.5.5-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655bca754a2ef4efcb0eb48a94d3f4593536d0f3d48f8ed44343c01d16a92f95", size = 624198, upload-time = "2026-08-10T14:27:25.229Z" }, + { url = "https://files.pythonhosted.org/packages/9b/30/87c212b5c684d0e72974f1063b7a9687631e8985902c06e1016542c874e7/greenlet-3.5.5-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ca5d6ae0739e5764f2cfcfaa562ac5a990cbdaedca93251c5e3cf07c362371f", size = 629504, upload-time = "2026-08-10T14:30:07.967Z" }, { url = "https://files.pythonhosted.org/packages/78/ac/5c5b959999b6f09c3026b5dfe171575bc3121c5236ce74f495096f25b203/greenlet-3.5.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:147b25a42e5ca5be3d42356e8f608b37af715a1c196e9bf9d1627f3341adfe1d", size = 621439, upload-time = "2026-08-10T13:40:49.391Z" }, + { url = "https://files.pythonhosted.org/packages/63/2c/eb487fafc9f50ffff2b1e0b697f70fb34bf150821c08ab225aacf5583a7e/greenlet-3.5.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:1b5ed9162c0c098e0bbc2cf88a94f433c1b8926f831745252e099e5d83e17759", size = 432462, upload-time = "2026-08-10T14:30:02.309Z" }, { url = "https://files.pythonhosted.org/packages/c8/8b/6acf112ed8aee499f25b4d6949820fb02ac950ff9c1f3d793bd5be0599f2/greenlet-3.5.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:27493374cff1d1b7919dc8126547f2aea582737e3046147b434b1e12de56389b", size = 1581342, upload-time = "2026-08-10T14:15:05.653Z" }, { url = "https://files.pythonhosted.org/packages/b8/d7/734e5f198888876b42d7616ff6644c075baf6b8a2412deadd6b0e1b8b20c/greenlet-3.5.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:12e2ee66c2aba86133f10fd99d6a8856c6d351ffb7be0e4d52ef2cc5fbb705b2", size = 1645744, upload-time = "2026-08-10T13:40:30.353Z" }, { url = "https://files.pythonhosted.org/packages/de/30/1f42b88dc587b5899ee50616ad56ee40cafaf225df4fb829f10183c62a5c/greenlet-3.5.5-cp312-cp312-win_amd64.whl", hash = "sha256:49ddacd36af37735fab103846f4ee4d18a492dde72730d1699c0c8ebe30d9f18", size = 324171, upload-time = "2026-08-10T13:28:44.472Z" }, @@ -1396,7 +1402,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/3d/8cef5f724ec0d4add2af8961d504535ec60c3cca9e464f6d03bdba29d85b/greenlet-3.5.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b79fd2a5bc099b5e744f34c4c9a58954a5f4cb7529fb4b6e8446057d61b6edaa", size = 294730, upload-time = "2026-08-10T13:27:51.206Z" }, { url = "https://files.pythonhosted.org/packages/88/4b/8e7aa3f514273aecff30a16ab1bac09ff54cfc7e6860fdd8058c37ff2499/greenlet-3.5.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:634cf15a233a949136879dd388e25d3296e16f3f1e217d2456797b8579ebc6ed", size = 614536, upload-time = "2026-08-10T14:14:36.589Z" }, { url = "https://files.pythonhosted.org/packages/85/48/4e95e9dd5a8a397dc6a6345dd7f1935113d0fca4f85e89d3976da9cd988d/greenlet-3.5.5-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:499adea519f748407fc6806d20eedabac2884fd73b9f38d81236e190ba20dfef", size = 626924, upload-time = "2026-08-10T14:27:27.048Z" }, + { url = "https://files.pythonhosted.org/packages/0e/84/eaa476d6bf3816828d0d70e80dcc36bf30a058233bd889e707e693f6e860/greenlet-3.5.5-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7278591501941bb2456af102bb9cd59aab48c6cfd6e2dd68fa1290bb0c49a42", size = 632726, upload-time = "2026-08-10T14:30:09.874Z" }, { url = "https://files.pythonhosted.org/packages/89/5d/398a1c71fa7a277deeb376c999979de6786f08fc2d5747a0b9d6e11738dd/greenlet-3.5.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2eabb980975cba5b93a95f6f69287d05fc05ac955bfd6a320a7c083eeb52c0b0", size = 623906, upload-time = "2026-08-10T13:40:50.501Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f2/0cc2849ede68579291e9c59b3ab6ec1958f98681cca5b14d8fc75bf674a4/greenlet-3.5.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:4dfc7c4470354e7b09184d1a3a985761053a2fd694ddb5b5c80242afc2c8c90b", size = 434966, upload-time = "2026-08-10T14:30:03.729Z" }, { url = "https://files.pythonhosted.org/packages/04/1b/745450fc5ea9e0cb17d840d248f284db3363de736d362c7d2d883e3eadba/greenlet-3.5.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537", size = 1581430, upload-time = "2026-08-10T14:15:06.853Z" }, { url = "https://files.pythonhosted.org/packages/d4/29/d51b296e3191bb15d3d81ec375af1909e4466c0f395d744ed475801798a9/greenlet-3.5.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4441153ffba21b90d3ca89fe3d31f5c093ae6c0bf0cfdfc98f54cde22f95b62e", size = 1645684, upload-time = "2026-08-10T13:40:32.133Z" }, { url = "https://files.pythonhosted.org/packages/12/63/369f1a1625e64e9e31df3963c6044056e3fdfa3fa3fdba3c54ffefa6e987/greenlet-3.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:95c5b1f4b3a193f8a0c2de4bfdcb48d119f7f1063941f1de1f2168051b3e52dd", size = 324075, upload-time = "2026-08-10T13:26:58.974Z" }, @@ -1404,7 +1412,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/8c/080e881fa2be95ff1ddbd6994b2bab3b1a78df3b3fcab39306011764fcc7/greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d", size = 295309, upload-time = "2026-08-10T13:26:03.032Z" }, { url = "https://files.pythonhosted.org/packages/25/cc/0ac614e6586c0e42d4cc281a5819150f4f43685744a4c5ff77139286409d/greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328", size = 661185, upload-time = "2026-08-10T14:14:37.867Z" }, { url = "https://files.pythonhosted.org/packages/5e/b9/6808725354be8ad305dfe5172377664fc9642d4fc043be246b3314cf4482/greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926", size = 673419, upload-time = "2026-08-10T14:27:28.652Z" }, + { url = "https://files.pythonhosted.org/packages/eb/52/f005d579acde46c3d1cc3cab1c9f3d5708c8a3006a4120e8cf5da801afe9/greenlet-3.5.5-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d98ef6f92e67c6dbf299dbfd8facc1b0d2d9cedf91e325e73b3d0373fe4309d8", size = 677863, upload-time = "2026-08-10T14:30:11.663Z" }, { url = "https://files.pythonhosted.org/packages/42/2e/40c509967da7f254680826a2fa0dd22138ec79946c70b97542d74cde8b43/greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e", size = 670822, upload-time = "2026-08-10T13:40:51.833Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8a/a75f8a2bdcef3c358a3147cdc9db3aa83755f0a038f766ab0bedb66f512c/greenlet-3.5.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:159df1942d88e8f784cbb38d6f18bdb365cd11319cfbb3e89623de2b97892d53", size = 480554, upload-time = "2026-08-10T14:30:05.171Z" }, { url = "https://files.pythonhosted.org/packages/2d/22/c3c2eee4a8fe191d6d1d183086c56133d646024e3d70bfd414829f64560b/greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc", size = 1628469, upload-time = "2026-08-10T14:15:08.11Z" }, { url = "https://files.pythonhosted.org/packages/f7/87/25babd09b94cb1f03e71db815fde463f0262e40cfbd953d58a8d77311351/greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9", size = 1691952, upload-time = "2026-08-10T13:40:33.502Z" }, { url = "https://files.pythonhosted.org/packages/2e/3d/5cc9701117ea4dc0eb7bf1f4f9b7888a6e2e5277ddfae095805ace50f2b6/greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1", size = 327458, upload-time = "2026-08-10T13:27:02.868Z" }, @@ -1412,7 +1422,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/e0/50cd600b469e5734c72709b6b1838b6bc63f307b573c772c3132d6ecfe92/greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277", size = 305471, upload-time = "2026-08-10T13:26:20.568Z" }, { url = "https://files.pythonhosted.org/packages/75/a3/77acd66dfc6387b5219b2080806c0cabb73c10eb1bb44b413c40a62015ba/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b", size = 672470, upload-time = "2026-08-10T14:14:39.058Z" }, { url = "https://files.pythonhosted.org/packages/b9/71/0d178142dca3ec19f46fb2212ae73d30ad53b9d548dc64804086033a7089/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272", size = 679973, upload-time = "2026-08-10T14:27:30.072Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ac/0d7887aa4bbfc9eba075cc428244dfc96f623478454d5ec81180d0d6bd5a/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e9ec2e7c98e895fcea0c5cc57b2606cf86ece6d0a56578f3eb225e2af4f0387", size = 681587, upload-time = "2026-08-10T14:30:13.519Z" }, { url = "https://files.pythonhosted.org/packages/6e/31/46eb8567302eaf787abf88d09df014e14ae3baf460af1b8b0efdbd3efcd5/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476", size = 676634, upload-time = "2026-08-10T13:40:53.004Z" }, + { url = "https://files.pythonhosted.org/packages/4f/18/8d58ba1c429b0383e3219a3d0e0bba241d0444d8ed05b73349953c7d7c7b/greenlet-3.5.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:102817506f6090b5176c746a82603341a549b40e5c3d5b72a4c672228a918c41", size = 510175, upload-time = "2026-08-10T14:30:07.047Z" }, { url = "https://files.pythonhosted.org/packages/a3/e9/b88bbf5b29970cb84172dc2c32aa3e5e579ceb94c808e81c826454138850/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874", size = 1637320, upload-time = "2026-08-10T14:15:09.317Z" }, { url = "https://files.pythonhosted.org/packages/6d/8c/7631ed29cc6f0392f11830076e172ce4885e70b0bc2c1bce1731176d4b4e/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71", size = 1697412, upload-time = "2026-08-10T13:40:34.924Z" }, { url = "https://files.pythonhosted.org/packages/da/0f/f7dd935f9c4cb1be49098770587f54d8a78518e55c89bce86c4fb4109057/greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0", size = 331514, upload-time = "2026-08-10T13:29:20.611Z" },