diff --git a/app/context/symbolic.py b/app/context/symbolic.py index cb858b8..985e7e1 100644 --- a/app/context/symbolic.py +++ b/app/context/symbolic.py @@ -1,7 +1,9 @@ from copy import deepcopy +from itertools import permutations from sympy import Add, Pow, Mul, Equality, pi, im, I, N, oo, simplify from sympy import re as real_part from sympy import StrictLessThan, LessThan, StrictGreaterThan, GreaterThan, Ne, And +from sympy import linear_eq_to_matrix # Order relations (chainable as `1 < x < 5`). INEQUALITY_TYPES = (StrictLessThan, LessThan, StrictGreaterThan, GreaterThan) @@ -21,6 +23,20 @@ def inequality_bounds(expr): return list(expr.args) return None + +def equality_parts(expr): + """The list of equality parts if `expr` is a single equality (`x = y`) or a + chained equality (`x = y = z`, parsed as a conjunction of the equalities + between adjacent operands), otherwise None. Disjoint from + `inequality_bounds`, which never matches a conjunction of equalities.""" + if isinstance(expr, Equality): + return [expr] + if isinstance(expr, And) and expr.args and all( + isinstance(arg, Equality) for arg in expr.args + ): + return list(expr.args) + return None + from ..utility.expression_utilities import ( default_parameters, parse_expression, @@ -138,6 +154,8 @@ def check_equality(criterion, parameters_dict, local_substitutions=[]): lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict, local_substitutions) lhs_is_inequality = inequality_bounds(lhs_expr) is not None rhs_is_inequality = inequality_bounds(rhs_expr) is not None + lhs_is_equality_chain = isinstance(lhs_expr, And) and equality_parts(lhs_expr) is not None + rhs_is_equality_chain = isinstance(rhs_expr, And) and equality_parts(rhs_expr) is not None if lhs_is_inequality or rhs_is_inequality: # Subtracting relational / And objects raises, so these cases must be # intercepted before the generic `lhs_expr - rhs_expr` comparison below. @@ -145,6 +163,13 @@ def check_equality(criterion, parameters_dict, local_substitutions=[]): result = check_inequality_equivalence(lhs_expr, rhs_expr, parameters_dict) is True else: result = False + elif lhs_is_equality_chain or rhs_is_equality_chain: + # Chained equalities are `And` objects for the same reason, so they must + # be intercepted here too. + if lhs_is_equality_chain and rhs_is_equality_chain: + result = check_equality_equivalence(lhs_expr, rhs_expr, parameters_dict) is True + else: + result = False elif isinstance(lhs_expr, Equality) and not isinstance(rhs_expr, Equality): result = False elif not isinstance(lhs_expr, Equality) and isinstance(rhs_expr, Equality): @@ -363,6 +388,146 @@ def check_inequality_equivalence(res, ans, parameters_dict): return _compare_chained_inequalities(res_bounds, ans_bounds, constants) +# Chains longer than this are not searched exhaustively; see +# `_compare_chained_equalities`. +MAX_CHAINED_EQUALITY_PARTS = 8 + + +def _compare_single_equality(res_eq, ans_eq, constants): + """ + Check whether two equalities describe the same relation, i.e. whether their + residuals (`lhs - rhs`) differ only by a constant factor. Where one residual + simplifies to zero the other is examined on its own, since the ratio would be + a division by zero. + """ + res_difference = res_eq.args[0] - res_eq.args[1] + ans_difference = ans_eq.args[0] - ans_eq.args[1] + if res_difference.simplify() == 0: + symbols_in_equality_ratio = ans_difference.simplify().free_symbols + elif ans_difference.simplify() == 0: + symbols_in_equality_ratio = res_difference.simplify().free_symbols + else: + symbols_in_equality_ratio = (res_difference/ans_difference).simplify().free_symbols + return {str(s) for s in symbols_in_equality_ratio}.issubset(constants) + + +def _chain_residuals(parts): + return [part.args[0] - part.args[1] for part in parts] + + +def _augmented_matrix(residuals, symbols): + """ + The coefficient matrix of `residuals` in `symbols` augmented with the + constant terms, or None if any residual is not affine in `symbols` or if the + coefficients are not purely numeric. + """ + try: + coefficients, constant_terms = linear_eq_to_matrix(residuals, list(symbols)) + except Exception: + return None + matrix = coefficients.row_join(constant_terms) + if matrix.free_symbols: + return None + return matrix + + +def _compare_chained_equalities_by_solution_set(res_parts, ans_parts): + """ + Compare two chained equalities by the solution set of the system their parts + describe. A chain asserts that all of its operands are equal to one another, + so the order in which the parts are written carries no meaning and the + adjacent differences of a permuted chain are different expressions that + nonetheless constrain exactly the same values: `y = x = z` gives `y - x` and + `x - z` where `x = y = z` gives `x - y` and `y - z`, and each pair spans the + other. Two affine systems have the same solution set exactly when their + augmented matrices have equal rank and stacking them adds no rank. + + Returns None when the residuals are not affine with numeric coefficients, in + which case the caller falls back to comparing the parts pairwise. + """ + res_residuals = _chain_residuals(res_parts) + ans_residuals = _chain_residuals(ans_parts) + symbols = sorted( + set().union(*[residual.free_symbols for residual in res_residuals + ans_residuals]), + key=str + ) + res_matrix = _augmented_matrix(res_residuals, symbols) + ans_matrix = _augmented_matrix(ans_residuals, symbols) + if res_matrix is None or ans_matrix is None: + return None + res_rank = res_matrix.rank() + ans_rank = ans_matrix.rank() + return res_rank == ans_rank == res_matrix.col_join(ans_matrix).rank() + + +def _compare_chained_equalities(res_parts, ans_parts, constants): + """ + Compare two chained equalities (`x = y = z`). Affine chains are compared by + solution set; anything else (for instance a chain scaled by a symbolic + constant, whose coefficients are not numeric) falls back to matching the + parts one-to-one with the same constant-ratio test used for a single + equality. Returns None for non-affine chains too long to search + exhaustively. + """ + result = _compare_chained_equalities_by_solution_set(res_parts, ans_parts) + if result is not None: + return result + if len(res_parts) != len(ans_parts): + return False + if len(res_parts) > MAX_CHAINED_EQUALITY_PARTS: + return None + for permuted_ans_parts in permutations(ans_parts): + if all( + _compare_single_equality(res_part, ans_part, constants) + for res_part, ans_part in zip(res_parts, permuted_ans_parts) + ): + return True + return False + + +def check_equality_equivalence(res, ans, parameters_dict): + """ + Check whether the response equality `res` is equivalent to the answer + equality `ans`. Both may be a single `sympy` equality (`x = y`) or a chained + equality (`x = y = z`, parsed as an `And` of the equalities between adjacent + operands); a chain is compared to another chain part by part, and a chain is + never equivalent to a single equality. + + Returns one of: + True - equivalent + False - not equivalent + "EXPRESSION_NOT_EQUALITY" - the response is not an equality, the answer is + "EQUALITY_NOT_EXPRESSION" - the response is an equality, the answer is not + "CHAIN_NOT_EQUALITY" - the response is a chained equality, the answer + is a single equality + "EQUALITY_NOT_CHAIN" - the response is a single equality, the answer + is a chained equality + None - undecidable (chain too long to search) + """ + res_parts = equality_parts(res) + ans_parts = equality_parts(ans) + if res_parts is None and ans_parts is not None: + return "EXPRESSION_NOT_EQUALITY" + if res_parts is not None and ans_parts is None: + return "EQUALITY_NOT_EXPRESSION" + if res_parts is None and ans_parts is None: + return False + + constants = set(parameters_dict["parsing_parameters"].get("constants", set())) + + if len(res_parts) == 1 and len(ans_parts) == 1: + return _compare_single_equality(res_parts[0], ans_parts[0], constants) + + # A chain is compared by solution set, which already accounts for a differing + # number of parts, so the counts only choose the feedback tag. A degenerate + # chain that states no more than a single equality (`x = y = y`) therefore + # still matches that equality. + result = _compare_chained_equalities(res_parts, ans_parts, constants) + if result is False and len(res_parts) != len(ans_parts): + return "CHAIN_NOT_EQUALITY" if len(res_parts) > len(ans_parts) else "EQUALITY_NOT_CHAIN" + return result + + def check_proportionality(criterion, parameters_dict, local_substitutions=[]): lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict, local_substitutions) result = None @@ -498,37 +663,20 @@ def set_equivalence(unused_input): } def equality_equivalence(unused_input): - result = False + # TODO: Remove when criteria for checking proportionality is implemented res = parameters_dict["reserved_expressions"]["response"] ans = parameters_dict["reserved_expressions"]["answer"] - - if (not isinstance(res, Equality)) and isinstance(ans, Equality): - return { - label+"_EXPRESSION_NOT_EQUALITY": None - } - - if isinstance(res, Equality) and (not isinstance(ans, Equality)): - return { - label+"_EQUALITY_NOT_EXPRESSION": None - } - - # TODO: Remove when criteria for checking proportionality is implemented - if isinstance(res, Equality) and isinstance(ans, Equality): - if (res.args[0]-res.args[1]).simplify() == 0: - symbols_in_equality_ratio = (ans.args[0]-ans.args[1]).simplify().free_symbols - elif (ans.args[0]-ans.args[1]).simplify() == 0: - symbols_in_equality_ratio = (res.args[0]-res.args[1]).simplify().free_symbols - else: - symbols_in_equality_ratio = ((res.args[0]-res.args[1])/(ans.args[0]-ans.args[1])).simplify().free_symbols - result = {str(s) for s in symbols_in_equality_ratio}.issubset(parameters_dict["parsing_parameters"]["constants"]) - if result is True: - return { - label+"_TRUE": None - } - else: - return { - label+"_FALSE": None - } + result = check_equality_equivalence(res, ans, parameters_dict) + result_to_tag = { + True: label+"_TRUE", + False: label+"_FALSE", + "EXPRESSION_NOT_EQUALITY": label+"_EXPRESSION_NOT_EQUALITY", + "EQUALITY_NOT_EXPRESSION": label+"_EQUALITY_NOT_EXPRESSION", + "CHAIN_NOT_EQUALITY": label+"_CHAIN_NOT_EQUALITY", + "EQUALITY_NOT_CHAIN": label+"_EQUALITY_NOT_CHAIN", + None: label+"_UNKNOWN", + } + return {result_to_tag[result]: None} def inequality_equivalence(unused_input): res = parameters_dict["reserved_expressions"]["response"] @@ -586,7 +734,7 @@ def same_symbols(unused_input): res = parameters_dict["reserved_expressions"]["response"] ans = parameters_dict["reserved_expressions"]["answer"] use_inequality_equivalence = inequality_bounds(res) is not None or inequality_bounds(ans) is not None - use_equality_equivalence = (isinstance(res, Equality) or isinstance(ans, Equality)) and not use_inequality_equivalence + use_equality_equivalence = (equality_parts(res) is not None or equality_parts(ans) is not None) and not use_inequality_equivalence # TODO: Make checking set equivalence its own context that calls symbolic comparisons instead if use_set_equivalence is True: @@ -674,6 +822,22 @@ def same_symbols(unused_input): feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EQUALITY_NOT_EXPRESSION") ) graph.attach(label+"_EQUALITY_NOT_EXPRESSION", END.label) + graph.attach( + label, + label+"_CHAIN_NOT_EQUALITY", + summary=str(lhs)+" is a chained equality, not a single equality.", + details=str(lhs)+" is a chained equality, but a single equality was expected.", + feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("CHAIN_NOT_EQUALITY") + ) + graph.attach(label+"_CHAIN_NOT_EQUALITY", END.label) + graph.attach( + label, + label+"_EQUALITY_NOT_CHAIN", + summary=str(lhs)+" is a single equality, not a chained equality.", + details=str(lhs)+" is a single equality, but a chained equality was expected.", + feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EQUALITY_NOT_CHAIN") + ) + graph.attach(label+"_EQUALITY_NOT_CHAIN", END.label) elif use_inequality_equivalence: graph.add_evaluation_node( label, diff --git a/app/docs/dev.md b/app/docs/dev.md index 13132e7..2caba28 100644 --- a/app/docs/dev.md +++ b/app/docs/dev.md @@ -74,6 +74,8 @@ There are currently two different contexts: - `numerical`: Comparison of expressions that can be evaluated to numerical values (e.g. expressions that are already numerical values or expressions only containing constants). Focuses on identifying if numerical values are greater than, less than, proportional to the expected answer or similar. - `symbolic`: Comparison of symbolic expressions that cannot be reduced to numerical values. - `equality`: Comparison of mathematical equalities (with the extra complexities that come with equivalence of equalities compared to equality of expressions). + + **Current implementation:** `criterion_equality_node` picks the `equality_equivalence` branch (flag `use_equality_equivalence`) when either reserved expression parses to an equality, which `equality_parts` reports for both a bare `Equality` and a chained equality. `parse_expression` parses `x = y = z` into `And(Eq(x, y), Eq(y, z))`, the conjunction of the equalities between adjacent operands; before this only the first two operands were parsed and the rest were silently discarded. `check_equality_equivalence` keeps the original constant-ratio test (`_compare_single_equality`) for the single-equality-to-single-equality case, and otherwise compares chains by solution set: a chain asserts that all of its operands are equal, so a permuted chain yields different adjacent differences that span the same space, and `_compare_chained_equalities_by_solution_set` therefore compares the ranks of the augmented coefficient matrices rather than matching parts up. That needs residuals that are affine with numeric coefficients; anything else (for instance a chain scaled by a symbolic constant) falls back to matching the parts one-to-one over every permutation, bounded by `MAX_CHAINED_EQUALITY_PARTS`. Because the solution-set comparison already accounts for a differing number of parts, the part counts only select the `CHAIN_NOT_EQUALITY` / `EQUALITY_NOT_CHAIN` feedback tags, which leaves a degenerate chain such as `x = y = y` matching `x = y`. Equalities mixed with order operators in one expression (`x = y < z`) are not meaningfully supported. - `inequality`: Same as `equality` except for mathematical inequalities (which will require different choices when it comes to what can be considered equivalence). It might be appropriate to combine `equality` and `inequality` into one context (called `statements` or similar). **Current implementation:** inequality answer/response equivalence is handled *inside* the `symbolic` context, parallel to equality equivalence. `criterion_equality_node` picks the `inequality_equivalence` branch (flag `use_inequality_equivalence`) when either reserved expression parses to a `sympy` order relation, and `check_inequality_equivalence` rewrites both sides as `D REL 0` and checks that `D_response / D_answer` is a positive constant with matching strictness. Order operators `<`, `<=`, `>`, `>=` are parsed into relations by `parse_expression` (`app/utility/expression_utilities.py`). A two-operator single-direction chain (`1 < x < 5`) is parsed into `And(, )`; `check_inequality_equivalence` (via `inequality_bounds`) matches the two response bounds against the two answer bounds in either pairing, reusing `_compare_single_inequality`. Longer or mixed-direction chains are rejected. `!=` (or `≠`, normalised to `!=` in `parse_expression`) parses to `Ne`; `_compare_single_inequality` delegates the both-`!=` case to `_compare_not_equal` (non-zero-constant ratio, no direction/strictness) and treats `!=` against an order operator as not equivalent. `!=` cannot be chained. Moving this into a dedicated `statements` context remains future work. diff --git a/app/docs/user.md b/app/docs/user.md index ad0c49c..725f92b 100644 --- a/app/docs/user.md +++ b/app/docs/user.md @@ -288,6 +288,12 @@ The example given in the example problem set uses an EXPRESSION response area th Some examples of expressions that are accepted as correct: `x^2-5\*y^2-7=0` $x^2-5y^2-7=0$, `x^2 = 5y^2+7` $x^2=5y^2+7$, `2x^2 = 10y^2+14` $2x^2=10y^2+14=0$. +Chained equalities of any length (e.g. `x = y = z`) are supported, in the answer and/or the response. A chain asserts that all of its operands are equal to one another, so it is parsed as the conjunction of the equalities between adjacent operands and compared by solution set. The order the operands are written in therefore carries no meaning: with answer `x = y = z` the responses `z = y = x`, `y = x = z`, `2x = 2y = 2z` and `x + 1 = y + 1 = z + 1` are all accepted, while `x = y = w` and `x = y = 5` are rejected. A degenerate chain that asserts no more than a single equality (e.g. `x = y = y`) matches that equality. + +A chain is not equivalent to a single equality that omits one of its constraints: with answer `x = y = z` the response `x = y` is rejected, and vice versa. Every `=` must have an expression on both sides, so `x = = y` and `x =` are rejected as unparseable. + +**Note:** equalities cannot be mixed with order operators in one expression (e.g. `x = y < z`), and chains whose parts are not affine are compared by matching the parts one-to-one instead of by solution set, which is order-insensitive but otherwise stricter. + #### Inequalities in the answer and response There is (limited) support for using inequalities in the response and answer. If the answer is `p REL q` and the response is `f REL' g`, where `REL` and `REL'` are order operators (`<`, `<=`, `>`, `>=`), the function rewrites each side as `D REL 0` (moving all terms to one side and flipping `>`/`>=` to `<`/`<=`) and checks that `D_response / D_answer` simplifies to a **positive** constant *and* that the two relations have the same strictness. `<` and `<=` are treated as different. diff --git a/app/feedback/symbolic.py b/app/feedback/symbolic.py index 0349d68..7b37a65 100644 --- a/app/feedback/symbolic.py +++ b/app/feedback/symbolic.py @@ -28,6 +28,8 @@ "EQUALITIES_EQUIVALENT": None, "EQUALITIES_NOT_EQUIVALENT": "The response is not the expected equality.", "EQUALITY_EQUIVALENCE_UNKNOWN": "Cannot determine if the given equality is equivalent to the expected equality.", + "CHAIN_NOT_EQUALITY": "The response was a chained equality but a single equality was expected.", + "EQUALITY_NOT_CHAIN": "The response was a single equality but a chained equality was expected.", "RESPONSE_NOT_INEQUALITY": "The response was an expression but was expected to be an inequality.", "ANSWER_NOT_INEQUALITY": "The response was an inequality but the answer is not, so they cannot be compared.", "INEQUALITIES_EQUIVALENT": None, diff --git a/app/tests/expression_utilities_test.py b/app/tests/expression_utilities_test.py index 2f903bd..454b8b2 100644 --- a/app/tests/expression_utilities_test.py +++ b/app/tests/expression_utilities_test.py @@ -557,4 +557,31 @@ def test_parse_chained_inequality_rejected(self, expr): @pytest.mark.parametrize("expr", ["x = 5", "2*x**2 = 10*y**2 + 14"]) def test_parse_equality_still_works(self, expr): parsed = parse_expression(expr, self.parsing_params()) - assert isinstance(parsed, Equality) \ No newline at end of file + assert isinstance(parsed, Equality) + + @pytest.mark.parametrize( + "expr,number_of_parts", + [ + ("x = y = z", 2), + ("x = y = z = w", 3), + ("2*x = y + 1 = z", 2), + ("a = b = c = d = e", 4), + ] + ) + def test_parse_chained_equality(self, expr, number_of_parts): + # A chained equality becomes the conjunction of the equalities between + # adjacent operands; before this every operand after the second was + # silently discarded. + parsed = parse_expression(expr, self.parsing_params()) + assert isinstance(parsed, And) + assert len(parsed.args) == number_of_parts + assert all(isinstance(arg, Equality) for arg in parsed.args) + + def test_parse_chained_equality_keeps_all_operands(self): + parsed = parse_expression("x = y = z", self.parsing_params()) + assert {str(s) for s in parsed.free_symbols} == {"x", "y", "z"} + + @pytest.mark.parametrize("expr", ["x = = y", "= x", "x =", "x = y ="]) + def test_parse_equality_with_empty_operand_rejected(self, expr): + with pytest.raises(ValueError): + parse_expression(expr, self.parsing_params()) \ No newline at end of file diff --git a/app/tests/symbolic_evaluation_test.py b/app/tests/symbolic_evaluation_test.py index 7993257..8e0f917 100644 --- a/app/tests/symbolic_evaluation_test.py +++ b/app/tests/symbolic_evaluation_test.py @@ -748,6 +748,63 @@ def test_not_equal_chained_or_mixed_is_rejected(self, response): assert result["is_correct"] is False assert "could not be parsed" in result["feedback"] + @pytest.mark.parametrize( + "response,answer,value", + [ + ("x = y = z", "x = y = z", True), + # A chain asserts that every operand is equal to the others, so the + # order the operands are written in carries no meaning. + ("z = y = x", "x = y = z", True), + ("y = x = z", "x = y = z", True), + ("x = z = y", "x = y = z", True), + # Equivalent up to a constant factor or offset, as for a single equality. + ("2*x = 2*y = 2*z", "x = y = z", True), + ("x + 1 = y + 1 = z + 1", "x = y = z", True), + # Longer chains. + ("x = y = z = w", "x = y = z = w", True), + ("w = z = y = x", "x = y = z = w", True), + ("x = y = z = v", "x = y = z = w", False), + # A different final operand must not be silently discarded. + ("x = y = w", "x = y = z", False), + ("x = y = 5", "x = y = 7", False), + ("x = y = 5", "x = y = 5", True), + # Degenerate chains that assert no more than a single equality. + ("x = y = y", "x = y", True), + ("x = y = x", "x = y", True), + ] + ) + def test_chained_equality_in_answer_and_response(self, response, answer, value): + params = {"strict_syntax": False, "elementary_functions": True} + result = evaluation_function(response, answer, params) + assert result["is_correct"] is value + + @pytest.mark.parametrize( + "response,answer,tag", + [ + ("x = y = z", "x = y", "response = answer_CHAIN_NOT_EQUALITY"), + ("2 = 2 = 3", "2 = 2", "response = answer_CHAIN_NOT_EQUALITY"), + ("x = y", "x = y = z", "response = answer_EQUALITY_NOT_CHAIN"), + ("x + y", "x = y = z", "response = answer_EXPRESSION_NOT_EQUALITY"), + ("x = y = z", "x + y", "response = answer_EQUALITY_NOT_EXPRESSION"), + ] + ) + def test_chained_equality_feedback_tags(self, response, answer, tag): + params = {"strict_syntax": False, "elementary_functions": True} + result = evaluation_function(response, answer, params, include_test_data=True) + assert result["is_correct"] is False + assert tag in result["tags"] + + @pytest.mark.parametrize( + "response,answer", + [("x = y = w", "x = y = z"), ("x = y = 5", "x = y = 7")] + ) + def test_chained_equality_is_not_truncated(self, response, answer): + # Regression: only the first two operands used to be parsed, so these + # responses compared equal to the answer. + params = {"strict_syntax": False, "elementary_functions": True} + result = evaluation_function(response, answer, params) + assert result["is_correct"] is False + def test_empty_old_format_input_symbols_codes_and_alternatives(self): answer = '(1+(gamma-1)/2)((-1)/(gamma-1))' response = '(1+(gamma-1)/2)((-1)/(gamma-1))' diff --git a/app/tests/symbolic_preview_test.py b/app/tests/symbolic_preview_test.py index cb86d62..0f16d5b 100644 --- a/app/tests/symbolic_preview_test.py +++ b/app/tests/symbolic_preview_test.py @@ -77,6 +77,14 @@ def test_sympy_with_equality_symbol(self): preview = result["preview"] assert preview.get("latex") == "\\frac{x^{2} + x + x}{x} = 1" + def test_preview_with_chained_equality(self): + response = "x = y = z" + params = Params(is_latex=False, simplify=False) + result = preview_function(response, params) + preview = result["preview"] + assert preview.get("sympy") == "x=y=z" + assert preview.get("latex") == "x=y=z" + def test_latex_with_plus_minus(self): response = r"\pm \frac{3}{\sqrt{5}} i" params = Params( diff --git a/app/utility/expression_utilities.py b/app/utility/expression_utilities.py index 93fdc71..0a801da 100644 --- a/app/utility/expression_utilities.py +++ b/app/utility/expression_utilities.py @@ -880,10 +880,25 @@ def parse_expression(expr_string, parsing_params): relations.append(relational_classes[operator](lhs, rhs, evaluate=False)) parsed_expr = relations[0] if len(relations) == 1 else And(*relations, evaluate=False) elif "=" in expr: + # A chained equality (`x = y = z`) asserts that every operand equals + # the others, so it is parsed as a conjunction of the equalities + # between adjacent operands, mirroring the chained order inequalities + # handled above. A single equality is left as a bare `Equality`. expr_parts = expr.split("=") - lhs = parse_expr(expr_parts[0], transformations=transformations, local_dict=symbol_dict) - rhs = parse_expr(expr_parts[1], transformations=transformations, local_dict=symbol_dict) - parsed_expr = Equality(lhs, rhs, evaluate=False) + if any(part.strip() == "" for part in expr_parts): + raise ValueError( + f"Failed to parse Sympy expression `{expr}`: every `=` must " + "have an expression on both sides." + ) + operands = [ + parse_expr(part, transformations=transformations, local_dict=symbol_dict) + for part in expr_parts + ] + equalities = [ + Equality(lhs, rhs, evaluate=False) + for lhs, rhs in zip(operands[:-1], operands[1:]) + ] + parsed_expr = equalities[0] if len(equalities) == 1 else And(*equalities, evaluate=False) elif parsing_params.get("simplify", False): parsed_expr = parse_expr(expr, transformations=transformations, local_dict=symbol_dict) if not isinstance(parsed_expr, Equality):