Skip to content

Commit 82f5d86

Browse files
author
Dylan Huang
committed
save
1 parent 1e29054 commit 82f5d86

9 files changed

Lines changed: 123 additions & 98 deletions

eval_protocol/pytest/pytest_utils.py

Lines changed: 80 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from eval_protocol.pytest.types import (
99
Dataset,
1010
DatasetPathParam,
11+
EvaluationTestMode,
1112
InputMessagesParam,
1213
InputParam,
1314
ModelParam,
@@ -44,6 +45,39 @@ def _aggregate(scores: List[float], method: str) -> float:
4445
raise ValueError(f"Unknown aggregation method: {method}")
4546

4647

48+
def _create_dynamically_parameterized_wrapper(test_func, wrapper_body, test_param_names):
49+
"""
50+
Creates a wrapper function with dynamic parameters for pytest parameterization.
51+
52+
This function takes a test function and creates a wrapper that:
53+
1. Preserves the original function's metadata using functools.wraps
54+
2. Creates a new function signature with the specified parameter names that maps to pytest.mark.parametrize decorator
55+
3. Returns a callable that can be used with pytest.mark.parametrize
56+
57+
The function signature is dynamically created to match the parameter names expected by
58+
pytest.mark.parametrize, ensuring that pytest can properly map the test parameters
59+
to the function arguments.
60+
61+
Args:
62+
test_func: The original test function to wrap
63+
wrapper_body: The function body that contains the actual test logic
64+
test_param_names: List of parameter names for the dynamic signature
65+
66+
Returns:
67+
A wrapper function with the specified parameter signature that calls wrapper_body
68+
"""
69+
from functools import wraps
70+
71+
@wraps(test_func)
72+
def wrapper(**kwargs):
73+
return wrapper_body(**kwargs)
74+
75+
parameters = [inspect.Parameter(name, inspect.Parameter.POSITIONAL_OR_KEYWORD) for name in test_param_names]
76+
wrapper.__signature__ = inspect.Signature(parameters)
77+
78+
return wrapper
79+
80+
4781
def evaluation_test(
4882
*,
4983
model: List[ModelParam],
@@ -57,7 +91,7 @@ def evaluation_test(
5791
num_runs: int = 1,
5892
max_dataset_rows: Optional[int] = None,
5993
mcp_config_path: Optional[str] = None,
60-
mode: str = "batch", # "batch" (default) or "pointwise"/"rowwise"
94+
mode: EvaluationTestMode = "batch",
6195
) -> Callable[
6296
[TestFunction],
6397
TestFunction,
@@ -82,7 +116,8 @@ def evaluation_test(
82116
num_runs: Number of times to repeat the evaluation.
83117
max_dataset_rows: Limit dataset to the first N rows.
84118
mode: Evaluation mode. "batch" (default) expects test function to handle
85-
full dataset. "pointwise"/"rowwise" applies test function to each row.
119+
full dataset. "pointwise" applies test function to each row. If your evaluation requires
120+
the full rollout of all rows to compute the score, use
86121
87122
Usage:
88123
With an input dataset and input params, the test function will be called with the following arguments:
@@ -128,22 +163,31 @@ def decorator(
128163
is_async = inspect.iscoroutinefunction(test_func)
129164

130165
sig = inspect.signature(test_func)
131-
166+
132167
# For pointwise/rowwise mode, we expect a different signature
133-
if mode in ["pointwise", "rowwise"]:
168+
if mode == "pointwise":
134169
# Pointwise mode: function should accept messages and other row-level params
135-
if "messages" not in sig.parameters:
136-
raise ValueError(f"In {mode} mode, test_func must have a parameter named 'messages'")
170+
if "row" not in sig.parameters:
171+
raise ValueError(f"In pointwise mode, your eval function must have a parameter named 'row'")
172+
173+
# validate that the function has a return type of EvaluationRow
174+
if sig.return_annotation is not EvaluationRow:
175+
raise ValueError("In pointwise mode, your eval function must return an EvaluationRow instance")
137176
else:
138177
# Batch mode: function should accept input_dataset and model
139178
if "input_dataset" not in sig.parameters:
140-
raise ValueError("test_func must have a parameter named 'input_dataset'")
179+
raise ValueError("In batch mode, your eval function must have a parameter named 'input_dataset'")
141180
if "model" not in sig.parameters:
142-
raise ValueError("test_func must have a parameter named 'model'")
181+
raise ValueError("In batch mode, your eval function must have a parameter named 'model'")
182+
183+
# validate that the function has a return type of List[EvaluationRow]
184+
if sig.return_annotation is not List[EvaluationRow]:
185+
raise ValueError("In batch mode, your eval function must return a list of EvaluationRow instances")
143186

144187
def execute_with_params(
145188
test_func: TestFunction,
146189
model: str,
190+
row: EvaluationRow | None = None,
147191
input_dataset: List[EvaluationRow] | None = None,
148192
input_params: InputParam | None = None,
149193
):
@@ -154,6 +198,8 @@ def execute_with_params(
154198
kwargs["input_params"] = input_params
155199
if model is not None:
156200
kwargs["model"] = model
201+
if row is not None:
202+
kwargs["row"] = row
157203
if is_async:
158204
# Handle async functions with proper event loop management
159205
try:
@@ -173,19 +219,16 @@ def execute_with_params(
173219
return results
174220

175221
# Calculate all possible combinations of parameters
176-
def generate_combinations(model: List[ModelParam]):
222+
def generate_combinations():
177223
combinations = []
178224

179-
# Always include models
180-
model_list = model
181-
182225
# Handle optional parameters with defaults
183226
datasets: List[Optional[DatasetPathParam]] = input_dataset if input_dataset is not None else [None] # type: ignore
184227
params: List[Optional[InputParam]] = input_params if input_params is not None else [None] # type: ignore
185228
messages: List[Optional[InputMessagesParam]] = input_messages if input_messages is not None else [None] # type: ignore
186229

187230
# Generate all combinations
188-
for m in model_list:
231+
for m in model:
189232
for ds in datasets:
190233
for ip in params:
191234
for im in messages:
@@ -199,7 +242,7 @@ def generate_combinations(model: List[ModelParam]):
199242

200243
return combinations
201244

202-
combinations = generate_combinations(model)
245+
combinations = generate_combinations()
203246

204247
# Create parameter tuples for pytest.mark.parametrize
205248
param_tuples = []
@@ -214,23 +257,14 @@ def generate_combinations(model: List[ModelParam]):
214257
param_tuple.append(messages)
215258
param_tuples.append(tuple(param_tuple))
216259

217-
# Determine the parameter names for the test function
218-
if mode in ["pointwise", "rowwise"]:
219-
# For pointwise mode, we generate simpler parameter names
220-
test_param_names = ["model"]
221-
if input_dataset is not None:
222-
test_param_names.append("dataset_path")
223-
if input_params is not None:
224-
test_param_names.append("input_params")
225-
else:
226-
# For batch mode, use the original parameter names
227-
test_param_names = ["model"]
228-
if input_dataset is not None:
229-
test_param_names.append("dataset_path")
230-
if input_params is not None:
231-
test_param_names.append("input_params")
232-
if input_messages is not None:
233-
test_param_names.append("input_messages")
260+
# For batch mode, use the original parameter names
261+
test_param_names = ["model"]
262+
if input_dataset is not None:
263+
test_param_names.append("dataset_path")
264+
if input_params is not None:
265+
test_param_names.append("input_params")
266+
if input_messages is not None:
267+
test_param_names.append("input_messages")
234268

235269
# Create wrapper function with exact signature that pytest expects
236270
def create_wrapper_with_signature():
@@ -276,9 +310,20 @@ def wrapper_body(**kwargs):
276310

277311
all_results: List[EvaluationRow] = []
278312
for _ in range(num_runs):
279-
if mode in ["pointwise", "rowwise"]:
313+
if mode == "pointwise":
280314
# Pointwise mode: apply the evaluator function to each row
281-
results = evaluate(input_dataset, test_func)
315+
for row in input_dataset:
316+
result = execute_with_params(
317+
test_func,
318+
model=model_name,
319+
row=row,
320+
input_params=kwargs.get("input_params") if "input_params" in kwargs else None,
321+
)
322+
if result is None or not isinstance(result, EvaluationRow):
323+
raise ValueError(
324+
f"Test function {test_func.__name__} did not return an EvaluationRow instance. You must return an EvaluationRow instance from your test function decorated with @evaluation_test."
325+
)
326+
all_results.append(result)
282327
else:
283328
# Batch mode: call the test function with the full dataset
284329
results = execute_with_params(
@@ -303,7 +348,7 @@ def wrapper_body(**kwargs):
303348
raise ValueError(
304349
f"Test function {test_func.__name__} returned a list containing non-EvaluationRow instances. You must return a list of EvaluationRow instances from your test function decorated with @evaluation_test."
305350
)
306-
all_results.extend(results)
351+
all_results.extend(results)
307352

308353
scores = [r.evaluation_result.score for r in all_results if r.evaluation_result]
309354
agg_score = _aggregate(scores, aggregation_method)
@@ -312,33 +357,7 @@ def wrapper_body(**kwargs):
312357
agg_score >= threshold_of_success
313358
), f"Aggregated score {agg_score:.3f} below threshold {threshold_of_success}"
314359

315-
# Create a function with the exact signature pytest expects without using exec
316-
from functools import wraps
317-
318-
if mode in ["pointwise", "rowwise"]:
319-
# For pointwise mode, create a wrapper that handles everything internally
320-
@wraps(test_func)
321-
def wrapper(**kwargs):
322-
return wrapper_body(**kwargs)
323-
324-
# Give it the pytest-compatible signature but the test_func name
325-
parameters = [
326-
inspect.Parameter(name, inspect.Parameter.POSITIONAL_OR_KEYWORD) for name in test_param_names
327-
]
328-
wrapper.__signature__ = inspect.Signature(parameters)
329-
wrapper.__name__ = test_func.__name__.replace('_evaluate', '_dataset') if '_evaluate' in test_func.__name__ else f"test_{test_func.__name__}"
330-
else:
331-
# For batch mode, use the original wrapper
332-
@wraps(test_func)
333-
def wrapper(**kwargs):
334-
return wrapper_body(**kwargs)
335-
336-
parameters = [
337-
inspect.Parameter(name, inspect.Parameter.POSITIONAL_OR_KEYWORD) for name in test_param_names
338-
]
339-
wrapper.__signature__ = inspect.Signature(parameters)
340-
341-
return wrapper
360+
return _create_dynamically_parameterized_wrapper(test_func, wrapper_body, test_param_names)
342361

343362
wrapper = create_wrapper_with_signature()
344363
wrapper = pytest.mark.parametrize(test_param_names, param_tuples)(wrapper)

eval_protocol/pytest/types.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"""
44

55
from dataclasses import dataclass
6-
from typing import Any, Callable, Dict, List
6+
from typing import Any, Callable, Dict, List, Literal
77

88
from ..models import EvaluationRow, Message
99

@@ -13,6 +13,17 @@
1313
InputMessagesParam = List[Message]
1414

1515
Dataset = List[EvaluationRow]
16+
17+
EvaluationTestMode = Literal["batch", "pointwise"]
18+
"""
19+
"batch": (default) expects test function to handle full dataset.
20+
"pointwise": applies test function to each row.
21+
22+
How to choose between "batch" and "pointwise":
23+
If your evaluation requires the rollout of all rows to be passed into your eval compute the score, use "batch".
24+
If your evaluation can be computed pointwise, use "pointwise" as EP can pipeline the rollouts and evals to be faster.
25+
"""
26+
1627
"""
1728
Test function types
1829
"""

tests/pytest/test_markdown_highlighting.py

Lines changed: 17 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -15,70 +15,62 @@
1515
def markdown_dataset_to_evaluation_row(data: List[Dict[str, Any]]) -> List[EvaluationRow]:
1616
"""
1717
Convert entries from markdown dataset to EvaluationRow objects.
18-
"""
18+
"""
1919
return [
20-
EvaluationRow(
21-
messages=[Message(role="user", content=row["prompt"])],
22-
ground_truth=str(row["num_highlights"])
23-
)
20+
EvaluationRow(messages=[Message(role="user", content=row["prompt"])], ground_truth=str(row["num_highlights"]))
2421
for row in data
2522
]
2623

2724

28-
def markdown_format_evaluate(messages: List[Message], ground_truth: Optional[str]=None, **kwargs) -> EvaluateResult:
25+
def markdown_format_evaluate(messages: List[Message], ground_truth: Optional[str] = None, **kwargs) -> EvaluateResult:
2926
"""
3027
Evaluation function that checks if the model's response contains the required number of formatted sections.
3128
"""
32-
29+
3330
assistant_response = messages[-1].content
34-
31+
3532
if not assistant_response:
36-
return EvaluateResult(
37-
score=0.0,
38-
reason="❌ No assistant response found"
39-
)
40-
33+
return EvaluateResult(score=0.0, reason="❌ No assistant response found")
34+
4135
required_highlights = int(ground_truth)
4236

4337
# Check if the response contains the required number of formatted sections
4438
# e.g. **bold** or *italic*
45-
39+
4640
actual_count = 0
4741
highlights = re.findall(r"\*[^\n\*]*\*", assistant_response)
4842
double_highlights = re.findall(r"\*\*[^\n\*]*\*\*", assistant_response)
49-
43+
5044
for highlight in highlights:
5145
if highlight.strip("*").strip():
5246
actual_count += 1
5347
for highlight in double_highlights:
5448
if highlight.removeprefix("**").removesuffix("**").strip():
5549
actual_count += 1
56-
50+
5751
meets_requirement = actual_count >= required_highlights
58-
52+
5953
if meets_requirement:
6054
return EvaluateResult(
61-
score=1.0,
62-
reason=f"✅ Found {actual_count} highlighted sections (required: {required_highlights})"
55+
score=1.0, reason=f"✅ Found {actual_count} highlighted sections (required: {required_highlights})"
6356
)
6457
else:
6558
return EvaluateResult(
66-
score=0.0,
67-
reason=f"❌ Only found {actual_count} highlighted sections (required: {required_highlights})"
59+
score=0.0, reason=f"❌ Only found {actual_count} highlighted sections (required: {required_highlights})"
6860
)
6961

7062

7163
@evaluation_test(
7264
input_dataset=["tests/pytest/data/markdown_dataset.jsonl"],
7365
dataset_adapter=markdown_dataset_to_evaluation_row,
7466
model=["accounts/fireworks/models/llama-v3p1-8b-instruct"],
75-
input_params=[{"temperature": 0.0, "max_tokens": 4096}],
67+
input_params=[{"temperature": 0.0, "max_tokens": 4096}],
7668
threshold_of_success=1.0,
7769
rollout_processor=default_single_turn_rollout_processor,
78-
num_runs=1
70+
num_runs=1,
7971
)
80-
def test_markdown_highlighting_evaluation(input_dataset, input_params, model):
72+
def test_markdown_highlighting_evaluation(input_dataset, input_params, model) -> List[EvaluationRow]:
8173
"""
8274
Test markdown highlighting validation using batch mode with evaluate().
8375
"""
84-
return evaluate(input_dataset, markdown_format_evaluate)
76+
return evaluate(input_dataset, markdown_format_evaluate)

tests/pytest/test_pytest_async.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,6 @@
1616
],
1717
model=["accounts/fireworks/models/kimi-k2-instruct"],
1818
)
19-
async def test_pytest_async(input_dataset: List[EvaluationRow], model):
19+
async def test_pytest_async(input_dataset: List[EvaluationRow], model) -> List[EvaluationRow]:
2020
"""Run math evaluation on sample dataset using pytest interface."""
2121
return input_dataset

tests/pytest/test_pytest_default_agent_rollout_processor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,6 @@
1919
rollout_processor=default_agent_rollout_processor,
2020
model=["fireworks_ai/accounts/fireworks/models/kimi-k2-instruct"],
2121
)
22-
def test_pytest_default_agent_rollout_processor(input_dataset: List[EvaluationRow], model):
22+
def test_pytest_default_agent_rollout_processor(input_dataset: List[EvaluationRow], model) -> List[EvaluationRow]:
2323
"""Run math evaluation on sample dataset using pytest interface."""
2424
return input_dataset

tests/pytest/test_pytest_input_messages.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,6 @@
1313
model=["accounts/fireworks/models/kimi-k2-instruct"],
1414
rollout_processor=default_single_turn_rollout_processor,
1515
)
16-
def test_input_messages_in_decorator(input_dataset: List[EvaluationRow], model):
16+
def test_input_messages_in_decorator(input_dataset: List[EvaluationRow], model) -> List[EvaluationRow]:
1717
"""Run math evaluation on sample dataset using pytest interface."""
1818
return input_dataset

tests/pytest/test_pytest_math_example.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import List
2+
from eval_protocol.models import EvaluationRow
13
from eval_protocol.pytest import default_single_turn_rollout_processor, evaluate, evaluation_test
24
from examples.math_example.main import evaluate as math_evaluate
35
from tests.pytest.helper.gsm8k_to_evaluation_row import gsm8k_to_evaluation_row
@@ -12,6 +14,6 @@
1214
threshold_of_success=0.0,
1315
rollout_processor=default_single_turn_rollout_processor,
1416
)
15-
def test_math_dataset(input_dataset, input_params, model):
17+
def test_math_dataset(input_dataset, input_params, model) -> List[EvaluationRow]:
1618
"""Run math evaluation on sample dataset using pytest interface."""
1719
return evaluate(input_dataset, math_evaluate)

0 commit comments

Comments
 (0)