From bf415552d930727fab3bae7fc473c1158972a56b Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Thu, 10 Sep 2026 14:53:02 +0100 Subject: [PATCH 1/6] Added support for handling inequalities in symbolic equivalence checks and preview functions --- app/context/symbolic.py | 157 +++++++++++++++++- app/docs/dev.md | 2 + app/docs/user.md | 10 +- app/feedback/symbolic.py | 7 + .../symbolic_preview.py | 6 +- app/preview_test.py | 13 ++ app/tests/expression_utilities_test.py | 49 +++++- app/tests/symbolic_evaluation_test.py | 65 ++++++++ app/utility/expression_utilities.py | 28 +++- 9 files changed, 329 insertions(+), 8 deletions(-) diff --git a/app/context/symbolic.py b/app/context/symbolic.py index 960c989..0c2001d 100644 --- a/app/context/symbolic.py +++ b/app/context/symbolic.py @@ -1,6 +1,9 @@ from copy import deepcopy -from sympy import Add, Pow, Mul, Equality, pi, im, I, N, oo +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 + +INEQUALITY_TYPES = (StrictLessThan, LessThan, StrictGreaterThan, GreaterThan) from ..utility.expression_utilities import ( default_parameters, @@ -117,7 +120,16 @@ def do_comparison(comparison_symbol, expression): def check_equality(criterion, parameters_dict, local_substitutions=[]): lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict, local_substitutions) - if isinstance(lhs_expr, Equality) and not isinstance(rhs_expr, Equality): + lhs_is_inequality = isinstance(lhs_expr, INEQUALITY_TYPES) + rhs_is_inequality = isinstance(rhs_expr, INEQUALITY_TYPES) + if lhs_is_inequality or rhs_is_inequality: + # Subtracting two relational objects raises, so these cases must be + # intercepted before the generic `lhs_expr - rhs_expr` comparison below. + if lhs_is_inequality and rhs_is_inequality: + result = check_inequality_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): result = False @@ -188,6 +200,67 @@ def check_order(criterion, parameters_dict, local_substitutions=[]): return result +def check_inequality_equivalence(res, ans, parameters_dict): + """ + Check whether the response inequality `res` is equivalent to the answer + inequality `ans` (both `sympy` relational objects). + + Each side `f REL g` is rewritten as `D REL 0` (all terms moved to one side) + and the relation normalised to `<` or `<=` by negating `D` when the operator + is `>` or `>=`. The two inequalities are equivalent when `D_res / D_ans` + simplifies to a positive constant and the (normalised) operators match. + + Returns one of: + True - equivalent + False - not equivalent (e.g. zero ratio) + "WRONG_DIRECTION" - ratio is a negative constant (opposite region) + "STRICTNESS_MISMATCH" - positive-constant ratio but `<` vs `<=` differ + "EXPRESSION_NOT_INEQUALITY" - response is not an inequality, answer is + "INEQUALITY_NOT_EXPRESSION" - response is an inequality, answer is not + None - undecidable (non-constant/unknown-sign ratio) + """ + res_is_inequality = isinstance(res, INEQUALITY_TYPES) + ans_is_inequality = isinstance(ans, INEQUALITY_TYPES) + if not res_is_inequality and ans_is_inequality: + return "RESPONSE_NOT_INEQUALITY" + if res_is_inequality and not ans_is_inequality: + return "ANSWER_NOT_INEQUALITY" + if not (res_is_inequality and ans_is_inequality): + return False + + def normalise(relation): + difference = relation.lhs - relation.rhs + operator = relation.rel_op + if operator in (">", ">="): + difference = -difference + operator = "<" if operator == ">" else "<=" + return simplify(difference), operator + + try: + difference_res, operator_res = normalise(res) + difference_ans, operator_ans = normalise(ans) + except Exception: + return None + + constants = set(parameters_dict["parsing_parameters"].get("constants", set())) + + if difference_res == 0 or difference_ans == 0: + if difference_res == 0 and difference_ans == 0: + return True if operator_res == operator_ans else "STRICTNESS_MISMATCH" + return None + + ratio = simplify(difference_res / difference_ans) + if not {str(s) for s in ratio.free_symbols}.issubset(constants): + return None + if ratio.is_zero: + return False + if ratio.is_positive: + return True if operator_res == operator_ans else "STRICTNESS_MISMATCH" + if ratio.is_negative: + return "WRONG_DIRECTION" + return None + + def check_proportionality(criterion, parameters_dict, local_substitutions=[]): lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict, local_substitutions) result = None @@ -355,6 +428,20 @@ def equality_equivalence(unused_input): label+"_FALSE": None } + def inequality_equivalence(unused_input): + res = parameters_dict["reserved_expressions"]["response"] + ans = parameters_dict["reserved_expressions"]["answer"] + result = check_inequality_equivalence(res, ans, parameters_dict) + result_to_tag = { + True: label+"_TRUE", + False: label+"_FALSE", + "WRONG_DIRECTION": label+"_WRONG_DIRECTION", + "STRICTNESS_MISMATCH": label+"_STRICTNESS_MISMATCH", + "EXPRESSION_NOT_INEQUALITY": label+"_EXPRESSION_NOT_INEQUALITY", + "INEQUALITY_NOT_EXPRESSION": label+"_INEQUALITY_NOT_EXPRESSION", + } + return {result_to_tag.get(result, label+"_UNKNOWN"): None} + graph = CriteriaGraph(label) END = CriteriaGraph.END graph.add_node(END) @@ -396,7 +483,8 @@ def same_symbols(unused_input): res = parameters_dict["reserved_expressions"]["response"] ans = parameters_dict["reserved_expressions"]["answer"] - use_equality_equivalence = isinstance(res, Equality) or isinstance(ans, Equality) + use_inequality_equivalence = isinstance(res, INEQUALITY_TYPES) or isinstance(ans, INEQUALITY_TYPES) + use_equality_equivalence = (isinstance(res, Equality) or isinstance(ans, Equality)) and not use_inequality_equivalence # TODO: Make checking set equivalence its own context that calls symbolic comparisons instead if use_set_equivalence is True: @@ -484,6 +572,69 @@ def same_symbols(unused_input): feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EQUALITY_NOT_EXPRESSION") ) graph.attach(label+"_EQUALITY_NOT_EXPRESSION", END.label) + elif use_inequality_equivalence: + graph.add_evaluation_node( + label, + summary=label, + details="Checks if "+str(lhs)+" is an equivalent inequality to "+str(rhs)+".", + evaluate=inequality_equivalence + ) + graph.attach( + label, + label+"_TRUE", + summary=str(lhs)+" is equivalent to "+str(rhs), + details=str(lhs)+" is an equivalent inequality to "+str(rhs)+".", + feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("INEQUALITIES_EQUIVALENT") + ) + graph.attach(label+"_TRUE", END.label) + graph.attach( + label, + label+"_FALSE", + summary=str(lhs)+" is not equivalent to "+str(rhs), + details=str(lhs)+" is not an equivalent inequality to "+str(rhs)+".", + feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("INEQUALITIES_NOT_EQUIVALENT") + ) + graph.attach(label+"_FALSE", END.label) + graph.attach( + label, + label+"_UNKNOWN", + summary="Cannot determine if "+str(lhs)+" is equivalent to "+str(rhs), + details="Cannot determine if "+str(lhs)+" is an equivalent inequality to "+str(rhs)+".", + feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("INEQUALITY_EQUIVALENCE_UNKNOWN") + ) + graph.attach(label+"_UNKNOWN", END.label) + graph.attach( + label, + label+"_WRONG_DIRECTION", + summary=str(lhs)+" is the opposite inequality to "+str(rhs), + details=str(lhs)+" points in the opposite direction to "+str(rhs)+".", + feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("INEQUALITIES_WRONG_DIRECTION") + ) + graph.attach(label+"_WRONG_DIRECTION", END.label) + graph.attach( + label, + label+"_STRICTNESS_MISMATCH", + summary=str(lhs)+" has a different strictness to "+str(rhs), + details=str(lhs)+" uses a strict/non-strict inequality where "+str(rhs)+" does not.", + feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("INEQUALITY_STRICTNESS_MISMATCH") + ) + graph.attach(label+"_STRICTNESS_MISMATCH", END.label) + graph.attach( + label, + label+"_EXPRESSION_NOT_INEQUALITY", + summary=str(lhs)+" is an expression, not an inequality.", + details=str(lhs)+" is an expression, not an inequality.", + feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EXPRESSION_NOT_INEQUALITY") + ) + graph.attach(label+"_EXPRESSION_NOT_INEQUALITY", END.label) + graph.attach( + label, + label+"_INEQUALITY_NOT_EXPRESSION", + summary=str(lhs)+" is an inequality, not an expression.", + details=str(lhs)+" is an inequality, not an expression.", + feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("INEQUALITY_NOT_EXPRESSION") + ) + graph.attach(label+"_INEQUALITY_NOT_EXPRESSION", END.label) else: graph.add_evaluation_node( label, diff --git a/app/docs/dev.md b/app/docs/dev.md index 7ada921..9355efd 100644 --- a/app/docs/dev.md +++ b/app/docs/dev.md @@ -75,6 +75,8 @@ There are currently two different contexts: - `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). - `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`); chained forms are rejected. Moving this into a dedicated `statements` context remains future work. - `collection`: Comparison of collections (e.g. sets, lists or intervals of the number line). Likely to consist mostly of code for handling comparison of individual elements using the other contexts, and configuring what counts as equivalence between different collections. ##### `symbolic` Criteria commands and grammar diff --git a/app/docs/user.md b/app/docs/user.md index 83c929b..8037eee 100644 --- a/app/docs/user.md +++ b/app/docs/user.md @@ -37,7 +37,7 @@ The `criteria` parameter reserves `response` and `answer` as keywords that will ##### Available criteria -**Note:** In the table below EXPRESSION is used to denote some mathematical expression, i.e. a string that contains mathematical symbols and operators, but no equal signs `=` or inequality signs `>`, '<'. +**Note:** In the table below EXPRESSION is used to denote some mathematical expression, i.e. a string that contains mathematical symbols and operators, but no equal signs `=` or inequality signs `>`, '<'. (A whole-response inequality such as `2x - 10 >= 0` is still supported when the answer is also an inequality — see *Inequalities in the answer and response* below.) | Name | Syntax | Description | Example | |-------|:-------------------------------|:------------------------------------|:--------------------| @@ -288,6 +288,14 @@ 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$. +#### 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. + +For example, with answer `2x - 10 >= 0` (`strict_syntax` false, `elementary_functions` true), the responses `x >= 5`, `5 <= x`, `4x - 20 >= 0` and `10 - 2x <= 0` are accepted, while `x > 5` is rejected (wrong strictness) and `x <= 5` is rejected (opposite direction). + +**Note:** `!=` is not supported. Chained inequalities such as `1 < x < 5` are not supported. A response that expands to a set of inequalities (e.g. via `plus_minus`) is not supported. + #### Checking the value of an expression or a physical quantity If the parameter `physical_quantity` is set to true, the evaluation function can handle expressions that describe physical quantities. Which units are permitted and how they should be written depends on the `units_string` and `strictness` parameters respectively. diff --git a/app/feedback/symbolic.py b/app/feedback/symbolic.py index 366ac07..7050f5e 100644 --- a/app/feedback/symbolic.py +++ b/app/feedback/symbolic.py @@ -28,6 +28,13 @@ "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.", + "EXPRESSION_NOT_INEQUALITY": "The response was an expression but was expected to be an inequality.", + "INEQUALITY_NOT_EXPRESSION": "The response was an inequality but was expected to be an expression.", + "INEQUALITIES_EQUIVALENT": None, + "INEQUALITIES_NOT_EQUIVALENT": "The response is not the expected inequality.", + "INEQUALITY_EQUIVALENCE_UNKNOWN": "Cannot determine if the given inequality is equivalent to the expected inequality.", + "INEQUALITIES_WRONG_DIRECTION": "The response is the opposite inequality to the one expected.", + "INEQUALITY_STRICTNESS_MISMATCH": "The response has the wrong strictness (`<` vs `<=`, or `>` vs `>=`).", "WITHIN_TOLERANCE": None, # "The difference between the response the answer is within specified error tolerance.", "NOT_NUMERICAL": None, # "The expression cannot be evaluated numerically.", }[tag] diff --git a/app/preview_implementations/symbolic_preview.py b/app/preview_implementations/symbolic_preview.py index 79e286b..53a8625 100644 --- a/app/preview_implementations/symbolic_preview.py +++ b/app/preview_implementations/symbolic_preview.py @@ -1,3 +1,5 @@ +import re + from sympy.parsing.sympy_parser import T as parser_transformations from ..utility.expression_utilities import ( default_parameters, @@ -84,7 +86,9 @@ def preview_function(response: str, params: Params) -> Result: if not response: return Result(preview=Preview(latex="", sympy="")) - response_list = response.split("=") + # Split on a lone "=" (equality) only, leaving relational operators + # (">=", "<=", "==", "!=") intact so inequality previews are not broken up. + response_list = re.split(r"(?=!])=(?!=)", response) response_latex = [] response_sympy = [] diff --git a/app/preview_test.py b/app/preview_test.py index eca9121..8ec4482 100644 --- a/app/preview_test.py +++ b/app/preview_test.py @@ -76,6 +76,19 @@ def test_natural_logarithm_notation(self): preview = result["preview"] assert preview["latex"] == r"\ln{\left(x \right)}" + @pytest.mark.parametrize( + "response,expected_latex", + [ + ("x > 5", "x > 5"), + ("x >= 5", r"x \geq 5"), + ("2 x - 10 <= 0", r"2 \cdot x - 10 \leq 0"), + ] + ) + def test_inequality_preview(self, response, expected_latex): + params = Params(is_latex=False, strict_syntax=False, elementary_functions=True) + result = preview_function(response, params) + assert result["preview"]["latex"] == expected_latex + @pytest.mark.parametrize( "response, is_latex, elementary_functions, response_latex, response_sympy", [ ("e", False, True, "e", "E",), diff --git a/app/tests/expression_utilities_test.py b/app/tests/expression_utilities_test.py index c743291..17a8fe5 100644 --- a/app/tests/expression_utilities_test.py +++ b/app/tests/expression_utilities_test.py @@ -1,5 +1,8 @@ +from copy import deepcopy + import pytest from sympy import Symbol, sqrt, sin as sympy_sin +from sympy import Equality, StrictLessThan, LessThan, StrictGreaterThan, GreaterThan from ..utility.expression_utilities import ( compute_relative_tolerance_from_significant_decimals, @@ -7,11 +10,14 @@ convert_bracket_notation, convert_unicode_dashes, create_expression_set, + create_sympy_parsing_params, + default_parameters, extract_latex, find_matching_parenthesis, has_matching_brackets, is_multiple_answers_wrapper, latex_symbols, + parse_expression, preprocess_expression, protect_elementary_functions_substitutions, substitute, @@ -480,4 +486,45 @@ def test_mismatched_brackets_returns_failure(self, expr): assert success is False assert result == expr assert feedback is not None - assert feedback[0] == "BRACKET_NOTATION_MISMATCH" \ No newline at end of file + assert feedback[0] == "BRACKET_NOTATION_MISMATCH" + + +class TestParseInequalities: + + def parsing_params(self): + params = deepcopy(default_parameters) + params.update({"strict_syntax": False, "elementary_functions": True}) + return create_sympy_parsing_params(params) + + @pytest.mark.parametrize( + "expr,expected_type,rel_op", + [ + ("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, ">="), + ] + ) + def test_parse_inequality_operators(self, expr, expected_type, rel_op): + parsed = parse_expression(expr, self.parsing_params()) + assert isinstance(parsed, expected_type) + assert parsed.rel_op == rel_op + + @pytest.mark.parametrize("expr", ["x >= 5", "x <= 5", "2*x - 10 >= 0"]) + def test_parse_inequality_regression_le_ge(self, expr): + # These raised before relational operators were handled explicitly. + parse_expression(expr, self.parsing_params()) + + @pytest.mark.parametrize("expr", ["1 < x < 5", "a < b > c", "x <= y <= z"]) + def test_parse_chained_inequality_rejected(self, expr): + with pytest.raises(ValueError): + parse_expression(expr, self.parsing_params()) + + @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 diff --git a/app/tests/symbolic_evaluation_test.py b/app/tests/symbolic_evaluation_test.py index 89243cb..ae8510b 100644 --- a/app/tests/symbolic_evaluation_test.py +++ b/app/tests/symbolic_evaluation_test.py @@ -606,6 +606,71 @@ def test_equality_sign_in_response_not_answer(self, response, answer): assert result["is_correct"] is False assert "response = answer_EQUALITY_NOT_EXPRESSION" in result["tags"] + @pytest.mark.parametrize( + "response,answer,value", + [ + ("x > 5", "x > 5", True), + ("5 < x", "x > 5", True), + ("2*x - 10 > 0", "x > 5", True), + ("10 - 2*x < 0", "x > 5", True), + ("x + 1 > 6", "x > 5", True), + ("x >= 5", "2*x - 10 >= 0", True), + ("4*x - 20 >= 0", "2*x - 10 >= 0", True), + ("x < 5", "x > 5", False), + ("x <= 5", "x >= 5", False), + ("x > 5", "x >= 5", False), + ("x >= 5", "x > 5", False), + ("3*x - 15 > 0", "x < 5", False), + ("x**2 > 5", "x > 5", False), + ] + ) + def test_inequality_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", + generate_input_variations( + response="2*x - 10 > 0", + answer="x > 5" + ) + ) + def test_inequality_in_answer_and_response_notation_variations(self, response, answer): + params = {"strict_syntax": False, "elementary_functions": True} + result = evaluation_function(response, answer, params) + assert result["is_correct"] is True + + @pytest.mark.parametrize( + "response,answer,value,tag", + [ + ("x > 5", "x > 5", True, "response = answer_TRUE"), + ("x < 5", "x > 5", False, "response = answer_WRONG_DIRECTION"), + ("x >= 5", "x > 5", False, "response = answer_STRICTNESS_MISMATCH"), + ("x**2 > 5", "x > 5", False, "response = answer_UNKNOWN"), + ("x + 3", "x > 5", False, "response = answer_EXPRESSION_NOT_INEQUALITY"), + ("x = 5", "x > 5", False, "response = answer_EXPRESSION_NOT_INEQUALITY"), + ("x > 5", "x + 3", False, "response = answer_INEQUALITY_NOT_EXPRESSION"), + ] + ) + def test_inequality_feedback_tags(self, response, answer, value, tag): + params = {"strict_syntax": False, "elementary_functions": True} + result = evaluation_function(response, answer, params, include_test_data=True) + assert result["is_correct"] is value + assert tag in result["tags"] + + def test_inequality_set_response_is_not_correct(self): + params = {"strict_syntax": False, "elementary_functions": True} + result = evaluation_function("x >= plus_minus 5", "x >= 5", params) + assert result["is_correct"] is False + + @pytest.mark.parametrize("response", ["1 < x < 5", "a < b > c"]) + def test_chained_inequality_is_rejected(self, response): + params = {"strict_syntax": False, "elementary_functions": True} + result = evaluation_function(response, "x > 5", params) + assert result["is_correct"] is False + assert "could not be parsed" in result["feedback"] + 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/utility/expression_utilities.py b/app/utility/expression_utilities.py index 50656da..0283dbb 100644 --- a/app/utility/expression_utilities.py +++ b/app/utility/expression_utilities.py @@ -26,7 +26,7 @@ from sympy.parsing.sympy_parser import parse_expr, split_symbols_custom, _token_splittable from sympy.parsing.sympy_parser import T as parser_transformations from sympy.printing.latex import LatexPrinter -from sympy import Basic, Symbol, Equality, Function +from sympy import Basic, Symbol, Equality, Function, Lt, Le, Gt, Ge import re from typing import Dict, List, TypedDict @@ -845,7 +845,31 @@ def parse_expression(expr_string, parsing_params): transformations += parser_transformations[11] - if "=" in expr: + # Relational (inequality) operands must be detected before the "=" split + # below, since ">=" and "<=" contain an "=" that would otherwise break + # `expr.split("=")`. Only a single, unchained relational operator is + # supported (chained forms like `1 < x < 5` are rejected). + relational_scan = expr.replace("<=", "\x00").replace(">=", "\x01") + number_of_relational_operators = ( + relational_scan.count("<") + relational_scan.count(">") + + relational_scan.count("\x00") + relational_scan.count("\x01") + ) + relational_classes = (("<=", Le), (">=", Ge), ("<", Lt), (">", Gt)) + + if number_of_relational_operators > 1: + raise ValueError( + f"Failed to parse Sympy expression `{expr}`: chained or multiple " + "relational operators are not supported." + ) + if number_of_relational_operators == 1: + for relational_string, relational_class in relational_classes: + if relational_string in expr: + left, right = expr.split(relational_string, 1) + lhs = parse_expr(left, transformations=transformations, local_dict=symbol_dict, evaluate=False) + rhs = parse_expr(right, transformations=transformations, local_dict=symbol_dict, evaluate=False) + parsed_expr = relational_class(lhs, rhs, evaluate=False) + break + elif "=" in expr: 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) From 3bed2c6cb1428b9b0b8fe9f21fd134b5f2298c9c Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Thu, 10 Sep 2026 15:19:22 +0100 Subject: [PATCH 2/6] Updated inequality feedback tags and streamlined symbolic equivalence handling --- app/context/symbolic.py | 38 ++++++++++++++++----------- app/feedback/symbolic.py | 4 +-- app/tests/symbolic_evaluation_test.py | 6 ++--- 3 files changed, 28 insertions(+), 20 deletions(-) diff --git a/app/context/symbolic.py b/app/context/symbolic.py index 0c2001d..df33c06 100644 --- a/app/context/symbolic.py +++ b/app/context/symbolic.py @@ -215,8 +215,8 @@ def check_inequality_equivalence(res, ans, parameters_dict): False - not equivalent (e.g. zero ratio) "WRONG_DIRECTION" - ratio is a negative constant (opposite region) "STRICTNESS_MISMATCH" - positive-constant ratio but `<` vs `<=` differ - "EXPRESSION_NOT_INEQUALITY" - response is not an inequality, answer is - "INEQUALITY_NOT_EXPRESSION" - response is an inequality, answer is not + "RESPONSE_NOT_INEQUALITY" - the response is not an inequality, the answer is + "ANSWER_NOT_INEQUALITY" - the response is an inequality, the answer is not None - undecidable (non-constant/unknown-sign ratio) """ res_is_inequality = isinstance(res, INEQUALITY_TYPES) @@ -244,9 +244,15 @@ def normalise(relation): constants = set(parameters_dict["parsing_parameters"].get("constants", set())) + # `difference` is `lhs - rhs`, so it is zero when an inequality compares an + # expression to itself, e.g. `x <= x`. Such an inequality is always true (or + # always false for `<` / `>`), and a zero `difference_ans` would make the + # ratio below a division by zero, so handle these cases up front. + if difference_res == 0 and difference_ans == 0: + if operator_res == operator_ans: + return True + return "STRICTNESS_MISMATCH" if difference_res == 0 or difference_ans == 0: - if difference_res == 0 and difference_ans == 0: - return True if operator_res == operator_ans else "STRICTNESS_MISMATCH" return None ratio = simplify(difference_res / difference_ans) @@ -255,7 +261,9 @@ def normalise(relation): if ratio.is_zero: return False if ratio.is_positive: - return True if operator_res == operator_ans else "STRICTNESS_MISMATCH" + if operator_res == operator_ans: + return True + return "STRICTNESS_MISMATCH" if ratio.is_negative: return "WRONG_DIRECTION" return None @@ -437,8 +445,8 @@ def inequality_equivalence(unused_input): False: label+"_FALSE", "WRONG_DIRECTION": label+"_WRONG_DIRECTION", "STRICTNESS_MISMATCH": label+"_STRICTNESS_MISMATCH", - "EXPRESSION_NOT_INEQUALITY": label+"_EXPRESSION_NOT_INEQUALITY", - "INEQUALITY_NOT_EXPRESSION": label+"_INEQUALITY_NOT_EXPRESSION", + "RESPONSE_NOT_INEQUALITY": label+"_RESPONSE_NOT_INEQUALITY", + "ANSWER_NOT_INEQUALITY": label+"_ANSWER_NOT_INEQUALITY", } return {result_to_tag.get(result, label+"_UNKNOWN"): None} @@ -621,20 +629,20 @@ def same_symbols(unused_input): graph.attach(label+"_STRICTNESS_MISMATCH", END.label) graph.attach( label, - label+"_EXPRESSION_NOT_INEQUALITY", + label+"_RESPONSE_NOT_INEQUALITY", summary=str(lhs)+" is an expression, not an inequality.", details=str(lhs)+" is an expression, not an inequality.", - feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EXPRESSION_NOT_INEQUALITY") + feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("RESPONSE_NOT_INEQUALITY") ) - graph.attach(label+"_EXPRESSION_NOT_INEQUALITY", END.label) + graph.attach(label+"_RESPONSE_NOT_INEQUALITY", END.label) graph.attach( label, - label+"_INEQUALITY_NOT_EXPRESSION", - summary=str(lhs)+" is an inequality, not an expression.", - details=str(lhs)+" is an inequality, not an expression.", - feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("INEQUALITY_NOT_EXPRESSION") + label+"_ANSWER_NOT_INEQUALITY", + summary=str(rhs)+" is an expression, not an inequality.", + details=str(rhs)+" is an expression, not an inequality.", + feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("ANSWER_NOT_INEQUALITY") ) - graph.attach(label+"_INEQUALITY_NOT_EXPRESSION", END.label) + graph.attach(label+"_ANSWER_NOT_INEQUALITY", END.label) else: graph.add_evaluation_node( label, diff --git a/app/feedback/symbolic.py b/app/feedback/symbolic.py index 7050f5e..0349d68 100644 --- a/app/feedback/symbolic.py +++ b/app/feedback/symbolic.py @@ -28,8 +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.", - "EXPRESSION_NOT_INEQUALITY": "The response was an expression but was expected to be an inequality.", - "INEQUALITY_NOT_EXPRESSION": "The response was an inequality but was expected to be an expression.", + "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, "INEQUALITIES_NOT_EQUIVALENT": "The response is not the expected inequality.", "INEQUALITY_EQUIVALENCE_UNKNOWN": "Cannot determine if the given inequality is equivalent to the expected inequality.", diff --git a/app/tests/symbolic_evaluation_test.py b/app/tests/symbolic_evaluation_test.py index ae8510b..9da0f04 100644 --- a/app/tests/symbolic_evaluation_test.py +++ b/app/tests/symbolic_evaluation_test.py @@ -648,9 +648,9 @@ def test_inequality_in_answer_and_response_notation_variations(self, response, a ("x < 5", "x > 5", False, "response = answer_WRONG_DIRECTION"), ("x >= 5", "x > 5", False, "response = answer_STRICTNESS_MISMATCH"), ("x**2 > 5", "x > 5", False, "response = answer_UNKNOWN"), - ("x + 3", "x > 5", False, "response = answer_EXPRESSION_NOT_INEQUALITY"), - ("x = 5", "x > 5", False, "response = answer_EXPRESSION_NOT_INEQUALITY"), - ("x > 5", "x + 3", False, "response = answer_INEQUALITY_NOT_EXPRESSION"), + ("x + 3", "x > 5", False, "response = answer_RESPONSE_NOT_INEQUALITY"), + ("x = 5", "x > 5", False, "response = answer_RESPONSE_NOT_INEQUALITY"), + ("x > 5", "x + 3", False, "response = answer_ANSWER_NOT_INEQUALITY"), ] ) def test_inequality_feedback_tags(self, response, answer, value, tag): From 291c4cd7e2a2c63ddfe045829037ee0f1c7a8302 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Thu, 10 Sep 2026 15:41:26 +0100 Subject: [PATCH 3/6] Added support for chained inequalities in symbolic equivalence checks and parsing --- app/context/symbolic.py | 115 ++++++++++++++++++------- app/docs/dev.md | 2 +- app/docs/user.md | 4 +- app/preview_test.py | 1 + app/tests/expression_utilities_test.py | 21 ++++- app/tests/symbolic_evaluation_test.py | 31 ++++++- app/utility/expression_utilities.py | 49 ++++++----- 7 files changed, 164 insertions(+), 59 deletions(-) diff --git a/app/context/symbolic.py b/app/context/symbolic.py index df33c06..9f67947 100644 --- a/app/context/symbolic.py +++ b/app/context/symbolic.py @@ -1,10 +1,23 @@ from copy import deepcopy 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 +from sympy import StrictLessThan, LessThan, StrictGreaterThan, GreaterThan, And INEQUALITY_TYPES = (StrictLessThan, LessThan, StrictGreaterThan, GreaterThan) + +def inequality_bounds(expr): + """The list of inequality parts if `expr` is a single inequality or a + conjunction of inequalities (a chained inequality such as `1 < x < 5`), + otherwise None.""" + if isinstance(expr, INEQUALITY_TYPES): + return [expr] + if isinstance(expr, And) and expr.args and all( + isinstance(arg, INEQUALITY_TYPES) for arg in expr.args + ): + return list(expr.args) + return None + from ..utility.expression_utilities import ( default_parameters, parse_expression, @@ -120,10 +133,10 @@ def do_comparison(comparison_symbol, expression): def check_equality(criterion, parameters_dict, local_substitutions=[]): lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict, local_substitutions) - lhs_is_inequality = isinstance(lhs_expr, INEQUALITY_TYPES) - rhs_is_inequality = isinstance(rhs_expr, INEQUALITY_TYPES) + lhs_is_inequality = inequality_bounds(lhs_expr) is not None + rhs_is_inequality = inequality_bounds(rhs_expr) is not None if lhs_is_inequality or rhs_is_inequality: - # Subtracting two relational objects raises, so these cases must be + # Subtracting relational / And objects raises, so these cases must be # intercepted before the generic `lhs_expr - rhs_expr` comparison below. if lhs_is_inequality and rhs_is_inequality: result = check_inequality_equivalence(lhs_expr, rhs_expr, parameters_dict) is True @@ -200,34 +213,18 @@ def check_order(criterion, parameters_dict, local_substitutions=[]): return result -def check_inequality_equivalence(res, ans, parameters_dict): +def _compare_single_inequality(res, ans, constants): """ - Check whether the response inequality `res` is equivalent to the answer - inequality `ans` (both `sympy` relational objects). + Compare one response inequality to one answer inequality. Each side `f REL g` is rewritten as `D REL 0` (all terms moved to one side) and the relation normalised to `<` or `<=` by negating `D` when the operator - is `>` or `>=`. The two inequalities are equivalent when `D_res / D_ans` - simplifies to a positive constant and the (normalised) operators match. + is `>` or `>=`. The two are equivalent when `D_res / D_ans` simplifies to a + positive constant and the (normalised) operators match. - Returns one of: - True - equivalent - False - not equivalent (e.g. zero ratio) - "WRONG_DIRECTION" - ratio is a negative constant (opposite region) - "STRICTNESS_MISMATCH" - positive-constant ratio but `<` vs `<=` differ - "RESPONSE_NOT_INEQUALITY" - the response is not an inequality, the answer is - "ANSWER_NOT_INEQUALITY" - the response is an inequality, the answer is not - None - undecidable (non-constant/unknown-sign ratio) + Returns one of: True, False, None (undecidable), "WRONG_DIRECTION" (negative + constant ratio) or "STRICTNESS_MISMATCH" (positive ratio but `<` vs `<=`). """ - res_is_inequality = isinstance(res, INEQUALITY_TYPES) - ans_is_inequality = isinstance(ans, INEQUALITY_TYPES) - if not res_is_inequality and ans_is_inequality: - return "RESPONSE_NOT_INEQUALITY" - if res_is_inequality and not ans_is_inequality: - return "ANSWER_NOT_INEQUALITY" - if not (res_is_inequality and ans_is_inequality): - return False - def normalise(relation): difference = relation.lhs - relation.rhs operator = relation.rel_op @@ -242,8 +239,6 @@ def normalise(relation): except Exception: return None - constants = set(parameters_dict["parsing_parameters"].get("constants", set())) - # `difference` is `lhs - rhs`, so it is zero when an inequality compares an # expression to itself, e.g. `x <= x`. Such an inequality is always true (or # always false for `<` / `>`), and a zero `difference_ans` would make the @@ -269,6 +264,68 @@ def normalise(relation): return None +def _compare_chained_inequalities(res_bounds, ans_bounds, constants): + """ + Compare two chained inequalities (`1 < x < 5`) bound by bound. The response's + two bound inequalities are matched against the answer's two in either pairing; + equivalent only when some pairing makes both bounds equivalent. + """ + res_lower, res_upper = res_bounds + saw_none = False + saw_strictness = False + for ans_first, ans_second in ( + (ans_bounds[0], ans_bounds[1]), + (ans_bounds[1], ans_bounds[0]), + ): + first = _compare_single_inequality(res_lower, ans_first, constants) + second = _compare_single_inequality(res_upper, ans_second, constants) + if first is True and second is True: + return True + if first is None or second is None: + saw_none = True + elif {first, second} <= {True, "STRICTNESS_MISMATCH"}: + saw_strictness = True + if saw_strictness: + return "STRICTNESS_MISMATCH" + if saw_none: + return None + return False + + +def check_inequality_equivalence(res, ans, parameters_dict): + """ + Check whether the response inequality `res` is equivalent to the answer + inequality `ans`. Both may be a single `sympy` relation or a two-part chained + inequality (`1 < x < 5`, parsed as an `And` of two relations); a chain is + compared to another chain bound by bound. + + Returns one of: + True - equivalent + False - not equivalent (e.g. zero ratio, different arity) + "WRONG_DIRECTION" - ratio is a negative constant (opposite region) + "STRICTNESS_MISMATCH" - positive-constant ratio but `<` vs `<=` differ + "RESPONSE_NOT_INEQUALITY" - the response is not an inequality, the answer is + "ANSWER_NOT_INEQUALITY" - the response is an inequality, the answer is not + None - undecidable (non-constant/unknown-sign ratio) + """ + res_bounds = inequality_bounds(res) + ans_bounds = inequality_bounds(ans) + if res_bounds is None and ans_bounds is not None: + return "RESPONSE_NOT_INEQUALITY" + if res_bounds is not None and ans_bounds is None: + return "ANSWER_NOT_INEQUALITY" + if res_bounds is None and ans_bounds is None: + return False + if len(res_bounds) != len(ans_bounds): + return False + + constants = set(parameters_dict["parsing_parameters"].get("constants", set())) + + if len(res_bounds) == 1: + return _compare_single_inequality(res_bounds[0], ans_bounds[0], constants) + return _compare_chained_inequalities(res_bounds, ans_bounds, constants) + + def check_proportionality(criterion, parameters_dict, local_substitutions=[]): lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict, local_substitutions) result = None @@ -491,7 +548,7 @@ def same_symbols(unused_input): res = parameters_dict["reserved_expressions"]["response"] ans = parameters_dict["reserved_expressions"]["answer"] - use_inequality_equivalence = isinstance(res, INEQUALITY_TYPES) or isinstance(ans, INEQUALITY_TYPES) + 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 # TODO: Make checking set equivalence its own context that calls symbolic comparisons instead diff --git a/app/docs/dev.md b/app/docs/dev.md index 9355efd..c7afb8f 100644 --- a/app/docs/dev.md +++ b/app/docs/dev.md @@ -76,7 +76,7 @@ There are currently two different contexts: - `equality`: Comparison of mathematical equalities (with the extra complexities that come with equivalence of equalities compared to equality of expressions). - `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`); chained forms are rejected. Moving this into a dedicated `statements` context remains future work. + **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. Moving this into a dedicated `statements` context remains future work. - `collection`: Comparison of collections (e.g. sets, lists or intervals of the number line). Likely to consist mostly of code for handling comparison of individual elements using the other contexts, and configuring what counts as equivalence between different collections. ##### `symbolic` Criteria commands and grammar diff --git a/app/docs/user.md b/app/docs/user.md index 8037eee..74394d8 100644 --- a/app/docs/user.md +++ b/app/docs/user.md @@ -294,7 +294,9 @@ There is (limited) support for using inequalities in the response and answer. If For example, with answer `2x - 10 >= 0` (`strict_syntax` false, `elementary_functions` true), the responses `x >= 5`, `5 <= x`, `4x - 20 >= 0` and `10 - 2x <= 0` are accepted, while `x > 5` is rejected (wrong strictness) and `x <= 5` is rejected (opposite direction). -**Note:** `!=` is not supported. Chained inequalities such as `1 < x < 5` are not supported. A response that expands to a set of inequalities (e.g. via `plus_minus`) is not supported. +Two-part chained inequalities that point in one direction (e.g. `1 < x < 5` or `5 >= x > 1`) are also supported, in the answer and/or the response. Each chain is split into its lower- and upper-bound inequality and the bounds are compared with the rule above. For example, with answer `1 < x < 5` the responses `5 > x > 1`, `0 < x - 1 < 4` and `2 < 2x < 10` are accepted, while `1 <= x < 5` is rejected (wrong strictness on the lower bound). + +**Note:** `!=` is not supported. Chains of three or more operators (`1 <= x <= y <= 5`) and mixed-direction chains (`1 < x > 5`) are not supported. A response that expands to a set of inequalities (e.g. via `plus_minus`) is not supported. #### Checking the value of an expression or a physical quantity diff --git a/app/preview_test.py b/app/preview_test.py index 8ec4482..8b3d94d 100644 --- a/app/preview_test.py +++ b/app/preview_test.py @@ -82,6 +82,7 @@ def test_natural_logarithm_notation(self): ("x > 5", "x > 5"), ("x >= 5", r"x \geq 5"), ("2 x - 10 <= 0", r"2 \cdot x - 10 \leq 0"), + ("1 < x < 5", r"1 < x \wedge x < 5"), ] ) def test_inequality_preview(self, response, expected_latex): diff --git a/app/tests/expression_utilities_test.py b/app/tests/expression_utilities_test.py index 17a8fe5..50e324f 100644 --- a/app/tests/expression_utilities_test.py +++ b/app/tests/expression_utilities_test.py @@ -2,7 +2,7 @@ import pytest from sympy import Symbol, sqrt, sin as sympy_sin -from sympy import Equality, StrictLessThan, LessThan, StrictGreaterThan, GreaterThan +from sympy import Equality, StrictLessThan, LessThan, StrictGreaterThan, GreaterThan, And from ..utility.expression_utilities import ( compute_relative_tolerance_from_significant_decimals, @@ -519,8 +519,25 @@ def test_parse_inequality_regression_le_ge(self, expr): # These raised before relational operators were handled explicitly. parse_expression(expr, self.parsing_params()) - @pytest.mark.parametrize("expr", ["1 < x < 5", "a < b > c", "x <= y <= z"]) + @pytest.mark.parametrize( + "expr,part_types", + [ + ("1 < x < 5", (StrictLessThan, StrictLessThan)), + ("5 >= x > 1", (GreaterThan, StrictGreaterThan)), + ("0 < x - 1 <= 4", (StrictLessThan, LessThan)), + ] + ) + def test_parse_chained_inequality(self, expr, part_types): + parsed = parse_expression(expr, self.parsing_params()) + assert isinstance(parsed, And) + assert len(parsed.args) == 2 + assert {type(arg) for arg in parsed.args} == set(part_types) + + @pytest.mark.parametrize( + "expr", ["a < b > c", "1 < x > 5", "1 <= x <= y <= 5", "a < b < c < d"] + ) def test_parse_chained_inequality_rejected(self, expr): + # Mixed-direction chains and chains of three or more operators. with pytest.raises(ValueError): parse_expression(expr, self.parsing_params()) diff --git a/app/tests/symbolic_evaluation_test.py b/app/tests/symbolic_evaluation_test.py index 9da0f04..7d3452c 100644 --- a/app/tests/symbolic_evaluation_test.py +++ b/app/tests/symbolic_evaluation_test.py @@ -664,10 +664,35 @@ def test_inequality_set_response_is_not_correct(self): result = evaluation_function("x >= plus_minus 5", "x >= 5", params) assert result["is_correct"] is False - @pytest.mark.parametrize("response", ["1 < x < 5", "a < b > c"]) - def test_chained_inequality_is_rejected(self, response): + @pytest.mark.parametrize( + "response,answer,value", + [ + ("1 < x < 5", "1 < x < 5", True), + ("5 > x > 1", "1 < x < 5", True), + ("0 < x - 1 < 4", "1 < x < 5", True), + ("2 < 2*x < 10", "1 < x < 5", True), + ("-5 < -x < -1", "1 < x < 5", True), + ("1 <= x < 5", "1 < x < 5", False), + ("1 < x < 6", "1 < x < 5", False), + ("x > 1", "1 < x < 5", False), + ("1 < x < 5", "x > 1", False), + ] + ) + def test_chained_inequality_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 + + def test_chained_inequality_feedback_tags(self): + params = {"strict_syntax": False, "elementary_functions": True} + result = evaluation_function("1 <= x < 5", "1 < x < 5", params, include_test_data=True) + assert result["is_correct"] is False + assert "response = answer_STRICTNESS_MISMATCH" in result["tags"] + + @pytest.mark.parametrize("response", ["1 < x > 5", "1 <= x <= y <= 5", "a < b > c"]) + def test_chained_inequality_mixed_or_long_is_rejected(self, response): params = {"strict_syntax": False, "elementary_functions": True} - result = evaluation_function(response, "x > 5", params) + result = evaluation_function(response, "1 < x < 5", params) assert result["is_correct"] is False assert "could not be parsed" in result["feedback"] diff --git a/app/utility/expression_utilities.py b/app/utility/expression_utilities.py index 0283dbb..ee2f3ab 100644 --- a/app/utility/expression_utilities.py +++ b/app/utility/expression_utilities.py @@ -26,7 +26,7 @@ from sympy.parsing.sympy_parser import parse_expr, split_symbols_custom, _token_splittable from sympy.parsing.sympy_parser import T as parser_transformations from sympy.printing.latex import LatexPrinter -from sympy import Basic, Symbol, Equality, Function, Lt, Le, Gt, Ge +from sympy import Basic, Symbol, Equality, Function, Lt, Le, Gt, Ge, And import re from typing import Dict, List, TypedDict @@ -847,28 +847,31 @@ def parse_expression(expr_string, parsing_params): # Relational (inequality) operands must be detected before the "=" split # below, since ">=" and "<=" contain an "=" that would otherwise break - # `expr.split("=")`. Only a single, unchained relational operator is - # supported (chained forms like `1 < x < 5` are rejected). - relational_scan = expr.replace("<=", "\x00").replace(">=", "\x01") - number_of_relational_operators = ( - relational_scan.count("<") + relational_scan.count(">") - + relational_scan.count("\x00") + relational_scan.count("\x01") - ) - relational_classes = (("<=", Le), (">=", Ge), ("<", Lt), (">", Gt)) - - if number_of_relational_operators > 1: - raise ValueError( - f"Failed to parse Sympy expression `{expr}`: chained or multiple " - "relational operators are not supported." - ) - if number_of_relational_operators == 1: - for relational_string, relational_class in relational_classes: - if relational_string in expr: - left, right = expr.split(relational_string, 1) - lhs = parse_expr(left, transformations=transformations, local_dict=symbol_dict, evaluate=False) - rhs = parse_expr(right, transformations=transformations, local_dict=symbol_dict, evaluate=False) - parsed_expr = relational_class(lhs, rhs, evaluate=False) - break + # `expr.split("=")`. A single inequality (`x < 5`) and a two-operator + # chain pointing in one direction (`1 < x < 5`) are supported; longer or + # mixed-direction chains are rejected. + relational_parts = re.split(r"(<=|>=|<|>)", expr) + relational_classes = {"<=": Le, ">=": Ge, "<": Lt, ">": Gt} + + if len(relational_parts) >= 3: + operands = relational_parts[0::2] + operators = relational_parts[1::2] + if len(operators) > 2: + raise ValueError( + f"Failed to parse Sympy expression `{expr}`: only single or " + "chained (two-operator) inequalities are supported." + ) + if len(operators) == 2 and len({op.replace("=", "") for op in operators}) > 1: + raise ValueError( + f"Failed to parse Sympy expression `{expr}`: a chained " + "inequality must point in one direction, e.g. `1 < x < 5`." + ) + relations = [] + for left, operator, right in zip(operands[:-1], operators, operands[1:]): + lhs = parse_expr(left, transformations=transformations, local_dict=symbol_dict, evaluate=False) + rhs = parse_expr(right, transformations=transformations, local_dict=symbol_dict, evaluate=False) + 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: expr_parts = expr.split("=") lhs = parse_expr(expr_parts[0], transformations=transformations, local_dict=symbol_dict) From 6d262a7a629e0cad5d9ec83cb73d74d95f82e886 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Thu, 10 Sep 2026 15:43:54 +0100 Subject: [PATCH 4/6] Extended support for non-strict chained inequalities in tests and feedback tagging --- app/tests/expression_utilities_test.py | 2 ++ app/tests/symbolic_evaluation_test.py | 23 +++++++++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/app/tests/expression_utilities_test.py b/app/tests/expression_utilities_test.py index 50e324f..cf09bd4 100644 --- a/app/tests/expression_utilities_test.py +++ b/app/tests/expression_utilities_test.py @@ -523,6 +523,8 @@ def test_parse_inequality_regression_le_ge(self, expr): "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)), ] diff --git a/app/tests/symbolic_evaluation_test.py b/app/tests/symbolic_evaluation_test.py index 7d3452c..8148f63 100644 --- a/app/tests/symbolic_evaluation_test.py +++ b/app/tests/symbolic_evaluation_test.py @@ -676,6 +676,16 @@ def test_inequality_set_response_is_not_correct(self): ("1 < x < 6", "1 < x < 5", False), ("x > 1", "1 < x < 5", False), ("1 < x < 5", "x > 1", False), + # non-strict (closed interval) chains + ("1 <= x <= 5", "1 <= x <= 5", True), + ("5 >= x >= 1", "1 <= x <= 5", True), + ("0 <= x - 1 <= 4", "1 <= x <= 5", True), + ("2 <= 2*x <= 10", "1 <= x <= 5", True), + ("-5 <= -x <= -1", "1 <= x <= 5", True), + ("1 < x <= 5", "1 <= x <= 5", False), + ("1 <= x < 5", "1 <= x <= 5", False), + ("1 < x < 5", "1 <= x <= 5", False), + ("1 <= x <= 6", "1 <= x <= 5", False), ] ) def test_chained_inequality_in_answer_and_response(self, response, answer, value): @@ -683,9 +693,18 @@ def test_chained_inequality_in_answer_and_response(self, response, answer, value result = evaluation_function(response, answer, params) assert result["is_correct"] is value - def test_chained_inequality_feedback_tags(self): + @pytest.mark.parametrize( + "response,answer", + [ + ("1 <= x < 5", "1 < x < 5"), + ("1 < x <= 5", "1 <= x <= 5"), + ("1 <= x < 5", "1 <= x <= 5"), + ("1 < x < 5", "1 <= x <= 5"), + ] + ) + def test_chained_inequality_feedback_tags(self, response, answer): params = {"strict_syntax": False, "elementary_functions": True} - result = evaluation_function("1 <= x < 5", "1 < x < 5", params, include_test_data=True) + result = evaluation_function(response, answer, params, include_test_data=True) assert result["is_correct"] is False assert "response = answer_STRICTNESS_MISMATCH" in result["tags"] From 1be55a243e9cfbcefc65fe740c80890b970a36f5 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Thu, 10 Sep 2026 17:30:54 +0100 Subject: [PATCH 5/6] Added support for `!=` in symbolic equivalence checks, parsing, and tests, with restrictions on chaining and combinations with order operators --- app/context/symbolic.py | 71 ++++++++++++++++++++------ app/docs/user.md | 4 +- app/preview_test.py | 2 + app/tests/expression_utilities_test.py | 17 ++++-- app/tests/symbolic_evaluation_test.py | 33 ++++++++++++ app/utility/expression_utilities.py | 23 ++++++--- 6 files changed, 121 insertions(+), 29 deletions(-) diff --git a/app/context/symbolic.py b/app/context/symbolic.py index 9f67947..cb858b8 100644 --- a/app/context/symbolic.py +++ b/app/context/symbolic.py @@ -1,16 +1,19 @@ from copy import deepcopy 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, And +from sympy import StrictLessThan, LessThan, StrictGreaterThan, GreaterThan, Ne, And +# Order relations (chainable as `1 < x < 5`). INEQUALITY_TYPES = (StrictLessThan, LessThan, StrictGreaterThan, GreaterThan) +# Non-order relations handled by the same machinery but never chained. +RELATION_TYPES = INEQUALITY_TYPES + (Ne,) def inequality_bounds(expr): - """The list of inequality parts if `expr` is a single inequality or a - conjunction of inequalities (a chained inequality such as `1 < x < 5`), - otherwise None.""" - if isinstance(expr, INEQUALITY_TYPES): + """The list of relation parts if `expr` is a single relation (`x < 5`, + `x != 5`) or a conjunction of order inequalities (a chained inequality such + as `1 < x < 5`), otherwise None.""" + if isinstance(expr, RELATION_TYPES): return [expr] if isinstance(expr, And) and expr.args and all( isinstance(arg, INEQUALITY_TYPES) for arg in expr.args @@ -213,18 +216,50 @@ def check_order(criterion, parameters_dict, local_substitutions=[]): return result +def _compare_not_equal(res, ans, constants): + """ + `f != g` is equivalent to `p != q` when `(f - g) / (p - q)` simplifies to a + non-zero constant. Direction and strictness do not apply to `!=`. + + Returns True, False or None (undecidable). + """ + difference_res = simplify(res.lhs - res.rhs) + difference_ans = simplify(ans.lhs - ans.rhs) + if difference_res == 0 and difference_ans == 0: + return True + if difference_res == 0 or difference_ans == 0: + return None + ratio = simplify(difference_res / difference_ans) + if not {str(s) for s in ratio.free_symbols}.issubset(constants): + return None + if ratio.is_zero: + return False + if ratio.is_positive or ratio.is_negative: + return True + return None + + def _compare_single_inequality(res, ans, constants): """ - Compare one response inequality to one answer inequality. + Compare one response relation to one answer relation. - Each side `f REL g` is rewritten as `D REL 0` (all terms moved to one side) - and the relation normalised to `<` or `<=` by negating `D` when the operator - is `>` or `>=`. The two are equivalent when `D_res / D_ans` simplifies to a - positive constant and the (normalised) operators match. + For order operators each side `f REL g` is rewritten as `D REL 0` (all terms + moved to one side) and normalised to `<` or `<=` by negating `D` when the + operator is `>` or `>=`; the two are equivalent when `D_res / D_ans` + simplifies to a positive constant and the normalised operators match. `!=` is + delegated to `_compare_not_equal`; `!=` against an order operator is never + equivalent. Returns one of: True, False, None (undecidable), "WRONG_DIRECTION" (negative constant ratio) or "STRICTNESS_MISMATCH" (positive ratio but `<` vs `<=`). """ + res_is_not_equal = res.rel_op == "!=" + ans_is_not_equal = ans.rel_op == "!=" + if res_is_not_equal != ans_is_not_equal: + return False + if res_is_not_equal and ans_is_not_equal: + return _compare_not_equal(res, ans, constants) + def normalise(relation): difference = relation.lhs - relation.rhs operator = relation.rel_op @@ -294,18 +329,20 @@ def _compare_chained_inequalities(res_bounds, ans_bounds, constants): def check_inequality_equivalence(res, ans, parameters_dict): """ - Check whether the response inequality `res` is equivalent to the answer - inequality `ans`. Both may be a single `sympy` relation or a two-part chained - inequality (`1 < x < 5`, parsed as an `And` of two relations); a chain is - compared to another chain bound by bound. + Check whether the response relation `res` is equivalent to the answer + relation `ans`. Both may be a single `sympy` relation (an order operator or + `!=`) or a two-part chained order inequality (`1 < x < 5`, parsed as an `And` + of two relations); a chain is compared to another chain bound by bound, and a + chain is never equivalent to a single relation. Returns one of: True - equivalent - False - not equivalent (e.g. zero ratio, different arity) + False - not equivalent (e.g. zero ratio, different arity, + `!=` vs an order operator) "WRONG_DIRECTION" - ratio is a negative constant (opposite region) "STRICTNESS_MISMATCH" - positive-constant ratio but `<` vs `<=` differ - "RESPONSE_NOT_INEQUALITY" - the response is not an inequality, the answer is - "ANSWER_NOT_INEQUALITY" - the response is an inequality, the answer is not + "RESPONSE_NOT_INEQUALITY" - the response is not a relation, the answer is + "ANSWER_NOT_INEQUALITY" - the response is a relation, the answer is not None - undecidable (non-constant/unknown-sign ratio) """ res_bounds = inequality_bounds(res) diff --git a/app/docs/user.md b/app/docs/user.md index 74394d8..ad0c49c 100644 --- a/app/docs/user.md +++ b/app/docs/user.md @@ -296,7 +296,9 @@ For example, with answer `2x - 10 >= 0` (`strict_syntax` false, `elementary_func Two-part chained inequalities that point in one direction (e.g. `1 < x < 5` or `5 >= x > 1`) are also supported, in the answer and/or the response. Each chain is split into its lower- and upper-bound inequality and the bounds are compared with the rule above. For example, with answer `1 < x < 5` the responses `5 > x > 1`, `0 < x - 1 < 4` and `2 < 2x < 10` are accepted, while `1 <= x < 5` is rejected (wrong strictness on the lower bound). -**Note:** `!=` is not supported. Chains of three or more operators (`1 <= x <= y <= 5`) and mixed-direction chains (`1 < x > 5`) are not supported. A response that expands to a set of inequalities (e.g. via `plus_minus`) is not supported. +Not-equal, `!=` (or `≠`), is supported as a single relation. `f != g` is equivalent to `p != q` when `(f - g) / (p - q)` simplifies to a non-zero constant (direction and strictness do not apply). For example, with answer `x != 5` the responses `5 != x`, `2x != 10` and `x - 5 != 0` are accepted; `x = 5` is not. + +**Note:** `!=` cannot be chained (`x != y != 5`) or combined with order operators (`1 < x != 5`). Chains of three or more operators (`1 <= x <= y <= 5`) and mixed-direction chains (`1 < x > 5`) are not supported. A response that expands to a set of inequalities (e.g. via `plus_minus`) is not supported. #### Checking the value of an expression or a physical quantity diff --git a/app/preview_test.py b/app/preview_test.py index 8b3d94d..5793b49 100644 --- a/app/preview_test.py +++ b/app/preview_test.py @@ -83,6 +83,8 @@ def test_natural_logarithm_notation(self): ("x >= 5", r"x \geq 5"), ("2 x - 10 <= 0", r"2 \cdot x - 10 \leq 0"), ("1 < x < 5", r"1 < x \wedge x < 5"), + ("x != 5", r"x \neq 5"), + ("x ≠ 5", r"x \neq 5"), ] ) def test_inequality_preview(self, response, expected_latex): diff --git a/app/tests/expression_utilities_test.py b/app/tests/expression_utilities_test.py index cf09bd4..2f903bd 100644 --- a/app/tests/expression_utilities_test.py +++ b/app/tests/expression_utilities_test.py @@ -2,7 +2,7 @@ import pytest from sympy import Symbol, sqrt, sin as sympy_sin -from sympy import Equality, StrictLessThan, LessThan, StrictGreaterThan, GreaterThan, And +from sympy import Equality, StrictLessThan, LessThan, StrictGreaterThan, GreaterThan, Ne, And from ..utility.expression_utilities import ( compute_relative_tolerance_from_significant_decimals, @@ -535,11 +535,22 @@ def test_parse_chained_inequality(self, expr, part_types): 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_parse_not_equal(self, expr): + parsed = parse_expression(expr, self.parsing_params()) + assert isinstance(parsed, Ne) + assert parsed.rel_op == "!=" + @pytest.mark.parametrize( - "expr", ["a < b > c", "1 < x > 5", "1 <= x <= y <= 5", "a < b < c < d"] + "expr", + [ + "a < b > c", "1 < x > 5", "1 <= x <= y <= 5", "a < b < c < d", + "x != y != 5", "1 < x != 5", + ] ) def test_parse_chained_inequality_rejected(self, expr): - # Mixed-direction chains and chains of three or more operators. + # Mixed-direction chains, chains of three or more operators, and `!=` + # combined with any other relational operator. with pytest.raises(ValueError): parse_expression(expr, self.parsing_params()) diff --git a/app/tests/symbolic_evaluation_test.py b/app/tests/symbolic_evaluation_test.py index 8148f63..7993257 100644 --- a/app/tests/symbolic_evaluation_test.py +++ b/app/tests/symbolic_evaluation_test.py @@ -715,6 +715,39 @@ def test_chained_inequality_mixed_or_long_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 != 5", "x != 5", True), + ("5 != x", "x != 5", True), + ("x - 5 != 0", "x != 5", True), + ("2*x != 10", "x != 5", True), + ("-x != -5", "x != 5", True), + ("x ≠ 5", "x != 5", True), + ("x != 3", "x != 5", False), + ("x = 5", "x != 5", False), + ("x != 5", "x = 5", False), + ("x > 5", "x != 5", False), + ] + ) + def test_not_equal_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 + + def test_not_equal_feedback_tag(self): + params = {"strict_syntax": False, "elementary_functions": True} + result = evaluation_function("2*x != 10", "x != 5", params, include_test_data=True) + assert result["is_correct"] is True + assert "response = answer_TRUE" in result["tags"] + + @pytest.mark.parametrize("response", ["x != y != 5", "1 < x != 5"]) + def test_not_equal_chained_or_mixed_is_rejected(self, response): + params = {"strict_syntax": False, "elementary_functions": True} + result = evaluation_function(response, "x != 5", params) + assert result["is_correct"] is False + assert "could not be parsed" in result["feedback"] + 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/utility/expression_utilities.py b/app/utility/expression_utilities.py index ee2f3ab..93fdc71 100644 --- a/app/utility/expression_utilities.py +++ b/app/utility/expression_utilities.py @@ -26,7 +26,7 @@ from sympy.parsing.sympy_parser import parse_expr, split_symbols_custom, _token_splittable from sympy.parsing.sympy_parser import T as parser_transformations from sympy.printing.latex import LatexPrinter -from sympy import Basic, Symbol, Equality, Function, Lt, Le, Gt, Ge, And +from sympy import Basic, Symbol, Equality, Function, Lt, Le, Gt, Ge, Ne, And import re from typing import Dict, List, TypedDict @@ -818,6 +818,7 @@ def parse_expression(expr_string, parsing_params): parsed_expr_set = set() for expr in expr_set: + expr = expr.replace("≠", "!=") if not strict_syntax: expr, _ = convert_bracket_notation(expr) expr = preprocess_according_to_chosen_convention(expr, parsing_params) @@ -845,13 +846,14 @@ def parse_expression(expr_string, parsing_params): transformations += parser_transformations[11] - # Relational (inequality) operands must be detected before the "=" split - # below, since ">=" and "<=" contain an "=" that would otherwise break - # `expr.split("=")`. A single inequality (`x < 5`) and a two-operator - # chain pointing in one direction (`1 < x < 5`) are supported; longer or - # mixed-direction chains are rejected. - relational_parts = re.split(r"(<=|>=|<|>)", expr) - relational_classes = {"<=": Le, ">=": Ge, "<": Lt, ">": Gt} + # Relational operands must be detected before the "=" split below, since + # ">=", "<=" and "!=" contain an "=" that would otherwise break + # `expr.split("=")`. A single relation (`x < 5`, `x != 5`) and a + # two-operator order chain pointing in one direction (`1 < x < 5`) are + # supported; longer or mixed-direction chains, and `!=` combined with any + # other operator, are rejected. + relational_parts = re.split(r"(<=|>=|!=|<|>)", expr) + relational_classes = {"<=": Le, ">=": Ge, "<": Lt, ">": Gt, "!=": Ne} if len(relational_parts) >= 3: operands = relational_parts[0::2] @@ -861,6 +863,11 @@ def parse_expression(expr_string, parsing_params): f"Failed to parse Sympy expression `{expr}`: only single or " "chained (two-operator) inequalities are supported." ) + if "!=" in operators and len(operators) > 1: + raise ValueError( + f"Failed to parse Sympy expression `{expr}`: `!=` cannot be " + "combined with other relational operators." + ) if len(operators) == 2 and len({op.replace("=", "") for op in operators}) > 1: raise ValueError( f"Failed to parse Sympy expression `{expr}`: a chained " From 7af3d15065ccd2a7a56f7c1c780d8ac22e17f773 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Thu, 10 Sep 2026 21:01:36 +0100 Subject: [PATCH 6/6] Documented support for `!=` in symbolic equivalence checks and its handling in the current implementation --- app/docs/dev.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/docs/dev.md b/app/docs/dev.md index c7afb8f..13132e7 100644 --- a/app/docs/dev.md +++ b/app/docs/dev.md @@ -76,7 +76,7 @@ There are currently two different contexts: - `equality`: Comparison of mathematical equalities (with the extra complexities that come with equivalence of equalities compared to equality of expressions). - `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. Moving this into a dedicated `statements` context remains future work. + **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. - `collection`: Comparison of collections (e.g. sets, lists or intervals of the number line). Likely to consist mostly of code for handling comparison of individual elements using the other contexts, and configuring what counts as equivalence between different collections. ##### `symbolic` Criteria commands and grammar