Varshithdharmajv commited on
Commit
693764f
·
verified ·
1 Parent(s): 4546935

Upload math_verify/parser.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. math_verify/parser.py +732 -0
math_verify/parser.py ADDED
@@ -0,0 +1,732 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MIT License
2
+
3
+ # Copyright (c) 2024 The HuggingFace Team
4
+
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ # of this software and associated documentation files (the "Software"), to deal
7
+ # in the Software without restriction, including without limitation the rights
8
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ # copies of the Software, and to permit persons to whom the Software is
10
+ # furnished to do so, subject to the following conditions:
11
+
12
+ # The above copyright notice and this permission notice shall be included in all
13
+ # copies or substantial portions of the Software.
14
+
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ # SOFTWARE.
22
+
23
+ import logging
24
+ import re
25
+ from dataclasses import dataclass, field, replace
26
+ from functools import lru_cache
27
+ from itertools import groupby
28
+ from typing import Literal, Sequence
29
+
30
+ import sympy
31
+ from latex2sympy2_extended.latex2sympy2 import (
32
+ NormalizationConfig,
33
+ latex2sympy,
34
+ normalize_latex,
35
+ )
36
+ from latex2sympy2_extended.sets import FiniteSet
37
+ from sympy import Basic, MatrixBase, Number
38
+ from sympy.parsing import parse_expr
39
+
40
+ from math_verify.errors import TimeoutException
41
+ from math_verify.grader import should_treat_as_complex
42
+ from math_verify.utils import timeout
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+ TIMEOUT_WARNING_SHOWN = False
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class LatexExtractionConfig:
51
+ """Config for extracting latex from the prediction.
52
+
53
+ Attributes:
54
+ try_extract_without_anchor (bool): Whether to try extracting latex without requiring specific anchors like "answer:" or "final answer is"
55
+ boxed_match_priority (int): Priority for matching boxed expressions (e.g., \boxed{}).
56
+ - 0: Highest priority (matched first)
57
+ - 50: Default priority (matched after final answer patterns)
58
+ - -1: Disable boxed expression matching
59
+ normalization_config (NormalizationConfig): Configuration for LaTeX normalization.
60
+ Controls preprocessing of LaTeX expressions including:
61
+ - Basic LaTeX cleanup
62
+ - Unit handling
63
+ - Operator formatting
64
+ - Boxed expression extraction
65
+ - Equation parsing
66
+ Defaults to a comprehensive normalization configuration.
67
+ """
68
+
69
+ try_extract_without_anchor: bool = True
70
+ boxed_match_priority: int = 50
71
+ normalization_config: NormalizationConfig = field(
72
+ default_factory=lambda: NormalizationConfig(
73
+ basic_latex=True,
74
+ units=True,
75
+ malformed_operators=True,
76
+ nits=True,
77
+ boxed="all",
78
+ equations=False,
79
+ )
80
+ )
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class ExprExtractionConfig:
85
+ """Config for extracting mathematical expressions.
86
+
87
+ Attributes:
88
+ try_extract_without_anchor (bool): Whether to try extracting expressions without requiring specific anchors like "answer:" or "final answer is"
89
+ """
90
+
91
+ try_extract_without_anchor: bool = True
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class StringExtractionConfig:
96
+ """Config for extracting literal strings.
97
+
98
+ Attributes:
99
+ strings (tuple[str]): The strings to extract
100
+ try_extract_without_anchor (bool): Whether to try extracting strings without requiring specific anchors like "answer:" or "final answer is"
101
+ """
102
+
103
+ strings: tuple[str, ...] = field(default_factory=lambda: ("A", "B", "C", "D"))
104
+ try_extract_without_anchor: bool = True
105
+ lowercase: bool = True
106
+
107
+
108
+ ExtractionTarget = LatexExtractionConfig | ExprExtractionConfig | StringExtractionConfig
109
+
110
+
111
+ @lru_cache(maxsize=10)
112
+ def lazy_string_regex(
113
+ string_extraction_config: StringExtractionConfig,
114
+ ) -> list[tuple[re.Pattern[str], int]]:
115
+ # First get indices to predict
116
+ string_keys = f"(?P<string_keys>{'|'.join([re.escape(i) for i in string_extraction_config.strings])})"
117
+
118
+ # The strings are either surrounded with <space>**answer**., or '<space>answer.' or the same without the dot
119
+ full_stop_re = r"\."
120
+ comma_re = r","
121
+ colon_re = r":"
122
+ space_re = r"\s"
123
+
124
+ answer_prefix_re = rf"(^|{space_re})(?:\*\*)?"
125
+ answer_suffix_re = (
126
+ rf"(?:\*\*)?(?:{full_stop_re}|{comma_re}|{colon_re}|{space_re}|$)"
127
+ )
128
+ answer_re = f"{answer_prefix_re}{string_keys}{answer_suffix_re}"
129
+ answer_re_start = rf"^(?:\*\*)?{string_keys}{answer_suffix_re}"
130
+
131
+ answer_word = "(?i:answer)"
132
+
133
+ regexes = []
134
+
135
+ final_answer_prefixed_re = rf"(?i:final answer is)\:?\s*{string_keys}\.?\s?I hope"
136
+
137
+ # To allow stuff like "final answer is to your question"
138
+ final_answer_prefixed_just_is = (
139
+ rf"(?i:final answer.{{0,100}}?)\s+is\:?\s*{string_keys}"
140
+ )
141
+ regexes.extend(
142
+ [
143
+ (final_answer_prefixed_re, 0),
144
+ (final_answer_prefixed_just_is, 50),
145
+ ]
146
+ )
147
+
148
+ regexes.extend(
149
+ [
150
+ # Most specific patterns first
151
+ (f"{answer_word}{colon_re}.{{0,50}}?{answer_re}", 100),
152
+ # Answer word patterns
153
+ (f"{answer_word}.{{0,50}}?{answer_re}", 200),
154
+ ]
155
+ )
156
+
157
+ if string_extraction_config.try_extract_without_anchor:
158
+ # Start of line patterns
159
+ regexes.append((answer_re_start, 250))
160
+ # Plain string patterns
161
+ regexes.append((answer_re, 300))
162
+
163
+ return [(re.compile(pattern), priority) for pattern, priority in regexes]
164
+
165
+
166
+ # All of the regexes are cached, to avoid repeated compiling during processing of same task
167
+ @lru_cache(maxsize=1)
168
+ def lazy_expr_regex(
169
+ expr_config: ExprExtractionConfig,
170
+ ) -> list[tuple[re.Pattern[str], int]]:
171
+ # Basic number patterns (no LaTeX)
172
+ number_re = (
173
+ # Format 1: Numbers with thousand separators (e.g., "1,234.56" or "1 234.56")
174
+ r"(?<!\d)(?:"
175
+ r"(?P<integer1>-?[1-9]\d{0,2}(?:[ ,]\d{3})+)(?P<decimal1>\.\d+)?|"
176
+ # Format 2: Simple numbers with decimal point or comma (e.g., "123.45" or "123,45")
177
+ r"(?P<integer2>-?\d+)(?P<decimal2>[.,]\d+)|"
178
+ # Format 3: Decimal part only (e.g., ".123")
179
+ r"(?P<decimal3>\.\d+)|"
180
+ # Format 4: Integer only (e.g., "123")
181
+ r"(?P<integer3>-?\d+)"
182
+ r")(?P<percent>\s*(?:%|[Pp]ercent|\s*[Pp]ercentage|\s*[Pp]ct))?"
183
+ )
184
+
185
+ # Expressions such as 1/2
186
+ operators = [r"\+", r"\-", r"\*", r"\×", r"\/", r"\^", r"\(", r"\)", r"\÷"]
187
+ operators_re = "".join(operators)
188
+ all_expr_chars = r"[\d\.\s" + operators_re + r"]"
189
+ # Expression should have at minimum at least one operator and must start with a digit
190
+ expr_re = (
191
+ rf"(?P<expr>-?\(?-?\d{all_expr_chars}*[{operators_re}]{all_expr_chars}+\)?)"
192
+ )
193
+
194
+ # Punctuation regexes
195
+ full_stop_re = r"\."
196
+ comma_re = r","
197
+ colon_re = r":"
198
+ space_re = r"\s"
199
+
200
+ currency_units = re.escape("$€£¥₹₽₪₩₫฿₡₢₣₤₥₦₧₨₩₪₫₭₮₯₰₱₲₳₴₵₶₷₸₹₺₻₼₽₾₿")
201
+ expr_prefix_re = rf"(?:^|{space_re}|\=)(?:\*\*)?"
202
+ expr_suffix_re = (
203
+ rf"(?:\*\*)?(?:{full_stop_re}|{comma_re}|{colon_re}|{space_re}|\)|\$|$)"
204
+ )
205
+ # Expressions must be prefixed and suffixed while, digits don't need suffix and can have currency units preceeded, this is to ensure
206
+ # That we can extract stuff like $100 or 100m2, while we don't extract XDY2K as 2
207
+ expr_with_anchors = rf"(?:{expr_prefix_re}{expr_re}{expr_suffix_re})"
208
+ number_with_anchors = rf"(?:{expr_prefix_re}[{currency_units}]?{number_re})"
209
+ expr_or_number = rf"(?:{expr_with_anchors}|{number_with_anchors})"
210
+ regexes: list[tuple[str, int]] = []
211
+
212
+ final_answer_prefixed_re = (
213
+ rf"(?i:final answer is)\:?\s*{expr_or_number}\.?\s?I hope"
214
+ )
215
+ final_answer_prefixed_just_is = (
216
+ rf"(?i:final answer.{{0,100}}?)\s+is\:?{expr_or_number}"
217
+ )
218
+ regexes.append((final_answer_prefixed_re, 0))
219
+ regexes.append((final_answer_prefixed_just_is, 50))
220
+
221
+ answer_prefix_re = r"(?i:answer)"
222
+
223
+ # Match after the last equals with answer word - require the number pattern,
224
+ equals_re_colon = rf"{answer_prefix_re}{colon_re}(?:.{{0,100}}=\s*|.{{0,50}}?){expr_or_number}(?!\s*=)"
225
+ equals_re = (
226
+ rf"{answer_prefix_re}(?:.{{0,100}}=\s*|.{{0,50}}?){expr_or_number}(?!\s*=)"
227
+ )
228
+ regexes.extend([(equals_re_colon, 100), (equals_re, 200)])
229
+
230
+ if expr_config.try_extract_without_anchor:
231
+ # If everything fails, try to match plain expr/number
232
+ regexes.append((expr_with_anchors, 300))
233
+ regexes.append((number_with_anchors, 300))
234
+
235
+ return [(re.compile(pattern), priority) for pattern, priority in regexes]
236
+
237
+
238
+ def make_latex_env_pattern(
239
+ prefix: str = "", context: Literal["boxed", "plain"] = "plain"
240
+ ) -> str:
241
+ """Creates a LaTeX environment pattern with uniquely prefixed group names.
242
+
243
+ Args:
244
+ prefix (str): Prefix to add to group names to make them unique
245
+ context (Literal["boxed", "plain", "fraction"]): Type of content to match inside the environments
246
+ - "boxed": Match environments containing \boxed{...}
247
+ - "plain": Match any LaTeX content
248
+ - "fraction": Match only fractions
249
+
250
+ Returns:
251
+ str: Regex pattern for matching LaTeX environments with percent suffix
252
+ """
253
+ percent_re_group = rf"(?P<{prefix}percent>(?:\\?%|[Pp]ercent|[Pp]ercentage|[Pp]ct))"
254
+
255
+ # Define base content patterns
256
+ display_dollar_content = r"(?:[^$]|\$(?!\$))"
257
+ # Either \ not followed by ] or everything but \
258
+ display_content_bracket = r"(?:[^\\]|\\(?!\]))"
259
+ inline_dollar_content = r"(?:\\[$]|[^\n$])"
260
+ inline_content_parenthesis = r"(?:[^\\\n]|\\(?!\)))"
261
+ inline_content_bracket = r"[^\n\]\[]"
262
+
263
+ if context == "boxed":
264
+ # Rewrite patterns to optionally include boxed content
265
+ display_dollar_content = rf"{display_dollar_content}*?\\boxed{{{display_dollar_content}+?}}{display_dollar_content}*?"
266
+ display_content_bracket = rf"{display_content_bracket}*?\\boxed{{{display_content_bracket}+?}}{display_content_bracket}*?"
267
+ inline_dollar_content = rf"{inline_dollar_content}*?\\boxed{{{inline_dollar_content}+?}}{inline_dollar_content}*?"
268
+ inline_content_parenthesis = rf"{inline_content_parenthesis}*?\\boxed{{{inline_content_parenthesis}+?}}{inline_content_parenthesis}*?"
269
+ inline_content_bracket = rf"{inline_content_bracket}*?\\boxed{{{inline_content_bracket}+?}}{inline_content_bracket}*?"
270
+ else:
271
+ display_dollar_content = rf"{display_dollar_content}+?"
272
+ display_content_bracket = rf"{display_content_bracket}+?"
273
+ inline_dollar_content = rf"{inline_dollar_content}+?"
274
+ inline_content_parenthesis = rf"{inline_content_parenthesis}+?"
275
+ inline_content_bracket = rf"{inline_content_bracket}+?"
276
+
277
+ # Build list of regex patterns
278
+ patterns = [
279
+ # Display math environments (allow multiline)
280
+ rf"(?<!\\)\$\$(?P<{prefix}latexDisplayDollar>{display_dollar_content})(?<!\\)\$\$",
281
+ rf"(?<!\\)\\\[(?P<{prefix}latexDisplayBracket>{display_content_bracket})(?<!\\)\\\]",
282
+ # Inline math environments (single line only)
283
+ rf"(?<!\\|\d)\$(?P<{prefix}latexInlineDollar>{inline_dollar_content})(?<!\\)\$",
284
+ rf"(?<!\\)\\\((?P<{prefix}latexInlineParenthesis>{inline_content_parenthesis})(?<!\\)\\\)",
285
+ rf"\s\[(?P<{prefix}latexInlineBracket>{inline_content_bracket})\]\s",
286
+ ]
287
+ if context == "plain":
288
+ simple_number = r"-?\d+(?:[.,]\d+)?"
289
+ patterns.append(
290
+ rf"(?P<{prefix}latexFraction>-?\\frac{{{simple_number}}}{{{simple_number}}})"
291
+ )
292
+
293
+ # Join patterns with | and wrap in parentheses
294
+ latex_env_re = rf"(?:(?:{'|'.join(patterns)})\s*{percent_re_group}?)"
295
+
296
+ return latex_env_re
297
+
298
+
299
+ @lru_cache(maxsize=1)
300
+ def lazy_latex_regex(
301
+ latex_config: LatexExtractionConfig,
302
+ ) -> list[tuple[re.Pattern[str], int]]:
303
+ # Pattern for multiple latex environments connected by and/or (also considering oxford comma)
304
+ # Create patterns for up to 5 connected expressions
305
+ first_latex_group = make_latex_env_pattern("first_")
306
+ next_groups = "".join(
307
+ [
308
+ rf"(?:\s*(?:,?and|,?or|,)\s*{make_latex_env_pattern(f'next{i}_')})?"
309
+ for i in range(1, 6)
310
+ ]
311
+ )
312
+
313
+ latex_envs_re = rf"(?:{first_latex_group}{next_groups})"
314
+ colon_re = r":"
315
+ answer_prefix_re = r"(?i:answer)"
316
+
317
+ # We first match boxed env, for some reason that's the most common case of output
318
+ # Then we match the latex with environments, then we try to match the fraction
319
+ regexes: list[tuple[str, int]] = []
320
+ for latex_re in [latex_envs_re]:
321
+ final_answer_prefixed_re = rf"(?i:final answer is)\:?\s*{latex_re}\.?\s?I hope"
322
+ final_answer_prefixed_just_is = (
323
+ rf"(?i:final answer.{{0,100}}?)\s+is\:?\s*{latex_re}"
324
+ )
325
+ regexes.append((final_answer_prefixed_re, 0))
326
+ regexes.append((final_answer_prefixed_just_is, 50))
327
+
328
+ # Match with answer word - higher priority than plain latex
329
+ answer_re_colon = f"{answer_prefix_re}{colon_re}.{{0,50}}?{latex_re}"
330
+ answer_re = f"{answer_prefix_re}.{{0,50}}?{latex_re}"
331
+
332
+ regexes.extend([(answer_re_colon, 100), (answer_re, 200)])
333
+
334
+ # Match plain LaTeX - lowest priority
335
+ if latex_config.try_extract_without_anchor:
336
+ regexes.append((latex_re, 300))
337
+
338
+ # This ensures that boxed is matched right after the final answer xxxx
339
+ if latex_config.boxed_match_priority >= 0:
340
+ latex_re_boxed = make_latex_env_pattern(prefix="first_", context="boxed")
341
+ next_groups = "".join(
342
+ [
343
+ rf"\s*(?:\s*(?:,?and|,?or|,)\s*{make_latex_env_pattern(f'next{i}_', context='boxed')})?"
344
+ for i in range(1, 6)
345
+ ]
346
+ )
347
+ latex_re_boxed = rf"{latex_re_boxed}{next_groups}"
348
+ regexes.append((latex_re_boxed, latex_config.boxed_match_priority))
349
+ # Match plain boxed, the issue with plain boxed is that it's impossible to know where it stops, so if there are
350
+ # till last }. We do the actuall extraction in the normalization step.
351
+ regexes.append(
352
+ (r"(?P<first_latexBoxed>\\boxed{.+})", latex_config.boxed_match_priority)
353
+ )
354
+
355
+ return [(re.compile(pattern, re.DOTALL), priority) for pattern, priority in regexes]
356
+
357
+
358
+ def get_extraction_regexes(
359
+ target_types: Sequence[ExtractionTarget],
360
+ ) -> list[tuple[list[tuple[re.Pattern[str], int]], ExtractionTarget]]:
361
+ extraction_regexes: list[
362
+ tuple[list[tuple[re.Pattern[str], int]], ExtractionTarget]
363
+ ] = [
364
+ (
365
+ (lazy_latex_regex(target_type), target_type)
366
+ if isinstance(target_type, LatexExtractionConfig)
367
+ else (
368
+ (lazy_expr_regex(target_type), target_type)
369
+ if isinstance(target_type, ExprExtractionConfig)
370
+ else (lazy_string_regex(target_type), target_type)
371
+ )
372
+ )
373
+ for target_type in target_types
374
+ ]
375
+ return extraction_regexes
376
+
377
+
378
+ # Small cache, to catche repeated calls invalid parsing
379
+ @lru_cache(maxsize=20)
380
+ def parse_latex_cached(latex: str):
381
+ # First try to parse the latex as is
382
+ try:
383
+ return latex2sympy(
384
+ latex,
385
+ is_real=not should_treat_as_complex(latex),
386
+ convert_degrees=False,
387
+ normalization_config=None,
388
+ )
389
+ except Exception as e:
390
+ # If that fails, try to parse just the last equation
391
+ last_eq_latex = get_last_eq(latex)
392
+ if last_eq_latex != latex:
393
+ return latex2sympy(
394
+ last_eq_latex,
395
+ is_real=not should_treat_as_complex(last_eq_latex),
396
+ convert_degrees=False,
397
+ normalization_config=None,
398
+ )
399
+ else:
400
+ raise e
401
+
402
+
403
+ @lru_cache(maxsize=20)
404
+ def parse_expr_cached(expr: str):
405
+ return parse_expr(expr, evaluate=False)
406
+
407
+
408
+ def extract_expr(match: re.Match) -> tuple[str | sympy.Expr | None, str]:
409
+ # First combine the number
410
+ groups = match.groupdict()
411
+ # Expr group will always exist because every regex has it
412
+ expr = groups.get("expr", "")
413
+ integer = next(
414
+ (val for name, val in groups.items() if name.startswith("integer") and val), ""
415
+ )
416
+ decimal = next(
417
+ (val for name, val in groups.items() if name.startswith("decimal") and val), ""
418
+ )
419
+
420
+ is_percentage = True if groups.get("percent", None) else False
421
+
422
+ if integer or decimal:
423
+ # This makes sure we can convert numbers like 0001 to 1. Do note that this can convert 0 to '', so we assume an empty string was 0 and convert it back afterwards.
424
+ integer = integer.translate(str.maketrans("", "", ", ")).lstrip("0")
425
+ if len(integer) == 0:
426
+ integer = "0"
427
+
428
+ decimal = decimal.replace(",", ".")
429
+ number_str = f"{integer}{decimal}"
430
+ number = Number(number_str)
431
+
432
+ if is_percentage:
433
+ number = convert_to_pct(number)
434
+ return number, number_str
435
+
436
+ # Otherwise just return the expression
437
+ # Remove new lines and spaces
438
+ if expr:
439
+ try:
440
+ return (
441
+ parse_expr_cached(expr.replace("\n", " ").replace("^", "**")),
442
+ expr,
443
+ )
444
+ except Exception:
445
+ pass
446
+ return None, expr
447
+
448
+
449
+ def convert_to_pct(number: Number):
450
+ return sympy.Mul(number, sympy.Rational(1, 100), evaluate=False)
451
+
452
+
453
+ equation_split_regex = re.compile(r"(?<!\\|\<|\!|\>)=")
454
+
455
+
456
+ def get_last_eq(latex: str):
457
+ # This is to ensure that a=1,b=2 is not splitted
458
+ if "," not in latex and ";" not in latex:
459
+ eq_parts = equation_split_regex.split(latex)
460
+ # We only shorten if there are more than 2 parts, otherwise we keep equation as is
461
+ if len(eq_parts) > 2:
462
+ return eq_parts[-1]
463
+ return latex
464
+
465
+
466
+ @lru_cache(maxsize=20)
467
+ def extract_latex(
468
+ match: re.Match, latex_config: LatexExtractionConfig
469
+ ) -> tuple[sympy.Expr | str | None, str]:
470
+ latex_exprs = []
471
+ latex_strs = []
472
+
473
+ # Get all latex groups (both first_ and nextN_ prefixes)
474
+ first_latex_group = next(
475
+ (
476
+ (val, name)
477
+ for name, val in match.groupdict().items()
478
+ if name.startswith("first_latex") and val
479
+ ),
480
+ None,
481
+ )
482
+
483
+ # Get all nextN_ groups
484
+ next_latex_groups = [
485
+ next(
486
+ (
487
+ (val, name)
488
+ for name, val in match.groupdict().items()
489
+ if name.startswith(f"next{i}_latex") and val
490
+ ),
491
+ None,
492
+ )
493
+ for i in range(1, 6)
494
+ ]
495
+
496
+ all_latex = list(
497
+ filter(lambda x: x is not None, [first_latex_group] + next_latex_groups)
498
+ )
499
+
500
+ for latex, name in all_latex:
501
+ name_without_prefix = name.split("_")[0]
502
+ group_name = name.split("_")[1] if len(name.split("_")) > 1 else None
503
+ is_percentage = (
504
+ True if match.groupdict().get(f"{name_without_prefix}_percent") else False
505
+ )
506
+
507
+ # Use modified config if group name is 'boxed'
508
+ config = latex_config.normalization_config
509
+ if group_name == "latexBoxed":
510
+ config = replace(config, boxed="last") # Use replace to modify single field
511
+
512
+ normalized_latex = normalize_latex(
513
+ latex,
514
+ config=config,
515
+ )
516
+ latex_strs.append(normalized_latex)
517
+
518
+ try:
519
+ parsed_latex = parse_latex_cached(normalized_latex)
520
+ if is_percentage:
521
+ parsed_latex = convert_to_pct(parsed_latex)
522
+ latex_exprs.append(parsed_latex)
523
+ except Exception:
524
+ latex_exprs.append(None)
525
+ pass
526
+
527
+ if not latex_exprs:
528
+ return None, ""
529
+
530
+ # If we have multiple expressions and all of them are parsed, wrap them in a Tuple
531
+ if len(latex_exprs) > 1 and all(expr is not None for expr in latex_exprs):
532
+ # To handle solution is: 1,2 and 3
533
+ all_elements = []
534
+ for expr in latex_exprs:
535
+ if isinstance(expr, FiniteSet):
536
+ all_elements.extend(expr.args)
537
+ else:
538
+ all_elements.append(expr)
539
+ return FiniteSet(*all_elements), " and ".join(latex_strs)
540
+
541
+ # Otherwise return the single expression
542
+ return latex_exprs[0], latex_strs[0]
543
+
544
+
545
+ def extract_string(match: re.Match, string_config: StringExtractionConfig):
546
+ extracted_str = match.group("string_keys")
547
+ parsed_str = extracted_str
548
+ if string_config.lowercase:
549
+ parsed_str = extracted_str.lower()
550
+ return parsed_str, extracted_str
551
+
552
+
553
+ def extract_match(
554
+ match: re.Match, target_type: ExtractionTarget
555
+ ) -> tuple[Basic | MatrixBase | str | None, str]:
556
+ """Extracts the match from the regex match.
557
+
558
+ Args:
559
+ match (re.Match): The regex match object containing the extracted text
560
+ target_type (ExtractionTarget): The type of extraction to perform (latex, expression, or indices)
561
+
562
+ Returns:
563
+ tuple[Basic | MatrixBase | str | None, str]: A tuple containing:
564
+ - The extracted and parsed value (if successful) or None (if parsing failed)
565
+ - The string representation of the extracted text
566
+ """
567
+ if isinstance(target_type, LatexExtractionConfig):
568
+ return extract_latex(match, target_type)
569
+ elif isinstance(target_type, ExprExtractionConfig):
570
+ return extract_expr(match)
571
+ elif isinstance(target_type, StringExtractionConfig):
572
+ return extract_string(match, target_type)
573
+
574
+
575
+ def extract_target_from_pred(
576
+ pred: str,
577
+ target_res: list[tuple[list[tuple[re.Pattern[str], int]], ExtractionTarget]],
578
+ fallback_mode: Literal["no_fallback", "first_match"] = "no_fallback",
579
+ extraction_mode: Literal["first_match", "any_match"] = "any_match",
580
+ ):
581
+ """Extracts targets from a prediction string using regex patterns.
582
+ Returns first sucesffuly extracted match.
583
+
584
+ Args:
585
+ pred (str): The prediction string to extract from
586
+ target_res (list[tuple[list[tuple[re.Pattern[str], int]], ExtractionTarget]]): List of regex patterns and their priorities for each target type
587
+ fallback_mode (Literal["no_fallback", "first_match"], optional): How to handle extraction failures. Defaults to "no_fallback".
588
+ - "no_fallback": Return only successfully parsed match
589
+ - "first_match": Additionaly Include the first string match no matter how parsing finished
590
+ extraction_mode (Literal["first_match", "any_match"], optional): How to handle extraction failures. Defaults to "any_match".
591
+ - "first_match": Only tries to extract the first match
592
+ - "any_match": Tries to extract any match
593
+
594
+ Returns:
595
+ list: List of extracted predictions, with first fallbac string appended if fallback_mode is "first_match"
596
+ """
597
+ extracted_predictions = []
598
+ fallbacks = []
599
+
600
+ # Get all patterns and sort by priority
601
+ all_patterns = [
602
+ (pattern, target_type, priority)
603
+ for target_patterns, target_type in target_res
604
+ for pattern, priority in target_patterns
605
+ ]
606
+
607
+ # Group patterns by priority using itertools.groupby
608
+ match_found = False
609
+ sorted_patterns = sorted(all_patterns, key=lambda x: x[2])
610
+ grouped_patterns = list(
611
+ (gr, list(val)) for gr, val in groupby(sorted_patterns, key=lambda x: x[2])
612
+ )
613
+ for _, patterns_group in grouped_patterns:
614
+ # Find all matches for each pattern in this priority group
615
+ matches_with_pos = (
616
+ (match, match.start(), match.end(), target_type)
617
+ for pattern, target_type, _ in patterns_group
618
+ for match in pattern.finditer(pred)
619
+ )
620
+
621
+ # Sort matches by end position (rightmost first) and then by start position (leftmost first)
622
+ matches_with_pos = sorted(
623
+ matches_with_pos, key=lambda x: (x[2], -x[1]), reverse=True
624
+ )
625
+
626
+ # Try to extract from each match, starting from rightmost
627
+ for match, _, _, target_type in matches_with_pos:
628
+ extracted_match, str_fallback = extract_match(match, target_type)
629
+
630
+ match_found = True
631
+ if str_fallback:
632
+ fallbacks.append(str_fallback)
633
+
634
+ if extracted_match is not None:
635
+ extracted_predictions.append(extracted_match)
636
+ break
637
+
638
+ if extraction_mode == "first_match":
639
+ break
640
+
641
+ # If we extracted something or found something and we're in first_match mode, stop processing other priorities
642
+ if extracted_predictions or (match_found and extraction_mode == "first_match"):
643
+ break
644
+
645
+ if fallback_mode == "first_match" and fallbacks:
646
+ extracted_predictions += [fallbacks[0]]
647
+
648
+ return extracted_predictions
649
+
650
+
651
+ def parse(
652
+ pred: str,
653
+ extraction_config: Sequence[ExtractionTarget] = [
654
+ LatexExtractionConfig(),
655
+ ExprExtractionConfig(),
656
+ ],
657
+ fallback_mode: Literal["no_fallback", "first_match"] = "first_match",
658
+ extraction_mode: Literal["first_match", "any_match"] = "any_match",
659
+ parsing_timeout: int = 5,
660
+ raise_on_error: bool = False,
661
+ ):
662
+ """Extracts and parses mathematical expressions from a prediction string.
663
+
664
+ This function attempts to extract mathematical expressions from text using various strategies
665
+ (LaTeX, plain expressions, etc.) and converts them to SymPy objects.
666
+
667
+ Args:
668
+ pred (str): The prediction string to parse.
669
+ extraction_config (Sequence[ExtractionTarget], optional): Configuration for what types of expressions
670
+ to extract and how to extract them. Defaults to [LatexExtractionConfig(), ExprExtractionConfig()].
671
+ fallback_mode (Literal["no_fallback", "first_match"], optional): How to handle extraction failures. Defaults to "first_match".
672
+ - "no_fallback": Return only successfully parsed expressions
673
+ - "first_match": Include the first string match even if parsing failed
674
+ extraction_mode (Literal["first_match", "any_match"], optional): Strategy for extracting matches. Defaults to "any_match".
675
+ - "first_match": Stop after finding the first match
676
+ - "any_match": Try to extract all possible matches, stops after first sucesful parsing attempt
677
+ parsing_timeout (int, optional): Maximum time in seconds to spend parsing each expression. Defaults to 3. Any timeout seconds > 0 or not None will result in the function to raise a ValueError if it's called in a threaded environment.
678
+ raise_on_error (bool, optional): Whether to raise an exception if an error occurs during parsing or return an empty list. Defaults to False.
679
+
680
+ Returns:
681
+ list: List of extracted predictions. Each prediction can be:
682
+ - SymPy expression (for successfully parsed mathematical expressions)
683
+ - String (for fallback matches when fallback_mode="first_match")
684
+ Empty list if no matches are found.
685
+
686
+ Examples:
687
+ >>> parse("The answer is $\\frac{1}{2}$")
688
+ [Rational(1, 2)]
689
+ >>> parse("The answer is 1/2")
690
+ [Rational(1, 2)]
691
+ >>> parse("The answer is A", extraction_config=[StringExtractionConfig()])
692
+ ['a']
693
+ """
694
+ global TIMEOUT_WARNING_SHOWN
695
+ if not TIMEOUT_WARNING_SHOWN and (parsing_timeout is None or parsing_timeout <= 0):
696
+ logger.warning(
697
+ "Timeout is disabled as parsing_timeout is None or <= 0, you must provide \
698
+ the logic for timeout interuption yourself to prevent code getting stuck."
699
+ )
700
+ TIMEOUT_WARNING_SHOWN = True
701
+
702
+ try:
703
+ target_res = get_extraction_regexes(extraction_config)
704
+ return timeout(timeout_seconds=parsing_timeout)(extract_target_from_pred)(
705
+ pred,
706
+ target_res,
707
+ fallback_mode=fallback_mode,
708
+ extraction_mode=extraction_mode,
709
+ )
710
+ except ValueError as e:
711
+ # Check if it's the signal error
712
+ if str(e) == "signal only works in main thread of the main interpreter":
713
+ raise ValueError(
714
+ "Math-Verify 'parse' function doesn't support threaded environment due to usage of signal.alarm() in timeout mechanism. If you need to run in multithreaded environment it's recommended to set the parsing_timeout=None, which will run without timeout (and signal handling). In this case you need to handle the timeouting yourself."
715
+ ) from e
716
+ if raise_on_error:
717
+ raise e from e
718
+ else:
719
+ logger.debug(f"Error parsing: {pred}", exc_info=True)
720
+ return []
721
+ except Exception as e:
722
+ if raise_on_error:
723
+ raise e from e
724
+ else:
725
+ logger.debug(f"Error parsing: {pred}", exc_info=True)
726
+ return []
727
+ except TimeoutException as e:
728
+ if raise_on_error:
729
+ raise TimeoutException(f"Timeout during parsing: {pred}") from e
730
+ else:
731
+ logger.warning(f"Timeout during parsing: {pred}")
732
+ return []