Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions src/math_verify/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,19 @@ def lazy_expr_regex(
r")(?P<percent>\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.
horizontal_space_re = r"[ \t]"
mixed_whole_re = r"(?:[1-9]\d{0,2}(?:[ ,]\d{3})+|\d+)"
mixed_number_re = (
rf"(?P<mixed_number>(?P<mixed_sign>[+-]?)(?P<mixed_whole>{mixed_whole_re})"
rf"{horizontal_space_re}+(?P<mixed_numerator>\d+)"
rf"{horizontal_space_re}*/{horizontal_space_re}*"
r"(?P<mixed_denominator>\d+))"
rf"(?P<mixed_percent>{horizontal_space_re}*"
r"(?:%|[Pp]ercent|[Pp]ercentage|[Pp]ct))?"
)

# Expressions such as 1/2
operators = [r"\+", r"\-", r"\*", r"\×", r"\/", r"\^", r"\(", r"\)", r"\÷"]
operators_re = "".join(operators)
Expand All @@ -207,8 +220,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 = (
Expand All @@ -231,6 +249,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))

Expand Down Expand Up @@ -419,7 +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 = True if groups.get("percent", None) else False
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})"
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.
Expand Down
323 changes: 323 additions & 0 deletions tests/test_mixed_number_extraction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,323 @@
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


@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."
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_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"
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


@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
finite_values = [parse(value, EXPR_EXTRACTION) for value in finite_candidates]
expected_equivalences = [False] * len(finite_values)

# When
parsed = parse(prediction, EXPR_EXTRACTION)
equivalences = [verify(finite_value, parsed) for finite_value in finite_values]

# Then
assert equivalences == expected_equivalences
assert parsed[1] == expected_fallback


@pytest.mark.parametrize(
("prediction", "inserted_operation_values"),
[
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_values
):
# Given
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)
equivalences = [
verify(incorrectly_combined, parsed)
for incorrectly_combined in incorrectly_combined_values
]

# Then
assert equivalences == expected_equivalences


@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(
("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