Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9a96d52
GH-223 Added tests to eval fn to validate that custom multi-character…
m-messer Sep 5, 2025
62a8118
Merge branch 'main' into bug/GH-223-implicit-multi-custom-sym
m-messer Oct 2, 2025
5928b78
Added normalise function to support custom multi character symbols an…
m-messer Oct 2, 2025
f994b77
Started refactor to use a transformer instead
m-messer Oct 9, 2025
c86500f
Merge branch 'main' into bug/GH-223-implicit-multi-custom-sym
m-messer Dec 1, 2025
26a757e
Added a comprehensive test suite
m-messer Dec 1, 2025
e56cb7e
Fixed typo
m-messer Dec 1, 2025
b359f5e
Updated to use correct parser
m-messer Dec 1, 2025
9283bf9
Added missing parameters
m-messer Dec 1, 2025
cee00a8
Switched approach to use substitutions before transformers.
m-messer Dec 2, 2025
17ed2fd
Fixed issue with Chi and Lambda Special functions
m-messer Dec 2, 2025
6431044
Fixed number of parameters
m-messer Dec 2, 2025
7aa99b3
Removed redundant function and fixed existing list comprehension
m-messer Dec 2, 2025
acbf5e2
Merge branch 'main' into bug/GH-223-implicit-multi-custom-sym
m-messer Dec 3, 2025
e17b83f
Merge branch 'main' into bug/GH-223-implicit-multi-custom-sym
m-messer Dec 3, 2025
35f2841
Removed documentation for the workaround with multicharacter and impl…
m-messer Dec 3, 2025
56b0ece
Added test for preview of multicharacter and implicit multiplication
m-messer Dec 3, 2025
59dc54f
Fixed spelling mistake
m-messer Dec 5, 2025
d772a7e
Fixed spelling mistake
m-messer Dec 5, 2025
df96aa4
Removed redundant comments
m-messer Dec 5, 2025
814e4de
Updated class name and comment to make it clearer that the tests are …
m-messer Dec 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 0 additions & 26 deletions app/docs/user.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@ Changes the implicit multiplication convention. If unset it will default to `equ

If set to `implicit_higher_precedence` then implicit multiplication will have higher precedence than explicit multiplication, i.e. `1/ab` will be equal to `1/(ab)` and `1/a*b` will be equal to `(1/a)*b`.

**_NOTE:_** Currently, if implicit multiplication has higher precedence, then multi-character custom symbols are not supported. For example, if you have defined the symbol `bc` with an answer `a/(bc*d)` then `a/(bcd)` will fail, as it is treated as `a/(b * c * d)`.

If set to `equal_precedence` then implicit multiplication will have the same precedence than explicit multiplication, i.e. both `1/ab` and `1/a*b` will be equal to `(1/a)*b`.

#### `criteria`
Expand Down Expand Up @@ -65,7 +63,6 @@ All feedback for all incorrect responses will be replaced with the string that t
The $\pm$ and $\mp$ symbols can be represented in the answer or response by `plus_minus` and `minus_plus` respectively.

Answers or responses that contain $\pm$ or $\mp$ has two possible interpretations which requires further criteria for equality. The grading parameter `multiple_answers_criteria` controls this. The default setting, `all`, is that each answer must have a corresponding answer and vice versa. The setting `all_responses` check that all responses are valid answers and the setting `all_answers` checks that all answers are found among the responses.

#### `physical_quantity`

If unset, `physical_quantity` will default to `false`.
Expand Down Expand Up @@ -611,8 +608,6 @@ If you want to use a symbol that is usually reserved for some reserved character
1. Create an input symbol where the code is different that the symbol you want to use, e.g. ‘Ef’ or 'Euler' instead of ‘E’
2. Add the symbol you want to use as an alternative, e.g. the alternatives could be set to ‘E’

See note in [`convention`](#convention) for the limitations of using multi character symbols and implicit multiplication higher precedence.

#### Example:
For the answer:
$A/(\epsilon*l)$, `e` is reserved as Euler's number, so we replace `e` with `ef` or any other character(s) that are not reserved or used in the expression and provide alternatives as input symbols:
Expand All @@ -630,27 +625,6 @@ Here the answer $A/(ef*l)$ is marked as correct, and so are the alternatives:
- $A/(e*l)$
- $A/(Ep*l)$




#### Example
As implicit multiplication with higher precedence cannot decypher what is a multi-character code and what are two variables that should be multiplied (see [`convention`](#convention) for more detail), single letter codes should be used.
With `"convention": "implicit_higher_precedence"` set

For the answer:
$A/(\epsilon*l)$, `e` is reserved as Euler's number, so we replace `e` with `b` or any other character(s) that are not reserved or used in the expression and provide alternatives as input symbols:
Symbol: $\epsilon$
Code: b
Alternatives: ϵ,ε,E,e,Ep

Here the following are marked as correct:
- $A/(b*l)$ or $A/(bl)$ or $A/bl$
- $A/(ϵ*l)$ or $A/(ϵl)$ or $A/ϵl$
- $A/(ε*l)$ or $A/(εl)$ or $A/εl$
- $A/(E*l)$ or $A/(El)$ or $A/El$
- $A/(e*l)$ or $A/(el)$ or $A/e*l$
- $A/(Ep*l)$ or $A/(Epl)$ or $A/Epl$

### Overriding greek letters or other reserved symbols with input symbols

Sometimes there can be ambiguities in the expected responses. For example `xi` in a response could either be interpreted as the greek letter $\xi$ or as the multiplication $x \cdot i$.
Expand Down
51 changes: 51 additions & 0 deletions app/evaluation_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,5 +118,56 @@ def test_euler_preview_evaluate(self):
assert result["is_correct"] is True


def test_theta_character_implict_multi_variable(self):
params = {
"strict_syntax": False,
"elementary_functions": True,
"convention": "implicit_higher_precedence",
"symbols": {
"a": {"aliases": ["a"]},
"bc": {"aliases": ["bc"]},
"d": {"aliases": ["d"]}
},
}
answer = "a/(theta*d)"
response_full = "a/(theta*d)"
response_implicit_bracket = "a/(thetad)"
response_implicit_no_bracket = "a/thetad"

result = evaluation_function(response_full, answer, params)
assert result["is_correct"] is True, "Response: a/(theta*d)"

result = evaluation_function(response_implicit_bracket, answer, params)
assert result["is_correct"] is True, "Response: a/(thetad)"

result = evaluation_function(response_implicit_no_bracket, answer, params)
assert result["is_correct"] is True, "Response: a/thetad"

def test_multi_character_implicit_multi_variable(self):
params = {
"strict_syntax": False,
"elementary_functions": True,
"convention": "implicit_higher_precedence",
"symbols": {
"a": {"aliases": ["a"]},
"bc": {"aliases": ["bc"]},
"d": {"aliases": ["d"]}
},
}
answer = "a/(bc*d)"
response_full = "a/(bc*d)"
response_implicit_bracket = "a/(bcd)"
response_implicit_no_bracket = "a/bcd"

result = evaluation_function(response_full, answer, params)
assert result["is_correct"] is True, "Response: a/(bc*d)"

result = evaluation_function(response_implicit_bracket, answer, params)
assert result["is_correct"] is True, "Response: a/(bcd)"

result = evaluation_function(response_implicit_no_bracket, answer, params)
assert result["is_correct"] is True, "Response: a/bcd"


if __name__ == "__main__":
pytest.main(['-xk not slow', '--tb=short', '--durations=10', os.path.abspath(__file__)])
28 changes: 28 additions & 0 deletions app/preview_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,5 +172,33 @@ def test_lh_rh_response(self):
assert result["preview"]["latex"] == "x + y=x + y"
assert result["preview"]["sympy"] == "x + y=y + x"

def test_multi_character_implicit_multi_variable(self):
params = {
"strict_syntax": False,
"elementary_functions": True,
"convention": "implicit_higher_precedence",
"symbols": {
"a": {"aliases": ["a"], "latex": "a"},
"bc": {"aliases": ["bc"], "latex": "bc"},
"d": {"aliases": ["d"], "latex": "d"}
},
}

response_full = "a/(bc*d)"
response_implicit_bracket = "a/(bcd)"
response_implicit_no_bracket = "a/bcd"

result = preview_function(response_full, params)
assert result["preview"]["latex"] == '\\frac{a}{bc \\cdot d}'
assert result["preview"]["sympy"] == "a/(bc*d)"

result = preview_function(response_implicit_bracket, params)
assert result["preview"]["latex"] == '\\frac{a}{bc \\cdot d}'
assert result["preview"]["sympy"] == "a/(bcd)"

result = preview_function(response_implicit_no_bracket, params)
assert result["preview"]["latex"] =='\\frac{a}{bc \\cdot d}'
assert result["preview"]["sympy"] == "a/bcd"

if __name__ == "__main__":
pytest.main(['-xk not slow', "--tb=line", os.path.abspath(__file__)])
223 changes: 223 additions & 0 deletions app/tests/multi_character_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import pytest
from ..utility.expression_utilities import parse_expression, create_sympy_parsing_params

class TestMultiCharImplicitMultiHigherPrecedenceIntegration:
"""
Integration tests for multi-characters symbols and the convention of implicit_higher_precedence enabled.
"""

def assert_expression_equality(self, response, answer, symbols_dict, local_dict=None):
"""
Helper to parse both response and answer with the same parameters
and assert they result in equivalent SymPy expressions.
"""
if local_dict is None:
local_dict = {}

# define parameters
params = {
"complexNumbers": False,
"strict_syntax": False,
"elementary_functions": True,
"convention": "implicit_higher_precedence",
"symbols": symbols_dict,
}

parsing_params = create_sympy_parsing_params(params)

try:
parsed_response = parse_expression(response, parsing_params)
parsed_answer = parse_expression(answer, parsing_params)
except Exception as e:
pytest.fail(f"Parsing failed for input '{response}' or '{answer}': {str(e)}")

assert parsed_response == parsed_answer, \
f"\nInput: {response}\nExpected: {parsed_answer}\nGot: {parsed_response}"

def test_multi_character_implicit_multi_variable(self):
symbols = {
"a": {"aliases": ["a"]},
"bc": {"aliases": ["bc"]},
"d": {"aliases": ["d"]}
}
answer = "a/(bc*d)"

# Case 1: Full explicit
self.assert_expression_equality("a/(bc*d)", answer, symbols)

# Case 2: Implicit with brackets
self.assert_expression_equality("a/(bcd)", answer, symbols)

# Case 3: Implicit no brackets
self.assert_expression_equality("a/bcd", answer, symbols)

def test_multiple_divisions_with_implicit(self):
"""Test multiple divisions in sequence"""
symbols = {
"a": {"aliases": ["a"]},
"b": {"aliases": ["b"]},
"c": {"aliases": ["c"]},
"d": {"aliases": ["d"]},
"f": {"aliases": ["f"]}
}

answer = "a/(b*c)/(d*f)"
response = "a/bc/df"
self.assert_expression_equality(response, answer, symbols)

def test_addition_with_division_and_implicit(self):
"""Test that addition doesn't interfere"""
symbols = {
"a": {"aliases": ["a"]},
"b": {"aliases": ["b"]},
"c": {"aliases": ["c"]},
"d": {"aliases": ["d"]}
}
answer = "a/(b*c) + d"
response = "a/bc + d"
self.assert_expression_equality(response, answer, symbols)

def test_multiplication_after_division(self):
"""Test explicit multiplication after division"""
symbols = {
"a": {"aliases": ["a"]},
"b": {"aliases": ["b"]},
"c": {"aliases": ["c"]},
"d": {"aliases": ["d"]}
}

answer = "(a/(b*c))*d"
response = "a/bc*d"
self.assert_expression_equality(response, answer, symbols)

def test_power_with_implicit_multiplication(self):
symbols = {
"a": {"aliases": ["a"]},
"b": {"aliases": ["b"]},
"c": {"aliases": ["c"]},
}
answer = "a/(b**2*c)"
response = "a/b^2c"
self.assert_expression_equality(response, answer, symbols)

def test_longer_multi_character_symbols(self):
symbols = {
"x": {"aliases": ["x"]},
"abc": {"aliases": ["abc"]},
"df": {"aliases": ["df"]},
}
answer = "x/(abc*df)"
test_cases = [
"x/(abc*df)",
"x/(abcdf)",
"x/abcdf",
]
for response in test_cases:
self.assert_expression_equality(response, answer, symbols)

def test_overlapping_symbol_names(self):
symbols = {
"a": {"aliases": ["a"]},
"ab": {"aliases": ["ab"]},
"abc": {"aliases": ["abc"]},
"c": {"aliases": ["c"]},
}
answer = "a/(abc*c)"
response = "a/abcc"
self.assert_expression_equality(response, answer, symbols)

def test_numbers_with_implicit_multiplication(self):
"""Test with numeric literals"""
symbols = {
"a": {"aliases": ["a"]},
"b": {"aliases": ["b"]},
}

answer = "a/(2*b)"
response = "a/2b"
self.assert_expression_equality(response, answer, symbols)

def test_number_variable_number(self):
"""Test number followed by variable followed by number"""
symbols = {
"x": {"aliases": ["x"]},
}

answer = "1/(2*x*3)"
response = "1/2x3"
self.assert_expression_equality(response, answer, symbols)

def test_implicit_before_parentheses(self):
"""Test implicit multiplication before parentheses"""
symbols = {
"a": {"aliases": ["a"]},
"b": {"aliases": ["b"]},
"c": {"aliases": ["c"]},
"d": {"aliases": ["d"]},
}

answer = "a*(b*c*d)"
response = "a(bcd)"
self.assert_expression_equality(response, answer, symbols)

def test_implicit_before_parentheses_with_multichar(self):
"""Test implicit multiplication before parentheses with multi-char symbols"""
symbols = {
"a": {"aliases": ["a"]},
"bc": {"aliases": ["bc"]},
"d": {"aliases": ["d"]},
}

answer = "a*(bc*d)"
response = "a(bcd)"
self.assert_expression_equality(response, answer, symbols)

def test_complex_expression(self):
"""Test a complex expression"""
symbols = {
"a": {"aliases": ["a"]},
"b": {"aliases": ["b"]},
"c": {"aliases": ["c"]},
"d": {"aliases": ["d"]},
"f": {"aliases": ["f"]},
"g": {"aliases": ["g"]},
}
answer = "a/(b*c) + d/(g*f)"
response = "a/bc + d/gf"
self.assert_expression_equality(response, answer, symbols)

def test_three_way_implicit_multiplication(self):
"""Test three or more implicitly multiplied terms"""
symbols = {
"a": {"aliases": ["a"]},
"b": {"aliases": ["b"]},
"c": {"aliases": ["c"]},
"d": {"aliases": ["d"]},
"f": {"aliases": ["f"]},
}
answer = "a/(b*c*d*f)"
response = "a/bcdf"
self.assert_expression_equality(response, answer, symbols)

def test_explicit_multiplication_should_not_be_grouped(self):
"""Test that explicit multiplication maintains standard precedence"""
symbols = {
"a": {"aliases": ["a"]},
"b": {"aliases": ["b"]},
"c": {"aliases": ["c"]},
}
answer = "(a/b)*c"
response = "a/b*c"
self.assert_expression_equality(response, answer, symbols)

def test_mixed_implicit_and_explicit(self):
"""Test mixing implicit and explicit multiplication"""
symbols = {
"a": {"aliases": ["a"]},
"b": {"aliases": ["b"]},
"c": {"aliases": ["c"]},
"d": {"aliases": ["d"]},
}
answer = "(a/(b*c))*d"
response = "a/bc*d"
self.assert_expression_equality(response, answer, symbols)
9 changes: 9 additions & 0 deletions app/tests/symbolic_evaluation_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,15 @@ def test_complex_numbers(self, response, answer):
response = "zeta(2)"
answer = "pi**2/6"
input_variations += generate_input_variations(response, answer)
response = "Lambda(x, x**2)"
answer = "Lambda(x, x**2)"
input_variations += generate_input_variations(response, answer)
response = "Lambda((x, y), x + y)"
answer = "Lambda((x, y), x + y)"
input_variations += generate_input_variations(response, answer)
response = "chi(x)"
answer = "chi(x)"
input_variations += generate_input_variations(response, answer)

@pytest.mark.parametrize("response,answer", generate_input_variations(response, answer))
def test_special_functions(self, response, answer):
Expand Down
Empty file added app/utility/__init__.py
Empty file.
Loading