diff --git a/expression_parsing/CHANGELOG.md b/expression_parsing/CHANGELOG.md index 9e94b36..960bcdb 100644 --- a/expression_parsing/CHANGELOG.md +++ b/expression_parsing/CHANGELOG.md @@ -41,6 +41,8 @@ The first public release. Further tidy-up after the 0.2.0 extraction: previewing ### Added +- (since 0.3.0rc3) `parse_expression` reads inequalities and `!=`. `x < 5`, `x <= 5`, `x > 5` and `x >= 5` give the unevaluated SymPy relations, `x != 5` gives `Ne(x, 5)`, and a chain of two inequalities pointing the same way (`1 < x <= 5`) gives `And` of both relations. As with `=`, each operand is parsed as a whole expression, and is simplified only with `simplify`. `≠`, `≤` and `≥` are written as `!=`, `<=` and `>=` (`UNICODE_OPERATORS`). These raise `ExpressionParsingError`: more than two inequalities (`a < b < c < d`), a chain pointing both ways (`1 < x > 5`), `=` or `!=` combined with another relation (`1 < x != 5`), and a relation missing an operand (`x <`). `!=` used to be read as a factorial followed by `=`, so `x != 5` was `Eq(factorial(x), 5)`, and `x <= 5` failed to parse. `split_relations(expr, error)` (exported, with `RELATIONAL_OPERATORS`) returns the operands and the operators between them, checked by the rules above. It replaces `split_equation` in `parse_expression`; `parse_latex` still splits on `=` only, as latex2sympy2_extended reads `\leq` and the others itself. From compareExpressions#276. +- (since 0.3.0rc3) A chain of equalities: `x = y = z` gives `And(Eq(x, y), Eq(y, z))` from both `parse_expression` and `parse_latex`, where more than one `=` used to raise "An expression can contain at most one '='". A single `=` still gives a bare `Equality`. `=` in a chain can't be mixed with another relation (`x = y < z`), and an empty side (`x = = y`) raises `ExpressionParsingError` (`LatexParseError` from `parse_latex`). From compareExpressions#278. - `SympyParsingConfig.replace` also takes `extra_unsplittable_symbols` and `extra_symbol_assumptions`, alongside parameter changes (`config.replace(simplify=False, extra_unsplittable_symbols=(...))`); they needed `dataclasses.replace` before. - `notation_warnings(response, strict_syntax)` returns the `NotationWarning`s for a response: `EXPONENT` (`^`) and `FACTORIAL` (`!`) under strict syntax, and `TRIPLE_FACTORIAL` (`!!!`) always. `NotationWarning` is a `StrEnum` whose values are compareExpressions' feedback tags (`NOTATION_WARNING_EXPONENT`, ...), so a tag lookup keeps working. Wording the warnings is still up to the caller. It was `notation_warnings` in compareExpressions' `evaluation.py`, and behaves the same: the checks are substring tests, so under strict syntax `!=` also warns `FACTORIAL`, and `!!!` warns both `FACTORIAL` and `TRIPLE_FACTORIAL`. - DEBUG logging through module loggers (`lambdafeedback.expression_parsing.*`), with a `NullHandler` on the package logger; nothing is logged at INFO or above. See the repository README. Preprocessing, parsing and `parse_latex` log each step's result. diff --git a/expression_parsing/src/lambdafeedback/expression_parsing/__init__.py b/expression_parsing/src/lambdafeedback/expression_parsing/__init__.py index ca69a56..62796d1 100644 --- a/expression_parsing/src/lambdafeedback/expression_parsing/__init__.py +++ b/expression_parsing/src/lambdafeedback/expression_parsing/__init__.py @@ -48,12 +48,14 @@ ) from .params import Convention, ExpressionParams, SymbolSpec, parse_symbol_assumptions from .preprocessing import ( + RELATIONAL_OPERATORS, convert_absolute_notation, convert_bracket_notation, create_expression_set, has_matching_brackets, is_multiple_answers_wrapper, preprocess_expression, + split_relations, ) from .substitution import ( FunctionName, @@ -80,6 +82,7 @@ "MAX_NUMBER_EXPONENT", "MAX_PRODUCT_TERMS", "MAX_SIMPLIFY_POWER", + "RELATIONAL_OPERATORS", "UNEVALUATED_SUM", "UNICODE_OPERATORS", "AbsoluteValueNotationError", @@ -120,6 +123,7 @@ "plain_number_end", "preprocess_expression", "sanitise_latex", + "split_relations", "substitute", "substitute_input_symbols", "substitutions_sort_key", diff --git a/expression_parsing/src/lambdafeedback/expression_parsing/latex.py b/expression_parsing/src/lambdafeedback/expression_parsing/latex.py index ec8e776..b924de3 100644 --- a/expression_parsing/src/lambdafeedback/expression_parsing/latex.py +++ b/expression_parsing/src/lambdafeedback/expression_parsing/latex.py @@ -362,8 +362,8 @@ def text(parsed: Any) -> str: for expression in responses: sides = split_equation(expression, LatexParseError) try: - if len(sides) == 2: - # An equation is written "lhs = rhs", as parse_expression reads it, with each + if len(sides) > 1: + # An equation is written "lhs = rhs" (or "x = y = z"), as parse_expression reads it, with each # side parsed separately ("Eq(x, 2)" would not survive parse_expression with # elementary_functions, where the E of Eq is Euler's number). parsed_responses.add(" = ".join(text(_latex2sympy(side)) for side in sides)) diff --git a/expression_parsing/src/lambdafeedback/expression_parsing/preprocessing.py b/expression_parsing/src/lambdafeedback/expression_parsing/preprocessing.py index 1525a4b..38d7a1e 100644 --- a/expression_parsing/src/lambdafeedback/expression_parsing/preprocessing.py +++ b/expression_parsing/src/lambdafeedback/expression_parsing/preprocessing.py @@ -19,13 +19,53 @@ def split_equation(expr: str, error: type[ExpressionParsingError]) -> list[str]: - """``[expr]``, or ``[lhs, rhs]`` for an equation; raises ``error`` for more than one ``=``.""" + """``[expr]``, or the sides of an equation (``x = y = z`` has three); raises ``error`` for an empty side.""" sides = expr.split("=") - if len(sides) > 2: - raise error(f"An expression can contain at most one '=': {expr}", expression=expr) + if len(sides) > 1 and any(not side.strip() for side in sides): + raise error(f"A relation needs an expression on each side: {expr}", expression=expr) return sides +RELATIONAL_OPERATORS = ("<=", ">=", "!=", "<", ">", "=") +"""The relations a response may state, two-character operators first (scanned in this order).""" + + +def _relational_operator_at(expr: str, index: int) -> str | None: + return next((operator for operator in RELATIONAL_OPERATORS if expr.startswith(operator, index)), None) + + +def split_relations(expr: str, error: type[ExpressionParsingError]) -> tuple[list[str], list[str]]: + """The operands of ``expr`` and the relational operators between them: ``1 < x <= 5`` gives + ``(["1 ", " x ", " 5"], ["<", "<="])``, and an expression ``([expr], [])``. + + Allowed are one relation (``x = 1``, ``x < 1``, ``x != 1``), a chain of + equalities (``x = y = z``) or two order relations pointing the same way + (``1 < x <= 5``). Raises ``error`` for anything else, and for a relation + missing an operand (``x <``, ``x = = y``). + """ + operands, operators = [], [] + start = index = 0 + while index < len(expr): + operator = _relational_operator_at(expr, index) + if operator is None: + index += 1 + continue + operands.append(expr[start:index]) + operators.append(operator) + index = start = index + len(operator) + operands.append(expr[start:]) + if len(operators) > 1 and set(operators) != {"="}: + if "=" in operators or "!=" in operators: + raise error(f"'=' and '!=' can't be combined with another relation: {expr}", expression=expr) + if len(operators) > 2: + raise error(f"Only two inequalities can be chained, as in 1 < x < 5: {expr}", expression=expr) + if operators[0][0] != operators[1][0]: + raise error(f"A chain of inequalities must point one way, as in 1 < x < 5: {expr}", expression=expr) + if operators and any(not operand.strip() for operand in operands): + raise error(f"A relation needs an expression on each side: {expr}", expression=expr) + return operands, operators + + def has_matching_brackets(expr: str) -> bool: """True if every ``(``, ``[``, ``{`` is closed by the same kind, correctly nested. diff --git a/expression_parsing/src/lambdafeedback/expression_parsing/symbol_tables.py b/expression_parsing/src/lambdafeedback/expression_parsing/symbol_tables.py index 1c44a63..7f2aec9 100644 --- a/expression_parsing/src/lambdafeedback/expression_parsing/symbol_tables.py +++ b/expression_parsing/src/lambdafeedback/expression_parsing/symbol_tables.py @@ -142,6 +142,9 @@ def _with_upper_case(entries: list[tuple[str, list[str]]]) -> tuple[SymbolAliase "∙": "*", # BULLET OPERATOR "÷": "/", # DIVISION SIGN "∕": "/", # DIVISION SLASH + "≠": "!=", # NOT EQUAL TO + "≤": "<=", # LESS-THAN OR EQUAL TO + "≥": ">=", # GREATER-THAN OR EQUAL TO } ) """Unicode operators (as typed on phones or pasted from documents) and their ASCII forms.""" diff --git a/expression_parsing/src/lambdafeedback/expression_parsing/sympy_parsing.py b/expression_parsing/src/lambdafeedback/expression_parsing/sympy_parsing.py index c99e033..0234b0f 100644 --- a/expression_parsing/src/lambdafeedback/expression_parsing/sympy_parsing.py +++ b/expression_parsing/src/lambdafeedback/expression_parsing/sympy_parsing.py @@ -8,13 +8,36 @@ from dataclasses import dataclass, field, replace from functools import cached_property from io import StringIO +from itertools import pairwise from keyword import iskeyword from token import NAME, OP from tokenize import TokenError, generate_tokens from typing import Any import sympy -from sympy import Basic, Chi, E, Equality, Function, I, Lambda, Set, Sum, Symbol, Tuple, beta, gamma, summation, zeta +from sympy import ( + And, + Basic, + Chi, + E, + Equality, + Function, + Ge, + Gt, + I, + Lambda, + Le, + Lt, + Ne, + Set, + Sum, + Symbol, + Tuple, + beta, + gamma, + summation, + zeta, +) from sympy.parsing.sympy_parser import ( _token_splittable, auto_number, @@ -35,7 +58,7 @@ from .conventions import apply_convention from .errors import ExpressionParsingError, SymbolAssumptionError from .params import ExpressionParams -from .preprocessing import convert_bracket_notation, create_expression_set, split_equation +from .preprocessing import convert_bracket_notation, create_expression_set, split_relations from .substitution import ( Substitution, elementary_function_substitutions, @@ -354,15 +377,34 @@ def _substitute_names(expr: str, config: SympyParsingConfig) -> str: return " ".join(substitute(expr, sorted_substitutions(substitutions)).split()) +def _relation(operator: str, lhs: Any, rhs: Any) -> Basic: + """The unevaluated SymPy relation ``lhs operator rhs``.""" + if operator == "=": + return Equality(lhs, rhs, evaluate=False) + elif operator == "!=": + return Ne(lhs, rhs, evaluate=False) + elif operator == "<": + return Lt(lhs, rhs, evaluate=False) + elif operator == "<=": + return Le(lhs, rhs, evaluate=False) + elif operator == ">": + return Gt(lhs, rhs, evaluate=False) + elif operator == ">=": + return Ge(lhs, rhs, evaluate=False) + raise ValueError(f"Not a relational operator: {operator!r}") + + def _evaluate(expr: str, config: SympyParsingConfig, transformations: tuple[Any, ...]) -> Basic: symbol_dict = dict(config.symbol_dict) - sides = split_equation(expr, ExpressionParsingError) + operands, operators = split_relations(expr, ExpressionParsingError) parsed: Any - if len(sides) == 2: - # Each side as if it were a whole expression: unevaluated unless simplifying + if operators: + # Each operand as if it were a whole expression: unevaluated unless simplifying # (evaluating 9**9**9 = x would never finish). - lhs, rhs = (_parse_side(side, config, transformations, symbol_dict) for side in sides) - parsed = Equality(lhs, rhs, evaluate=False) + sides = [_parse_side(operand, config, transformations, symbol_dict) for operand in operands] + relations = [_relation(operator, *pair) for operator, pair in zip(operators, pairwise(sides), strict=True)] + # A chain (1 < x < 5) states each relation between neighbours. + parsed = relations[0] if len(relations) == 1 else And(*relations, evaluate=False) else: parsed = _parse_side(expr, config, transformations, symbol_dict) if not isinstance(parsed, Basic): diff --git a/expression_parsing/tests/test_regressions.py b/expression_parsing/tests/test_regressions.py index 9605956..233e1a9 100644 --- a/expression_parsing/tests/test_regressions.py +++ b/expression_parsing/tests/test_regressions.py @@ -9,6 +9,7 @@ import pytest from sympy import ( Abs, + And, Eq, Function, Integral, @@ -130,9 +131,10 @@ def test_capital_e_after_implicit_multiplication(self): assert str(parse_expression("2E", parsing_params())) == "2*E" assert str(parse_expression("xE", parsing_params())) == "E*x" - def test_chained_equalities_are_rejected(self): - with pytest.raises(ValueError, match="="): - parse_expression("a=b=c", parsing_params()) + def test_chained_equalities_are_each_equation(self): + # Was silently Eq(a, b), then rejected; a chain since compareExpressions#278. + a, b, c = Symbol("a"), Symbol("b"), Symbol("c") + assert parse_expression("a=b=c", parsing_params()) == And(Eq(a, b), Eq(b, c)) def test_arithmetic_on_sets_is_rejected_in_strict_syntax(self): with warnings.catch_warnings(): @@ -211,10 +213,13 @@ def test_simplify_keeps_an_equation(self): # Simplifying the Eq as a whole turned "x = x" into "True". assert parse_latex("x = x", {}, simplify=True) == "x = x" - def test_more_than_one_equals_sign_is_rejected(self): - with pytest.raises(LatexParseError, match="=") as info: - parse_latex("x = y = 2", {}) - assert info.value.expression == "x = y = 2" + def test_chained_equality_keeps_every_side(self): + assert parse_latex("x = y = 2", {}) == "x = y = 2" + + def test_empty_side_is_rejected(self): + with pytest.raises(LatexParseError, match="on each side") as info: + parse_latex("x = = 2", {}) + assert info.value.expression == "x = = 2" class TestPublicReleaseReview: diff --git a/expression_parsing/tests/test_relations.py b/expression_parsing/tests/test_relations.py new file mode 100644 index 0000000..afe52df --- /dev/null +++ b/expression_parsing/tests/test_relations.py @@ -0,0 +1,132 @@ +"""Relations in a response: equations, inequalities and ``!=`` (from compareExpressions#276).""" + +import pytest +from sympy import And, Equality, GreaterThan, LessThan, Ne, StrictGreaterThan, StrictLessThan, Symbol + +from lambdafeedback.expression_parsing import ( + ExpressionParams, + ExpressionParsingError, + LatexParseError, + SympyParsingConfig, + parse_expression, + parse_latex, + preprocess_expression, + split_relations, +) + +x = Symbol("x") + + +def parse(expr, **params): + expression_params = ExpressionParams.from_dict({"elementary_functions": True, **params}) + preprocessed = preprocess_expression(expr, expression_params) + return parse_expression(preprocessed, SympyParsingConfig.from_params(expression_params)) + + +@pytest.mark.parametrize( + ("expr", "expected_type"), + [ + ("x > 5", StrictGreaterThan), + ("x < 5", StrictLessThan), + ("x >= 5", GreaterThan), + ("x <= 5", LessThan), + ("5 < x", StrictLessThan), + ("2*x - 10 >= 0", GreaterThan), + ("2x - 10 > 0", StrictGreaterThan), + ("x>=5", GreaterThan), + ("x ≤ 5", LessThan), + ("x ≥ 5", GreaterThan), + ], +) +def test_inequality(expr, expected_type): + assert isinstance(parse(expr), expected_type) + + +@pytest.mark.parametrize( + ("expr", "part_types"), + [ + ("1 < x < 5", (StrictLessThan, StrictLessThan)), + ("1 <= x <= 5", (LessThan, LessThan)), + ("5 >= x >= 1", (GreaterThan, GreaterThan)), + ("5 >= x > 1", (GreaterThan, StrictGreaterThan)), + ("0 < x - 1 <= 4", (StrictLessThan, LessThan)), + ], +) +def test_chained_inequality_is_both_relations(expr, part_types): + parsed = parse(expr) + assert isinstance(parsed, And) + assert len(parsed.args) == 2 + assert {type(arg) for arg in parsed.args} == set(part_types) + + +@pytest.mark.parametrize("expr", ["x != 5", "5 != x", "x - 5 != 0", "x ≠ 5", "x!=5"]) +def test_not_equal(expr): + # `!=` was read as factorial and `=`: `x != 5` was Eq(factorial(x), 5). + assert isinstance(parse(expr), Ne) + + +@pytest.mark.parametrize("expr", ["x = 5", "2*x**2 = 10*y**2 + 14"]) +def test_equation_is_still_an_equality(expr): + assert isinstance(parse(expr), Equality) + + +def test_operands_are_left_unevaluated_unless_simplifying(): + assert parse("x < 2 + 3").rhs != 5 + assert parse("x < 2 + 3", simplify=True).rhs == 5 + + +@pytest.mark.parametrize( + ("expr", "message"), + [ + ("1 < x > 5", "point one way"), + ("a < b > c", "point one way"), + ("1 <= x <= y <= 5", "two inequalities"), + ("a < b < c < d", "two inequalities"), + ("x != y != 5", "can't be combined"), + ("1 < x != 5", "can't be combined"), + ("x < y = 5", "can't be combined"), + ("x = y < 5", "can't be combined"), + ("x = y != 5", "can't be combined"), + ("x <", "on each side"), + ("< 5", "on each side"), + ("x = = y", "on each side"), + ("x = y =", "on each side"), + ], +) +def test_rejected(expr, message): + with pytest.raises(ExpressionParsingError, match=message): + parse(expr) + + +@pytest.mark.parametrize("expr", ["x = y = z", "x=y=z"]) +def test_chained_equality_is_each_equation(expr): + # From compareExpressions#278: more than one "=" used to be rejected. + y, z = Symbol("y"), Symbol("z") + assert parse(expr) == And(Equality(x, y), Equality(y, z)) + + +def test_long_chained_equality(): + parsed = parse("a = b = c = d") + assert isinstance(parsed, And) + assert len(parsed.args) == 3 + assert all(isinstance(arg, Equality) for arg in parsed.args) + + +def test_latex_chained_equality_parses_through(): + y, z = Symbol("y"), Symbol("z") + assert parse(parse_latex("x = y = z", {})) == And(Equality(x, y), Equality(y, z)) + + +def test_latex_empty_side_is_rejected(): + with pytest.raises(LatexParseError, match="on each side"): + parse_latex("x = = y", {}) + + +def test_split_relations(): + assert split_relations("1 < x <= 5", ExpressionParsingError) == (["1 ", " x ", " 5"], ["<", "<="]) + assert split_relations("x + 1", ExpressionParsingError) == (["x + 1"], []) + + +def test_latex_inequality_parses_through(): + # parse_latex writes the relation as text, which parse_expression then reads. + assert parse(parse_latex(r"x \leq 5", {})) == LessThan(x, 5) diff --git a/tools/parity/expected_changes.json b/tools/parity/expected_changes.json index b540324..880264f 100644 --- a/tools/parity/expected_changes.json +++ b/tools/parity/expected_changes.json @@ -116,8 +116,11 @@ "value": "ERROR" }, "expr.parse|assumptions|a=b=c": { - "reason": "expression_parsing: an expression with more than one '=' is rejected instead of silently parsing as Eq of the first two parts", - "value": "ERROR" + "reason": "expression_parsing: a chain of equalities a=b=c is And(Eq(a, b), Eq(b, c)) (compareExpressions#278); it silently parsed as Eq of the first two parts", + "value": { + "latex": "a = b \\wedge b = c", + "parsed": "Eq(a, b) & Eq(b, c)" + } }, "expr.parse|assumptions|x – 1": { "reason": "expression_parsing: Unicode minus and en dash are read as '-' (normalise_unicode_operators); was the product x*−*1", @@ -142,8 +145,11 @@ "value": "ERROR" }, "expr.parse|complex|a=b=c": { - "reason": "expression_parsing: an expression with more than one '=' is rejected instead of silently parsing as Eq of the first two parts", - "value": "ERROR" + "reason": "expression_parsing: a chain of equalities a=b=c is And(Eq(a, b), Eq(b, c)) (compareExpressions#278); it silently parsed as Eq of the first two parts", + "value": { + "latex": "a = b \\wedge b = c", + "parsed": "Eq(a, b) & Eq(b, c)" + } }, "expr.parse|complex|x – 1": { "reason": "expression_parsing: Unicode minus and en dash are read as '-' (normalise_unicode_operators); was the product x*−*1", @@ -175,8 +181,11 @@ } }, "expr.parse|default|a=b=c": { - "reason": "expression_parsing: an expression with more than one '=' is rejected instead of silently parsing as Eq of the first two parts", - "value": "ERROR" + "reason": "expression_parsing: a chain of equalities a=b=c is And(Eq(a, b), Eq(b, c)) (compareExpressions#278); it silently parsed as Eq of the first two parts", + "value": { + "latex": "a = b \\wedge b = c", + "parsed": "Eq(a, b) & Eq(b, c)" + } }, "expr.parse|default|x – 1": { "reason": "expression_parsing: Unicode minus and en dash are read as '-' (normalise_unicode_operators); was the product x*−*1", @@ -208,8 +217,11 @@ "value": "ERROR" }, "expr.parse|elementary|a=b=c": { - "reason": "expression_parsing: an expression with more than one '=' is rejected instead of silently parsing as Eq of the first two parts", - "value": "ERROR" + "reason": "expression_parsing: a chain of equalities a=b=c is And(Eq(a, b), Eq(b, c)) (compareExpressions#278); it silently parsed as Eq of the first two parts", + "value": { + "latex": "a = b \\wedge b = c", + "parsed": "Eq(a, b) & Eq(b, c)" + } }, "expr.parse|elementary|x – 1": { "reason": "expression_parsing: Unicode minus and en dash are read as '-' (normalise_unicode_operators); was the product x*−*1", @@ -234,8 +246,11 @@ "value": "ERROR" }, "expr.parse|implicit_higher|a=b=c": { - "reason": "expression_parsing: an expression with more than one '=' is rejected instead of silently parsing as Eq of the first two parts", - "value": "ERROR" + "reason": "expression_parsing: a chain of equalities a=b=c is And(Eq(a, b), Eq(b, c)) (compareExpressions#278); it silently parsed as Eq of the first two parts", + "value": { + "latex": "a = b \\wedge b = c", + "parsed": "Eq(a, b) & Eq(b, c)" + } }, "expr.parse|implicit_higher|x – 1": { "reason": "expression_parsing: Unicode minus and en dash are read as '-' (normalise_unicode_operators); was the product x*−*1", @@ -256,8 +271,11 @@ "value": "ERROR" }, "expr.parse|strict|a=b=c": { - "reason": "expression_parsing: an expression with more than one '=' is rejected instead of silently parsing as Eq of the first two parts", - "value": "ERROR" + "reason": "expression_parsing: a chain of equalities a=b=c is And(Eq(a, b), Eq(b, c)) (compareExpressions#278); it silently parsed as Eq of the first two parts", + "value": { + "latex": "a = b \\wedge b = c", + "parsed": "Eq(a, b) & Eq(b, c)" + } }, "expr.parse|strict|as": { "reason": "expression_parsing: Python keywords (as, in, is, ...) are read as symbols; in strict syntax 'as' is one symbol (was a*s)", @@ -296,8 +314,11 @@ "value": "ERROR" }, "expr.parse|symbols|a=b=c": { - "reason": "expression_parsing: an expression with more than one '=' is rejected instead of silently parsing as Eq of the first two parts", - "value": "ERROR" + "reason": "expression_parsing: a chain of equalities a=b=c is And(Eq(a, b), Eq(b, c)) (compareExpressions#278); it silently parsed as Eq of the first two parts", + "value": { + "latex": "a = b \\wedge b = c", + "parsed": "Eq(a, b) & Eq(b, c)" + } }, "expr.parse|symbols|lam + lambda": { "reason": "expression_parsing: task symbols are normalised once (lambda -> lamda) in ExpressionParams, so every path maps the task's lambda to lamda and prints it with the task's LaTeX",