ai4math 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ai4math/__init__.py +130 -0
- ai4math/answers/__init__.py +33 -0
- ai4math/answers/extract.py +299 -0
- ai4math/answers/normalize.py +263 -0
- ai4math/datasets/__init__.py +21 -0
- ai4math/datasets/loaders.py +276 -0
- ai4math/demo.py +239 -0
- ai4math/formal/__init__.py +40 -0
- ai4math/formal/autoprove.py +301 -0
- ai4math/formal/base.py +104 -0
- ai4math/formal/lean.py +271 -0
- ai4math/formal/lean_cli.py +248 -0
- ai4math/metrics/__init__.py +19 -0
- ai4math/metrics/scoring.py +214 -0
- ai4math/models/__init__.py +13 -0
- ai4math/models/claude_cli.py +165 -0
- ai4math/pipeline.py +264 -0
- ai4math/prompts/__init__.py +25 -0
- ai4math/prompts/templates.py +184 -0
- ai4math/py.typed +0 -0
- ai4math/symbolic/__init__.py +37 -0
- ai4math/symbolic/tools.py +385 -0
- ai4math/verify/__init__.py +21 -0
- ai4math/verify/equivalence.py +434 -0
- ai4math-0.1.0.dist-info/METADATA +240 -0
- ai4math-0.1.0.dist-info/RECORD +28 -0
- ai4math-0.1.0.dist-info/WHEEL +4 -0
- ai4math-0.1.0.dist-info/licenses/LICENSE +202 -0
ai4math/__init__.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""ai4math — tools for AI-assisted mathematics.
|
|
2
|
+
|
|
3
|
+
A small, dependency-light toolkit for the mechanics that every AI-for-mathematics
|
|
4
|
+
project ends up rewriting: extracting a final answer from a model's reasoning,
|
|
5
|
+
deciding whether it matches the reference, scoring sampled attempts, and calling
|
|
6
|
+
a computer algebra system as a tool.
|
|
7
|
+
|
|
8
|
+
The library covers both halves of the field as described by Yang et al. (2024,
|
|
9
|
+
arXiv:2412.16075). The informal, natural-language half is implemented here and
|
|
10
|
+
needs only SymPy. The formal half — proving theorems in an interactive theorem
|
|
11
|
+
prover — is defined as a protocol with an optional Lean backend, because a proof
|
|
12
|
+
assistant toolchain does not belong in a small package's dependencies.
|
|
13
|
+
|
|
14
|
+
Quick start::
|
|
15
|
+
|
|
16
|
+
from ai4math import extract_answer, verify
|
|
17
|
+
|
|
18
|
+
text = "Adding gives 4, so the answer is \\\\boxed{4}."
|
|
19
|
+
answer = extract_answer(text) # '4'
|
|
20
|
+
assert verify("4", answer) # graded correct
|
|
21
|
+
|
|
22
|
+
Grading a model over a dataset::
|
|
23
|
+
|
|
24
|
+
from ai4math import evaluate, BUILTIN_EXAMPLES
|
|
25
|
+
|
|
26
|
+
report = evaluate(BUILTIN_EXAMPLES, my_model_fn, n_samples=4)
|
|
27
|
+
print(report.as_dict()) # pass@1, pass@4, maj@n
|
|
28
|
+
|
|
29
|
+
Layout:
|
|
30
|
+
|
|
31
|
+
* :mod:`ai4math.answers` — extract and normalize final answers
|
|
32
|
+
* :mod:`ai4math.verify` — decide answer equivalence
|
|
33
|
+
* :mod:`ai4math.metrics` — ``pass@k``, majority vote, best-of-n
|
|
34
|
+
* :mod:`ai4math.symbolic` — a guarded SymPy tool surface for models
|
|
35
|
+
* :mod:`ai4math.prompts` — prompt templates with matching answer parsers
|
|
36
|
+
* :mod:`ai4math.datasets` — problem records and loaders
|
|
37
|
+
* :mod:`ai4math.formal` — theorem-prover protocol, Lean adapters, and a
|
|
38
|
+
generate-compile-repair proof loop
|
|
39
|
+
* :mod:`ai4math.models` — adapters turning an LLM into a plain callable
|
|
40
|
+
* :mod:`ai4math.pipeline` — end-to-end solve, grade and aggregate
|
|
41
|
+
* :mod:`ai4math.demo` — ``python -m ai4math.demo`` for a Lean-verified proof
|
|
42
|
+
|
|
43
|
+
The formal half is reached through :mod:`ai4math.formal` rather than re-exported
|
|
44
|
+
here, keeping the top-level namespace to what works with SymPy alone.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
from ai4math.answers import (
|
|
50
|
+
extract_all_boxed,
|
|
51
|
+
extract_answer,
|
|
52
|
+
extract_boxed,
|
|
53
|
+
extract_final_answer,
|
|
54
|
+
normalize_answer,
|
|
55
|
+
normalize_latex,
|
|
56
|
+
)
|
|
57
|
+
from ai4math.datasets import BUILTIN_EXAMPLES, MathProblem, load_json, load_jsonl
|
|
58
|
+
from ai4math.metrics import (
|
|
59
|
+
EvalSummary,
|
|
60
|
+
accuracy,
|
|
61
|
+
best_of_n,
|
|
62
|
+
majority_vote,
|
|
63
|
+
pass_at_k,
|
|
64
|
+
)
|
|
65
|
+
from ai4math.pipeline import (
|
|
66
|
+
EvalReport,
|
|
67
|
+
ModelFn,
|
|
68
|
+
SolveResult,
|
|
69
|
+
evaluate,
|
|
70
|
+
solve,
|
|
71
|
+
solve_and_verify,
|
|
72
|
+
)
|
|
73
|
+
from ai4math.prompts import TEMPLATES, PromptTemplate, get_template
|
|
74
|
+
from ai4math.symbolic import (
|
|
75
|
+
SymbolicError,
|
|
76
|
+
differentiate,
|
|
77
|
+
evaluate_expr,
|
|
78
|
+
factor_expr,
|
|
79
|
+
integrate_expr,
|
|
80
|
+
simplify_expr,
|
|
81
|
+
solve_equation,
|
|
82
|
+
)
|
|
83
|
+
from ai4math.verify import VerificationResult, verify, verify_equivalence
|
|
84
|
+
|
|
85
|
+
__version__ = "0.1.0"
|
|
86
|
+
|
|
87
|
+
__all__ = [
|
|
88
|
+
"__version__",
|
|
89
|
+
# answers
|
|
90
|
+
"extract_answer",
|
|
91
|
+
"extract_boxed",
|
|
92
|
+
"extract_all_boxed",
|
|
93
|
+
"extract_final_answer",
|
|
94
|
+
"normalize_answer",
|
|
95
|
+
"normalize_latex",
|
|
96
|
+
# verify
|
|
97
|
+
"verify",
|
|
98
|
+
"verify_equivalence",
|
|
99
|
+
"VerificationResult",
|
|
100
|
+
# metrics
|
|
101
|
+
"accuracy",
|
|
102
|
+
"pass_at_k",
|
|
103
|
+
"majority_vote",
|
|
104
|
+
"best_of_n",
|
|
105
|
+
"EvalSummary",
|
|
106
|
+
# symbolic
|
|
107
|
+
"simplify_expr",
|
|
108
|
+
"solve_equation",
|
|
109
|
+
"differentiate",
|
|
110
|
+
"integrate_expr",
|
|
111
|
+
"factor_expr",
|
|
112
|
+
"evaluate_expr",
|
|
113
|
+
"SymbolicError",
|
|
114
|
+
# prompts
|
|
115
|
+
"PromptTemplate",
|
|
116
|
+
"TEMPLATES",
|
|
117
|
+
"get_template",
|
|
118
|
+
# datasets
|
|
119
|
+
"MathProblem",
|
|
120
|
+
"load_jsonl",
|
|
121
|
+
"load_json",
|
|
122
|
+
"BUILTIN_EXAMPLES",
|
|
123
|
+
# pipeline
|
|
124
|
+
"solve",
|
|
125
|
+
"solve_and_verify",
|
|
126
|
+
"evaluate",
|
|
127
|
+
"SolveResult",
|
|
128
|
+
"EvalReport",
|
|
129
|
+
"ModelFn",
|
|
130
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Extracting and normalizing final answers from mathematical solutions."""
|
|
2
|
+
|
|
3
|
+
from ai4math.answers.extract import (
|
|
4
|
+
BoxedSpan,
|
|
5
|
+
extract_all_boxed,
|
|
6
|
+
extract_answer,
|
|
7
|
+
extract_boxed,
|
|
8
|
+
extract_final_answer,
|
|
9
|
+
extract_last_number,
|
|
10
|
+
find_boxed_spans,
|
|
11
|
+
remove_boxed,
|
|
12
|
+
)
|
|
13
|
+
from ai4math.answers.normalize import (
|
|
14
|
+
normalize_answer,
|
|
15
|
+
normalize_latex,
|
|
16
|
+
split_tuple,
|
|
17
|
+
strip_units,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"BoxedSpan",
|
|
22
|
+
"extract_all_boxed",
|
|
23
|
+
"extract_answer",
|
|
24
|
+
"extract_boxed",
|
|
25
|
+
"extract_final_answer",
|
|
26
|
+
"extract_last_number",
|
|
27
|
+
"find_boxed_spans",
|
|
28
|
+
"normalize_answer",
|
|
29
|
+
"normalize_latex",
|
|
30
|
+
"remove_boxed",
|
|
31
|
+
"split_tuple",
|
|
32
|
+
"strip_units",
|
|
33
|
+
]
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Extract final answers from natural-language mathematical solutions.
|
|
2
|
+
|
|
3
|
+
Model outputs rarely arrive in a machine-readable form: the answer is buried at
|
|
4
|
+
the end of a chain of reasoning, wrapped in ``\\boxed{...}``, announced with a
|
|
5
|
+
phrase like "the final answer is", or simply left as the last number in the
|
|
6
|
+
text. Every evaluation harness reimplements this extraction step, usually
|
|
7
|
+
slightly differently. This module collects the strategies that recur across
|
|
8
|
+
OpenAI's PRM800K grader, EleutherAI's ``lm-evaluation-harness`` (Minerva-style
|
|
9
|
+
prompting) and HuggingFace's ``math-verify``, behind one explicit ladder.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from collections.abc import Iterable, Sequence
|
|
16
|
+
from typing import NamedTuple
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"FINAL_ANSWER_PATTERNS",
|
|
20
|
+
"BoxedSpan",
|
|
21
|
+
"extract_all_boxed",
|
|
22
|
+
"extract_answer",
|
|
23
|
+
"extract_boxed",
|
|
24
|
+
"extract_final_answer",
|
|
25
|
+
"extract_last_number",
|
|
26
|
+
"find_boxed_spans",
|
|
27
|
+
"remove_boxed",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
#: LaTeX macros that conventionally mark a final answer.
|
|
31
|
+
_BOX_MACROS = ("\\boxed", "\\fbox", "\\framebox")
|
|
32
|
+
|
|
33
|
+
#: Phrases that introduce a final answer, most explicit first. The Minerva
|
|
34
|
+
#: template ("Final Answer: The final answer is $X$. I hope it is correct.")
|
|
35
|
+
#: comes first because matching it is unambiguous.
|
|
36
|
+
#: Each pattern stops its capture at a sentence boundary (``. `` followed by
|
|
37
|
+
#: whitespace) rather than running to end of line, so a solution that states an
|
|
38
|
+
#: answer and then keeps talking yields just the answer — and so that
|
|
39
|
+
#: ``finditer`` can find a *later* restatement in the same line.
|
|
40
|
+
FINAL_ANSWER_PATTERNS: tuple[str, ...] = (
|
|
41
|
+
r"final answer is\s*:?\s*(?P<ans>.+?)(?:\.\s*I hope it is correct\.?|\.\s|\.$|$)",
|
|
42
|
+
r"final answer\s*:\s*(?P<ans>.+?)(?:\.\s|\.$|$)",
|
|
43
|
+
r"the answer is\s*:?\s*(?P<ans>.+?)(?:\.\s|\.$|$)",
|
|
44
|
+
r"answer\s*:\s*(?P<ans>.+?)(?:\.\s|\.$|$)",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
#: A clause boundary: a comma followed by a word, as in "3, that is ...".
|
|
48
|
+
#: Tuple answers such as "1, 2" are unaffected because the tail is a digit, and
|
|
49
|
+
#: bracketed answers are exempted wholesale.
|
|
50
|
+
_TRAILING_CLAUSE = re.compile(r",\s+(?=[A-Za-z])")
|
|
51
|
+
|
|
52
|
+
_BRACKET_PAIRS = (("(", ")"), ("[", "]"), ("{", "}"))
|
|
53
|
+
|
|
54
|
+
_TRAILING_JUNK = re.compile(r"(?:\.\s*I hope it is correct\.?|[.\s]+)$", re.IGNORECASE)
|
|
55
|
+
|
|
56
|
+
# A signed number: optional thousands separators, decimals, exponent.
|
|
57
|
+
_NUMBER = re.compile(
|
|
58
|
+
r"-?\d+(?:[,\s]\d{3})*(?:\.\d+)?(?:[eE][-+]?\d+)?|-?\.\d+"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class BoxedSpan(NamedTuple):
|
|
63
|
+
"""Location and contents of one ``\\boxed{...}``-style span."""
|
|
64
|
+
|
|
65
|
+
start: int
|
|
66
|
+
"""Index of the first character of the macro (e.g. of ``\\`` in ``\\boxed``)."""
|
|
67
|
+
end: int
|
|
68
|
+
"""Index one past the last character of the span."""
|
|
69
|
+
content: str
|
|
70
|
+
"""The text inside the braces, with surrounding whitespace stripped."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _matching_brace(text: str, open_idx: int) -> int | None:
|
|
74
|
+
"""Index of the ``}`` matching the ``{`` at *open_idx*, or None.
|
|
75
|
+
|
|
76
|
+
Counts nesting depth while treating any backslash as escaping the next
|
|
77
|
+
character, so ``\\{`` and ``\\}`` do not affect the balance.
|
|
78
|
+
"""
|
|
79
|
+
if open_idx >= len(text) or text[open_idx] != "{":
|
|
80
|
+
return None
|
|
81
|
+
depth = 0
|
|
82
|
+
i = open_idx
|
|
83
|
+
while i < len(text):
|
|
84
|
+
char = text[i]
|
|
85
|
+
if char == "\\":
|
|
86
|
+
i += 2
|
|
87
|
+
continue
|
|
88
|
+
if char == "{":
|
|
89
|
+
depth += 1
|
|
90
|
+
elif char == "}":
|
|
91
|
+
depth -= 1
|
|
92
|
+
if depth == 0:
|
|
93
|
+
return i
|
|
94
|
+
i += 1
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def find_boxed_spans(text: str) -> list[BoxedSpan]:
|
|
99
|
+
"""Find every ``\\boxed``/``\\fbox`` span in *text*, in order of appearance.
|
|
100
|
+
|
|
101
|
+
Handles both the braced form ``\\boxed{42}`` and the brace-less form
|
|
102
|
+
``\\boxed 42`` that appears in some datasets, where the answer runs to the
|
|
103
|
+
next ``$`` or end of line.
|
|
104
|
+
|
|
105
|
+
>>> find_boxed_spans(r"so \\boxed{42}")[0].content
|
|
106
|
+
'42'
|
|
107
|
+
>>> find_boxed_spans(r"\\boxed{\\frac{1}{2}}")[0].content
|
|
108
|
+
'\\\\frac{1}{2}'
|
|
109
|
+
"""
|
|
110
|
+
spans: list[BoxedSpan] = []
|
|
111
|
+
for macro in _BOX_MACROS:
|
|
112
|
+
start = text.find(macro)
|
|
113
|
+
while start != -1:
|
|
114
|
+
cursor = start + len(macro)
|
|
115
|
+
while cursor < len(text) and text[cursor] in " \t":
|
|
116
|
+
cursor += 1
|
|
117
|
+
if cursor < len(text) and text[cursor] == "{":
|
|
118
|
+
close = _matching_brace(text, cursor)
|
|
119
|
+
if close is not None:
|
|
120
|
+
spans.append(
|
|
121
|
+
BoxedSpan(start, close + 1, text[cursor + 1 : close].strip())
|
|
122
|
+
)
|
|
123
|
+
start = text.find(macro, close + 1)
|
|
124
|
+
continue
|
|
125
|
+
else:
|
|
126
|
+
# Brace-less form: run to the next '$' or newline.
|
|
127
|
+
stop = len(text)
|
|
128
|
+
for terminator in ("$", "\n"):
|
|
129
|
+
found = text.find(terminator, cursor)
|
|
130
|
+
if found != -1:
|
|
131
|
+
stop = min(stop, found)
|
|
132
|
+
content = text[cursor:stop].strip()
|
|
133
|
+
if content:
|
|
134
|
+
spans.append(BoxedSpan(start, stop, content))
|
|
135
|
+
start = text.find(macro, stop)
|
|
136
|
+
continue
|
|
137
|
+
start = text.find(macro, cursor)
|
|
138
|
+
spans.sort(key=lambda span: span.start)
|
|
139
|
+
return spans
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def extract_all_boxed(text: str) -> list[str]:
|
|
143
|
+
"""Contents of every boxed span in *text*, in order of appearance."""
|
|
144
|
+
return [span.content for span in find_boxed_spans(text)]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def extract_boxed(text: str, which: str = "last") -> str | None:
|
|
148
|
+
"""Contents of one boxed span, or None if there is none.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
text: Solution text to search.
|
|
152
|
+
which: ``"last"`` (the default, and the convention used by MATH-style
|
|
153
|
+
evaluation, since models often box intermediate results) or
|
|
154
|
+
``"first"``.
|
|
155
|
+
|
|
156
|
+
>>> extract_boxed(r"first \\boxed{1} then \\boxed{2}")
|
|
157
|
+
'2'
|
|
158
|
+
>>> extract_boxed("no box here") is None
|
|
159
|
+
True
|
|
160
|
+
"""
|
|
161
|
+
if which not in ("first", "last"):
|
|
162
|
+
raise ValueError(f"which must be 'first' or 'last', got {which!r}")
|
|
163
|
+
spans = find_boxed_spans(text)
|
|
164
|
+
if not spans:
|
|
165
|
+
return None
|
|
166
|
+
return spans[0].content if which == "first" else spans[-1].content
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def remove_boxed(text: str) -> str:
|
|
170
|
+
"""Strip a ``\\boxed{...}`` wrapper, returning *text* unchanged if absent.
|
|
171
|
+
|
|
172
|
+
>>> remove_boxed(r"\\boxed{42}")
|
|
173
|
+
'42'
|
|
174
|
+
>>> remove_boxed("42")
|
|
175
|
+
'42'
|
|
176
|
+
"""
|
|
177
|
+
spans = find_boxed_spans(text)
|
|
178
|
+
if len(spans) == 1 and spans[0].start == 0 and spans[0].end == len(text.rstrip()):
|
|
179
|
+
return spans[0].content
|
|
180
|
+
return text
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def extract_final_answer(
|
|
184
|
+
text: str, patterns: Sequence[str] | None = None
|
|
185
|
+
) -> str | None:
|
|
186
|
+
"""Extract an answer announced by a phrase such as "the final answer is".
|
|
187
|
+
|
|
188
|
+
Tries *patterns* in order and, within each, uses the **last** match — a
|
|
189
|
+
solution's concluding statement rather than an earlier, revised attempt.
|
|
190
|
+
Trailing prose and punctuation are trimmed from the captured span.
|
|
191
|
+
|
|
192
|
+
>>> extract_final_answer("... so the final answer is 42.")
|
|
193
|
+
'42'
|
|
194
|
+
"""
|
|
195
|
+
active = FINAL_ANSWER_PATTERNS if patterns is None else patterns
|
|
196
|
+
for pattern in active:
|
|
197
|
+
matches = list(re.finditer(pattern, text, re.IGNORECASE | re.MULTILINE))
|
|
198
|
+
if matches:
|
|
199
|
+
candidate = _trim_prose(matches[-1].group("ans"))
|
|
200
|
+
if candidate:
|
|
201
|
+
return candidate
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _strip_math_delimiters(text: str) -> str:
|
|
206
|
+
r"""Remove enclosing ``$...$``, ``\(...\)`` or ``\[...\]`` delimiters.
|
|
207
|
+
|
|
208
|
+
These mark where mathematics starts, and are not part of the answer.
|
|
209
|
+
|
|
210
|
+
>>> _strip_math_delimiters("$12$")
|
|
211
|
+
'12'
|
|
212
|
+
"""
|
|
213
|
+
result = text.strip()
|
|
214
|
+
for opener, closer in (("$$", "$$"), ("$", "$"), ("\\(", "\\)"), ("\\[", "\\]")):
|
|
215
|
+
while (
|
|
216
|
+
result.startswith(opener)
|
|
217
|
+
and result.endswith(closer)
|
|
218
|
+
and len(result) > len(opener) + len(closer) - 1
|
|
219
|
+
):
|
|
220
|
+
candidate = result[len(opener) : -len(closer)].strip()
|
|
221
|
+
if not candidate:
|
|
222
|
+
break
|
|
223
|
+
result = candidate
|
|
224
|
+
return result
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _trim_prose(candidate: str) -> str:
|
|
228
|
+
"""Strip trailing prose, punctuation and math delimiters from a capture."""
|
|
229
|
+
trimmed = _TRAILING_JUNK.sub("", candidate.strip())
|
|
230
|
+
stripped = trimmed.strip()
|
|
231
|
+
bracketed = any(
|
|
232
|
+
stripped.startswith(left) and stripped.endswith(right)
|
|
233
|
+
for left, right in _BRACKET_PAIRS
|
|
234
|
+
)
|
|
235
|
+
if not bracketed:
|
|
236
|
+
trimmed = _TRAILING_CLAUSE.split(trimmed, maxsplit=1)[0]
|
|
237
|
+
return _strip_math_delimiters(_TRAILING_JUNK.sub("", trimmed.strip()))
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def extract_last_number(text: str) -> str | None:
|
|
241
|
+
"""Last numeric literal in *text*, with thousands separators removed.
|
|
242
|
+
|
|
243
|
+
The weakest strategy, and the last resort of the extraction ladder: it is
|
|
244
|
+
right for GSM8K-style word problems whose answers are always integers, and
|
|
245
|
+
wrong for anything symbolic.
|
|
246
|
+
|
|
247
|
+
>>> extract_last_number("he sold 12 apples for $1,500 total")
|
|
248
|
+
'1500'
|
|
249
|
+
"""
|
|
250
|
+
matches = _NUMBER.findall(text)
|
|
251
|
+
if not matches:
|
|
252
|
+
return None
|
|
253
|
+
return re.sub(r"[,\s]", "", matches[-1])
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def extract_answer(
|
|
257
|
+
text: str,
|
|
258
|
+
strategies: Iterable[str] = ("boxed", "phrase", "last_number"),
|
|
259
|
+
) -> str | None:
|
|
260
|
+
"""Extract a final answer by trying *strategies* in order.
|
|
261
|
+
|
|
262
|
+
This is the entry point most callers want. The default ladder runs from
|
|
263
|
+
most to least reliable: an explicit ``\\boxed{}``, then a concluding phrase,
|
|
264
|
+
then the last number in the text. The first strategy that yields something
|
|
265
|
+
non-empty wins.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
text: Full model output or reference solution.
|
|
269
|
+
strategies: Any of ``"boxed"``, ``"phrase"``, ``"last_number"``.
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
The extracted answer string, or None if every strategy failed.
|
|
273
|
+
|
|
274
|
+
Raises:
|
|
275
|
+
ValueError: If *strategies* names an unknown strategy.
|
|
276
|
+
|
|
277
|
+
>>> extract_answer(r"Thus the answer is $\\boxed{7}$.")
|
|
278
|
+
'7'
|
|
279
|
+
>>> extract_answer("I count 3 then 5 apples")
|
|
280
|
+
'5'
|
|
281
|
+
"""
|
|
282
|
+
if not text:
|
|
283
|
+
return None
|
|
284
|
+
runners = {
|
|
285
|
+
"boxed": extract_boxed,
|
|
286
|
+
"phrase": extract_final_answer,
|
|
287
|
+
"last_number": extract_last_number,
|
|
288
|
+
}
|
|
289
|
+
for name in strategies:
|
|
290
|
+
try:
|
|
291
|
+
runner = runners[name]
|
|
292
|
+
except KeyError:
|
|
293
|
+
raise ValueError(
|
|
294
|
+
f"unknown strategy {name!r}; expected one of {sorted(runners)}"
|
|
295
|
+
) from None
|
|
296
|
+
found = runner(text)
|
|
297
|
+
if found:
|
|
298
|
+
return found
|
|
299
|
+
return None
|