From 9fc5bf97e9641dff024dc512dd602d4a571e435a Mon Sep 17 00:00:00 2001 From: dkajtoch Date: Tue, 1 Sep 2026 12:05:41 +0100 Subject: [PATCH 1/3] Support plain mixed-number extraction --- src/math_verify/parser.py | 27 ++++- tests/test_mixed_number_extraction.py | 148 ++++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 tests/test_mixed_number_extraction.py diff --git a/src/math_verify/parser.py b/src/math_verify/parser.py index 9c5c0e1..b6b284d 100644 --- a/src/math_verify/parser.py +++ b/src/math_verify/parser.py @@ -184,6 +184,14 @@ def lazy_expr_regex( r")(?P\s*(?:%|[Pp]ercent|\s*[Pp]ercentage|\s*[Pp]ct))?" ) + # Mixed numbers such as 4 4/9. This must be matched before the generic + # expression and number patterns so a failed parse cannot fall back to 4. + mixed_whole_re = r"(?:[1-9]\d{0,2}(?:[ ,]\d{3})+|\d+)" + mixed_number_re = ( + rf"(?P(?P-?)(?P{mixed_whole_re})" + r"\s+(?P\d+)\s*/\s*(?P\d+))" + ) + # Expressions such as 1/2 operators = [r"\+", r"\-", r"\*", r"\×", r"\/", r"\^", r"\(", r"\)", r"\÷"] operators_re = "".join(operators) @@ -207,8 +215,13 @@ def lazy_expr_regex( # Expressions must be prefixed and suffixed while, digits don't need suffix and can have currency units preceeded, this is to ensure # That we can extract stuff like $100 or 100m2, while we don't extract XDY2K as 2 expr_with_anchors = rf"(?:{expr_prefix_re}{expr_re}{expr_suffix_re})" + mixed_number_with_anchors = ( + rf"(?:{expr_prefix_re}{mixed_number_re}{expr_suffix_re})" + ) number_with_anchors = rf"(?:{expr_prefix_re}[{currency_units}]?{number_re})" - expr_or_number = rf"(?:{expr_with_anchors}|{number_with_anchors})" + expr_or_number = ( + rf"(?:{mixed_number_with_anchors}|{expr_with_anchors}|{number_with_anchors})" + ) regexes: list[tuple[str, int]] = [] final_answer_prefixed_re = ( @@ -231,6 +244,7 @@ def lazy_expr_regex( if expr_config.try_extract_without_anchor: # If everything fails, try to match plain expr/number + regexes.append((mixed_number_with_anchors, 300)) regexes.append((expr_with_anchors, 300)) regexes.append((number_with_anchors, 300)) @@ -419,6 +433,17 @@ def extract_expr(match: re.Match) -> tuple[str | sympy.Expr | None, str]: (val for name, val in groups.items() if name.startswith("decimal") and val), "" ) + mixed_number = groups.get("mixed_number", "") + if mixed_number: + whole = groups["mixed_whole"].translate(str.maketrans("", "", ", ")) + whole = whole.lstrip("0") or "0" + numerator = groups["mixed_numerator"].lstrip("0") or "0" + denominator = groups["mixed_denominator"].lstrip("0") or "0" + normalized_mixed_number = f"{whole}+({numerator}/{denominator})" + if groups.get("mixed_sign") == "-": + normalized_mixed_number = f"-({normalized_mixed_number})" + return parse_expr_cached(normalized_mixed_number), mixed_number + is_percentage = True if groups.get("percent", None) else False if integer or decimal: diff --git a/tests/test_mixed_number_extraction.py b/tests/test_mixed_number_extraction.py new file mode 100644 index 0000000..e5eceb5 --- /dev/null +++ b/tests/test_mixed_number_extraction.py @@ -0,0 +1,148 @@ +import pytest +import sympy + +from math_verify import ExprExtractionConfig, parse, verify + +EXPR_EXTRACTION = (ExprExtractionConfig(),) + + +def test_parse_positive_mixed_number_as_complete_value(): + # Given + prediction = "4 4/9" + expected_value = sympy.Rational(40, 9) + expected_fallback = "4 4/9" + + # When + parsed = parse(prediction, EXPR_EXTRACTION) + + # Then + assert sympy.simplify(parsed[0] - expected_value) == 0 + assert parsed[1] == expected_fallback + + +def test_parse_negative_mixed_number_as_negative_complete_value(): + # Given + prediction = "-4 4/9" + expected_value = sympy.Rational(-40, 9) + expected_fallback = "-4 4/9" + + # When + parsed = parse(prediction, EXPR_EXTRACTION) + + # Then + assert sympy.simplify(parsed[0] - expected_value) == 0 + assert parsed[1] == expected_fallback + + +def test_extract_mixed_number_from_answer_sentence(): + # Given + prediction = "The answer is 4 4/9." + expected_value = sympy.Rational(40, 9) + expected_fallback = "4 4/9" + + # When + parsed = parse(prediction) + + # Then + assert sympy.simplify(parsed[0] - expected_value) == 0 + assert parsed[1] == expected_fallback + + +def test_mixed_number_is_equivalent_to_improper_fraction(): + # Given + gold = parse("40/9", EXPR_EXTRACTION) + prediction = parse("The answer is 4 4/9.", EXPR_EXTRACTION) + expected = True + + # When + equivalent = verify(gold, prediction) + + # Then + assert equivalent is expected + + +def test_mixed_number_does_not_match_only_its_whole_number_prefix(): + # Given + gold = parse("4", EXPR_EXTRACTION) + prediction = parse("The answer is 4 4/9.", EXPR_EXTRACTION) + expected = False + + # When + equivalent = verify(gold, prediction) + + # Then + assert equivalent is expected + + +def test_parse_mixed_number_with_zero_whole_part(): + # Given + prediction = "0 4/9" + expected_value = sympy.Rational(4, 9) + expected_fallback = "0 4/9" + + # When + parsed = parse(prediction, EXPR_EXTRACTION) + + # Then + assert sympy.simplify(parsed[0] - expected_value) == 0 + assert parsed[1] == expected_fallback + + +def test_parse_mixed_number_with_spaced_thousands_whole_part(): + # Given + prediction = "1 000 4/9" + expected_value = sympy.Rational(9004, 9) + expected_fallback = "1 000 4/9" + + # When + parsed = parse(prediction, EXPR_EXTRACTION) + + # Then + assert sympy.simplify(parsed[0] - expected_value) == 0 + assert parsed[1] == expected_fallback + + +def test_zero_denominator_does_not_fall_back_to_whole_number_prefix(): + # Given + invalid_mixed_number = "4 4/0" + whole_number = parse("4", EXPR_EXTRACTION) + expected = False + + # When + parsed = parse(invalid_mixed_number, EXPR_EXTRACTION) + equivalent = verify(whole_number, parsed) + + # Then + assert equivalent is expected + assert parsed[1] == invalid_mixed_number + + +@pytest.mark.parametrize( + ("prediction", "expected_value"), + [ + pytest.param("4 + 4/9", sympy.Rational(40, 9), id="sum"), + pytest.param("10/2", sympy.Integer(5), id="fraction"), + pytest.param("1 000", sympy.Integer(1000), id="spaced-thousands"), + pytest.param("$5.00", sympy.Integer(5), id="currency-decimal"), + pytest.param("28%", sympy.Rational(7, 25), id="percentage"), + pytest.param( + "There are 4 objects and 4/9 remain.", + sympy.Rational(4, 9), + id="independent-numbers", + ), + pytest.param( + "4 4/9 2", + sympy.Integer(2), + id="mixed-looking-sequence-followed-by-independent-number", + ), + ], +) +def test_existing_expression_syntax_is_unchanged(prediction, expected_value): + # Given + expected = expected_value + + # When + parsed = parse(prediction, EXPR_EXTRACTION) + + # Then + assert sympy.simplify(parsed[0] - expected) == 0 From 05aa3a67e1e5aa4a0dbecea17550a17cc2d5ed2d Mon Sep 17 00:00:00 2001 From: dkajtoch Date: Tue, 1 Sep 2026 12:26:54 +0100 Subject: [PATCH 2/3] Harden mixed-number parsing rules --- src/math_verify/parser.py | 23 +++- tests/test_mixed_number_extraction.py | 181 ++++++++++++++++++++++++-- 2 files changed, 191 insertions(+), 13 deletions(-) diff --git a/src/math_verify/parser.py b/src/math_verify/parser.py index b6b284d..bc7cda6 100644 --- a/src/math_verify/parser.py +++ b/src/math_verify/parser.py @@ -186,10 +186,15 @@ def lazy_expr_regex( # Mixed numbers such as 4 4/9. This must be matched before the generic # expression and number patterns so a failed parse cannot fall back to 4. + horizontal_space_re = r"[ \t]" mixed_whole_re = r"(?:[1-9]\d{0,2}(?:[ ,]\d{3})+|\d+)" mixed_number_re = ( - rf"(?P(?P-?)(?P{mixed_whole_re})" - r"\s+(?P\d+)\s*/\s*(?P\d+))" + rf"(?P(?P[+-]?)(?P{mixed_whole_re})" + rf"{horizontal_space_re}+(?P\d+)" + rf"{horizontal_space_re}*/{horizontal_space_re}*" + r"(?P\d+))" + rf"(?P{horizontal_space_re}*" + r"(?:%|[Pp]ercent|[Pp]ercentage|[Pp]ct))?" ) # Expressions such as 1/2 @@ -433,18 +438,26 @@ def extract_expr(match: re.Match) -> tuple[str | sympy.Expr | None, str]: (val for name, val in groups.items() if name.startswith("decimal") and val), "" ) + is_percentage = bool(groups.get("percent") or groups.get("mixed_percent")) + mixed_number = groups.get("mixed_number", "") if mixed_number: whole = groups["mixed_whole"].translate(str.maketrans("", "", ", ")) whole = whole.lstrip("0") or "0" numerator = groups["mixed_numerator"].lstrip("0") or "0" denominator = groups["mixed_denominator"].lstrip("0") or "0" + if int(denominator) == 0 or int(numerator) >= int(denominator): + # Returning None would let extraction fall through to a finite + # component such as the whole-number prefix. + return sympy.nan, mixed_number + normalized_mixed_number = f"{whole}+({numerator}/{denominator})" if groups.get("mixed_sign") == "-": normalized_mixed_number = f"-({normalized_mixed_number})" - return parse_expr_cached(normalized_mixed_number), mixed_number - - is_percentage = True if groups.get("percent", None) else False + parsed_mixed_number = parse_expr_cached(normalized_mixed_number) + if is_percentage: + parsed_mixed_number = convert_to_pct(parsed_mixed_number) + return parsed_mixed_number, mixed_number if integer or decimal: # 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. diff --git a/tests/test_mixed_number_extraction.py b/tests/test_mixed_number_extraction.py index e5eceb5..4bae0d9 100644 --- a/tests/test_mixed_number_extraction.py +++ b/tests/test_mixed_number_extraction.py @@ -34,6 +34,25 @@ def test_parse_negative_mixed_number_as_negative_complete_value(): assert parsed[1] == expected_fallback +@pytest.mark.parametrize( + ("prediction", "expected_fallback"), + [ + pytest.param("11 5/6", "11 5/6", id="unsigned"), + pytest.param("+11 5/6", "+11 5/6", id="explicit-positive"), + ], +) +def test_parse_mixed_number_with_optional_positive_sign(prediction, expected_fallback): + # Given + expected_value = sympy.Rational(71, 6) + + # When + parsed = parse(prediction, EXPR_EXTRACTION) + + # Then + assert sympy.simplify(parsed[0] - expected_value) == 0 + assert parsed[1] == expected_fallback + + def test_extract_mixed_number_from_answer_sentence(): # Given prediction = "The answer is 4 4/9." @@ -88,6 +107,20 @@ def test_parse_mixed_number_with_zero_whole_part(): assert parsed[1] == expected_fallback +def test_parse_mixed_number_with_zero_numerator_as_whole_value(): + # Given + prediction = "4 0/9" + expected_value = sympy.Integer(4) + expected_fallback = "4 0/9" + + # When + parsed = parse(prediction, EXPR_EXTRACTION) + + # Then + assert verify([expected_value], parsed) is True + assert parsed[1] == expected_fallback + + def test_parse_mixed_number_with_spaced_thousands_whole_part(): # Given prediction = "1 000 4/9" @@ -102,19 +135,151 @@ def test_parse_mixed_number_with_spaced_thousands_whole_part(): assert parsed[1] == expected_fallback -def test_zero_denominator_does_not_fall_back_to_whole_number_prefix(): +@pytest.mark.parametrize( + ("prediction", "expected_fallback", "finite_candidates"), + [ + pytest.param("4 4/0", "4 4/0", ("4", "0"), id="zero-denominator"), + pytest.param("4 0/0", "4 0/0", ("4", "0"), id="zero-over-zero"), + pytest.param("-4 0/0", "-4 0/0", ("-4", "0"), id="negative"), + pytest.param("0 0/0", "0 0/0", ("0",), id="zero-whole"), + pytest.param("1 000 0/0", "1 000 0/0", ("1000", "0"), id="spaced-thousands"), + pytest.param( + "-1 000 0/0", + "-1 000 0/0", + ("-1000", "0"), + id="negative-spaced-thousands", + ), + pytest.param( + "11 6/6", + "11 6/6", + ("11", "1", "12", "10"), + id="fraction-equal-to-one", + ), + pytest.param( + "11 7/6", + "11 7/6", + ("11", "7/6", "73/6", "59/6"), + id="improper-fraction", + ), + pytest.param( + "The answer is 11 6/6.", + "11 6/6", + ("11", "1", "12", "10"), + id="answer-anchored-improper", + ), + pytest.param( + "The answer is 4 0/0.", + "4 0/0", + ("4", "0"), + id="answer-anchored-zero-denominator", + ), + ], +) +def test_invalid_mixed_number_has_no_finite_component_equivalence( + prediction, expected_fallback, finite_candidates +): # Given - invalid_mixed_number = "4 4/0" - whole_number = parse("4", EXPR_EXTRACTION) - expected = False + finite_values = [parse(value, EXPR_EXTRACTION) for value in finite_candidates] + expected_equivalences = [False] * len(finite_values) # When - parsed = parse(invalid_mixed_number, EXPR_EXTRACTION) - equivalent = verify(whole_number, parsed) + parsed = parse(prediction, EXPR_EXTRACTION) + equivalences = [verify(finite_value, parsed) for finite_value in finite_values] # Then - assert equivalent is expected - assert parsed[1] == invalid_mixed_number + assert equivalences == expected_equivalences + assert parsed[1] == expected_fallback + + +@pytest.mark.parametrize( + ("prediction", "inserted_operation_value"), + [ + pytest.param("11/10 4/9", "139/90", id="fractional-whole"), + pytest.param("11.0 4/9", "103/9", id="decimal-whole"), + pytest.param("(11) 4/9", "103/9", id="parenthesized-whole"), + pytest.param("11/10\n4/9", "139/90", id="line-separated-fractions"), + ], +) +def test_non_mixed_syntax_does_not_gain_an_implicit_operation( + prediction, inserted_operation_value +): + # Given + incorrectly_combined = parse(inserted_operation_value, EXPR_EXTRACTION) + + # When + parsed = parse(prediction, EXPR_EXTRACTION) + equivalent = verify(incorrectly_combined, parsed) + + # Then + assert equivalent is False + + +@pytest.mark.parametrize( + "line_separator", + [pytest.param("\n", id="newline"), pytest.param("\r\n", id="crlf")], +) +def test_line_separator_does_not_form_a_mixed_number(line_separator): + # Given + prediction = f"11{line_separator}5/6" + incorrectly_combined = parse("71/6", EXPR_EXTRACTION) + existing_non_mixed_value = parse("5", EXPR_EXTRACTION) + + # When + parsed = parse(prediction, EXPR_EXTRACTION) + matches_mixed_number = verify(incorrectly_combined, parsed) + preserves_existing_value = verify(existing_non_mixed_value, parsed) + + # Then + assert matches_mixed_number is False + assert preserves_existing_value is True + + +def test_explicit_addition_and_subtraction_remain_distinct(): + # Given + addition = "11 + 4/9" + subtraction = "11 - 4/9" + expected_addition = sympy.Rational(103, 9) + expected_subtraction = sympy.Rational(95, 9) + + # When + parsed_addition = parse(addition, EXPR_EXTRACTION) + parsed_subtraction = parse(subtraction, EXPR_EXTRACTION) + + # Then + assert sympy.simplify(parsed_addition[0] - expected_addition) == 0 + assert sympy.simplify(parsed_subtraction[0] - expected_subtraction) == 0 + assert verify(parsed_addition, parsed_subtraction) is False + + +@pytest.mark.parametrize( + "suffix", + [ + pytest.param("%", id="symbol"), + pytest.param(" %", id="spaced-symbol"), + pytest.param("percent", id="percent"), + pytest.param(" percent", id="spaced-percent"), + pytest.param("percentage", id="percentage"), + pytest.param(" percentage", id="spaced-percentage"), + pytest.param("pct", id="pct"), + pytest.param(" pct", id="spaced-pct"), + ], +) +def test_mixed_number_percentage_applies_to_complete_value(suffix): + # Given + prediction = f"4 4/9{suffix}" + percentage_value = parse("40/900", EXPR_EXTRACTION) + unscaled_value = parse("40/9", EXPR_EXTRACTION) + expected_fallback = "4 4/9" + + # When + parsed = parse(prediction, EXPR_EXTRACTION) + matches_percentage = verify(percentage_value, parsed) + matches_unscaled_value = verify(unscaled_value, parsed) + + # Then + assert matches_percentage is True + assert matches_unscaled_value is False + assert parsed[1] == expected_fallback @pytest.mark.parametrize( From 212311444c5483b264d828377424dfc6c18fe78c Mon Sep 17 00:00:00 2001 From: dkajtoch Date: Tue, 1 Sep 2026 12:28:17 +0100 Subject: [PATCH 3/3] Strengthen mixed-number syntax tests --- tests/test_mixed_number_extraction.py | 28 ++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/tests/test_mixed_number_extraction.py b/tests/test_mixed_number_extraction.py index 4bae0d9..abd944e 100644 --- a/tests/test_mixed_number_extraction.py +++ b/tests/test_mixed_number_extraction.py @@ -192,26 +192,36 @@ def test_invalid_mixed_number_has_no_finite_component_equivalence( @pytest.mark.parametrize( - ("prediction", "inserted_operation_value"), + ("prediction", "inserted_operation_values"), [ - pytest.param("11/10 4/9", "139/90", id="fractional-whole"), - pytest.param("11.0 4/9", "103/9", id="decimal-whole"), - pytest.param("(11) 4/9", "103/9", id="parenthesized-whole"), - pytest.param("11/10\n4/9", "139/90", id="line-separated-fractions"), + pytest.param("11/10 4/9", ("139/90", "59/90"), id="fractional-whole"), + pytest.param("11.0 4/9", ("103/9", "95/9"), id="decimal-whole"), + pytest.param("(11) 4/9", ("103/9", "95/9"), id="parenthesized-whole"), + pytest.param( + "11/10\n4/9", + ("139/90", "59/90"), + id="line-separated-fractions", + ), ], ) def test_non_mixed_syntax_does_not_gain_an_implicit_operation( - prediction, inserted_operation_value + prediction, inserted_operation_values ): # Given - incorrectly_combined = parse(inserted_operation_value, EXPR_EXTRACTION) + incorrectly_combined_values = [ + parse(value, EXPR_EXTRACTION) for value in inserted_operation_values + ] + expected_equivalences = [False] * len(incorrectly_combined_values) # When parsed = parse(prediction, EXPR_EXTRACTION) - equivalent = verify(incorrectly_combined, parsed) + equivalences = [ + verify(incorrectly_combined, parsed) + for incorrectly_combined in incorrectly_combined_values + ] # Then - assert equivalent is False + assert equivalences == expected_equivalences @pytest.mark.parametrize(