From 9570707bf995b4508c2a23f2a1e34460a77713a1 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Thu, 2 Oct 2025 16:51:47 +0100 Subject: [PATCH 1/8] =?UTF-8?q?Removed=20invalid=20test=20of=20strict=5Fsy?= =?UTF-8?q?ntax=20-inf=20and=20-=E2=88=9E.=20These=20are=20equal=20(at=20l?= =?UTF-8?q?east=20according=20to=20SymPy=20and=20correctly=20parse.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/tests/symbolic_evaluation_tests.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/tests/symbolic_evaluation_tests.py b/app/tests/symbolic_evaluation_tests.py index 33fcbce..1471c33 100644 --- a/app/tests/symbolic_evaluation_tests.py +++ b/app/tests/symbolic_evaluation_tests.py @@ -775,10 +775,6 @@ def test_warning_inappropriate_symbol(self): '(0,002*6800*v)/1,2', '(0.002*6800*v)/1.2' ), - ( - '-∞', - '-inf' - ), ( 'x.y', 'x*y' From 102f73bcafec30a16cd7e73968d4d4ceac5530c7 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Thu, 2 Oct 2025 18:32:53 +0100 Subject: [PATCH 2/8] Added support for ! and !! factorials --- app/tests/symbolic_evaluation_tests.py | 34 ++++- app/utility/expression_utilities.py | 185 ++++++++++++++++++++++++- 2 files changed, 209 insertions(+), 10 deletions(-) diff --git a/app/tests/symbolic_evaluation_tests.py b/app/tests/symbolic_evaluation_tests.py index 1471c33..728fa66 100644 --- a/app/tests/symbolic_evaluation_tests.py +++ b/app/tests/symbolic_evaluation_tests.py @@ -1861,15 +1861,41 @@ def test_sum_in_answer(self, response, answer, value): result = evaluation_function(response, answer, params) assert result["is_correct"] is value - def test_exclamation_mark_for_factorial(self): - response = "3!" - answer = "factorial(3)" + @pytest.mark.parametrize( + "response, answer, value", + [ + ("3!", "factorial(3)", True), + ("(n+1)!", "factorial(n+1)", True), + ("n!", "factorial(n)", True), + ("a!=b", "factorial(3)", False), + ("2*n!", "2*factorial(n)", True), + ] + ) + def test_exclamation_mark_for_factorial(self, response, answer, value): params = { "strict_syntax": False, "elementary_functions": True, } result = evaluation_function(response, answer, params) - assert result["is_correct"] is True + assert result["is_correct"] is value + + @pytest.mark.parametrize( + "response, answer, value", + [ + ("3!!", "factorial2(3)", True), + ("(n+1)!!", "factorial2(n+1)", True), + ("n!!", "factorial2(n)", True), + ("a!=b", "factorial2(3)", False), + ("2*n!!", "2*factorial2(n)", True), + ] + ) + def test_double_exclamation_mark_for_factorial(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_alternatives_to_input_symbols_takes_priority_over_elementary_function_alternatives(self): answer = "Ef*exp(x)" diff --git a/app/utility/expression_utilities.py b/app/utility/expression_utilities.py index fb88a22..dd66fb3 100644 --- a/app/utility/expression_utilities.py +++ b/app/utility/expression_utilities.py @@ -24,6 +24,10 @@ from sympy.printing.latex import LatexPrinter from sympy import Basic, Symbol, Equality, Function +from sympy import factorial as _sympy_factorial +from sympy.functions.combinatorial.factorials import factorial2 as _sympy_factorial2 + + import re from typing import Dict, List, TypedDict @@ -661,6 +665,149 @@ def preprocess_expression(name, expr, parameters): success = False return success, expr, abs_feedback +def convert_double_bang_factorial(s: str) -> str: + """ + Convert double postfix factorial (e.g., n!!, (x+1)!!, 3!!) to function form: factorial2(n), etc. + Safeguards: + - Does NOT treat '!=' specially (since we target '!!'). + - Requires two consecutive '!' characters (no whitespace in between). + - Handles balanced parenthesis operands (e.g., '(... )!!'). + - Handles identifiers and numeric literals. + """ + n = len(s) + i = 0 + last = 0 + out = [] + + while i < n: + ch = s[i] + if ch == '!' and (i + 1) < n and s[i + 1] == '!': + # Look left to find the operand (skip whitespace) + j = i - 1 + while j >= 0 and s[j].isspace(): + j -= 1 + if j < 0: + # Nothing to the left; keep as-is + i += 1 + continue + + # Case 1: operand ends with ')': parenthesized group + if s[j] == ')': + depth = 1 + k = j - 1 + while k >= 0 and depth > 0: + if s[k] == ')': + depth += 1 + elif s[k] == '(': + depth -= 1 + k -= 1 + if depth == 0: + L = k + 1 # index of '(' + R = j # index of ')' + out.append(s[last:L]) + out.append('factorial2(') + out.append(s[L:R+1]) + out.append(')') + last = i + 2 # consume both '!' + i += 2 + continue + else: + # Unbalanced parentheses; leave as-is + i += 1 + continue + + # Case 2: operand is an identifier/number ending at j + k = j + while k >= 0 and (s[k].isalnum() or s[k] in ('_', '.')): + k -= 1 + L = k + 1 + if L <= j: + out.append(s[last:L]) + out.append('factorial2(') + out.append(s[L:j+1]) + out.append(')') + last = i + 2 + i += 2 + continue + # If we get here, no valid operand token; fall through and keep scanning. + + i += 1 + + out.append(s[last:]) + return ''.join(out) + +def convert_bang_factorial(s: str) -> str: + """ + Convert single postfix factorial (e.g., n!, (x+1)!, 3!) to function form: factorial(n), etc. + Safeguards: + - Does NOT convert '!='. + - Does NOT convert '!!' (handled by convert_double_bang_factorial). + """ + n = len(s) + i = 0 + last = 0 + out = [] + + while i < n: + ch = s[i] + if ch == '!': + # Skip '!=' and '!!' (the latter handled in a separate pass) + nxt = s[i+1] if i + 1 < n else '' + if nxt in ('=', '!'): + i += 1 + continue + + # Move left to find the operand (skip whitespace) + j = i - 1 + while j >= 0 and s[j].isspace(): + j -= 1 + if j < 0: + i += 1 + continue + + # Parenthesized operand + if s[j] == ')': + depth = 1 + k = j - 1 + while k >= 0 and depth > 0: + if s[k] == ')': + depth += 1 + elif s[k] == '(': + depth -= 1 + k -= 1 + if depth == 0: + L = k + 1 + R = j + out.append(s[last:L]) + out.append('factorial(') + out.append(s[L:R+1]) + out.append(')') + last = i + 1 + i += 1 + continue + else: + i += 1 + continue + + # Identifier/number operand + k = j + while k >= 0 and (s[k].isalnum() or s[k] in ('_', '.')): + k -= 1 + L = k + 1 + if L <= j: + out.append(s[last:L]) + out.append('factorial(') + out.append(s[L:j+1]) + out.append(')') + last = i + 1 + i += 1 + continue + + i += 1 + + out.append(s[last:]) + return ''.join(out) + def parse_expression(expr_string, parsing_params): ''' @@ -678,27 +825,52 @@ def parse_expression(expr_string, parsing_params): extra_transformations = parsing_params.get("extra_transformations", ()) unsplittable_symbols = parsing_params.get("unsplittable_symbols", ()) symbol_dict = parsing_params.get("symbol_dict", {}) + + # --- Ensure factorial and factorial2 are known and not split --- + symbol_dict = dict(symbol_dict) + symbol_dict.setdefault("factorial", _sympy_factorial) + symbol_dict.setdefault("factorial2", _sympy_factorial2) + + if 'factorial' not in unsplittable_symbols or 'factorial2' not in unsplittable_symbols: + unsplittable_symbols = tuple( + list(unsplittable_symbols) + + [s for s in ('factorial', 'factorial2') if s not in unsplittable_symbols] + ) + separate_unsplittable_symbols = [(x, " "+x) for x in unsplittable_symbols] substitutions = separate_unsplittable_symbols parsed_expr_set = set() for expr in expr_set: expr = preprocess_according_to_chosen_convention(expr, parsing_params) + substitutions = list(set(substitutions)) substitutions.sort(key=substitutions_sort_key) - if parsing_params["elementary_functions"] is True: + if parsing_params.get("elementary_functions") is True: substitutions += protect_elementary_functions_substitutions(expr) + + expr = convert_double_bang_factorial(expr) # n!! -> factorial2(n) + expr = convert_bang_factorial(expr) # n! -> factorial(n) + substitutions = list(set(substitutions)) substitutions.sort(key=substitutions_sort_key) expr = substitute(expr, substitutions) expr = " ".join(expr.split()) + can_split = lambda x: False if x in unsplittable_symbols else _token_splittable(x) if strict_syntax is True: transformations = parser_transformations[0:4]+extra_transformations else: - transformations = parser_transformations[0:5, 6]+extra_transformations+(split_symbols_custom(can_split),)+parser_transformations[8, 9] + transformations = ( + parser_transformations[0:5, 6] # keep your existing set-up + + extra_transformations + + (split_symbols_custom(can_split),) + + parser_transformations[8, 9] + ) + if parsing_params.get("rationalise", False): transformations += parser_transformations[11] + if "=" in expr: expr_parts = expr.split("=") lhs = parse_expr(expr_parts[0], transformations=transformations, local_dict=symbol_dict) @@ -710,11 +882,12 @@ def parse_expression(expr_string, parsing_params): parsed_expr = parsed_expr.simplify() else: parsed_expr = parse_expr(expr, transformations=transformations, local_dict=symbol_dict, evaluate=False) + if not isinstance(parsed_expr, Basic): raise ValueError(f"Failed to parse Sympy expression `{expr}`") parsed_expr_set.add(parsed_expr) - if len(expr_set) == 1: - return parsed_expr_set.pop() - else: - return parsed_expr_set + if len(expr_set) == 1: + return parsed_expr_set.pop() + else: + return parsed_expr_set From 7cce2fa86fc30a82ca5c9030f8851a6ce612c721 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Thu, 2 Oct 2025 18:34:50 +0100 Subject: [PATCH 3/8] Added more ! tests --- app/tests/symbolic_evaluation_tests.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/tests/symbolic_evaluation_tests.py b/app/tests/symbolic_evaluation_tests.py index 728fa66..9ae6ddd 100644 --- a/app/tests/symbolic_evaluation_tests.py +++ b/app/tests/symbolic_evaluation_tests.py @@ -1869,6 +1869,7 @@ def test_sum_in_answer(self, response, answer, value): ("n!", "factorial(n)", True), ("a!=b", "factorial(3)", False), ("2*n!", "2*factorial(n)", True), + ("3!", "3!", True) ] ) def test_exclamation_mark_for_factorial(self, response, answer, value): @@ -1887,6 +1888,7 @@ def test_exclamation_mark_for_factorial(self, response, answer, value): ("n!!", "factorial2(n)", True), ("a!=b", "factorial2(3)", False), ("2*n!!", "2*factorial2(n)", True), + ("3!!", "3!!", True), ] ) def test_double_exclamation_mark_for_factorial(self, response, answer, value): From 1e00c6e5ad72a5050533e51ce62a40090175d38c Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Thu, 2 Oct 2025 18:41:55 +0100 Subject: [PATCH 4/8] Fixed issues with plus_minus and other failing tests --- app/utility/expression_utilities.py | 34 +++++++++-------------------- 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/app/utility/expression_utilities.py b/app/utility/expression_utilities.py index dd66fb3..139e934 100644 --- a/app/utility/expression_utilities.py +++ b/app/utility/expression_utilities.py @@ -825,8 +825,9 @@ def parse_expression(expr_string, parsing_params): extra_transformations = parsing_params.get("extra_transformations", ()) unsplittable_symbols = parsing_params.get("unsplittable_symbols", ()) symbol_dict = parsing_params.get("symbol_dict", {}) + separate_unsplittable_symbols = [(x, " "+x) for x in unsplittable_symbols] + substitutions = separate_unsplittable_symbols - # --- Ensure factorial and factorial2 are known and not split --- symbol_dict = dict(symbol_dict) symbol_dict.setdefault("factorial", _sympy_factorial) symbol_dict.setdefault("factorial2", _sympy_factorial2) @@ -837,40 +838,26 @@ def parse_expression(expr_string, parsing_params): + [s for s in ('factorial', 'factorial2') if s not in unsplittable_symbols] ) - separate_unsplittable_symbols = [(x, " "+x) for x in unsplittable_symbols] - substitutions = separate_unsplittable_symbols - parsed_expr_set = set() for expr in expr_set: expr = preprocess_according_to_chosen_convention(expr, parsing_params) - substitutions = list(set(substitutions)) substitutions.sort(key=substitutions_sort_key) - if parsing_params.get("elementary_functions") is True: + if parsing_params["elementary_functions"] is True: substitutions += protect_elementary_functions_substitutions(expr) - - expr = convert_double_bang_factorial(expr) # n!! -> factorial2(n) - expr = convert_bang_factorial(expr) # n! -> factorial(n) - + expr = convert_double_bang_factorial(expr) + expr = convert_bang_factorial(expr) substitutions = list(set(substitutions)) substitutions.sort(key=substitutions_sort_key) expr = substitute(expr, substitutions) expr = " ".join(expr.split()) - can_split = lambda x: False if x in unsplittable_symbols else _token_splittable(x) if strict_syntax is True: transformations = parser_transformations[0:4]+extra_transformations else: - transformations = ( - parser_transformations[0:5, 6] # keep your existing set-up - + extra_transformations - + (split_symbols_custom(can_split),) - + parser_transformations[8, 9] - ) - + transformations = parser_transformations[0:5, 6]+extra_transformations+(split_symbols_custom(can_split),)+parser_transformations[8, 9] if parsing_params.get("rationalise", False): transformations += parser_transformations[11] - if "=" in expr: expr_parts = expr.split("=") lhs = parse_expr(expr_parts[0], transformations=transformations, local_dict=symbol_dict) @@ -882,12 +869,11 @@ def parse_expression(expr_string, parsing_params): parsed_expr = parsed_expr.simplify() else: parsed_expr = parse_expr(expr, transformations=transformations, local_dict=symbol_dict, evaluate=False) - if not isinstance(parsed_expr, Basic): raise ValueError(f"Failed to parse Sympy expression `{expr}`") parsed_expr_set.add(parsed_expr) - if len(expr_set) == 1: - return parsed_expr_set.pop() - else: - return parsed_expr_set + if len(expr_set) == 1: + return parsed_expr_set.pop() + else: + return parsed_expr_set \ No newline at end of file From e0ae03071a8f199cff8242b6f73bd7542eb9591e Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Thu, 9 Oct 2025 09:57:12 +0100 Subject: [PATCH 5/8] Updated Sympy to 1.14 --- app/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/requirements.txt b/app/requirements.txt index 1ac96c7..4da78da 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -1,6 +1,6 @@ pydot typing_extensions mpmath==1.2.1 -sympy==1.12 +sympy==1.14 antlr4-python3-runtime==4.7.2 git+https://github.com/lambda-feedback/latex2sympy.git@master#egg=latex2sympy2 \ No newline at end of file From a07755dada9ddc2735d13d9b95e68bba9c761ed9 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Thu, 9 Oct 2025 10:46:20 +0100 Subject: [PATCH 6/8] Switched to in-built functionality of Sympy --- app/utility/expression_utilities.py | 165 +--------------------------- 1 file changed, 5 insertions(+), 160 deletions(-) diff --git a/app/utility/expression_utilities.py b/app/utility/expression_utilities.py index 139e934..baeb92d 100644 --- a/app/utility/expression_utilities.py +++ b/app/utility/expression_utilities.py @@ -24,10 +24,6 @@ from sympy.printing.latex import LatexPrinter from sympy import Basic, Symbol, Equality, Function -from sympy import factorial as _sympy_factorial -from sympy.functions.combinatorial.factorials import factorial2 as _sympy_factorial2 - - import re from typing import Dict, List, TypedDict @@ -665,149 +661,6 @@ def preprocess_expression(name, expr, parameters): success = False return success, expr, abs_feedback -def convert_double_bang_factorial(s: str) -> str: - """ - Convert double postfix factorial (e.g., n!!, (x+1)!!, 3!!) to function form: factorial2(n), etc. - Safeguards: - - Does NOT treat '!=' specially (since we target '!!'). - - Requires two consecutive '!' characters (no whitespace in between). - - Handles balanced parenthesis operands (e.g., '(... )!!'). - - Handles identifiers and numeric literals. - """ - n = len(s) - i = 0 - last = 0 - out = [] - - while i < n: - ch = s[i] - if ch == '!' and (i + 1) < n and s[i + 1] == '!': - # Look left to find the operand (skip whitespace) - j = i - 1 - while j >= 0 and s[j].isspace(): - j -= 1 - if j < 0: - # Nothing to the left; keep as-is - i += 1 - continue - - # Case 1: operand ends with ')': parenthesized group - if s[j] == ')': - depth = 1 - k = j - 1 - while k >= 0 and depth > 0: - if s[k] == ')': - depth += 1 - elif s[k] == '(': - depth -= 1 - k -= 1 - if depth == 0: - L = k + 1 # index of '(' - R = j # index of ')' - out.append(s[last:L]) - out.append('factorial2(') - out.append(s[L:R+1]) - out.append(')') - last = i + 2 # consume both '!' - i += 2 - continue - else: - # Unbalanced parentheses; leave as-is - i += 1 - continue - - # Case 2: operand is an identifier/number ending at j - k = j - while k >= 0 and (s[k].isalnum() or s[k] in ('_', '.')): - k -= 1 - L = k + 1 - if L <= j: - out.append(s[last:L]) - out.append('factorial2(') - out.append(s[L:j+1]) - out.append(')') - last = i + 2 - i += 2 - continue - # If we get here, no valid operand token; fall through and keep scanning. - - i += 1 - - out.append(s[last:]) - return ''.join(out) - -def convert_bang_factorial(s: str) -> str: - """ - Convert single postfix factorial (e.g., n!, (x+1)!, 3!) to function form: factorial(n), etc. - Safeguards: - - Does NOT convert '!='. - - Does NOT convert '!!' (handled by convert_double_bang_factorial). - """ - n = len(s) - i = 0 - last = 0 - out = [] - - while i < n: - ch = s[i] - if ch == '!': - # Skip '!=' and '!!' (the latter handled in a separate pass) - nxt = s[i+1] if i + 1 < n else '' - if nxt in ('=', '!'): - i += 1 - continue - - # Move left to find the operand (skip whitespace) - j = i - 1 - while j >= 0 and s[j].isspace(): - j -= 1 - if j < 0: - i += 1 - continue - - # Parenthesized operand - if s[j] == ')': - depth = 1 - k = j - 1 - while k >= 0 and depth > 0: - if s[k] == ')': - depth += 1 - elif s[k] == '(': - depth -= 1 - k -= 1 - if depth == 0: - L = k + 1 - R = j - out.append(s[last:L]) - out.append('factorial(') - out.append(s[L:R+1]) - out.append(')') - last = i + 1 - i += 1 - continue - else: - i += 1 - continue - - # Identifier/number operand - k = j - while k >= 0 and (s[k].isalnum() or s[k] in ('_', '.')): - k -= 1 - L = k + 1 - if L <= j: - out.append(s[last:L]) - out.append('factorial(') - out.append(s[L:j+1]) - out.append(')') - last = i + 1 - i += 1 - continue - - i += 1 - - out.append(s[last:]) - return ''.join(out) - def parse_expression(expr_string, parsing_params): ''' @@ -828,16 +681,6 @@ def parse_expression(expr_string, parsing_params): separate_unsplittable_symbols = [(x, " "+x) for x in unsplittable_symbols] substitutions = separate_unsplittable_symbols - symbol_dict = dict(symbol_dict) - symbol_dict.setdefault("factorial", _sympy_factorial) - symbol_dict.setdefault("factorial2", _sympy_factorial2) - - if 'factorial' not in unsplittable_symbols or 'factorial2' not in unsplittable_symbols: - unsplittable_symbols = tuple( - list(unsplittable_symbols) - + [s for s in ('factorial', 'factorial2') if s not in unsplittable_symbols] - ) - parsed_expr_set = set() for expr in expr_set: expr = preprocess_according_to_chosen_convention(expr, parsing_params) @@ -845,12 +688,12 @@ def parse_expression(expr_string, parsing_params): substitutions.sort(key=substitutions_sort_key) if parsing_params["elementary_functions"] is True: substitutions += protect_elementary_functions_substitutions(expr) - expr = convert_double_bang_factorial(expr) - expr = convert_bang_factorial(expr) + substitutions = list(set(substitutions)) substitutions.sort(key=substitutions_sort_key) expr = substitute(expr, substitutions) expr = " ".join(expr.split()) + can_split = lambda x: False if x in unsplittable_symbols else _token_splittable(x) if strict_syntax is True: transformations = parser_transformations[0:4]+extra_transformations @@ -858,6 +701,7 @@ def parse_expression(expr_string, parsing_params): transformations = parser_transformations[0:5, 6]+extra_transformations+(split_symbols_custom(can_split),)+parser_transformations[8, 9] if parsing_params.get("rationalise", False): transformations += parser_transformations[11] + if "=" in expr: expr_parts = expr.split("=") lhs = parse_expr(expr_parts[0], transformations=transformations, local_dict=symbol_dict) @@ -869,6 +713,7 @@ def parse_expression(expr_string, parsing_params): parsed_expr = parsed_expr.simplify() else: parsed_expr = parse_expr(expr, transformations=transformations, local_dict=symbol_dict, evaluate=False) + if not isinstance(parsed_expr, Basic): raise ValueError(f"Failed to parse Sympy expression `{expr}`") parsed_expr_set.add(parsed_expr) @@ -876,4 +721,4 @@ def parse_expression(expr_string, parsing_params): if len(expr_set) == 1: return parsed_expr_set.pop() else: - return parsed_expr_set \ No newline at end of file + return parsed_expr_set From 4abbfed4b5279da3c080679e3e1db1d674dba37c Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Thu, 9 Oct 2025 10:52:25 +0100 Subject: [PATCH 7/8] Added warning for triple factorial --- app/evaluation.py | 4 ++++ app/feedback/symbolic.py | 1 + app/tests/symbolic_evaluation_tests.py | 8 ++++++++ 3 files changed, 13 insertions(+) diff --git a/app/evaluation.py b/app/evaluation.py index a99ee01..7877a0c 100644 --- a/app/evaluation.py +++ b/app/evaluation.py @@ -288,6 +288,10 @@ def evaluation_function(response, answer, params, include_test_data=False) -> di if "!" in response: evaluation_result.add_feedback(("NOTATION_WARNING_FACTORIAL", symbolic_comparison_internal_messages("NOTATION_WARNING_FACTORIAL")(dict()))) + if "!!!" in response: + evaluation_result.add_feedback( + ("NOTATION_WARNING_TRIPLE_FACTORIAL", symbolic_comparison_internal_messages("NOTATION_WARNING_TRIPLE_FACTORIAL")(dict()))) + reserved_expressions_success, reserved_expressions = parse_reserved_expressions(reserved_expressions_strings, parameters, evaluation_result) if reserved_expressions_success is False: return evaluation_result.serialise(include_test_data) diff --git a/app/feedback/symbolic.py b/app/feedback/symbolic.py index c4f5c6f..d341fa8 100644 --- a/app/feedback/symbolic.py +++ b/app/feedback/symbolic.py @@ -21,6 +21,7 @@ "PARSE_ERROR": f"`{inputs.get('x','')}` could not be parsed as a valid mathematical expression. Ensure that correct codes for input symbols are used, correct notation is used, that the expression is unambiguous and that all parentheses are closed.", "NOTATION_WARNING_EXPONENT": "Note that `^` cannot be used to denote exponentiation, use `**` instead.", "NOTATION_WARNING_FACTORIAL": "Note that `!` cannot be used to denote factorial, use `factorial(...)` instead.", + "NOTATION_WARNING_TRIPLE_FACTORIAL": "Note that `!!!` is not supported.", "EXPRESSION_NOT_EQUALITY": "The response was an expression but was expected to be an equality.", "EQUALITY_NOT_EXPRESSION": "The response was an equality but was expected to be an expression.", "EQUALITIES_EQUIVALENT": None, diff --git a/app/tests/symbolic_evaluation_tests.py b/app/tests/symbolic_evaluation_tests.py index 9ae6ddd..cfca45e 100644 --- a/app/tests/symbolic_evaluation_tests.py +++ b/app/tests/symbolic_evaluation_tests.py @@ -1899,6 +1899,14 @@ def test_double_exclamation_mark_for_factorial(self, response, answer, value): result = evaluation_function(response, answer, params) assert result["is_correct"] is value + def test_warning_for_triple_factorial(self): + answer = '2^4!' + response = '2^4!!!' + params = {'strict_syntax': False} + result = evaluation_function(response, answer, params, include_test_data=True) + assert result["is_correct"] is False + assert "NOTATION_WARNING_TRIPLE_FACTORIAL" in result["tags"] + def test_alternatives_to_input_symbols_takes_priority_over_elementary_function_alternatives(self): answer = "Ef*exp(x)" params = { From f956890e3801f02be79a4007a95d533fe234c84a Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Thu, 9 Oct 2025 16:56:40 +0100 Subject: [PATCH 8/8] Added another factorial test --- app/tests/symbolic_evaluation_tests.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/tests/symbolic_evaluation_tests.py b/app/tests/symbolic_evaluation_tests.py index cfca45e..96f89e8 100644 --- a/app/tests/symbolic_evaluation_tests.py +++ b/app/tests/symbolic_evaluation_tests.py @@ -1869,7 +1869,8 @@ def test_sum_in_answer(self, response, answer, value): ("n!", "factorial(n)", True), ("a!=b", "factorial(3)", False), ("2*n!", "2*factorial(n)", True), - ("3!", "3!", True) + ("3!", "3!", True), + ("3*sin(n)!", "3*factorial(sin(n))", True) ] ) def test_exclamation_mark_for_factorial(self, response, answer, value):