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 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