Files
wehub-resource-sync 85742ab165
CPU Test / Lint - next (push) Waiting to run
Dashboard / Chromatic (push) Waiting to run
CPU Test / Lint - fast (push) Waiting to run
CPU Test / Build documentation (push) Waiting to run
CPU Test / Test (Store, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (Utilities, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (Weave, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (AgentOps, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (LLM proxy, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (Others, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (Store, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (Utilities, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (Weave, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (AgentOps, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (LLM proxy, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (Others, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (Store, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (Utilities, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (Weave, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (AgentOps, latest, Python 3.13) (push) Waiting to run
CPU Test / Test (LLM proxy, latest, Python 3.13) (push) Waiting to run
CPU Test / Test (Others, latest, Python 3.13) (push) Waiting to run
CPU Test / Test (Store, latest, Python 3.13) (push) Waiting to run
CPU Test / Lint - slow (push) Waiting to run
CPU Test / Lint - JavaScript (push) Waiting to run
CPU Test / Test (AgentOps, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (LLM proxy, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (Others, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (Utilities, latest, Python 3.13) (push) Waiting to run
CPU Test / Test (Weave, latest, Python 3.13) (push) Waiting to run
CPU Test / Test (JavaScript) (push) Waiting to run
Deploy Documentation / deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:44:17 +08:00

943 lines
36 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
# pyright: reportPrivateUsage=false
from typing import Any, Dict, Iterator, List, Literal, Optional, Sequence, Tuple, cast
from unittest.mock import AsyncMock, Mock
import pytest
from openai import AsyncOpenAI
import agentlightning.algorithm.apo.apo as apo_module
from agentlightning.adapter import TraceAdapter
from agentlightning.adapter.messages import TraceToMessages
from agentlightning.algorithm.apo.apo import APO, RolloutResultForAPO, VersionedPromptTemplate, batch_iter_over_dataset
from agentlightning.semconv import AGL_ANNOTATION
from agentlightning.types import (
Dataset,
NamedResources,
)
from agentlightning.types import OtelResource as SpanResource
from agentlightning.types import (
PromptTemplate,
Rollout,
Span,
SpanContext,
TraceStatus,
)
class DummyTraceMessagesAdapter(TraceToMessages):
def __init__(self) -> None:
super().__init__()
self.seen_spans: Sequence[Span] | None = None
def adapt(self, source: Sequence[Span], /) -> List[Dict[str, Any]]: # type: ignore[override]
self.seen_spans = list(source)
return [dict(payload="converted")]
class WrongAdapter(TraceAdapter[List[int]]):
def adapt(self, source: Sequence[Span], /) -> List[int]:
return [len(source)]
class DummyStore:
def __init__(self) -> None:
self.update_resources_calls: List[Tuple[str, NamedResources]] = []
self.enqueue_calls: List[Dict[str, Any]] = []
self.wait_calls: List[Dict[str, Any]] = []
self.wait_results_queue: List[List[Rollout]] = []
self.query_spans_map: Dict[str, List[Span]] = {}
self._counter = 0
async def update_resources(self, resources_id: str, resources: NamedResources) -> Mock:
self.update_resources_calls.append((resources_id, resources))
update_mock = Mock()
update_mock.resources_id = resources_id
return update_mock
async def enqueue_rollout(
self,
*,
input: Dict[str, Any],
mode: str,
resources_id: Optional[str] = None,
) -> Mock:
rollout_id = f"rollout-{self._counter}"
self._counter += 1
self.enqueue_calls.append(
{"rollout_id": rollout_id, "input": input, "mode": mode, "resources_id": resources_id}
)
result = Mock()
result.rollout_id = rollout_id
return result
async def wait_for_rollouts(self, rollout_ids: Sequence[str], timeout: float) -> List[Rollout]:
self.wait_calls.append({"rollout_ids": tuple(rollout_ids), "timeout": timeout})
if self.wait_results_queue:
return self.wait_results_queue.pop(0)
return []
async def query_spans(
self,
rollout_id: str,
attempt_id: str | Literal["latest"] | None = None,
**_: Any,
) -> List[Span]:
return list(self.query_spans_map.get(rollout_id, []))
def make_completion(content: str | None) -> Mock:
"""Create a mock OpenAI completion response."""
message_mock = Mock()
message_mock.content = content
choice_mock = Mock()
choice_mock.message = message_mock
completion_mock = Mock()
completion_mock.choices = [choice_mock]
return completion_mock
def make_openai_client(create_mock: AsyncMock) -> Mock:
"""Create a mock AsyncOpenAI client with the given create method."""
client = Mock(spec=AsyncOpenAI)
completions = Mock()
completions.create = create_mock
chat = Mock()
chat.completions = completions
client.chat = chat
return client
def make_reward_span(rollout_id: str, attempt_id: str, reward: float, sequence_id: int) -> Span:
hex_id = f"{sequence_id:032x}"
span_hex = f"{sequence_id:016x}"
return Span(
rollout_id=rollout_id,
attempt_id=attempt_id,
sequence_id=sequence_id,
trace_id=hex_id,
span_id=span_hex,
parent_id=None,
name=AGL_ANNOTATION,
status=TraceStatus(status_code="OK"),
attributes={"reward": reward},
events=[],
links=[],
start_time=None,
end_time=None,
context=SpanContext(trace_id=hex_id, span_id=span_hex, is_remote=False, trace_state={}),
parent=None,
resource=SpanResource(attributes={}, schema_url=""),
)
def test_batch_iter_over_dataset_returns_full_dataset(monkeypatch: pytest.MonkeyPatch) -> None:
dataset = [{"id": idx} for idx in range(3)]
monkeypatch.setattr(apo_module.random, "shuffle", lambda seq: None) # type: ignore
iterator = batch_iter_over_dataset(cast(Dataset[Any], dataset), batch_size=5)
first_batch = next(iterator)
second_batch = next(iterator)
assert len(first_batch) == len(dataset)
assert len(second_batch) == len(dataset)
assert {item["id"] for item in first_batch} == {0, 1, 2}
def test_batch_iter_over_dataset_cycles_batches(monkeypatch: pytest.MonkeyPatch) -> None:
dataset = [{"id": idx} for idx in range(4)]
def fake_shuffle(seq: List[int]) -> None:
seq.reverse()
monkeypatch.setattr(apo_module.random, "shuffle", fake_shuffle)
iterator = batch_iter_over_dataset(cast(Dataset[Any], dataset), batch_size=2)
batch_one = next(iterator)
batch_two = next(iterator)
batch_three = next(iterator)
assert len(batch_one) == 2
assert len(batch_two) == 2
assert {item["id"] for item in batch_three}.issubset({item["id"] for item in batch_one + batch_two}) # type: ignore
def test_apo_init_sets_configuration() -> None:
client = Mock(spec=AsyncOpenAI)
apo = APO[Any](
client,
gradient_model="g-model",
apply_edit_model="a-model",
diversity_temperature=0.7,
gradient_batch_size=3,
val_batch_size=5,
beam_width=2,
branch_factor=3,
beam_rounds=4,
rollout_batch_timeout=42.0,
run_initial_validation=False,
)
assert apo.async_openai_client is client
assert apo.gradient_model == "g-model"
assert apo.apply_edit_model == "a-model"
assert apo.diversity_temperature == 0.7
assert apo.gradient_batch_size == 3
assert apo.val_batch_size == 5
assert apo.beam_width == 2
assert apo.branch_factor == 3
assert apo.beam_rounds == 4
assert apo.rollout_batch_timeout == 42.0
assert apo.run_initial_validation is False
assert apo._history_best_prompt is None
assert apo._history_best_score == float("-inf")
def test_get_seed_prompt_template_returns_prompt() -> None:
client = Mock(spec=AsyncOpenAI)
apo = APO[Any](client)
prompt = PromptTemplate(template="Seed: {x}", engine="f-string")
resources: NamedResources = {
"seed": prompt,
"other": PromptTemplate(template="Other", engine="f-string"),
}
apo.set_initial_resources(resources)
resource_name, seed_prompt = apo.get_seed_prompt_template()
assert resource_name == "seed"
assert seed_prompt is prompt
def test_get_seed_prompt_template_requires_resources() -> None:
client = Mock(spec=AsyncOpenAI)
apo = APO[Any](client)
with pytest.raises(ValueError):
apo.get_seed_prompt_template()
def test_get_seed_prompt_template_requires_prompt_resource() -> None:
client = Mock(spec=AsyncOpenAI)
apo = APO[Any](client)
apo.set_initial_resources({})
with pytest.raises(ValueError):
apo.get_seed_prompt_template()
def test_get_adapter_returns_trace_messages_adapter() -> None:
client = Mock(spec=AsyncOpenAI)
apo = APO[Any](client)
adapter = DummyTraceMessagesAdapter()
apo.set_adapter(adapter)
assert apo.get_adapter() is adapter
def test_get_adapter_requires_trace_messages_adapter() -> None:
client = Mock(spec=AsyncOpenAI)
apo = APO[Any](client)
apo.set_adapter(WrongAdapter())
with pytest.raises(ValueError):
apo.get_adapter()
def test_get_best_prompt_requires_history() -> None:
apo = APO[Any](Mock(spec=AsyncOpenAI))
with pytest.raises(ValueError):
apo.get_best_prompt()
def test_get_best_prompt_returns_prompt() -> None:
apo = APO[Any](Mock(spec=AsyncOpenAI))
prompt = PromptTemplate(template="Best", engine="f-string")
apo._history_best_prompt = prompt
assert apo.get_best_prompt() is prompt
@pytest.mark.asyncio
async def test_compute_textual_gradient_samples_batch(monkeypatch: pytest.MonkeyPatch) -> None:
create_mock = AsyncMock(return_value=make_completion("critique"))
client = make_openai_client(create_mock)
apo = APO[Any](client, gradient_model="test-gradient-model", gradient_batch_size=2, diversity_temperature=0.8)
versioned_prompt = apo._create_versioned_prompt(PromptTemplate(template="prompt", engine="f-string"))
rollouts: List[RolloutResultForAPO] = [
RolloutResultForAPO(status="succeeded", final_reward=float(i), spans=[], messages=[]) for i in range(3)
]
sample_mock = Mock(return_value=rollouts[:2])
monkeypatch.setattr(apo_module.random, "sample", sample_mock)
monkeypatch.setattr(apo_module.random, "choice", lambda seq: seq[0]) # type: ignore
result = await apo.compute_textual_gradient(versioned_prompt, rollouts)
assert result == "critique"
sample_mock.assert_called_once_with(rollouts, 2)
# Verify OpenAI call was made with correct parameters
create_mock.assert_awaited_once()
call_kwargs = create_mock.await_args.kwargs # type: ignore
assert call_kwargs["model"] == "test-gradient-model"
assert call_kwargs["temperature"] == 0.8
assert len(call_kwargs["messages"]) == 1
assert call_kwargs["messages"][0]["role"] == "user"
assert call_kwargs["messages"][0]["content"].startswith("You optimize a prompt template.")
@pytest.mark.asyncio
async def test_compute_textual_gradient_uses_all_rollouts_when_insufficient(monkeypatch: pytest.MonkeyPatch) -> None:
create_mock = AsyncMock(return_value=make_completion("critique"))
client = make_openai_client(create_mock)
apo = APO[Any](client, gradient_batch_size=3)
versioned_prompt = apo._create_versioned_prompt(PromptTemplate(template="prompt", engine="f-string"))
rollouts: List[RolloutResultForAPO] = [
RolloutResultForAPO(status="succeeded", final_reward=1.0, spans=[], messages=[])
]
sample_mock = Mock(side_effect=AssertionError("sample should not be called"))
monkeypatch.setattr(apo_module.random, "sample", sample_mock)
monkeypatch.setattr(apo_module.random, "choice", lambda seq: seq[0]) # type: ignore
result = await apo.compute_textual_gradient(versioned_prompt, rollouts)
assert result == "critique"
@pytest.mark.asyncio
async def test_textual_gradient_and_apply_edit_returns_new_prompt(monkeypatch: pytest.MonkeyPatch) -> None:
# Use two separate mocks for gradient and edit calls
gradient_mock = AsyncMock(return_value=make_completion("critique text"))
edit_mock = AsyncMock(return_value=make_completion("new prompt"))
call_count = 0
async def create_side_effect(*args: Any, **kwargs: Any) -> Mock:
nonlocal call_count
call_count += 1
return gradient_mock.return_value if call_count == 1 else edit_mock.return_value
create_mock = AsyncMock(side_effect=create_side_effect)
client = make_openai_client(create_mock)
apo = APO[Any](client, gradient_model="grad-model", apply_edit_model="edit-model", diversity_temperature=0.9)
monkeypatch.setattr(apo_module.random, "choice", lambda seq: seq[0]) # type: ignore
monkeypatch.setattr(apo_module.random, "sample", lambda population, k: list(population)[:k]) # type: ignore
poml_calls: List[Dict[str, Any]] = []
def poml_side_effect(template: Any, context: Dict[str, Any], format: str) -> Dict[str, Any]:
poml_calls.append({"template": template, "context": context, "format": format})
return {"messages": [{"role": "user", "content": "msg"}]}
monkeypatch.setattr(apo_module.poml, "poml", poml_side_effect)
versioned_prompt = apo._create_versioned_prompt(PromptTemplate(template="old prompt", engine="f-string"))
rollouts: List[RolloutResultForAPO] = [
RolloutResultForAPO(status="succeeded", final_reward=1.0, spans=[], messages=[])
]
result = await apo.textual_gradient_and_apply_edit(versioned_prompt, rollouts)
assert result == "new prompt"
assert create_mock.await_count == 2
# Verify gradient computation call
first_call = create_mock.await_args_list[0].kwargs
assert first_call["model"] == "grad-model"
assert first_call["temperature"] == 0.9
# Verify edit application call
second_call = create_mock.await_args_list[1].kwargs
assert second_call["model"] == "edit-model"
assert second_call["temperature"] == 0.9
# Verify critique was passed to edit context
assert len(poml_calls) == 2
assert poml_calls[1]["context"]["critique"] == "critique text"
assert poml_calls[1]["context"]["prompt_template"] == "old prompt"
@pytest.mark.asyncio
async def test_textual_gradient_and_apply_edit_returns_original_if_no_critique(monkeypatch: pytest.MonkeyPatch) -> None:
# Mock OpenAI to return None content
create_mock = AsyncMock(return_value=make_completion(None))
client = make_openai_client(create_mock)
apo = APO[Any](client)
monkeypatch.setattr(apo_module.random, "choice", lambda seq: seq[0]) # type: ignore
monkeypatch.setattr(apo_module.random, "sample", lambda population, k: list(population)[:k]) # type: ignore
versioned_prompt = apo._create_versioned_prompt(PromptTemplate(template="old prompt", engine="f-string"))
rollouts: List[RolloutResultForAPO] = [
RolloutResultForAPO(status="succeeded", final_reward=1.0, spans=[], messages=[])
]
result = await apo.textual_gradient_and_apply_edit(versioned_prompt, rollouts)
# Should return original prompt when gradient computation fails
assert result == "old prompt"
# Verify gradient computation was attempted
create_mock.assert_awaited_once()
@pytest.mark.asyncio
async def test_get_rollout_results_adapts_spans() -> None:
apo = APO[Any](Mock(spec=AsyncOpenAI))
store = DummyStore()
adapter = DummyTraceMessagesAdapter()
apo.set_store(store) # type: ignore
apo.set_adapter(adapter)
rollout = Rollout(
rollout_id="r-1",
input={"task": "value"},
start_time=0.0,
status="succeeded",
mode="train",
)
span1 = make_reward_span("r-1", "attempt", 1.0, sequence_id=1)
span2 = make_reward_span("r-1", "attempt", 2.0, sequence_id=2)
store.query_spans_map["r-1"] = [span1, span2]
results = await apo.get_rollout_results([rollout])
assert len(results) == 1
# Verify final reward is correctly extracted
assert results[0]["final_reward"] == 2.0
# Verify status is correctly mapped
assert results[0]["status"] == "succeeded"
# Verify adapter was called with correct spans
assert adapter.seen_spans is not None
assert len(adapter.seen_spans) == 2
assert adapter.seen_spans[0] == span1
assert adapter.seen_spans[1] == span2
# Verify messages were converted
assert results[0]["messages"] == [{"payload": "converted"}]
# Verify spans were serialized
assert len(results[0]["spans"]) == 2
assert results[0]["spans"][0]["rollout_id"] == "r-1"
assert results[0]["spans"][0]["name"] == AGL_ANNOTATION
assert results[0]["spans"][0]["attributes"]["reward"] == 1.0
assert results[0]["spans"][1]["attributes"]["reward"] == 2.0
@pytest.mark.asyncio
async def test_evaluate_prompt_on_batch_runs_rollouts() -> None:
client = Mock(spec=AsyncOpenAI)
apo = APO[Any](client, rollout_batch_timeout=100.0)
store = DummyStore()
adapter = DummyTraceMessagesAdapter()
apo.set_store(store) # type: ignore
apo.set_adapter(adapter)
dataset = [{"task": 1}, {"task": 2}]
# Set up spans for rollouts
store.query_spans_map["rollout-0"] = [make_reward_span("rollout-0", "attempt", 1.0, sequence_id=1)]
store.query_spans_map["rollout-1"] = [make_reward_span("rollout-1", "attempt", 0.0, sequence_id=1)]
store.wait_results_queue.append(
[
Rollout(
rollout_id="rollout-0",
input=dataset[0],
start_time=0.0,
status="succeeded",
mode="train",
),
Rollout(
rollout_id="rollout-1",
input=dataset[1],
start_time=0.0,
status="failed",
mode="train",
),
]
)
prompt_template = PromptTemplate(template="test prompt", engine="f-string")
versioned_prompt = apo._create_versioned_prompt(prompt_template)
rollout_results, average = await apo.evaluate_prompt_on_batch(versioned_prompt, "seed", dataset, mode="train")
# Verify results
assert len(rollout_results) == 2
assert rollout_results[0]["final_reward"] == 1.0
assert rollout_results[0]["status"] == "succeeded"
assert rollout_results[1]["final_reward"] == 0.0
assert rollout_results[1]["status"] == "failed"
assert average == pytest.approx(0.5) # type: ignore
# Verify resource was added with correct prompt
assert len(store.update_resources_calls) == 1
resources_id, resources_payload = store.update_resources_calls[0]
assert resources_id == versioned_prompt.version
assert "seed" in resources_payload
added_resource = resources_payload["seed"]
assert isinstance(added_resource, PromptTemplate)
assert added_resource.template == "test prompt"
assert added_resource.engine == "f-string"
# Verify enqueue was called correctly
assert len(store.enqueue_calls) == 2
assert store.enqueue_calls[0]["input"] == dataset[0]
assert store.enqueue_calls[0]["mode"] == "train"
assert store.enqueue_calls[0]["resources_id"] == versioned_prompt.version
assert store.enqueue_calls[1]["input"] == dataset[1]
assert store.enqueue_calls[1]["mode"] == "train"
assert store.enqueue_calls[1]["resources_id"] == versioned_prompt.version
# Verify wait was called with correct rollout IDs
assert len(store.wait_calls) == 1
assert set(store.wait_calls[0]["rollout_ids"]) == {"rollout-0", "rollout-1"}
assert store.wait_calls[0]["timeout"] == 0.0
def test_initialize_beam_sets_history(monkeypatch: pytest.MonkeyPatch) -> None:
client = Mock(spec=AsyncOpenAI)
apo = APO[Any](client, gradient_batch_size=2, val_batch_size=1)
prompt = PromptTemplate(template="Seed", engine="f-string")
apo.set_initial_resources({"seed": prompt})
monkeypatch.setattr(apo_module.random, "shuffle", lambda seq: None) # type: ignore
train_dataset: Sequence[Dict[str, str]] = [{"x": "1"}, {"x": "2"}]
val_dataset: Sequence[Dict[str, str]] = [{"y": "value"}]
resource_name, seed_prompt, grad_iter, val_iter = apo._initialize_beam(train_dataset, val_dataset) # type: ignore
assert resource_name == "seed"
assert seed_prompt is prompt
assert apo._history_best_prompt is prompt
assert apo._history_best_score == float("-inf")
assert len(next(grad_iter)) == len(train_dataset)
assert len(next(val_iter)) == len(val_dataset)
def test_initialize_beam_requires_train_dataset() -> None:
apo = APO[Any](Mock(spec=AsyncOpenAI))
apo.set_initial_resources({"seed": PromptTemplate(template="Seed", engine="f-string")})
with pytest.raises(ValueError):
apo._initialize_beam(None, []) # type: ignore
def test_initialize_beam_requires_val_dataset() -> None:
apo = APO[Any](Mock(spec=AsyncOpenAI))
apo.set_initial_resources({"seed": PromptTemplate(template="Seed", engine="f-string")})
with pytest.raises(ValueError):
apo._initialize_beam([], None) # type: ignore
def test_sample_parent_prompts_replicates_when_beam_too_small(monkeypatch: pytest.MonkeyPatch) -> None:
apo = APO[Any](Mock(spec=AsyncOpenAI), beam_width=3)
beam_prompt = apo._create_versioned_prompt(PromptTemplate(template="Seed", engine="f-string"))
beam = [beam_prompt]
monkeypatch.setattr(apo_module.random, "sample", lambda population, k: (_ for _ in ()).throw(AssertionError())) # type: ignore
sampled = apo._sample_parent_prompts(beam, round_num=0)
assert len(sampled) == apo.beam_width
assert all(index == 0 and prompt is beam_prompt for index, prompt in sampled)
def test_sample_parent_prompts_uses_random_sample(monkeypatch: pytest.MonkeyPatch) -> None:
apo = APO[Any](Mock(spec=AsyncOpenAI), beam_width=2)
prompt_a = apo._create_versioned_prompt(PromptTemplate(template="A", engine="f-string"))
prompt_b = apo._create_versioned_prompt(PromptTemplate(template="B", engine="f-string"))
prompt_c = apo._create_versioned_prompt(PromptTemplate(template="C", engine="f-string"))
monkeypatch.setattr(apo_module.random, "sample", lambda population, k: [0, 2]) # type: ignore
sampled = apo._sample_parent_prompts([prompt_a, prompt_b, prompt_c], round_num=1)
assert sampled == [(0, prompt_a), (2, prompt_c)]
@pytest.mark.asyncio
async def test_generate_candidate_prompts_creates_branch_factor_children() -> None:
client = Mock(spec=AsyncOpenAI)
apo = APO[Any](client, branch_factor=2)
store = DummyStore()
adapter = DummyTraceMessagesAdapter()
apo.set_store(store) # type: ignore
apo.set_adapter(adapter)
parent_prompt = apo._create_versioned_prompt(PromptTemplate(template="Seed", engine="f-string"))
grad_batches: Iterator[Sequence[Dict[str, Any]]] = iter(
[
[{"task": "a"}],
[{"task": "b"}],
]
)
# Set up rollouts to complete immediately
store.query_spans_map["rollout-0"] = [make_reward_span("rollout-0", "attempt", 0.5, sequence_id=1)]
store.query_spans_map["rollout-1"] = [make_reward_span("rollout-1", "attempt", 0.6, sequence_id=1)]
store.wait_results_queue.extend(
[
[Rollout(rollout_id="rollout-0", input={"task": "a"}, start_time=0.0, status="succeeded", mode="train")],
[Rollout(rollout_id="rollout-1", input={"task": "b"}, start_time=0.0, status="succeeded", mode="train")],
]
)
counter = 0
async def edit_side_effect(
current_prompt: VersionedPromptTemplate,
rollout: List[RolloutResultForAPO],
**_: Any,
) -> str:
nonlocal counter
counter += 1
return f"{current_prompt.prompt_template.template}-{counter}"
apo.textual_gradient_and_apply_edit = AsyncMock(side_effect=edit_side_effect)
candidates = await apo._generate_candidate_prompts([(0, parent_prompt)], "seed", grad_batches, round_num=0)
# Verify correct number of candidates generated
assert len(candidates) == apo.branch_factor
assert {candidate.prompt_template.template for candidate in candidates} == {"Seed-1", "Seed-2"}
assert all(candidate.prompt_template.engine == "f-string" for candidate in candidates)
# Verify evaluate_prompt_on_batch was called for each candidate generation
assert len(store.enqueue_calls) == 2
assert store.enqueue_calls[0]["input"] == {"task": "a"}
assert store.enqueue_calls[1]["input"] == {"task": "b"}
assert all(call["mode"] == "train" for call in store.enqueue_calls)
assert all(call["resources_id"] == parent_prompt.version for call in store.enqueue_calls)
# Verify textual_gradient_and_apply_edit was called correct number of times
assert apo.textual_gradient_and_apply_edit.await_count == 2
@pytest.mark.asyncio
async def test_generate_candidate_prompts_skips_failed_generations() -> None:
"""Test that None returns from textual_gradient_and_apply_edit are skipped."""
client = Mock(spec=AsyncOpenAI)
apo = APO[Any](client, branch_factor=3)
store = DummyStore()
# Keep strong reference to prevent garbage collection since APO uses weakref
apo._test_adapter = adapter = DummyTraceMessagesAdapter() # type: ignore
apo.set_store(store) # type: ignore
apo.set_adapter(adapter)
parent_prompt = apo._create_versioned_prompt(PromptTemplate(template="Seed", engine="f-string"))
grad_batches: Iterator[Sequence[Dict[str, Any]]] = iter([[{"task": f"t{i}"}] for i in range(3)])
# Set up rollouts
for i in range(3):
store.query_spans_map[f"rollout-{i}"] = [make_reward_span(f"rollout-{i}", "attempt", 0.5, sequence_id=1)]
store.wait_results_queue.append(
[
Rollout(
rollout_id=f"rollout-{i}", input={"task": f"t{i}"}, start_time=0.0, status="succeeded", mode="train"
)
]
)
# Mock to return None for second call, valid prompts for others
call_count = 0
async def edit_side_effect(
current_prompt: VersionedPromptTemplate,
rollout: List[RolloutResultForAPO],
**_: Any,
) -> Optional[str]:
nonlocal call_count
call_count += 1
if call_count == 2:
return None # Simulate failure
return f"{current_prompt.prompt_template.template}-{call_count}"
apo.textual_gradient_and_apply_edit = AsyncMock(side_effect=edit_side_effect)
candidates = await apo._generate_candidate_prompts([(0, parent_prompt)], "seed", grad_batches, round_num=0)
# Should only have 2 candidates (one failed)
assert len(candidates) == 2
assert {candidate.prompt_template.template for candidate in candidates} == {"Seed-1", "Seed-3"}
# Verify all three attempts were made
assert apo.textual_gradient_and_apply_edit.await_count == 3
@pytest.mark.asyncio
async def test_evaluate_and_select_beam_sorts_by_score() -> None:
apo = APO[Any](Mock(spec=AsyncOpenAI), beam_width=2)
candidates = [
apo._create_versioned_prompt(PromptTemplate(template="A", engine="f-string")),
apo._create_versioned_prompt(PromptTemplate(template="B", engine="f-string")),
apo._create_versioned_prompt(PromptTemplate(template="C", engine="f-string")),
]
scores = {"A": 1.0, "B": 0.2, "C": 2.0}
async def evaluate(
prompt: VersionedPromptTemplate,
resource_name: str,
dataset: Sequence[Dict[str, Any]],
mode: str,
**_: Any,
) -> Any:
return [], scores[prompt.prompt_template.template]
apo.evaluate_prompt_on_batch = AsyncMock(side_effect=evaluate) # type: ignore[assignment]
val_iterator: Iterator[Sequence[Dict[str, Any]]] = iter([[{"task": "val"}]])
selected = await apo._evaluate_and_select_beam(candidates, "seed", val_iterator, round_num=0)
assert [prompt.prompt_template.template for prompt in selected] == ["C", "A"]
@pytest.mark.asyncio
async def test_evaluate_and_select_beam_raises_on_empty_candidates() -> None:
"""Test that ValueError is raised when no candidates remain after evaluation."""
client = Mock(spec=AsyncOpenAI)
apo = APO[Any](client, beam_width=2)
# Empty candidate list
candidates: List[VersionedPromptTemplate] = []
val_iterator: Iterator[Sequence[Dict[str, Any]]] = iter([[{"task": "val"}]])
with pytest.raises(ValueError, match="No beam candidates any more"):
await apo._evaluate_and_select_beam(candidates, "seed", val_iterator, round_num=0)
@pytest.mark.asyncio
async def test_update_best_prompt_updates_history() -> None:
apo = APO[Any](Mock(spec=AsyncOpenAI))
old_versioned = apo._create_versioned_prompt(PromptTemplate(template="Old", engine="f-string"))
new_versioned = apo._create_versioned_prompt(PromptTemplate(template="New", engine="f-string"))
apo._history_best_prompt = old_versioned.prompt_template
apo._history_best_score = 0.5
apo._history_best_version = old_versioned.version
apo.evaluate_prompt_on_batch = AsyncMock(return_value=([], 1.2)) # type: ignore[assignment]
await apo._update_best_prompt([new_versioned], "seed", [{"task": "val"}], round_num=0) # type: ignore
assert apo._history_best_prompt is new_versioned.prompt_template
assert apo._history_best_score == pytest.approx(1.2) # type: ignore
assert apo._history_best_version == new_versioned.version
@pytest.mark.asyncio
async def test_update_best_prompt_keeps_history_when_not_improved() -> None:
apo = APO[Any](Mock(spec=AsyncOpenAI))
old_versioned = apo._create_versioned_prompt(PromptTemplate(template="Old", engine="f-string"))
new_versioned = apo._create_versioned_prompt(PromptTemplate(template="New", engine="f-string"))
apo._history_best_prompt = old_versioned.prompt_template
apo._history_best_score = 2.0
apo._history_best_version = old_versioned.version
apo.evaluate_prompt_on_batch = AsyncMock(return_value=([], 1.5)) # type: ignore[assignment]
await apo._update_best_prompt([new_versioned], "seed", [{"task": "val"}], round_num=0) # type: ignore
assert apo._history_best_prompt is old_versioned.prompt_template
assert apo._history_best_score == pytest.approx(2.0) # type: ignore
assert apo._history_best_version == old_versioned.version
def test_apo_init_defaults_run_initial_validation_to_true() -> None:
"""Test that run_initial_validation defaults to True when not specified."""
client = Mock(spec=AsyncOpenAI)
apo = APO[Any](client)
assert apo.run_initial_validation is True
@pytest.mark.asyncio
async def test_run_performs_initial_validation_when_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
"""Test that initial validation runs on seed prompt when run_initial_validation=True."""
async_client = Mock(spec=AsyncOpenAI)
apo = APO[Any](
async_client,
gradient_batch_size=1,
val_batch_size=1,
beam_width=1,
branch_factor=1,
beam_rounds=0, # No optimization rounds, just initial validation
run_initial_validation=True,
)
seed_prompt = PromptTemplate(template="Seed", engine="f-string")
apo.set_initial_resources({"seed": seed_prompt})
store = DummyStore()
apo._test_adapter = adapter = DummyTraceMessagesAdapter() # type: ignore
apo.set_store(store) # type: ignore
apo.set_adapter(adapter)
# Set up initial validation rollout
store.query_spans_map["rollout-0"] = [make_reward_span("rollout-0", "attempt", 0.75, sequence_id=1)]
store.wait_results_queue.append(
[
Rollout(
rollout_id="rollout-0",
input={"task": "val"},
start_time=0.0,
status="succeeded",
mode="val",
)
]
)
monkeypatch.setattr(apo_module.random, "shuffle", lambda seq: None) # type: ignore
val_dataset = [{"task": "val"}]
await apo.run(train_dataset=[{"task": "train"}], val_dataset=val_dataset) # type: ignore
# Verify initial validation was performed
assert apo._history_best_prompt is seed_prompt
assert apo._history_best_score == pytest.approx(0.75) # type: ignore
# Verify a validation rollout was enqueued for initial validation
val_calls = [c for c in store.enqueue_calls if c["mode"] == "val"]
assert len(val_calls) == 1
@pytest.mark.asyncio
async def test_run_skips_initial_validation_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
"""Test that initial validation is skipped when run_initial_validation=False."""
create_mock = AsyncMock(side_effect=[make_completion("critique text"), make_completion("improved prompt")])
async_client = make_openai_client(create_mock)
apo = APO[Any](
async_client,
gradient_batch_size=1,
val_batch_size=1,
beam_width=1,
branch_factor=1,
beam_rounds=1,
run_initial_validation=False, # Disable initial validation
)
seed_prompt = PromptTemplate(template="Seed", engine="f-string")
apo.set_initial_resources({"seed": seed_prompt})
store = DummyStore()
apo._test_adapter = adapter = DummyTraceMessagesAdapter() # type: ignore
apo.set_store(store) # type: ignore
apo.set_adapter(adapter)
# Set up spans for rollouts (train + val for candidates + final val)
rollout_rewards = [0.4, 0.5, 0.6, 1.1]
for i, reward in enumerate(rollout_rewards):
store.query_spans_map[f"rollout-{i}"] = [make_reward_span(f"rollout-{i}", "attempt", reward, sequence_id=1)]
store.wait_results_queue.append(
[
Rollout(
rollout_id=f"rollout-{i}",
input={"task": f"data-{i}"},
start_time=0.0,
status="succeeded",
mode="train" if i == 0 else "val",
)
]
)
monkeypatch.setattr(apo_module.random, "shuffle", lambda seq: None) # type: ignore
monkeypatch.setattr(apo_module.random, "sample", lambda population, k: list(population)[:k]) # type: ignore
monkeypatch.setattr(apo_module.random, "choice", lambda seq: seq[0]) # type: ignore
train_dataset = [{"task": "train"}]
val_dataset = [{"task": "val"}]
await apo.run(train_dataset=train_dataset, val_dataset=val_dataset) # type: ignore
# Verify best prompt was updated through normal optimization (not initial validation)
best_prompt = apo.get_best_prompt()
assert best_prompt.template == "improved prompt"
# Count validation rollouts - should NOT include initial validation
# Only candidate evaluation + final best prompt evaluation
val_calls = [c for c in store.enqueue_calls if c["mode"] == "val"]
# With run_initial_validation=False, we expect: 2 val calls (seed+new candidate) + 1 final val = 3 total
assert len(val_calls) == 3
@pytest.mark.asyncio
async def test_run_updates_best_prompt_with_real_openai_client(monkeypatch: pytest.MonkeyPatch) -> None:
"""Integration test for the full run method with minimal mocking."""
create_mock = AsyncMock(side_effect=[make_completion("critique text"), make_completion("improved prompt")])
async_client = make_openai_client(create_mock)
apo = APO[Any](
async_client,
gradient_batch_size=1,
val_batch_size=1,
beam_width=1,
branch_factor=1,
beam_rounds=1,
run_initial_validation=False, # Skip initial validation for this test
)
seed_prompt = PromptTemplate(template="Seed", engine="f-string")
apo.set_initial_resources({"seed": seed_prompt})
store = DummyStore()
# Keep strong reference to prevent garbage collection since APO uses weakref
apo._test_adapter = adapter = DummyTraceMessagesAdapter() # type: ignore
apo.set_store(store) # type: ignore
apo.set_adapter(adapter)
# Set up spans for all expected rollouts
# For 1 round with beam_width=1, branch_factor=1, run_initial_validation=False, we expect:
# 1. Training rollout for gradient computation
# 2. Validation rollouts for candidate evaluation (seed + new candidate = 2)
# 3. Final validation rollout on full dataset for best prompt
rollout_rewards = [0.4, 0.5, 0.6, 1.1]
for i, reward in enumerate(rollout_rewards):
store.query_spans_map[f"rollout-{i}"] = [make_reward_span(f"rollout-{i}", "attempt", reward, sequence_id=1)]
store.wait_results_queue.append(
[
Rollout(
rollout_id=f"rollout-{i}",
input={"task": f"data-{i}"},
start_time=0.0,
status="succeeded",
mode="train" if i == 0 else "val",
)
]
)
monkeypatch.setattr(apo_module.random, "shuffle", lambda seq: None) # type: ignore
monkeypatch.setattr(apo_module.random, "sample", lambda population, k: list(population)[:k]) # type: ignore
monkeypatch.setattr(apo_module.random, "choice", lambda seq: seq[0]) # type: ignore
train_dataset = [{"task": "train"}]
val_dataset = [{"task": "val"}]
await apo.run(train_dataset=train_dataset, val_dataset=val_dataset) # type: ignore
# Verify best prompt was updated
best_prompt = apo.get_best_prompt()
assert best_prompt.template == "improved prompt"
# Verify OpenAI was called twice (gradient + edit)
assert create_mock.await_count == 2
gradient_call = create_mock.await_args_list[0]
assert gradient_call.kwargs["model"] == apo.gradient_model
edit_call = create_mock.await_args_list[1]
assert edit_call.kwargs["model"] == apo.apply_edit_model
# Verify resources were updated (seed prompt + new candidate prompts)
assert len(store.update_resources_calls) >= 2
assert all(isinstance(entry[0], str) for entry in store.update_resources_calls)
# Verify rollouts were enqueued (1 train + multiple val)
assert len(store.enqueue_calls) >= 3
train_calls = [c for c in store.enqueue_calls if c["mode"] == "train"]
val_calls = [c for c in store.enqueue_calls if c["mode"] == "val"]
assert len(train_calls) == 1
assert len(val_calls) >= 2
# Verify history was updated correctly
assert apo._history_best_prompt is not None
assert apo._history_best_score > 0