Skip to content

Commit ae15d1c

Browse files
Merge pull request #4150 from AI-Hypercomputer:pr/extract-answer-boxed
PiperOrigin-RevId: 936252907
2 parents f16ed4e + cddd7dc commit ae15d1c

2 files changed

Lines changed: 171 additions & 6 deletions

File tree

src/maxtext/trainers/post_train/rl/utils_rl.py

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -484,12 +484,53 @@ def check_numbers(
484484

485485

486486
def extract_answer(response: str, tmvp_config: Any) -> str:
487-
"""Function to extract the answer from the text based on the tmvp_config format."""
488-
answer_fallback = get_answer_fallback_regex(tmvp_config)
489-
# Find the *last* occurrence of the answer tag (most likely the final answer).
490-
fallback_matches = answer_fallback.findall(response)
491-
extracted_response = fallback_matches[-1].strip() if fallback_matches else FALLBACK_ANSWER
492-
return extracted_response
487+
"""Extract the final numeric answer from the model's response.
488+
489+
Strategy (priority order):
490+
1. Narrow the search scope to the LAST
491+
`{solution_start_token}...{solution_end_token}` block (default
492+
`<answer>...</answer>`) if present; otherwise use the full response.
493+
2. Inside the search scope, find the last `\\boxed{N}` via a brace-
494+
balanced scan (handles nested braces in LaTeX). Fall back to a
495+
permissive `\\boxed{N}` regex if no balanced match is found.
496+
3. If no boxed expression is found, fall back to the same configured
497+
solution-tag regex over the full response, for recipes that emit the
498+
answer as plain text rather than `\\boxed{N}`.
499+
500+
Step 1 + 2 are required for modern reasoning models (Qwen3, DeepSeek-R1,
501+
etc.) that emit `<think>...</think>\\boxed{N}` or `<answer>\\boxed{N}</answer>`
502+
framing. Without `\\boxed` extraction the legacy regex returns the raw
503+
`\\boxed{N}` string and math_verify cannot match it against a bare numeric
504+
gold. Step 3 keeps the function backward-compatible with recipes that
505+
emit plain-text answers inside the configured solution tags.
506+
507+
The solution tags have a single source of truth: both the scoping (step 1)
508+
and the plain-text fallback (step 3) reuse `get_answer_fallback_regex`,
509+
built from `tmvp_config.solution_start_token` / `solution_end_token`.
510+
"""
511+
answer_tag_regex = get_answer_fallback_regex(tmvp_config)
512+
answer_matches = answer_tag_regex.findall(response)
513+
content = answer_matches[-1] if answer_matches else response
514+
boxed_matches: list[str] = []
515+
stack: list[int] = []
516+
for i, ch in enumerate(content):
517+
if ch == "{":
518+
stack.append(i)
519+
elif ch == "}":
520+
if not stack:
521+
continue
522+
op = stack.pop()
523+
if content[:op].endswith(r"\boxed"):
524+
boxed_matches.append(content[op + 1 : i].strip())
525+
if boxed_matches:
526+
return boxed_matches[-1]
527+
m = re.search(r"\\boxed\s*\{?\s*([a-zA-Z0-9\.,\-]+)\s*\}?", content)
528+
if m:
529+
return m.group(1).strip()
530+
fallback_matches = answer_tag_regex.findall(response)
531+
if fallback_matches:
532+
return fallback_matches[-1].strip()
533+
return FALLBACK_ANSWER
493534

494535

495536
def extract_hash_answer(text: str) -> str | None:
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# Copyright 2023-2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Unit tests for utils_rl.extract_answer (CPU-only).
16+
17+
Covers the two-part contract of the boxed-extraction change:
18+
1. `\\boxed{N}` is extracted (with/without <answer> tags, nested LaTeX,
19+
multiple boxed, whitespace, negatives, and answer-tag scoping).
20+
2. Legacy plain-text answers inside the solution tags still work, so
21+
existing recipes that do not emit `\\boxed` are unaffected.
22+
"""
23+
24+
import unittest
25+
from types import SimpleNamespace
26+
27+
import pytest
28+
29+
from maxtext.trainers.post_train.rl import utils_rl
30+
31+
pytestmark = [pytest.mark.post_training]
32+
33+
34+
def _make_config():
35+
"""Minimal config carrying the solution/reasoning tokens extract_answer reads."""
36+
return SimpleNamespace(
37+
reasoning_start_token="<reasoning>",
38+
reasoning_end_token="</reasoning>",
39+
solution_start_token="<answer>",
40+
solution_end_token="</answer>",
41+
)
42+
43+
44+
class ExtractAnswerTest(unittest.TestCase):
45+
"""Verify boxed extraction and legacy-fallback behavior of extract_answer."""
46+
47+
def setUp(self):
48+
super().setUp()
49+
self.config = _make_config()
50+
51+
# ---- boxed extraction ----
52+
53+
@pytest.mark.cpu_only
54+
def test_boxed_inside_answer_tags(self):
55+
got = utils_rl.extract_answer("<reasoning>2+2</reasoning><answer>\\boxed{4}</answer>", self.config)
56+
self.assertEqual(got, "4")
57+
58+
@pytest.mark.cpu_only
59+
def test_boxed_without_answer_tags(self):
60+
got = utils_rl.extract_answer("the result is \\boxed{42}", self.config)
61+
self.assertEqual(got, "42")
62+
63+
@pytest.mark.cpu_only
64+
def test_boxed_nested_latex(self):
65+
"""Brace-balanced scan keeps the full nested LaTeX content."""
66+
got = utils_rl.extract_answer("<answer>\\boxed{\\frac{1}{2}}</answer>", self.config)
67+
self.assertEqual(got, "\\frac{1}{2}")
68+
69+
@pytest.mark.cpu_only
70+
def test_multiple_boxed_returns_last(self):
71+
got = utils_rl.extract_answer("first \\boxed{1} then \\boxed{99}", self.config)
72+
self.assertEqual(got, "99")
73+
74+
@pytest.mark.cpu_only
75+
def test_boxed_strips_whitespace(self):
76+
got = utils_rl.extract_answer("<answer>\\boxed{ 7 }</answer>", self.config)
77+
self.assertEqual(got, "7")
78+
79+
@pytest.mark.cpu_only
80+
def test_boxed_negative(self):
81+
got = utils_rl.extract_answer("answer: \\boxed{-3}", self.config)
82+
self.assertEqual(got, "-3")
83+
84+
@pytest.mark.cpu_only
85+
def test_answer_tag_scopes_over_reasoning_boxed(self):
86+
"""A boxed value in <reasoning> must not win over the one in <answer>."""
87+
resp = "<reasoning>maybe \\boxed{1}</reasoning><answer>\\boxed{8}</answer>"
88+
self.assertEqual(utils_rl.extract_answer(resp, self.config), "8")
89+
90+
@pytest.mark.cpu_only
91+
def test_scoping_follows_configured_solution_tokens(self):
92+
"""Scoping uses solution_start/end_token, not a hardcoded <answer> tag."""
93+
config = SimpleNamespace(
94+
reasoning_start_token="<reasoning>",
95+
reasoning_end_token="</reasoning>",
96+
solution_start_token="<sol>",
97+
solution_end_token="</sol>",
98+
)
99+
resp = "<reasoning>maybe \\boxed{1}</reasoning><sol>\\boxed{8}</sol>"
100+
self.assertEqual(utils_rl.extract_answer(resp, config), "8")
101+
102+
# ---- legacy fallback (no boxed) ----
103+
104+
@pytest.mark.cpu_only
105+
def test_legacy_plain_answer_in_tags(self):
106+
"""A plain-text answer inside <answer> tags (no boxed) still extracts."""
107+
got = utils_rl.extract_answer("<reasoning>work</reasoning><answer>42</answer>", self.config)
108+
self.assertEqual(got, "42")
109+
110+
@pytest.mark.cpu_only
111+
def test_legacy_last_answer_wins(self):
112+
got = utils_rl.extract_answer("<answer>1</answer> ... <answer>5</answer>", self.config)
113+
self.assertEqual(got, "5")
114+
115+
# ---- no answer ----
116+
117+
@pytest.mark.cpu_only
118+
def test_no_answer_returns_fallback_constant(self):
119+
got = utils_rl.extract_answer("I have no idea", self.config)
120+
self.assertEqual(got, utils_rl.FALLBACK_ANSWER)
121+
122+
123+
if __name__ == "__main__":
124+
unittest.main()

0 commit comments

Comments
 (0)