ELBench-live / tests /test_response_format_retry.py
adelevett's picture
Deploy ELBench-live dashboard
5db30ac verified
Raw History Blame Contribute Delete
21.5 kB
import asyncio
import json
import shutil
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "src"
if str(SRC) not in sys.path:
sys.path.insert(0, str(SRC))
from elbench.config import load_project_config # noqa: E402
from elbench.execution import BenchmarkRunner, RunOptions # noqa: E402
from elbench.execution.rate_limit import SlidingWindowRateLimiter # noqa: E402
from elbench.execution.runner import _build_concise_answer_retry_prompt # noqa: E402
from elbench.execution.response_format import ( # noqa: E402
ResponseFormatSpec,
attach_format_metadata,
build_concise_retry_prompt,
build_retry_followup,
get_response_format_spec,
should_force_concise_retry,
)
from elbench.persistence import CheckpointStore, JsonlWriter # noqa: E402
from elbench.schemas.config import RateLimitConfig, RetryConfig # noqa: E402
from elbench.schemas.evaluation import GenerationRequest, ModelResponse, Sample # noqa: E402
class ResponseFormatRetryTest(unittest.TestCase):
def test_empty_length_response_with_reasoning_tokens_uses_concise_retry(self) -> None:
response = ModelResponse(
text="",
raw_payload={
"choices": [
{
"message": {"role": "assistant", "content": ""},
"finish_reason": "length",
}
]
},
usage={
"completion_tokens": 8188,
"completion_tokens_details": {"reasoning_tokens": 7861},
},
)
spec = ResponseFormatSpec(
name="single_choice_en",
instruction="The last line must be exactly: ANSWER: [LETTER].",
reminder_template="unused",
valid_letters=list("ABCD"),
answer_prefixes=["ANSWER"],
)
self.assertTrue(should_force_concise_retry(response))
followup = build_retry_followup(spec, response)
self.assertEqual(followup["retry_reason"], "format_empty_after_reasoning")
self.assertEqual(followup["max_tokens"], 64)
self.assertIsNone(followup["assistant_content"])
self.assertIn("Do not include any reasoning", followup["user_reminder"])
self.assertIn("ANSWER: [LETTER]", followup["user_reminder"])
def test_concise_retry_prompt_removes_step_by_step_instruction(self) -> None:
base_prompt = "\n".join(
[
"Question:",
"Example question?",
"Options:",
"A) example",
"ANSWER: A",
"",
"Answer the following multiple choice question.",
"Think step by step before answering.",
"The last line of your response must be exactly: ANSWER: [LETTER].",
"",
"Question:",
"What is 2 + 2?",
"Options:",
"A) 3",
"B) 4",
]
)
spec = ResponseFormatSpec(
name="single_choice_en",
instruction="The last line must be exactly: ANSWER: [LETTER].",
reminder_template="unused",
valid_letters=list("AB"),
answer_prefixes=["ANSWER"],
)
retry_prompt = build_concise_retry_prompt(base_prompt, spec)
self.assertNotIn("Think step by step", retry_prompt)
self.assertIn("exactly one uppercase option letter", retry_prompt)
self.assertIn("Question:", retry_prompt)
self.assertIn("What is 2 + 2?", retry_prompt)
self.assertNotIn("Example question?", retry_prompt)
self.assertIn("Your answer:", retry_prompt)
def test_nonempty_response_keeps_normal_format_retry(self) -> None:
response = ModelResponse(
text="I think the answer is A.",
raw_payload={
"choices": [
{
"message": {"role": "assistant", "content": "I think the answer is A."},
"finish_reason": "stop",
}
]
},
usage={"completion_tokens": 12},
)
spec = ResponseFormatSpec(
name="single_choice_en",
instruction="The last line must be exactly: ANSWER: [LETTER].",
reminder_template="Your previous response did not follow the required answer format.",
valid_letters=list("ABCD"),
answer_prefixes=["ANSWER"],
)
self.assertFalse(should_force_concise_retry(response))
followup = build_retry_followup(spec, response)
self.assertEqual(followup["retry_reason"], "format_invalid")
self.assertEqual(followup["assistant_content"], "I think the answer is A.")
self.assertIn("did not follow the required answer format", followup["user_reminder"])
def test_nonempty_length_response_uses_concise_retry(self) -> None:
response = ModelResponse(
text="Long reasoning that used the full output budget before the final answer.",
raw_payload={
"choices": [
{
"message": {
"role": "assistant",
"content": "Long reasoning that used the full output budget before the final answer.",
},
"finish_reason": "length",
}
]
},
usage={"completion_tokens": 1024},
)
spec = ResponseFormatSpec(
name="single_choice_en",
instruction="The last line must be exactly: ANSWER: [LETTER].",
reminder_template="unused",
valid_letters=list("ABCD"),
answer_prefixes=["ANSWER"],
)
self.assertTrue(should_force_concise_retry(response))
followup = build_retry_followup(spec, response)
self.assertEqual(followup["retry_reason"], "format_empty_after_reasoning")
self.assertEqual(followup["max_tokens"], 64)
self.assertIsNone(followup["assistant_content"])
def test_failed_choice_checkpoint_retries_with_short_concise_prompt(self) -> None:
class ConciseChoiceClient:
def __init__(self) -> None:
self.requests = []
async def generate(self, sample, request):
self.requests.append(request)
return ModelResponse(
text="ANSWER: A",
raw_payload={
"choices": [
{
"message": {"role": "assistant", "content": "ANSWER: A"},
"finish_reason": "stop",
}
]
},
usage={"completion_tokens": 3},
)
temp_dir = ROOT / "tmp_test_artifacts" / "failed_choice_checkpoint"
shutil.rmtree(temp_dir, ignore_errors=True)
temp_dir.mkdir(parents=True, exist_ok=True)
try:
runner = BenchmarkRunner(load_project_config(ROOT / "configs"))
client = ConciseChoiceClient()
sample = Sample(
sample_id="mmlu1",
source_file="mmlu_pro_sampled.jsonl",
source_path="mmlu_pro_sampled.jsonl",
module="general",
subset="mmlu_pro",
task="mmlu_pro",
prompt=(
"Answer the following multiple choice question.\n"
"Think step by step before answering.\n"
"Options:\nA. one\nB. two"
),
reference={"answer": "A"},
)
request = GenerationRequest(
prompt=sample.prompt,
temperature=0,
max_tokens=1024,
stream=False,
messages=[{"role": "user", "content": sample.prompt}],
provider_kwargs={},
)
checkpoint = CheckpointStore(temp_dir / "checkpoint.json")
checkpoint.failed_ids.add("mmlu_pro_sampled.jsonl::mmlu1")
response, retry_count = asyncio.run(
runner._generate_with_retry(
sample=sample,
client=client,
request=request,
retry_policy=RetryConfig(
max_attempts=2,
initial_delay_seconds=0,
max_delay_seconds=0,
jitter_ratio=0,
),
limiter=SlidingWindowRateLimiter(RateLimitConfig(max_concurrency=1)),
retry_writer=JsonlWriter(temp_dir / "retries.jsonl"),
checkpoint=checkpoint,
)
)
self.assertEqual(retry_count, 0)
self.assertEqual(response.text, "ANSWER: A")
self.assertEqual(len(client.requests), 1)
self.assertEqual(client.requests[0].max_tokens, 64)
self.assertNotIn("Think step by step", client.requests[0].prompt)
self.assertIn("exactly one uppercase option letter", client.requests[0].prompt)
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
def test_math_length_response_retries_with_concise_prompt(self) -> None:
class TruncatedMathClient:
def __init__(self) -> None:
self.requests = []
async def generate(self, sample, request):
self.requests.append(request)
if len(self.requests) == 1:
return ModelResponse(
text="Long derivation without a final boxed answer.",
raw_payload={
"choices": [
{
"message": {
"role": "assistant",
"content": "Long derivation without a final boxed answer.",
},
"finish_reason": "length",
}
]
},
usage={"completion_tokens": 1024},
)
return ModelResponse(
text="\\boxed{42}",
raw_payload={
"choices": [
{
"message": {"role": "assistant", "content": "\\boxed{42}"},
"finish_reason": "stop",
}
]
},
usage={"completion_tokens": 8},
)
temp_dir = ROOT / "tmp_test_artifacts" / "math_length_retry"
shutil.rmtree(temp_dir, ignore_errors=True)
temp_dir.mkdir(parents=True, exist_ok=True)
try:
runner = BenchmarkRunner(load_project_config(ROOT / "configs"))
client = TruncatedMathClient()
sample = Sample(
sample_id="math1",
source_file="math_500_sampled.jsonl",
source_path="math_500_sampled.jsonl",
module="general",
subset="math_500",
task="math_500",
prompt="Solve this. Please reason step by step, and put your final answer within \\boxed{}.",
reference={"target": "42"},
)
request = GenerationRequest(
prompt=sample.prompt,
temperature=0,
max_tokens=1024,
stream=False,
messages=[{"role": "user", "content": sample.prompt}],
provider_kwargs={},
)
response, retry_count = asyncio.run(
runner._generate_with_retry(
sample=sample,
client=client,
request=request,
retry_policy=RetryConfig(
max_attempts=2,
initial_delay_seconds=0,
max_delay_seconds=0,
jitter_ratio=0,
),
limiter=SlidingWindowRateLimiter(RateLimitConfig(max_concurrency=1)),
retry_writer=JsonlWriter(temp_dir / "retries.jsonl"),
checkpoint=CheckpointStore(temp_dir / "checkpoint.json"),
)
)
self.assertEqual(retry_count, 1)
self.assertEqual(response.text, "\\boxed{42}")
self.assertEqual(len(client.requests), 2)
self.assertIn("Do not include any reasoning", client.requests[1].prompt)
self.assertEqual(client.requests[1].max_tokens, 1024)
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
def test_math_retryable_error_retries_with_concise_prompt(self) -> None:
class FailingMathClient:
def __init__(self) -> None:
self.requests = []
async def generate(self, sample, request):
self.requests.append(request)
if len(self.requests) == 1:
raise RuntimeError("transient gateway timeout")
return ModelResponse(
text="\\boxed{42}",
raw_payload={
"choices": [
{
"message": {"role": "assistant", "content": "\\boxed{42}"},
"finish_reason": "stop",
}
]
},
usage={"completion_tokens": 8},
)
temp_dir = ROOT / "tmp_test_artifacts" / "math_error_retry"
shutil.rmtree(temp_dir, ignore_errors=True)
temp_dir.mkdir(parents=True, exist_ok=True)
try:
runner = BenchmarkRunner(load_project_config(ROOT / "configs"))
client = FailingMathClient()
sample = Sample(
sample_id="math1",
source_file="math_500_sampled.jsonl",
source_path="math_500_sampled.jsonl",
module="general",
subset="math_500",
task="math_500",
prompt="Solve this. Please reason step by step, and put your final answer within \\boxed{}.",
reference={"target": "42"},
)
request = GenerationRequest(
prompt=sample.prompt,
temperature=0,
max_tokens=1024,
stream=False,
messages=[{"role": "user", "content": sample.prompt}],
provider_kwargs={},
)
response, retry_count = asyncio.run(
runner._generate_with_retry(
sample=sample,
client=client,
request=request,
retry_policy=RetryConfig(
max_attempts=2,
initial_delay_seconds=0,
max_delay_seconds=0,
jitter_ratio=0,
),
limiter=SlidingWindowRateLimiter(RateLimitConfig(max_concurrency=1)),
retry_writer=JsonlWriter(temp_dir / "retries.jsonl"),
checkpoint=CheckpointStore(temp_dir / "checkpoint.json"),
)
)
self.assertEqual(retry_count, 1)
self.assertEqual(response.text, "\\boxed{42}")
self.assertEqual(len(client.requests), 2)
self.assertIn("Do not include any reasoning", client.requests[1].prompt)
self.assertEqual(client.requests[1].max_tokens, 1024)
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
def test_concise_math_retry_prompt_preserves_question_when_instruction_is_inline(self) -> None:
prompt = (
"Convert the point $(0,3)$ in rectangular coordinates to polar coordinates. "
"Please reason step by step, and put your final answer within \\boxed{}."
)
retry_prompt = _build_concise_answer_retry_prompt(prompt)
self.assertIn("Convert the point", retry_prompt)
self.assertNotIn("reason step by step", retry_prompt.lower())
self.assertIn("\\boxed{}", retry_prompt)
def test_highlevel_omni_format_accepts_multiple_choice_answer(self) -> None:
sample = Sample(
sample_id="o_multi",
source_file="高阶育人-omni.jsonl",
source_path="高阶育人-omni.jsonl",
module="高阶育人",
subset="omni",
task="highlevel_omni",
prompt="dummy",
reference={"answer": "AC", "reasonableness": 1},
)
response = ModelResponse(text="答案:AC")
response = attach_format_metadata(response, get_response_format_spec(sample))
self.assertTrue(response.format_valid)
self.assertEqual(response.format_metadata["parsed_answer"], "AC")
def test_single_choice_invalid_format_triggers_retry_and_recovers(self) -> None:
temp_dir = ROOT / "tmp_test_artifacts" / "elbench_format_retry"
shutil.rmtree(temp_dir, ignore_errors=True)
temp_dir.mkdir(parents=True, exist_ok=True)
try:
config = load_project_config(ROOT / "configs")
config.app.output_root = temp_dir / "outputs"
model = config.models["mock.default"].model_copy(deep=True)
model.provider_kwargs["mode"] = "format_retry_probe"
model.retry.max_attempts = 2
config.models["mock.default"] = model
runner = BenchmarkRunner(config)
result = asyncio.run(
runner.run(
RunOptions(
model_id="mock.default",
run_id="format-retry-probe",
modules={"通用模型"},
source_files={"mmlu_pro_sampled.jsonl"},
max_samples=1,
)
)
)
output_root = temp_dir / "outputs"
retries_path = output_root / "logs" / "format-retry-probe" / "mock.default.retries.jsonl"
judged_path = Path(result["judged_path"])
retries = [
json.loads(line)
for line in retries_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
judged = [
json.loads(line)
for line in judged_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
self.assertEqual(len(retries), 1)
self.assertEqual(retries[0]["retry_reason"], "format_invalid")
self.assertEqual(len(judged), 1)
self.assertEqual(judged[0]["retry_count"], 1)
self.assertEqual(judged[0]["judge_result"], "fail")
self.assertEqual(judged[0]["model_response"], "ANSWER: A")
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
def test_final_format_failure_is_counted_as_model_wrong_answer(self) -> None:
temp_dir = ROOT / "tmp_test_artifacts" / "elbench_format_final_failure"
shutil.rmtree(temp_dir, ignore_errors=True)
temp_dir.mkdir(parents=True, exist_ok=True)
try:
config = load_project_config(ROOT / "configs")
config.app.output_root = temp_dir / "outputs"
model = config.models["mock.default"].model_copy(deep=True)
model.provider_kwargs["mode"] = "empty"
model.retry.max_attempts = 2
config.models["mock.default"] = model
runner = BenchmarkRunner(config)
result = asyncio.run(
runner.run(
RunOptions(
model_id="mock.default",
run_id="format-final-failure-probe",
modules={"通用模型"},
source_files={"mmlu_pro_sampled.jsonl"},
max_samples=1,
)
)
)
judged_path = Path(result["judged_path"])
failures_path = Path(result["failures_path"])
judged = [
json.loads(line)
for line in judged_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
failure_lines = failures_path.read_text(encoding="utf-8").splitlines() if failures_path.exists() else []
failures = [json.loads(line) for line in failure_lines if line.strip()]
self.assertEqual(len(judged), 1)
self.assertEqual(len(failures), 0)
self.assertEqual(judged[0]["judge_result"], "fail")
self.assertEqual(judged[0]["score"], 0)
self.assertEqual(judged[0]["metadata"]["failure_category"], "model_instruction_following")
self.assertEqual(judged[0]["metadata"]["failure_type"], "response_format")
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
if __name__ == "__main__":
unittest.main()