Skip to content
Draft
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
2 changes: 2 additions & 0 deletions expression_parsing/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -80,6 +82,7 @@
"MAX_NUMBER_EXPONENT",
"MAX_PRODUCT_TERMS",
"MAX_SIMPLIFY_POWER",
"RELATIONAL_OPERATORS",
"UNEVALUATED_SUM",
"UNICODE_OPERATORS",
"AbsoluteValueNotationError",
Expand Down Expand Up @@ -120,6 +123,7 @@
"plain_number_end",
"preprocess_expression",
"sanitise_latex",
"split_relations",
"substitute",
"substitute_input_symbols",
"substitutions_sort_key",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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):
Expand Down
19 changes: 12 additions & 7 deletions expression_parsing/tests/test_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import pytest
from sympy import (
Abs,
And,
Eq,
Function,
Integral,
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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:
Expand Down
132 changes: 132 additions & 0 deletions expression_parsing/tests/test_relations.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading